3"""The checks that need a real parse, and the libclang setup they need it from.
5Five of this gate's seven findings are questions no regex can answer: whether
6an ``@param`` names a parameter the signature actually has, whether a function
7returning ``void`` claims a return value, whether a block names a different
8symbol than the one it sits on, and whether a block is stranded on a forward
9declaration whose definition is bare. Each needs real parameter names, real
10return types and real declaration-vs-definition identity.
12That dependency is also why the import lives here and fails loudly: a gate
13that silently degrades to "no findings" when libclang is missing is worse than
14one that is not run at all, because it reports success.
17from __future__
import annotations
22from dataclasses
import dataclass
23from pathlib
import Path
24from types
import ModuleType
25from typing
import TYPE_CHECKING
27from docattach_lex
import (
28 _blank_comments_and_literals,
29 check_banned_boilerplate,
30 check_consecutive_blocks,
33from docattach_model
import DocBlock, DocTags, Finding, parse_tags
34from docattach_scope
import REPO_ROOT
37 from clang.cindex
import Cursor, TranslationUnit
47def _require_libclang() -> ModuleType:
48 """Import libclang or exit(2).
50 Deliberately fatal. ``check_annotations.py`` used to ``exit(0)`` here,
51 which made a container missing the binding read as a clean strict gate --
52 strictly worse than not running the gate at all, because it reports
53 success. A gate that cannot run has not passed.
56 from clang
import cindex
59 "check_doc_attachment.py: FATAL -- the 'libclang' Python binding is missing,\n"
60 " so the documentation-attachment gate cannot run. This is an error, not a\n"
61 " skip: a gate that cannot run has not passed.\n"
62 " install the pinned repository tools: just setup-python\n"
68def _include_args(_cindex: ModuleType) -> list[str]:
69 """``-I`` flags for every first-party header root."""
70 roots: list[Path] = []
82 roots.extend(sorted(REPO_ROOT.glob(pattern)))
83 return [f
"-I{d}" for d
in roots
if d.is_dir()
and "third_party" not in d.parts]
89def _param_names(cursor: Cursor, cindex: ModuleType) -> list[str]:
90 """Named parameters of a function cursor, in order."""
93 for a
in cursor.get_arguments()
94 if a.kind == cindex.CursorKind.PARM_DECL
and a.spelling
98def _is_macro_expanded(cursor: Cursor) -> bool:
99 """True when the declaration came out of a macro expansion.
101 X-macro tables and declaration-generating macros produce cursors whose
102 spelling never appears in the source text, so parameter and name checks
103 against them are meaningless.
106 return cursor.location.is_from_main_file()
is False and cursor.extent.start.file
is None
107 except (AttributeError, ValueError):
111def typedef_names_for_anonymous(
112 tu: TranslationUnit, cindex: ModuleType, own_file: str
113) -> dict[tuple[int, int], str]:
114 """Map each anonymous record/enum's location to the typedef that names it.
116 ``typedef struct { ... } emu_args_t;`` -- the C23 shape this codebase uses
117 almost everywhere -- produces a STRUCT_DECL whose spelling is
118 ``struct (unnamed at ...)``. Comparing ``@struct emu_args_t`` against that
119 spelling reports every correctly-documented struct in the tree as a name
120 mismatch. The typedef is the symbol's real name, so resolve it.
122 out: dict[tuple[int, int], str] = {}
123 for cursor
in tu.cursor.walk_preorder():
124 if cursor.kind != cindex.CursorKind.TYPEDEF_DECL:
126 loc = cursor.location.file
127 if loc
is None or os.path.realpath(loc.name) != own_file:
130 decl = cursor.underlying_typedef_type.get_declaration()
131 except (AttributeError, ValueError):
133 if decl
is None or decl.location.file
is None:
135 out[(decl.location.line, decl.location.column)] = cursor.spelling
139def _display_name(cursor: Cursor, anon_names: dict[tuple[int, int], str]) -> str:
140 """The name a doc block would reasonably use for ``cursor``."""
141 spelling = cursor.spelling
or ""
142 if spelling
and "(unnamed" not in spelling
and "(anonymous" not in spelling:
144 return anon_names.get((cursor.location.line, cursor.location.column),
"")
149 """Everything the per-symbol checks need about the file being scanned."""
154 anon_names: dict[tuple[int, int], str]
156 attached: dict[int, DocBlock]
162 cursor: Cursor, ctx: FileCtx, tags: DocTags, name: str, line: int
164 """DOC005 -- the block names one symbol while sitting on another."""
165 findings: list[Finding] = []
166 for kind, ref
in tags.explicit_refs:
167 if kind
in {
"ingroup",
"name"}
or ref == name:
175 if _ref_in_declaration(cursor, ref, ctx.decl_text):
183 f
"block says '@{kind} {ref}' but is attached to '{name}'",
189 if tags.impl_of
and tags.impl_of != name:
196 f
"block says 'Implementation of `{tags.impl_of}()`' but is attached to '{name}'",
202def check_symbol(cursor: Cursor, ctx: FileCtx) -> list[Finding]:
203 """Run every attachment check for one documented declaration.
205 Attachment is resolved **positionally** (``attached``), never via
206 ``cursor.raw_comment``. libclang propagates a comment to every
207 redeclaration of a symbol, so a block on a forward declaration also reports
208 as the definition's own comment -- which double-reported every finding and
209 would have made the eventual fix look incomplete. The block a reader sees
210 above a declaration is the one this gate judges.
213 findings: list[Finding] = []
214 block = _decl_block(cursor, ctx.attached, ctx.decl_text)
217 tags = parse_tags(block.text)
218 name = _display_name(cursor, ctx.anon_names)
219 line = cursor.location.line
230 findings.extend(_check_names(cursor, ctx, tags, name, line))
232 if cursor.kind != cindex.CursorKind.FUNCTION_DECL:
234 findings.extend(_check_signature(cursor, ctx, tags, name, line))
239 cursor: Cursor, ctx: FileCtx, tags: DocTags, name: str, line: int
241 """DOC001/DOC002/DOC003 -- the block's claims against the real signature."""
242 cindex, path = ctx.cindex, ctx.path
243 findings: list[Finding] = []
244 actual = _param_names(cursor, cindex)
245 documented = [p
for p
in tags.params
if p]
248 unknown = [p
for p
in documented
if p
not in actual]
249 if unknown
and actual
is not None:
256 f
"@param {', '.join(sorted(set(unknown)))} -- no such parameter "
257 f
"(signature: {', '.join(actual) or 'void'})",
265 missing = [p
for p
in actual
if p
not in documented]
if documented
else []
273 f
"documents {len(documented)} of {len(actual)} parameters; "
274 f
"no @param for: {', '.join(missing)}",
279 if cursor.result_type.kind == cindex.TypeKind.VOID
and (tags.has_return
or tags.has_retval):
280 which =
"@retval" if tags.has_retval
else "@return"
282 Finding(path, line,
"DOC003", name, f
"{which} documented but the function returns void")
288def blocks_by_attach_line(text: str) -> dict[int, DocBlock]:
289 """Map each doc block to the source line of the construct it precedes.
291 Needed because ``cursor.raw_comment`` is **not** positional: libclang
292 propagates a comment across every redeclaration of a symbol, so the bare
293 definition of a function whose forward declaration carries a block reports
294 that same block as its own. Asking libclang alone therefore made DOC006
295 structurally unable to fire. This resolves attachment lexically instead,
296 which is what "attached to" actually means in the source.
298 code = _blank_comments_and_literals(text).splitlines()
299 out: dict[int, DocBlock] = {}
300 for block
in extract_doc_blocks(text):
303 for idx
in range(block.end_line, len(code)):
304 if code[idx].strip():
314DECL_PREFIX_RE = re.compile(
316 r"[A-Z][A-Z0-9_]*(?:\s*\([^)]*\))?"
317 r"|\[\[[^\]]*\]\]"
318 r"|static|inline|extern|const|volatile|register|_Noreturn"
323def _decl_block(cursor: Cursor, attached: dict[int, DocBlock], lines: list[str]) -> DocBlock |
None:
324 """The doc block lexically preceding ``cursor``, or None.
326 A declaration can start well above ``cursor.location.line``. libclang's
327 extent does **not** cover an annotation macro on its own line, so for
331 static void internal_foo(void) { }
333 the block attaches to the ``RA8_INTERNAL`` line while the cursor extent
334 starts on the ``static void`` line below it -- and a naive extent-based
335 lookup finds no block at all. Since CLAUDE.md mandates an RA8_* annotation
336 on every non-public function, that silently exempted a large share of the
337 tree from every symbol check here. Walk up past any declaration-prefix
338 lines before looking.
341 first = cursor.extent.start.line
342 except (AttributeError, ValueError):
343 first = cursor.location.line
344 first =
min(first, cursor.location.line)
345 while first > 1
and DECL_PREFIX_RE.match(lines[first - 2]
if first - 2 < len(lines)
else ""):
347 for ln
in range(first, cursor.location.line + 1):
353def _ref_in_declaration(cursor: Cursor, ref: str, decl_text: list[str]) -> bool:
354 """True when ``ref`` appears in the source lines ``cursor`` spans."""
356 lo = cursor.extent.start.line
357 hi = cursor.extent.end.line
358 except (AttributeError, ValueError):
360 pat = re.compile(
r"\b" + re.escape(ref) +
r"\b")
361 return any(pat.search(ln)
for ln
in decl_text[lo - 1 : hi])
364def _decls_between(all_decls: list[Cursor], first: Cursor, second: Cursor) -> bool:
365 """True when some *other* function is declared between ``first`` and ``second``."""
366 lo, hi = first.location.line, second.location.line
367 return any(lo < c.location.line < hi
and c.spelling != first.spelling
for c
in all_decls)
370def _own_function_decls(
371 tu: TranslationUnit, cindex: ModuleType, own_file: str
372) -> dict[str, list[Cursor]]:
373 """Group this file's own function declarations by symbol name.
375 Cursors from #included headers are dropped by comparing REAL paths, so a
376 symlinked or relatively-spelled include cannot smuggle a declaration in and
377 make an out-of-file prototype look like an in-file forward declaration.
379 decls: dict[str, list[Cursor]] = {}
380 for cursor
in tu.cursor.walk_preorder():
381 if cursor.kind != cindex.CursorKind.FUNCTION_DECL:
383 loc = cursor.location.file
384 if loc
is None or os.path.realpath(loc.name) != own_file:
386 decls.setdefault(cursor.spelling, []).append(cursor)
390def check_forward_decl_blocks(
391 tu: TranslationUnit, cindex: ModuleType, path: str, own_file: str, text: str
393 """DOC006 -- a block on a forward declaration whose definition is bare.
395 Scoped to a single file on purpose. A block on a *header* declaration with
396 a bare definition in the .c is exactly what CLAUDE.md prescribes; the defect
397 is the in-file forward declaration that hoards the block and leaves the
398 definition below it undocumented.
400 attached = blocks_by_attach_line(text)
401 decl_text = text.splitlines()
402 decls = _own_function_decls(tu, cindex, own_file)
405 (c
for group
in decls.values()
for c
in group), key=
lambda c: c.location.line
407 findings: list[Finding] = []
408 for name, cursors
in decls.items():
409 if len(cursors) < MIN_REDECLARATIONS:
411 definition = next((c
for c
in cursors
if c.is_definition()),
None)
412 if definition
is None:
415 if _decl_block(definition, attached, decl_text)
is not None:
417 documented_decl = next(
421 if not c.is_definition()
and _decl_block(c, attached, decl_text)
is not None
425 if documented_decl
is None:
439 if not _decls_between(all_decls, documented_decl, definition):
444 documented_decl.location.line,
447 f
"doc block sits on the forward declaration while the definition at line "
448 f
"{definition.location.line} is bare; move the block to the definition",
454def check_file(path: Path, cindex: ModuleType, args: list[str]) -> list[Finding]:
455 """Run the lexical + AST checks over one file."""
460 rel = str(path.relative_to(REPO_ROOT))
464 text = path.read_text(encoding=
"utf-8", errors=
"replace")
468 findings = check_consecutive_blocks(rel, text) + check_banned_boilerplate(rel, text)
470 index = cindex.Index.create()
472 tu = index.parse(str(path), args=args)
473 except cindex.TranslationUnitLoadError:
478 own = os.path.realpath(str(path))
479 findings.extend(_check_declarations(tu, cindex, rel, own, text))
480 findings.extend(check_forward_decl_blocks(tu, cindex, rel, own, text))
481 return _dedupe(findings)
484def _check_declarations(
485 tu: TranslationUnit, cindex: ModuleType, rel: str, own: str, text: str
487 """Run the per-symbol checks over every declaration this file owns."""
489 cindex.CursorKind.FUNCTION_DECL,
490 cindex.CursorKind.STRUCT_DECL,
491 cindex.CursorKind.UNION_DECL,
492 cindex.CursorKind.ENUM_DECL,
493 cindex.CursorKind.TYPEDEF_DECL,
494 cindex.CursorKind.FIELD_DECL,
495 cindex.CursorKind.ENUM_CONSTANT_DECL,
496 cindex.CursorKind.VAR_DECL,
498 anon_names = typedef_names_for_anonymous(tu, cindex, own)
499 ctx = FileCtx(cindex, rel, anon_names, blocks_by_attach_line(text), text.splitlines())
500 findings: list[Finding] = []
501 seen: set[tuple[int, str]] = set()
502 for cursor
in tu.cursor.walk_preorder():
503 if cursor.kind
not in interesting:
505 loc = cursor.location.file
506 if loc
is None or os.path.realpath(loc.name) != own:
508 key = (cursor.location.line, cursor.spelling
or "")
512 if _is_macro_expanded(cursor):
514 findings.extend(check_symbol(cursor, ctx))
518def _dedupe(findings: list[Finding]) -> list[Finding]:
519 """Collapse identical findings reported at two cursors.
521 One doc block above ``typedef enum {...} foo_t;`` is reached by both the
522 ENUM_DECL and the TYPEDEF_DECL cursor, which reports the identical finding
523 at two different lines (the ``typedef enum {`` line and the ``} foo_t;``
524 line). Keeping the first occurrence makes a fix count match the defect
527 deduped: dict[tuple[str, str, str], Finding] = {}
529 deduped.setdefault((f.code, f.symbol, f.detail), f)
530 return list(deduped.values())
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.