4"""cite_check.py -- validate Hardware User's Manual citations in source.
6This script scans C / header files for in-line annotations of the form
8 /* HUM Ch X.Y "section name", p NNNN */
10and runs one or both of two complementary passes:
12 Cite-VALIDATION (always on) -- for every cite that already exists,
14 1. The chapter number X exists in docs/reference/CHAPTER_MAP.md.
15 2. The page number NNNN falls inside chapter X's page range.
16 3. The comment is well-formed (a `HUM Ch` comment that does not
17 parse is reported as malformed).
19 Cite-COVERAGE (--require-cites) -- the complementary headline rule
20 from CLAUDE.md: every direct MMIO register read/write must be
21 immediately preceded by a `/* HUM ... */` cite. Flags accesses that
22 have none. Modelled block-aware (one cite covers the contiguous block
23 of accesses beneath it).
27 --warn (default) -- exit 0, print findings to stderr.
28 --strict (onward) -- exit 1 on any finding.
29 --require-cites -- also run the cite-COVERAGE pass (advisory unless
30 combined with --strict).
32The script also accepts a list of explicit file arguments. With no
33arguments it scans every first-party C file, derived from git ls-files
34via lint_targets (#358) -- so tools/ra8_emulator (which models RA8
35registers and cites the RA8 HUM) and port/usbx were previously omitted
36and their citations went unvalidated. Vendored C under port/threadx and both
37canonical third-party roots is dropped automatically.
39The chapter map is parsed from CHAPTER_MAP.md so the page-range
40truth lives in exactly one place. The pre-commit hook invokes this
41script after build_chapter_map.sh has been run; nothing here calls
44Citation format details:
46 /* HUM Ch X.Y "..." p NNNN */ single-page form
47 /* HUM Ch X.Y "..." p NNNN-MMMM */ page range form
49The X chapter number is required; subsection Y is optional in the
50parser (some module-stop-only writes only carry the chapter). The
51section-name string is enforced as present so a future linter pass
52can grep for human-readable context.
54The script is intentionally conservative: an unparseable comment
55that *looks* like a HUM cite (starts with `HUM Ch`) is reported as
56malformed even if it would otherwise pass the page-in-range check.
59from __future__
import annotations
66from collections.abc
import Iterable
68sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
70from lint_targets
import first_party_paths
71from selftest_assert
import expect, report
73REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
74CHAPTER_MAP_PATH = REPO_ROOT /
"docs" /
"reference" /
"CHAPTER_MAP.md"
76SOURCE_SUFFIXES = (
".c",
".h",
".cpp",
".hpp")
81 (?P<chapter>\d{1,2}) # chapter number
82 (?:\.(?P<sub>\d{1,3}(?:\.\d{1,3})*))? # optional subsection X.Y[.Z...]
84 "(?P<section>[^"]*)" # quoted section name
86 (?:(?:Table|Figure)\s+\d{1,3}(?:\.\d{1,3})*\s*)? # optional Table/Figure ref
88 (?P<start>\d{1,5}) # start page
89 (?:\s*-\s*(?P<end>\d{1,5}))? # optional end page
90 (?:\s*--[^*]*)? # optional trailing "-- note" clause before the close
96LOOSE_HUM_RE = re.compile(
r"/\*\s*HUM\s+Ch\b[^*]*\*/")
102def parse_chapter_map(path: pathlib.Path) -> dict[int, tuple[int, int, str]]:
103 """Parse CHAPTER_MAP.md into {chapter: (start_page, end_page, title)}.
105 The map's table rows look like:
107 | 1 | Overview | 69 | 110 |
109 Lines that don't match the row pattern are ignored.
111 if not path.exists():
112 msg = f
"chapter map missing: {path}"
113 raise FileNotFoundError(msg)
116 r"^\|\s*(\d{1,2})\s*\|\s*([^|]+?)\s*\|\s*(\d{1,5})\s*\|\s*(\d{1,5})\s*\|\s*$"
119 chapters: dict[int, tuple[int, int, str]] = {}
120 for line
in path.read_text(encoding=
"utf-8").splitlines():
121 m = row_re.match(line)
124 num = int(m.group(1))
125 title = m.group(2).strip()
126 start = int(m.group(3))
127 end = int(m.group(4))
128 if not (1 <= num <= MAX_HUM_CHAPTER):
130 chapters[num] = (start, end, title)
134def iter_source_files(targets: Iterable[pathlib.Path]) -> Iterable[pathlib.Path]:
135 """Yield every C/H source file under any of the given targets."""
140 if t.suffix.lower()
in SOURCE_SUFFIXES:
143 for sub
in t.rglob(
"*"):
144 if not sub.is_file():
146 if sub.suffix.lower()
not in SOURCE_SUFFIXES:
150 parts = set(sub.parts)
151 if "third_party" in parts
or "build" in parts:
182ACCESS_RE = re.compile(
r"(?:\)|[A-Za-z_]\w*)\s*->\s*[A-Z][A-Z0-9_]+\b")
184RAW_DEREF_RE = re.compile(
r"\*\s*\(\s*volatile\b")
188HUM_ON_LINE_RE = re.compile(
r"\bHUM\b")
191MAX_CITE_LOOKUP_LINES = 40
195 """Character state machine blanking comment and literal interiors.
197 One method per state. The machine is the whole algorithm, so it is
198 expressed as states rather than as one loop with a state variable and
199 four branches -- each method's contract is "consume from i, return the
200 next state and index", which is checkable a state at a time.
203 def __init__(self, text: str) ->
None:
204 """Prepare a mutable blanking buffer over ``text``.
206 ``out`` starts as a character-for-character copy so blanking preserves
207 every offset -- line and column numbers reported against the blanked
208 view stay valid for the original.
211 self.out = list(text)
214 def _peek(self, i: int) -> str:
215 """The character after ``i``, or "" at end of text.
217 Returning "" rather than raising lets every two-character token test
218 (``//``, ``/*``, ``*/``) run unguarded at the last position.
220 return self.text[i + 1]
if i + 1 < self.n
else ""
222 def _blank(self, i: int, count: int = 1) ->
None:
223 """Overwrite ``count`` characters with spaces, clamped to the end."""
224 for k
in range(i,
min(i + count, self.n)):
227 def _code(self, i: int, c: str) -> tuple[str, int]:
228 """Step the scanner through ordinary code, returning ``(next_state, index)``.
230 Code itself is left intact; this only recognises where a comment or
231 literal begins and hands control to the matching state.
234 if c ==
"/" and nxt ==
"/":
236 return "line_comment", i + 2
237 if c ==
"/" and nxt ==
"*":
239 return "block_comment", i + 2
241 return "string", i + 1
246 def _line_comment(self, i: int, c: str) -> tuple[str, int]:
247 """Blank a ``//`` comment, ending at the newline, which is preserved."""
251 return "line_comment", i + 1
253 def _block_comment(self, i: int, c: str) -> tuple[str, int]:
254 """Blank a ``/* */`` comment, preserving its interior newlines.
256 Keeping the newlines is what holds line numbers stable across a
259 if c ==
"*" and self._peek(i) ==
"/":
264 return "block_comment", i + 1
266 def _literal(self, i: int, c: str, state: str) -> tuple[str, int]:
267 """Blank a string or char literal, honouring backslash escapes.
269 An escaped closer must not end the literal, and a line continuation
270 inside one must not be mistaken for its end -- both are why this
271 consumes the escaped character rather than only the backslash.
273 closer =
'"' if state ==
"string" else "'"
276 if i + 1 < self.n
and self.text[i + 1] !=
"\n":
285 def run(self) -> str:
286 """Drive the machine over the whole text and return the blanked copy."""
291 state, i = self._code(i, c)
292 elif state ==
"line_comment":
293 state, i = self._line_comment(i, c)
294 elif state ==
"block_comment":
295 state, i = self._block_comment(i, c)
297 state, i = self._literal(i, c, state)
298 return "".join(self.out)
301def blank_comments_and_strings(text: str) -> str:
302 """Return `text` with comment and string interiors replaced by spaces.
304 Newlines and total length are preserved so per-line indexing still lines
305 up with the original. Blanking prose keeps a comment like
306 ``channel-index -> MSTP id`` or a log string like ``"SCR->CTL"`` from
307 being misread as a register access.
309 return _Blanker(text).run()
312def is_access_line(blanked_line: str) -> bool:
313 """True if a code (comment/string-blanked) line performs an MMIO access."""
314 return bool(ACCESS_RE.search(blanked_line))
or bool(RAW_DEREF_RE.search(blanked_line))
317def scan_access_coverage(path: pathlib.Path, text: str) -> tuple[list[str], int]:
318 """Return uncited-access findings AND the total access-line count, in one pass.
320 `cite_ratchet.py` needs both numbers for every file: the findings are the
321 debt it ratchets, and the total is its detector-health floor -- the uncited
322 count alone cannot distinguish "the tree got cited" from "ACCESS_RE stopped
323 matching", and the second reads as a burn-down. Blanking is the expensive
324 step (a per-character state machine over every source file), so both come
325 out of one blanking pass rather than two.
328 path: File the findings are reported against.
329 text: Raw source text.
332 A ``(findings, access_line_count)`` pair. The count includes CITED
333 accesses; only the findings are uncited.
335 orig = text.splitlines()
336 blanked = blank_comments_and_strings(text).splitlines()
338 _uncited_in_lines(path, orig, blanked),
339 sum(1
for line
in blanked
if is_access_line(line)),
343def _is_block_continuation(blanked_line: str) -> bool:
344 """True if a line is part of the same register-access block while walking up.
346 Blank lines, and statement fragments split across lines (a trailing binary
347 operator, or a leading continuation token), are transparent: one cite above
348 a block still covers accesses whose value expression wraps onto its own
351 s = blanked_line.strip()
354 if s.endswith((
"=",
"|",
"&",
"+",
"-",
"*",
",",
"(",
"<<",
">>",
"?",
":")):
356 return s[0]
in ".|&)+*?:"
359def _uncited_in_lines(path: pathlib.Path, orig: list[str], blanked: list[str]) -> list[str]:
360 """Findings for the already-blanked view of one file.
362 The single definition of "this access lacks a citation". Both
363 `find_uncited_accesses` and `scan_access_coverage` delegate here so a
364 second, drifting copy of the block-walk cannot appear.
367 path: File the findings are reported against.
368 orig: Original source lines -- citations are read from these, since
369 blanking erases comment interiors.
370 blanked: The comment/string-blanked view, line for line with `orig`.
373 One finding string per uncited access, in line order.
375 findings: list[str] = []
376 for i, bline
in enumerate(blanked):
377 if not is_access_line(bline):
380 if HUM_ON_LINE_RE.search(orig[i]):
385 while j >= 0
and steps < MAX_CITE_LOOKUP_LINES:
387 if blanked[j].strip() ==
"" and HUM_ON_LINE_RE.search(orig[j]):
390 if is_access_line(blanked[j])
or _is_block_continuation(blanked[j]):
397 f
"{path}:{i + 1}: MMIO access without preceding HUM cite: {orig[i].strip()[:70]}"
402def find_uncited_accesses(path: pathlib.Path, text: str) -> list[str]:
403 """Return findings for MMIO accesses lacking a preceding HUM cite.
405 Models the tree convention that one `/* HUM ... */` comment covers the
406 contiguous block of register accesses directly beneath it: an access is
407 covered when walking upward -- past blank lines, comment-only lines, other
408 accesses in the same block, and statement continuations -- reaches a HUM
409 cite before any other statement.
412 path: File the findings are reported against.
413 text: Raw source text.
416 One finding string per uncited access, in line order.
418 return _uncited_in_lines(path, text.splitlines(), blank_comments_and_strings(text).splitlines())
423 chapters: dict[int, tuple[int, int, str]],
425 require_cites: bool =
False,
427 """Return a list of finding strings for one file."""
428 findings: list[str] = []
430 text = path.read_text(encoding=
"utf-8", errors=
"replace")
431 except OSError
as exc:
432 return [f
"{path}: read error: {exc}"]
434 parsed_spans: list[tuple[int, int]] = []
435 for m
in CITE_RE.finditer(text):
436 parsed_spans.append(m.span())
437 chapter = int(m.group(
"chapter"))
438 start = int(m.group(
"start"))
439 end_raw = m.group(
"end")
440 end = int(end_raw)
if end_raw
else start
441 section = m.group(
"section")
443 line_no = text.count(
"\n", 0, m.start()) + 1
445 if chapter
not in chapters:
446 findings.append(f
"{path}:{line_no}: HUM Ch {chapter} not in chapter map")
448 ch_start, ch_end, ch_title = chapters[chapter]
450 if start < ch_start
or end > ch_end:
452 f
'{path}:{line_no}: HUM Ch {chapter} "{section}" '
453 f
"page range {start}-{end} outside chapter range "
454 f
"{ch_start}-{ch_end} ({ch_title})"
459 findings.append(f
"{path}:{line_no}: HUM Ch {chapter} reversed page range {start}-{end}")
465 for lm
in LOOSE_HUM_RE.finditer(text):
466 if any(s <= lm.start() < e
for s, e
in parsed_spans):
468 line_no = text.count(
"\n", 0, lm.start()) + 1
470 f
"{path}:{line_no}: malformed HUM cite "
471 f
'{lm.group(0)!r} -- expected /* HUM Ch X.Y "..." p NNNN */'
475 findings.extend(find_uncited_accesses(path, text))
480def _build_parser() -> argparse.ArgumentParser:
481 """Build the command-line parser for this gate."""
482 parser = argparse.ArgumentParser(description=__doc__)
486 help=
"files or directories to scan (default: every tracked first-party C file)",
491 help=
"warn-only mode: print findings, exit 0 (default)",
496 help=
"strict mode: exit 1 on any finding (onward)",
502 "also flag MMIO register accesses that lack a preceding HUM cite "
503 "(the citation-COVERAGE pass, complementary to the default "
504 "cite-VALIDATION pass). Advisory unless combined with --strict."
510 help=
"prove the validator fires on a malformed cite and the scope holds",
514 default=str(CHAPTER_MAP_PATH),
515 help=f
"path to CHAPTER_MAP.md (default: {CHAPTER_MAP_PATH})",
521 findings: list[str], file_count: int, *, strict: bool, require_cites: bool
523 """Print every finding and a one-line summary naming the mode it ran in.
525 The summary splits the two passes apart when --require-cites is on: an
526 uncited MMIO access and a malformed citation are different defects, and a
527 single total hides which one a reader has to go and fix.
529 for line
in findings:
530 print(line, file=sys.stderr)
531 verdict =
"strict" if strict
else "warn"
532 total = len(findings)
533 head = f
"cite_check.py: {total} finding(s) across {file_count} file(s) [{verdict}]"
535 uncited = sum(1
for line
in findings
if "MMIO access without preceding HUM cite" in line)
536 head += f
" ({uncited} uncited-access, {total - uncited} cite-validation)"
537 print(head, file=sys.stderr)
549 volatile r_canfd_regs_t* reg = canfd();
558 volatile r_canfd_regs_t* reg = canfd();
559 /* HUM Ch 25 "Ultra-Low-Power Timer" p 1190 */
570ra8_err_t drv_wait(void)
572 volatile r_canfd_regs_t* reg = canfd();
573 return ra8_hw_wait_flag_set32(®->CFDGSTS, mask, spin);
580bool shim_is_cdata(const XMLText* text)
591_ST_SHORT_REG_C =
"""\
594 volatile r_i3c_regs_t* reg = i3c();
600def _selftest_coverage(chapters: dict[int, tuple[int, int, str]], failures: list[str]) ->
None:
601 """Assert the cite-COVERAGE pass fires and stays quiet on the right inputs.
603 Both directions, because a coverage pass that quietly stopped matching
604 reports a shrinking backlog -- which reads as progress.
607 chapters: Parsed chapter map, as returned by ``parse_chapter_map``.
608 failures: Accumulator that ``expect`` appends failed labels to.
610 cases: tuple[tuple[str, str, bool, str], ...] = (
611 (
"uncited.c", _ST_UNCITED_C,
True,
"an uncited MMIO write fires under --require-cites"),
612 (
"cited.c", _ST_CITED_C,
False,
"the same write with a HUM cite above stays quiet"),
613 (
"addrof.c", _ST_ADDR_OF_C,
True,
"®->FIELD passed to a poll helper fires"),
614 (
"shim.cpp", _ST_CAMEL_CPP,
False,
"a C++ `->CData()` member call stays quiet"),
615 (
"short.c", _ST_SHORT_REG_C,
True,
"a two-character register name still fires"),
617 with tempfile.TemporaryDirectory()
as tmp:
618 for name, body, want_finding, label
in cases:
619 path = pathlib.Path(tmp) / name
620 path.write_text(body, encoding=
"utf-8")
621 got = bool(check_file(path, chapters, require_cites=
True))
622 expect(got == want_finding, label, failures)
627 path = pathlib.Path(tmp) /
"uncited.c"
629 not check_file(path, chapters),
630 "the coverage pass stays off unless --require-cites is given",
635def selftest() -> int:
636 """Prove a malformed cite fires, a well-formed one is quiet, and scope holds."""
637 print(
"cite_check.py --selftest")
638 failures: list[str] = []
639 chapters = parse_chapter_map(CHAPTER_MAP_PATH)
640 with tempfile.TemporaryDirectory()
as tmp:
641 bad = pathlib.Path(tmp) /
"bad.c"
642 bad.write_text(
"/* HUM Ch 25 a malformed cite with no page */\n", encoding=
"utf-8")
643 good = pathlib.Path(tmp) /
"good.c"
644 good.write_text(
'/* HUM Ch 25 "Ultra-Low-Power Timer" p 1190 */\n', encoding=
"utf-8")
645 expect(bool(check_file(bad, chapters)),
"a malformed HUM cite fires", failures)
647 not check_file(good, chapters),
648 "a well-formed, in-range cite stays quiet",
651 _selftest_coverage(chapters, failures)
652 scope = set(first_party_paths(SOURCE_SUFFIXES))
654 any(s.startswith(
"tools/")
for s
in scope),
655 "tools/ is in scope (the scan-dir list omitted it before #358)",
660 s.startswith((
"libs/third_party/",
"apps/shared_libs/third_party/"))
for s
in scope
662 "vendored SOUP stays out of scope",
665 return report(failures)
668def main(argv: list[str]) -> int:
669 """Verify every register access carries a Hardware User's Manual citation.
671 The rule exists because a register write with no citation cannot be
672 reviewed: the reader has no way to confirm the bit pattern against the
673 manual, and a wrong one produces hardware that misbehaves rather than
674 code that fails to build.
676 Citations are looked for only in COMMENTS, which is why the source is
677 scanned through the blanking pass above -- a manual section number
678 appearing in a string literal is not a citation.
680 Returns 0 when every access is cited, 1 otherwise.
682 args = _build_parser().parse_args(argv)
687 if args.warn
and args.strict:
688 print(
"cite_check.py: --warn and --strict are mutually exclusive", file=sys.stderr)
692 strict = args.strict
and not args.warn
695 chapters = parse_chapter_map(pathlib.Path(args.chapter_map))
696 except FileNotFoundError
as exc:
697 print(f
"cite_check.py: {exc}", file=sys.stderr)
701 targets = [pathlib.Path(p)
for p
in args.paths]
709 targets = [REPO_ROOT / rel
for rel
in first_party_paths(SOURCE_SUFFIXES)]
711 findings: list[str] = []
713 for f
in iter_source_files(targets):
715 findings.extend(check_file(f, chapters, require_cites=args.require_cites))
718 _report_findings(findings, file_count, strict=strict, require_cites=args.require_cites)
719 return 1
if strict
else 0
722 f
"cite_check.py: 0 findings across {file_count} file(s)",
728if __name__ ==
"__main__":
729 raise SystemExit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.