ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
docattach_ast.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The checks that need a real parse, and the libclang setup they need it from.
4
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.
11
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.
15"""
16
17from __future__ import annotations
18
19import os
20import re
21import sys
22from dataclasses import dataclass
23from pathlib import Path
24from types import ModuleType
25from typing import TYPE_CHECKING
26
27from docattach_lex import (
28 _blank_comments_and_literals,
29 check_banned_boilerplate,
30 check_consecutive_blocks,
31 extract_doc_blocks,
32)
33from docattach_model import DocBlock, DocTags, Finding, parse_tags
34from docattach_scope import REPO_ROOT
35
36if TYPE_CHECKING: # pragma: no cover -- libclang is imported at runtime by _require_libclang
37 from clang.cindex import Cursor, TranslationUnit
38
39#: A symbol must appear at least this many times (declaration + definition)
40#: before a forward-declaration-vs-definition split can even exist.
41MIN_REDECLARATIONS = 2
42
43
44# ---------------------------------------------------------------------------
45# libclang
46# ---------------------------------------------------------------------------
47def _require_libclang() -> ModuleType:
48 """Import libclang or exit(2).
49
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.
54 """
55 try:
56 from clang import cindex # noqa: PLC0415 # probe-then-import is the point
57 except ImportError:
58 sys.stderr.write(
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"
63 )
64 sys.exit(2)
65 return cindex
66
67
68def _include_args(_cindex: ModuleType) -> list[str]:
69 """``-I`` flags for every first-party header root."""
70 roots: list[Path] = []
71 for pattern in (
72 "libs/*/inc",
73 "libs/*/src",
74 "tests/include",
75 "tests",
76 "port/*/inc",
77 "tools/*/inc",
78 # A product under apps/ carries its headers one level deeper than a
79 # tool, beneath its category directory.
80 "apps/*/*/inc",
81 ):
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]
84
85
86# ---------------------------------------------------------------------------
87# AST pass
88# ---------------------------------------------------------------------------
89def _param_names(cursor: Cursor, cindex: ModuleType) -> list[str]:
90 """Named parameters of a function cursor, in order."""
91 return [
92 a.spelling
93 for a in cursor.get_arguments()
94 if a.kind == cindex.CursorKind.PARM_DECL and a.spelling
95 ]
96
97
98def _is_macro_expanded(cursor: Cursor) -> bool:
99 """True when the declaration came out of a macro expansion.
100
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.
104 """
105 try:
106 return cursor.location.is_from_main_file() is False and cursor.extent.start.file is None
107 except (AttributeError, ValueError):
108 return False
109
110
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.
115
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.
121 """
122 out: dict[tuple[int, int], str] = {}
123 for cursor in tu.cursor.walk_preorder():
124 if cursor.kind != cindex.CursorKind.TYPEDEF_DECL:
125 continue
126 loc = cursor.location.file
127 if loc is None or os.path.realpath(loc.name) != own_file:
128 continue
129 try:
130 decl = cursor.underlying_typedef_type.get_declaration()
131 except (AttributeError, ValueError):
132 continue
133 if decl is None or decl.location.file is None:
134 continue
135 out[(decl.location.line, decl.location.column)] = cursor.spelling
136 return out
137
138
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:
143 return spelling
144 return anon_names.get((cursor.location.line, cursor.location.column), "")
145
146
147@dataclass
148class FileCtx:
149 """Everything the per-symbol checks need about the file being scanned."""
150
151 cindex: object
152 path: str
153 #: Anonymous record/enum location -> the typedef that names it.
154 anon_names: dict[tuple[int, int], str]
155 #: Source line of a declaration -> the doc block that precedes it.
156 attached: dict[int, DocBlock]
157 #: The file's source lines, for span-level lookups.
158 decl_text: list[str]
159
160
161def _check_names(
162 cursor: Cursor, ctx: FileCtx, tags: DocTags, name: str, line: int
163) -> list[Finding]:
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:
168 continue
169 # The block names X; if X appears anywhere in the declaration the block
170 # sits on, it IS attached to the right thing and libclang simply named
171 # the cursor badly. `typedef UINT (*dfu_write_cb_t)(...)` is the case
172 # that matters: with the vendored USBX headers unresolved, clang
173 # recovers by reporting the cursor as `UINT`, which looked like a
174 # mismatch against a perfectly correct @typedef dfu_write_cb_t.
175 if _ref_in_declaration(cursor, ref, ctx.decl_text):
176 continue
177 findings.append(
178 Finding(
179 ctx.path,
180 line,
181 "DOC005",
182 name,
183 f"block says '@{kind} {ref}' but is attached to '{name}'",
184 )
185 )
186 break
187
188 # The sanctioned definition-site form naming a different function.
189 if tags.impl_of and tags.impl_of != name:
190 findings.append(
191 Finding(
192 ctx.path,
193 line,
194 "DOC005",
195 name,
196 f"block says 'Implementation of `{tags.impl_of}()`' but is attached to '{name}'",
197 )
198 )
199 return findings
200
201
202def check_symbol(cursor: Cursor, ctx: FileCtx) -> list[Finding]:
203 """Run every attachment check for one documented declaration.
204
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.
211 """
212 cindex = ctx.cindex
213 findings: list[Finding] = []
214 block = _decl_block(cursor, ctx.attached, ctx.decl_text)
215 if block is None:
216 return findings
217 tags = parse_tags(block.text)
218 name = _display_name(cursor, ctx.anon_names)
219 line = cursor.location.line
220 # An anonymous record with no naming typedef has no name a block could be
221 # checked against; the tag checks below would compare against "".
222 if not name:
223 return findings
224
225 # `@copydoc` substitutes another symbol's block wholesale at render time,
226 # so its tags describe that symbol by design. Nothing to cross-check.
227 if tags.has_copy:
228 return findings
229
230 findings.extend(_check_names(cursor, ctx, tags, name, line))
231
232 if cursor.kind != cindex.CursorKind.FUNCTION_DECL:
233 return findings
234 findings.extend(_check_signature(cursor, ctx, tags, name, line))
235 return findings
236
237
238def _check_signature(
239 cursor: Cursor, ctx: FileCtx, tags: DocTags, name: str, line: int
240) -> list[Finding]:
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]
246
247 # -- DOC001: a documented parameter the signature does not have.
248 unknown = [p for p in documented if p not in actual]
249 if unknown and actual is not None:
250 findings.append(
251 Finding(
252 path,
253 line,
254 "DOC001",
255 name,
256 f"@param {', '.join(sorted(set(unknown)))} -- no such parameter "
257 f"(signature: {', '.join(actual) or 'void'})",
258 )
259 )
260
261 # -- DOC002: a partially documented signature. Only fires when the block
262 # already documents at least one parameter: a block with no @param at all
263 # is a *presence* gap and belongs to doxy_audit.py, not here. A block that
264 # documents 1 of 8 is drift or paste residue -- an attachment defect.
265 missing = [p for p in actual if p not in documented] if documented else []
266 if missing:
267 findings.append(
268 Finding(
269 path,
270 line,
271 "DOC002",
272 name,
273 f"documents {len(documented)} of {len(actual)} parameters; "
274 f"no @param for: {', '.join(missing)}",
275 )
276 )
277
278 # -- DOC003: @return / @retval on a void function.
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"
281 findings.append(
282 Finding(path, line, "DOC003", name, f"{which} documented but the function returns void")
283 )
284
285 return findings
286
287
288def blocks_by_attach_line(text: str) -> dict[int, DocBlock]:
289 """Map each doc block to the source line of the construct it precedes.
290
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.
297 """
298 code = _blank_comments_and_literals(text).splitlines()
299 out: dict[int, DocBlock] = {}
300 for block in extract_doc_blocks(text):
301 if block.trailing:
302 continue
303 for idx in range(block.end_line, len(code)):
304 if code[idx].strip():
305 out[idx + 1] = block
306 break
307 return out
308
309
310#: Lines that belong to a declaration but sit *above* the extent libclang
311#: reports: the RA8_* annotation macros (which expand to attributes and are
312#: mandated tree-wide by CLAUDE.md), C23 attributes, and bare storage-class or
313#: qualifier keywords left on their own line by clang-format.
314DECL_PREFIX_RE = re.compile(
315 r"^\s*(?:"
316 r"[A-Z][A-Z0-9_]*(?:\s*\‍([^)]*\‍))?" # RA8_INTERNAL / RA8_BOUNDED_LOOP(x)
317 r"|\‍[\‍[[^\‍]]*\‍]\‍]" # [[noreturn]]
318 r"|static|inline|extern|const|volatile|register|_Noreturn"
319 r")\s*$"
320)
321
322
323def _decl_block(cursor: Cursor, attached: dict[int, DocBlock], lines: list[str]) -> DocBlock | None:
324 """The doc block lexically preceding ``cursor``, or None.
325
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
328
329 /** ... */
330 RA8_INTERNAL
331 static void internal_foo(void) { }
332
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.
339 """
340 try:
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 ""):
346 first -= 1
347 for ln in range(first, cursor.location.line + 1):
348 if ln in attached:
349 return attached[ln]
350 return None
351
352
353def _ref_in_declaration(cursor: Cursor, ref: str, decl_text: list[str]) -> bool:
354 """True when ``ref`` appears in the source lines ``cursor`` spans."""
355 try:
356 lo = cursor.extent.start.line
357 hi = cursor.extent.end.line
358 except (AttributeError, ValueError):
359 return False
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])
362
363
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)
368
369
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.
374
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.
378 """
379 decls: dict[str, list[Cursor]] = {}
380 for cursor in tu.cursor.walk_preorder():
381 if cursor.kind != cindex.CursorKind.FUNCTION_DECL:
382 continue
383 loc = cursor.location.file
384 if loc is None or os.path.realpath(loc.name) != own_file:
385 continue
386 decls.setdefault(cursor.spelling, []).append(cursor)
387 return decls
388
389
390def check_forward_decl_blocks(
391 tu: TranslationUnit, cindex: ModuleType, path: str, own_file: str, text: str
392) -> list[Finding]:
393 """DOC006 -- a block on a forward declaration whose definition is bare.
394
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.
399 """
400 attached = blocks_by_attach_line(text)
401 decl_text = text.splitlines()
402 decls = _own_function_decls(tu, cindex, own_file)
403
404 all_decls = sorted(
405 (c for group in decls.values() for c in group), key=lambda c: c.location.line
406 )
407 findings: list[Finding] = []
408 for name, cursors in decls.items():
409 if len(cursors) < MIN_REDECLARATIONS:
410 continue
411 definition = next((c for c in cursors if c.is_definition()), None)
412 if definition is None:
413 continue
414 # Positional, not cursor.raw_comment -- see blocks_by_attach_line().
415 if _decl_block(definition, attached, decl_text) is not None:
416 continue
417 documented_decl = next(
418 (
419 c
420 for c in cursors
421 if not c.is_definition() and _decl_block(c, attached, decl_text) is not None
422 ),
423 None,
424 )
425 if documented_decl is None:
426 continue
427 # The `-Wmissing-prototypes` idiom puts a local prototype directly above
428 # its own definition:
429 # /** ... */
430 # void NMI_Handler(void);
431 # void NMI_Handler(void) { ... }
432 # The block sits immediately above both, so no reader is misled and
433 # CLAUDE.md's "the authoritative block lives on the declaration" is
434 # satisfied. The defect is the block that got *separated* from its
435 # definition by other code -- ra8_rsip.c documented internal_sw_sha256
436 # 612 lines above the body, with a dozen other functions in between.
437 # Fire only when something else is declared between the two; no
438 # line-count threshold is involved.
439 if not _decls_between(all_decls, documented_decl, definition):
440 continue
441 findings.append(
442 Finding(
443 path,
444 documented_decl.location.line,
445 "DOC006",
446 name,
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",
449 )
450 )
451 return findings
452
453
454def check_file(path: Path, cindex: ModuleType, args: list[str]) -> list[Finding]:
455 """Run the lexical + AST checks over one file."""
456 # Ad-hoc runs may name a file outside the repo (bisecting a historical
457 # revision into a scratch dir, for one); fall back to the absolute path
458 # rather than raising.
459 try:
460 rel = str(path.relative_to(REPO_ROOT))
461 except ValueError:
462 rel = str(path)
463 try:
464 text = path.read_text(encoding="utf-8", errors="replace")
465 except OSError:
466 return []
467
468 findings = check_consecutive_blocks(rel, text) + check_banned_boilerplate(rel, text)
469
470 index = cindex.Index.create()
471 try:
472 tu = index.parse(str(path), args=args)
473 except cindex.TranslationUnitLoadError:
474 return findings
475 if tu is None:
476 return findings
477
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)
482
483
484def _check_declarations(
485 tu: TranslationUnit, cindex: ModuleType, rel: str, own: str, text: str
486) -> list[Finding]:
487 """Run the per-symbol checks over every declaration this file owns."""
488 interesting = {
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,
497 }
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:
504 continue
505 loc = cursor.location.file
506 if loc is None or os.path.realpath(loc.name) != own:
507 continue
508 key = (cursor.location.line, cursor.spelling or "")
509 if key in seen:
510 continue
511 seen.add(key)
512 if _is_macro_expanded(cursor):
513 continue
514 findings.extend(check_symbol(cursor, ctx))
515 return findings
516
517
518def _dedupe(findings: list[Finding]) -> list[Finding]:
519 """Collapse identical findings reported at two cursors.
520
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
525 count.
526 """
527 deduped: dict[tuple[str, str, str], Finding] = {}
528 for f in findings:
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.
Definition xz_config.h:157