ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_annotations.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""check_annotations.py -- libclang-based annotation enforcement.
5
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.
13
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.
18
19Two self-checks run before any rule does, because both failure modes
20look exactly like success:
21
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.
30
31Module layout
32-------------
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:
36
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
47
48Usage::
49
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
56"""
57
58from __future__ import annotations
59
60import argparse
61import concurrent.futures
62import json
63import os
64import pathlib
65import sys
66from dataclasses import dataclass
67
68sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
69
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 (
76 SOURCE_SUFFIXES,
77 discover_translation_units,
78 relative,
79 repo_root,
80)
81from annot_selftest import run_selftest
82from annot_walk import walk_tu
83
84
85def _parse_args(argv: list[str]) -> argparse.Namespace:
86 """Parse the command line."""
87 ap = argparse.ArgumentParser(description=__doc__)
88 ap.add_argument(
89 "--check", action="store_true", help="exit non-zero on any (non-informational) violation"
90 )
91 ap.add_argument("--list", action="store_true", help="dump every annotated symbol and exit")
92 ap.add_argument(
93 "--naming-audit",
94 action="store_true",
95 help="include the full s_/internal_/priv_ naming contract (whole tree only)",
96 )
97 ap.add_argument(
98 "--json", action="store_true", help="emit the findings and parse summary as JSON"
99 )
100 ap.add_argument("--quiet", action="store_true", help="suppress per-TU progress output")
101 ap.add_argument(
102 "--selftest",
103 action="store_true",
104 help="run the checker's own regression tests on synthetic TUs and exit",
105 )
106 ap.add_argument("paths", nargs="*", help="optional explicit file list (else scan everything)")
107 return ap.parse_args(argv)
108
109
110@dataclass
111class Sweep(WalkState):
112 """Everything one pass over the tree produced.
113
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.
117 """
118
119 tu_count: int = 0
120
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
125 return (
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"
130 )
131
132
133def _parse_and_walk_one_tu(tu_path: pathlib.Path) -> WalkState:
134 state = WalkState()
135 tu = parse_tu(tu_path, state.stats)
136 if tu is not None:
137 try:
138 walk_tu(tu, state)
139 except Exception as exc: # noqa: BLE001 -- TU boundary reports parser/library failures
140 sys.stderr.write(f" WARN: walk failed for {tu_path}: {exc}\n")
141 return state
142
143
144def _merge_walk_state(dst: Sweep, src: WalkState) -> None:
145 for k, sym in src.symbols.items():
146 if k not in dst.symbols:
147 dst.symbols[k] = sym
148 else:
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
156 )
157 if sym.return_type:
158 existing.return_type = sym.return_type
159 existing.has_pointer_param = existing.has_pointer_param or sym.has_pointer_param
160 if sym.is_defined:
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
167 if sym.section:
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)
176
177
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))
181 if not tus:
182 return out
183
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))
188
189 if len(tus) <= 1 or max_workers <= 1:
190 for tu_path in tus:
191 if progress:
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)
195 # Pre-initialize compiler args and resource directory once in parent process
196 tu_args(tus[0])
197
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]
202 if progress:
203 print(f" parsed {tu_path.relative_to(repo_root())}", file=sys.stderr)
204 state = future.result()
205 _merge_walk_state(out, state)
206 return out
207
208
209def _list_symbols(symbols: dict[str, AnnotatedSymbol]) -> int:
210 """Dump every annotated symbol, one per line."""
211 if not symbols:
212 print("(no symbols found)")
213 return 0
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:
217 continue
218 print(f"{s.name:<48} {relative(s.file):<60} {','.join(s.annotations)}")
219 return 0
220
221
222def _collect_violations(
223 sweep: Sweep,
224 loopbound_files: list[pathlib.Path],
225 *,
226 partial: bool,
227 naming_contract: bool,
228) -> list[Violation]:
229 """Run the self-checks and the rules appropriate to the scan's scope."""
230 violations = check_rule_keys()
231 # An explicit file list parses a fraction of the tree on purpose, so
232 # the whole-tree evidence checks cannot say anything about it.
233 if partial:
234 sys.stderr.write(
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"
238 )
239 violations.extend(enforce_rules(sweep, whole_tree=False, naming_contract=False))
240 else:
241 violations.extend(check_parse_integrity(sweep.stats, sweep.tu_count))
242 violations.extend(enforce_rules(sweep, naming_contract=naming_contract))
243 # The loop-bound marker scan is textual and per-file, so it stands on its
244 # own regardless of the parse. It runs in both modes; only the whole-tree
245 # gate insists the scan actually saw files (an empty glob there is a broken
246 # gate, not a clean tree).
247 violations.extend(enforce_loop_bounds(loopbound_files, require_nonempty=not partial))
248 return violations
249
250
251def _report(violations: list[Violation], summary: str, *, quiet: bool, json_output: bool) -> int:
252 """Print the findings and return the process exit code."""
253 if json_output:
254 print(
255 json.dumps(
256 {
257 "summary": summary,
258 "fatal_count": sum(not violation.warn_only for violation in violations),
259 "informational_count": sum(violation.warn_only for violation in violations),
260 "findings": [
261 {
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,
267 }
268 for violation in violations
269 ],
270 },
271 indent=2,
272 sort_keys=True,
273 )
274 )
275 return 1 if any(not violation.warn_only for violation in violations) else 0
276 if not violations:
277 if not quiet:
278 print(f"check_annotations: 0 violations across {summary}")
279 return 0
280
281 fatal = [v for v in violations if not v.warn_only]
282 informational = [v for v in violations if v.warn_only]
283
284 for v in violations:
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")
287
288 sys.stderr.write(
289 f"check_annotations: {len(fatal)} fatal, {len(informational)} informational "
290 f"across {summary}\n"
291 )
292 return 1 if fatal else 0
293
294
295def main(argv: list[str]) -> int:
296 """Run the annotation gate, or one of its three inspection modes.
297
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
304 in other tools.
305
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.
308 """
309 args = _parse_args(argv)
310
311 if args.selftest:
312 return run_selftest()
313
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")
318 return 2
319 if partial:
320 tus = [
321 pathlib.Path(p).resolve()
322 for p in args.paths
323 if pathlib.Path(p).suffix in SOURCE_SUFFIXES
324 ]
325 # The loop-bound scan also covers headers, so it takes the raw list
326 # (it self-filters by suffix), not the .c/.cpp-only translation units.
327 loopbound_files = [pathlib.Path(p).resolve() for p in args.paths]
328 else:
329 tus = discover_translation_units()
330 loopbound_files = discover_loopbound_files()
331
332 sweep = _sweep(tus, progress=not args.quiet and not args.check)
333
334 if args.list:
335 return _list_symbols(sweep.symbols)
336
337 violations = _collect_violations(
338 sweep,
339 loopbound_files,
340 partial=partial,
341 naming_contract=args.naming_audit,
342 )
343 return _report(
344 violations,
345 sweep.summary(),
346 quiet=args.quiet,
347 json_output=args.json,
348 )
349
350
351if __name__ == "__main__":
352 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298