4"""check_annotations.py -- libclang-based annotation enforcement.
6This script walks the AST of every C / C++ translation unit under
7``libs/``, ``src/``, ``examples/``, ``tests/``, ``port/`` and
8``tools/`` -- every first-party source root, per CLAUDE.md ("Scope") --
9and applies the project's annotation-enforcement rules. The annotation
10macros themselves are defined in ``libs/ra8_core/inc/ra8_attributes.h``
11and lower to ``[[clang::annotate("ra8_<rule>:...")]]`` markers that
12libclang exposes via ``AnnotateAttr`` cursors.
14The enforceable rules are documented in ``docs/ANNOTATIONS.md``; this
15script is the canonical implementation of their static checks. Every
16rule is fatal -- there is no warn-only mode. A gate that reports a
17known gap without failing is a gate that hides the gap.
19Two self-checks run before any rule does, because both failure modes
20look exactly like success:
22* ``annot_rulekeys.check_rule_keys()`` cross-checks every rule key this
23 script dispatches on against the annotation strings
24 ``ra8_attributes.h`` actually emits. A rule keyed on a string no macro
25 produces matches nothing and reports zero violations forever.
26* ``annot_clang.check_parse_integrity()`` fails when a header does not
27 resolve or when the fraction of call sites libclang could resolve
28 drops below ``MIN_CALL_RESOLUTION``. An incomplete parse silently
29 starves the call-graph rules, and fewer findings is not better news.
33This file is the entry point only: argument parsing, the sweep, and the
34report. The checker itself is a pipeline, split one module per stage so
35that no stage can quietly redefine another's assumptions:
37 :mod:`annot_scope` which files are in scope, and what module owns each
38 :mod:`annot_model` the records a parse produces and the rules consume
39 :mod:`annot_rulekeys` the rule vocabulary, cross-checked against the header
40 :mod:`annot_clang` libclang setup, compile flags, and the parse floor
41 :mod:`annot_source` source text read back for the two macro-level checks
42 :mod:`annot_walk` AST traversal into a USR-keyed symbol table
43 :mod:`annot_rules` one function per rule, dispatched by key
44 :mod:`annot_linkage` the rule defined over the ABSENCE of an annotation
45 :mod:`annot_loopbound` the textual per-loop bound-marker scan (no libclang)
46 :mod:`annot_selftest` synthetic-tree regression tests, both directions
50 python3 scripts/checks/check_annotations.py # report
51 python3 scripts/checks/check_annotations.py --check # CI gate
52 python3 scripts/checks/check_annotations.py --naming-audit # full prefix/storage audit
53 python3 scripts/checks/check_annotations.py --naming-audit --json # machine-readable inventory
54 python3 scripts/checks/check_annotations.py --list # dump symbols
55 python3 scripts/checks/check_annotations.py --selftest # regression test
58from __future__
import annotations
61import concurrent.futures
66from dataclasses
import dataclass
68sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
70from annot_clang
import check_parse_integrity, parse_tu, tu_args
71from annot_loopbound
import discover_loopbound_files, enforce_loop_bounds
72from annot_model
import AnnotatedSymbol, Violation, WalkState
73from annot_rulekeys
import check_rule_keys
74from annot_rules
import enforce_rules
75from annot_scope
import (
77 discover_translation_units,
81from annot_selftest
import run_selftest
82from annot_walk
import walk_tu
85def _parse_args(argv: list[str]) -> argparse.Namespace:
86 """Parse the command line."""
87 ap = argparse.ArgumentParser(description=__doc__)
89 "--check", action=
"store_true", help=
"exit non-zero on any (non-informational) violation"
91 ap.add_argument(
"--list", action=
"store_true", help=
"dump every annotated symbol and exit")
95 help=
"include the full s_/internal_/priv_ naming contract (whole tree only)",
98 "--json", action=
"store_true", help=
"emit the findings and parse summary as JSON"
100 ap.add_argument(
"--quiet", action=
"store_true", help=
"suppress per-TU progress output")
104 help=
"run the checker's own regression tests on synthetic TUs and exit",
106 ap.add_argument(
"paths", nargs=
"*", help=
"optional explicit file list (else scan everything)")
107 return ap.parse_args(argv)
111class Sweep(WalkState):
112 """Everything one pass over the tree produced.
114 The symbol table and the parse evidence travel together on purpose: a
115 rule result is only meaningful alongside the ``stats`` proving the parse
116 that produced it was complete.
121 def summary(self) -> str:
122 """One line of evidence about what the parse actually saw."""
123 seen = self.stats.calls_seen
124 resolution = self.stats.calls_resolved / seen
if seen
else 0.0
126 f
"{len(self.symbols)} functions, {len(self.data_symbols)} data definitions, "
127 f
"{self.stats.calls_resolved}/{seen} call sites resolved "
128 f
"({resolution:.1%}), {len(self.vector_entries)} vector-table entries, "
129 f
"{self.tu_count} TUs"
133def _parse_and_walk_one_tu(tu_path: pathlib.Path) -> WalkState:
135 tu = parse_tu(tu_path, state.stats)
139 except Exception
as exc:
140 sys.stderr.write(f
" WARN: walk failed for {tu_path}: {exc}\n")
144def _merge_walk_state(dst: Sweep, src: WalkState) ->
None:
145 for k, sym
in src.symbols.items():
146 if k
not in dst.symbols:
149 existing = dst.symbols[k]
150 for a
in sym.annotations:
151 if a
not in existing.annotations:
152 existing.annotations.append(a)
153 existing.is_static = existing.is_static
or sym.is_static
154 existing.has_internal_linkage = (
155 existing.has_internal_linkage
or sym.has_internal_linkage
158 existing.return_type = sym.return_type
159 existing.has_pointer_param = existing.has_pointer_param
or sym.has_pointer_param
161 existing.is_defined =
True
162 existing.file = sym.file
163 existing.line = sym.line
164 existing.end_line = sym.end_line
165 existing.decl_files.update(sym.decl_files)
166 existing.has_inline = existing.has_inline
or sym.has_inline
168 existing.section = sym.section
169 dst.data_symbols.update(src.data_symbols)
170 dst.calls.extend(src.calls)
171 dst.vector_entries.update(src.vector_entries)
172 dst.stats.calls_seen += src.stats.calls_seen
173 dst.stats.calls_resolved += src.stats.calls_resolved
174 dst.stats.missing_includes.update(src.stats.missing_includes)
175 dst.stats.unparsed.extend(src.stats.unparsed)
178def _sweep(tus: list[pathlib.Path], *, progress: bool) -> Sweep:
179 """Parse and walk every TU, returning the symbol table and the evidence."""
180 out = Sweep(tu_count=len(tus))
184 max_workers = os.cpu_count()
or 4
185 env_jobs = os.environ.get(
"RA8_MAX_JOBS")
or os.environ.get(
"CMAKE_BUILD_PARALLEL_LEVEL")
186 if env_jobs
and env_jobs.isdigit():
187 max_workers = max(1, int(env_jobs))
189 if len(tus) <= 1
or max_workers <= 1:
192 print(f
" parsing {tu_path.relative_to(repo_root())}", file=sys.stderr)
193 state = _parse_and_walk_one_tu(tu_path)
194 _merge_walk_state(out, state)
198 with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers)
as pool:
199 futures = {pool.submit(_parse_and_walk_one_tu, tu_path): tu_path
for tu_path
in tus}
200 for future
in concurrent.futures.as_completed(futures):
201 tu_path = futures[future]
203 print(f
" parsed {tu_path.relative_to(repo_root())}", file=sys.stderr)
204 state = future.result()
205 _merge_walk_state(out, state)
209def _list_symbols(symbols: dict[str, AnnotatedSymbol]) -> int:
210 """Dump every annotated symbol, one per line."""
212 print(
"(no symbols found)")
214 print(f
"{'symbol':<48} {'file':<60} {'annotations'}")
215 for s
in sorted(symbols.values(), key=
lambda x: (x.file, x.line)):
216 if not s.annotations:
218 print(f
"{s.name:<48} {relative(s.file):<60} {','.join(s.annotations)}")
222def _collect_violations(
224 loopbound_files: list[pathlib.Path],
227 naming_contract: bool,
229 """Run the self-checks and the rules appropriate to the scan's scope."""
230 violations = check_rule_keys()
235 "check_annotations: explicit file list -- parse-integrity and the "
236 "linkage rule need the whole tree and are skipped. Run without "
237 "arguments for the gate.\n"
239 violations.extend(enforce_rules(sweep, whole_tree=
False, naming_contract=
False))
241 violations.extend(check_parse_integrity(sweep.stats, sweep.tu_count))
242 violations.extend(enforce_rules(sweep, naming_contract=naming_contract))
247 violations.extend(enforce_loop_bounds(loopbound_files, require_nonempty=
not partial))
251def _report(violations: list[Violation], summary: str, *, quiet: bool, json_output: bool) -> int:
252 """Print the findings and return the process exit code."""
258 "fatal_count": sum(
not violation.warn_only
for violation
in violations),
259 "informational_count": sum(violation.warn_only
for violation
in violations),
262 "severity":
"informational" if violation.warn_only
else "fatal",
263 "rule": violation.rule,
264 "file": relative(violation.file),
265 "line": violation.line,
266 "message": violation.message,
268 for violation
in violations
275 return 1
if any(
not violation.warn_only
for violation
in violations)
else 0
278 print(f
"check_annotations: 0 violations across {summary}")
281 fatal = [v
for v
in violations
if not v.warn_only]
282 informational = [v
for v
in violations
if v.warn_only]
285 tag =
"INFO" if v.warn_only
else "FAIL"
286 sys.stderr.write(f
"[{tag}] {relative(v.file)}:{v.line}: [{v.rule}] {v.message}\n")
289 f
"check_annotations: {len(fatal)} fatal, {len(informational)} informational "
290 f
"across {summary}\n"
292 return 1
if fatal
else 0
295def main(argv: list[str]) -> int:
296 """Run the annotation gate, or one of its three inspection modes.
298 Naming paths on the command line deliberately DOWNGRADES the run rather
299 than narrowing it: parse-integrity and the linkage rule are both
300 whole-tree properties, so with an explicit file list they are skipped and
301 a warning goes to stderr. That mode is for iterating on one file. The gate
302 proper must be invoked with no paths, or it reports a clean tree having
303 parsed a fraction of it -- the exact failure this checker exists to catch
306 Returns 0 when nothing fatal was found; a warn-only violation is printed
307 as INFO and still exits 0, so only a real rule breach fails the build.
309 args = _parse_args(argv)
312 return run_selftest()
314 partial = bool(args.paths)
315 if partial
and args.naming_audit:
316 ap_error =
"--naming-audit is a whole-tree contract and cannot be combined with paths"
317 sys.stderr.write(f
"check_annotations: {ap_error}\n")
321 pathlib.Path(p).resolve()
323 if pathlib.Path(p).suffix
in SOURCE_SUFFIXES
327 loopbound_files = [pathlib.Path(p).resolve()
for p
in args.paths]
329 tus = discover_translation_units()
330 loopbound_files = discover_loopbound_files()
332 sweep = _sweep(tus, progress=
not args.quiet
and not args.check)
335 return _list_symbols(sweep.symbols)
337 violations = _collect_violations(
341 naming_contract=args.naming_audit,
347 json_output=args.json,
351if __name__ ==
"__main__":
352 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.