4"""cite_ratchet.py -- HUM citation-COVERAGE ratchet (vs committed baseline).
8`CLAUDE.md`, `docs/STYLE_GUIDE.md` and `docs/CITATION_POLICY.md` all state the
9same MANDATORY rule: every register read/write or access must carry an external
10Hardware User's Manual citation immediately above it. The detector for that rule
11exists -- `cite_check.py --require-cites` -- and it ran in NO gate (#534).
13Both call sites (the `cite-check` gate and `scripts/git/pre-commit`) invoked
14`cite_check.py --strict`, which is the cite-VALIDATION pass: it checks that
15citations which ALREADY EXIST parse and point at a real chapter and page. An
16MMIO write with no citation at all is invisible to it. So the headline half of
17the policy -- does an access HAVE a cite? -- was enforced nowhere, and a
18reviewer running exactly the command `CLAUDE.md` prescribes would approve an
19entirely uncited new driver.
21Turning `--require-cites --strict` on wholesale was not available: the tree
22carries a measured backlog of 2884 uncited accesses across 254 files. Those
23need ACCURATE per-register HUM subsection and page citations, which cannot be
24machine-fabricated -- a wrong subsection would pass validation while being
25factually false, which is worse than no citation. Two bad options were rejected:
27* leave the coverage pass out of every gate -- the status quo #534 exists to
28 end, and the reason the backlog's size was unknown until now;
29* exclude the directories carrying most of it -- which converts a measured
30 debt into a permanent blind spot, the "gate that silently does nothing"
31 pattern this tree keeps finding.
33So the coverage pass is IN the gate, every finding is counted, and this ratchet
34holds the line. It is the same shape as `tidy_ratchet.py` and
35`mcdc_compound_ratchet.py`, which this tree already uses for exactly this
38* NEW findings (any per-file count above the baseline, or any file absent from
39 the baseline that now has findings) FAIL.
40* Shrinkage PASSES with a notice to re-baseline, which locks the progress in so
41 the debt can never quietly grow back.
42* Reformatting, renaming a local, or moving an existing uncited access changes
43 no count, so it passes -- a ratchet, not a cliff.
45Closing the debt means the baseline reaching zero rows and being DELETED -- not
46being regenerated larger. `--update` refuses to grow a bucket for exactly that
47reason; a genuine increase has to be justified by a human editing the file,
48which leaves a reviewable diff.
50WHAT COUNTS AS AN ACCESS
51------------------------
52The measurement is `cite_check.find_uncited_accesses`, imported rather than
53re-parsed from console output. There is therefore exactly one definition of
54"this access lacks a citation", and no text seam between detector and gate that
55could silently stop matching.
57Note that `®->FIELD` -- taking the address of a register field -- counts.
58That is deliberate and is asserted in `cite_check.py --selftest`. In this tree
59address-of is overwhelmingly a register handed to a register-agnostic poll
60helper (`ra8_hw_wait_flag_set32(®->CFDGSTS, ...)`), an aliasing volatile
61pointer that is then read and written, or a DMA/DTC descriptor field naming the
62register the engine will write. In every one of those the load or store happens
63somewhere that does NOT name the register, so the address-of site is the only
64reviewable place a citation can live.
66BASELINE NORMALISATION -- per-file COUNTS, not raw finding lines.
67Raw findings carry line numbers, which churn on every unrelated edit above them,
68and a snippet of source, which embeds identifiers. A `file -> count` map is
69invariant under both, still trips the moment a file gains another uncited
70access, and stays small and diffable. Per-file rather than per-function because
71an uncited access is a property of a source location, not of a decision: many
72sit in register-map headers and in table-driven initialisation blocks that have
73no enclosing function at all.
76 python3 scripts/checks/cite_ratchet.py --selftest # assert it fires
77 python3 scripts/checks/cite_ratchet.py --check # the CI gate
78 python3 scripts/checks/cite_ratchet.py --update # re-baseline
79 python3 scripts/checks/cite_ratchet.py --list # burn-down list
81Copyright (c) 2026 Brighton Sikarskie
82SPDX-License-Identifier: MIT
85from __future__
import annotations
91from collections
import Counter
93sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
95from cite_check
import SOURCE_SUFFIXES, iter_source_files, scan_access_coverage
96from lint_targets
import first_party_paths
98REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
99BASELINE_FILE = REPO_ROOT /
".github" /
"cite-baseline.txt"
102"""Cap on offending buckets echoed before the report truncates."""
105"""Column count of one baseline row: file, count."""
107MIN_SCANNED_FILES = 1700
108"""Refuse to ratchet a scan that saw implausibly few source files.
110`cite_check`'s derived first-party C scope is 2638 files today (libs 947,
111tests 784, examples 458, tools 211, apps 143, port 94). A scan that finds a
112fraction of that is not looking at this repository -- a partial checkout, a
113`git ls-files` that came back short, an enumeration that stopped covering a
114top-level directory. It would report FEWER uncited accesses, which reads as a
115burn-down, and `--update` would freeze that as the accepted state. 1700 leaves
116room for genuine deletion while catching the loss of any whole top-level tree.
119MIN_ACCESS_LINES = 3000
120"""...and refuse a scan whose DETECTOR stopped matching register accesses.
122The file floor above cannot catch this: the right 2120 files get opened and
123`ACCESS_RE` simply matches nothing, so the backlog collapses to zero and
124`--check` passes cleanly forever. That is this repository's dominant defect
125class, so the count of MMIO access lines the detector found -- cited or not --
126is measured alongside the findings and floored too. The tree carries 3971 of
127them today; 3000 clears real churn while a detector that broke drops to
132def scan_files(files: list[pathlib.Path], root: pathlib.Path) -> tuple[Counter[str], int, int]:
133 """Measure uncited accesses over an explicit file list.
136 files: Source files to scan. Read as UTF-8 with replacement, matching
137 `cite_check.check_file`.
138 root: Directory the returned keys are made relative to.
141 A ``(counts, scanned_file_count, access_line_count)`` triple. ``counts``
142 maps a root-relative path to the number of uncited accesses in it. The
143 two scalars accompany it so the caller can refuse a scan whose scope
144 never got established -- a count is only trustworthy once the thing
145 that produced it is known to have looked at the tree AND to still be
146 matching register accesses at all.
148 counts: Counter[str] = Counter()
151 text = path.read_text(encoding=
"utf-8", errors=
"replace")
152 findings, seen = scan_access_coverage(path, text)
155 counts[str(path.relative_to(root))] = len(findings)
156 return counts, len(files), access_lines
159def tree_files() -> list[pathlib.Path]:
160 """Every first-party C file `cite_check` scans, as absolute paths.
163 The same derived scope `cite_check.main` uses with no path arguments,
164 so the ratchet and the detector can never disagree about what is in
167 targets = [REPO_ROOT / rel
for rel
in first_party_paths(SOURCE_SUFFIXES)]
168 return list(iter_source_files(targets))
171def scan_tree() -> tuple[Counter[str], int, int]:
172 """Run `scan_files` over the whole repository.
175 The ``(counts, scanned_file_count, access_line_count)`` triple for this
176 repository, keyed on repo-relative paths.
178 return scan_files(tree_files(), REPO_ROOT)
181def load_baseline() -> Counter[str]:
182 """Read the committed baseline into a Counter.
185 A ``file -> count`` Counter. A missing baseline file means an empty
186 one, which is the end state this ratchet is driving toward.
188 counts: Counter[str] = Counter()
189 if not BASELINE_FILE.is_file():
191 for raw
in BASELINE_FILE.read_text(encoding=
"ascii").splitlines():
193 if not line
or line.startswith(
"#"):
195 parts = line.split(
"\t")
196 if len(parts) != BASELINE_COLUMNS:
198 counts[parts[0]] = int(parts[1])
202def write_baseline(counts: Counter[str]) ->
None:
203 """Write `counts` out in the committed, sorted, diffable form.
206 counts: The ``file -> count`` map to freeze. Zero-valued buckets are
207 dropped so a burned-down file leaves the file entirely.
209 total = sum(counts.values())
211 "# HUM citation-COVERAGE ratchet baseline -- per-file uncited-access counts.",
212 "# Consumed by scripts/checks/cite_ratchet.py --check (CI gate: cite-check).",
214 "# Each row is a source file holding N direct MMIO register accesses with NO",
215 '# `/* HUM Ch X.Y "..." p NNNN */` citation above them. CLAUDE.md, the style',
216 "# guide and docs/CITATION_POLICY.md all call that citation MANDATORY, but the",
217 "# detector for it (cite_check.py --require-cites) ran in no gate at all, so",
218 "# the rule was aspirational and the debt went unmeasured (#534).",
220 f
"# Total at this baseline: {total} uncited access(es)",
221 f
"# across {len(counts)} file(s).",
223 "# The gate fails on any INCREASE, so the debt is frozen and can only be",
224 "# burned down; a newly-added uncited access raises a count and fails.",
226 "# Burn one down: read the register in the Hardware User's Manual",
227 "# (docs/reference/ra8d2-hardware-user-manual.pdf), then put the real chapter,",
228 "# subsection and page above the access:",
230 '# /* HUM Ch 25.2.3 "AGT Control Register" p 1194 */',
231 "# reg->AGTCR = k_ra8_agt_start;",
233 "# One citation covers the contiguous block of accesses beneath it. Do NOT",
234 "# guess a subsection: a wrong one passes cite_check --strict while being",
235 "# factually false, which is worse than the missing citation it replaced.",
237 "# Regenerate after burning findings down:",
238 "# python3 scripts/checks/cite_ratchet.py --update",
240 "# MOVING a file retires its row and creates a new one, which reads as growth",
241 "# -- so the gate fails and --update refuses, by design. Rename the row here",
242 "# BY HAND, keeping the count identical. That is a one-line reviewable diff,",
243 "# and it is deliberately not automated: rename detection that guessed wrong",
244 "# would silently absorb a genuinely new uncited access.",
246 "# Closing this out means this file reaching zero rows and being",
247 "# DELETED -- never regenerated larger.",
251 for path, n
in sorted(counts.items()):
253 lines.append(f
"{path}\t{n}")
254 BASELINE_FILE.write_text(
"\n".join(lines) +
"\n", encoding=
"ascii")
257def scope_reason(files: int, access_lines: int) -> str |
None:
258 """Return a refusal reason when the scan's scope is not credible, else None.
260 Guards BOTH directions of the ratchet: a scan that examined a fragment of
261 the tree, or whose detector stopped recognising register accesses, produces
262 a number that must not be compared against the baseline and must certainly
263 never be written as one.
266 files: How many source files the scan actually opened.
267 access_lines: How many MMIO access lines it recognised, cited or not.
270 A human-readable reason to refuse, or None when both floors are met.
272 if files < MIN_SCANNED_FILES:
274 f
"only {files} first-party C file(s) scanned, "
275 f
"below the {MIN_SCANNED_FILES} floor.\n"
276 " The scan is not looking at this repository (a partial checkout,\n"
277 " or a git ls-files enumeration that came back short). A partial\n"
278 " scan reports FEWER uncited accesses, which reads as a burn-down;\n"
279 " refusing it is the only way that cannot be mistaken for progress."
281 if access_lines < MIN_ACCESS_LINES:
283 f
"only {access_lines} MMIO access line(s) recognised, "
284 f
"below the {MIN_ACCESS_LINES} floor.\n"
285 " The right files were opened and the DETECTOR matched almost\n"
286 " nothing in them, so every count is meaningless in both\n"
287 " directions. What changed is cite_check.ACCESS_RE, not the code.\n"
288 " Run `python3 scripts/checks/cite_check.py --selftest` first."
293def report(current: Counter[str], baseline: Counter[str]) -> int:
294 """Compare a scan against the baseline and print the verdict.
297 current: Freshly measured ``file -> count`` map.
298 baseline: The committed ``file -> count`` map.
301 0 when every count is at or below the baseline, 1 when one grew.
304 for path, n
in sorted(current.items()):
305 was = baseline.get(path, 0)
307 grown.append((path, was, n))
309 total_now = sum(current.values())
310 total_was = sum(baseline.values())
313 print(file=sys.stderr)
314 print(
"cite ratchet: NEW uncited MMIO accesses above the baseline", file=sys.stderr)
315 print(file=sys.stderr)
316 for path, was, now
in grown[:MAX_DETAIL_LINES]:
317 print(f
" {path}: {was} -> {now}", file=sys.stderr)
318 if len(grown) > MAX_DETAIL_LINES:
319 print(f
" ... and {len(grown) - MAX_DETAIL_LINES} more file(s)", file=sys.stderr)
320 print(file=sys.stderr)
322 "Every direct register read/write or access needs a Hardware User's\n"
323 "Manual citation immediately above it:\n"
325 ' /* HUM Ch 25.2.3 "AGT Control Register" p 1194 */\n'
326 " reg->AGTCR = k_ra8_agt_start;\n"
328 "One citation covers the contiguous block of accesses beneath it. See\n"
329 "the offending lines with:\n"
330 " python3 scripts/checks/cite_check.py --require-cites <file>\n"
332 "The baseline is a burn-down of debt that predates enforcement, not a\n"
333 "place to record new debt. Do not --update to make this pass.",
338 print(f
"cite ratchet: {total_now} uncited access(es), baseline {total_was} -- no growth.")
339 if total_now < total_was:
340 burned = total_was - total_now
341 print(f
" {burned} access(es) burned down. Re-baseline to lock it in:")
342 print(
" python3 scripts/checks/cite_ratchet.py --update")
354 volatile r_agt_regs_t* reg = agt0();
363 volatile r_agt_regs_t* reg = agt0();
364 /* HUM Ch 25.2.3 "AGT Control Register" p 1194 */
371_ST_UNCITED_C_GROWN =
"""\
374 volatile r_agt_regs_t* reg = agt0();
382"""Files the selftest fixture plants: one uncited, one cited."""
385"""Uncited accesses in the fixture after the growth edit."""
388def _st_write(root: pathlib.Path, rel: str, body: str) -> pathlib.Path:
389 """Write `body` to `rel` under `root`, creating parent directories.
392 root: Fixture tree root.
393 rel: Path relative to `root`.
394 body: ASCII file contents.
397 The absolute path written.
400 dst.parent.mkdir(parents=
True, exist_ok=
True)
401 dst.write_text(body, encoding=
"ascii")
405def _selftest_scan(tmp: pathlib.Path) -> list[str]:
406 """Assert the REAL measurement counts the right accesses and no others.
409 tmp: A throwaway directory to build the fixture tree in.
412 A list of failure descriptions; empty when every assertion held.
414 failures: list[str] = []
416 uncited = _st_write(root,
"libs/uncited.c", _ST_UNCITED_C)
417 cited = _st_write(root,
"libs/cited.c", _ST_CITED_C)
419 counts, files, access_lines = scan_files([uncited, cited], root)
420 if counts.get(
"libs/uncited.c") != 1:
421 failures.append(
"scan_files() did not count the uncited MMIO write")
422 if "libs/cited.c" in counts:
423 failures.append(
"scan_files() counted an access that carries a HUM citation")
424 if files != _ST_FIXTURE_FILES:
425 failures.append(f
"scan_files() reported {files} scanned file(s), expected 2")
426 if access_lines != _ST_FIXTURE_FILES:
427 failures.append(f
"scan_files() saw {access_lines} access line(s), expected 2")
430 _st_write(root,
"libs/uncited.c", _ST_UNCITED_C_GROWN)
431 grown, _files, _lines = scan_files([uncited, cited], root)
432 if grown.get(
"libs/uncited.c") != _ST_GROWN_COUNT:
433 failures.append(
"scan_files() did not see a second uncited access added to a known file")
437def _selftest_ratchet() -> list[str]:
438 """Assert the growth verdict and the baseline round-trip, both directions.
441 A list of failure descriptions; empty when every assertion held.
443 failures: list[str] = []
445 base: Counter[str] = Counter({
"libs/f.c": 1})
447 if report(Counter({
"libs/f.c": 2}), base) == 0:
448 failures.append(
"report() passed a file whose count GREW above the baseline")
450 if report(Counter({
"libs/new.c": 1}), base) == 0:
451 failures.append(
"report() passed a finding in a file absent from the baseline")
453 if report(Counter({
"libs/f.c": 1}), base) != 0:
454 failures.append(
"report() failed an unchanged bucket")
455 if report(Counter(), base) != 0:
456 failures.append(
"report() failed a fully burned-down baseline")
460 original = BASELINE_FILE.read_text(encoding=
"ascii")
if BASELINE_FILE.is_file()
else None
462 fixture: Counter[str] = Counter({
"libs/a.c": 3,
"tests/test_b.c": 1})
463 write_baseline(fixture)
464 if load_baseline() != fixture:
465 failures.append(
"write_baseline()/load_baseline() did not round-trip")
468 BASELINE_FILE.unlink(missing_ok=
True)
470 BASELINE_FILE.write_text(original, encoding=
"ascii")
475def _selftest_scope_guard() -> list[str]:
476 """Assert the scope guards refuse an implausible scan, in both directions.
479 A list of failure descriptions; empty when every assertion held.
481 failures: list[str] = []
482 if scope_reason(MIN_SCANNED_FILES - 1, MIN_ACCESS_LINES)
is None:
483 failures.append(
"scope_reason() accepted a scan that saw too few source files")
484 if scope_reason(MIN_SCANNED_FILES, MIN_ACCESS_LINES - 1)
is None:
485 failures.append(
"scope_reason() accepted a scan whose detector matched almost nothing")
486 if scope_reason(MIN_SCANNED_FILES, MIN_ACCESS_LINES)
is not None:
487 failures.append(
"scope_reason() rejected a scan that is exactly at both floors")
491def selftest() -> int:
492 """Assert the measurement and the ratchet fire, in BOTH directions.
494 Runs the REAL `find_uncited_accesses` against a throwaway fixture tree, so
495 a detector that quietly stopped matching cannot pass as clean: it must
496 COUNT the uncited write, must NOT count the cited one, and must see a
497 second uncited access appear in a file it already knew about.
500 0 when every assertion held, 1 otherwise.
502 with tempfile.TemporaryDirectory()
as td:
503 failures = _selftest_scan(pathlib.Path(td))
504 failures += _selftest_ratchet() + _selftest_scope_guard()
507 print(
"SELFTEST FAILED:", file=sys.stderr)
508 for problem
in failures:
509 print(f
" - {problem}", file=sys.stderr)
512 "selftest: HUM citation-coverage ratchet OK "
513 "(counts an uncited access; ignores a cited one; fails on growth and on "
514 "an unseen file, passes on equal and on shrinkage; refuses an "
515 "implausible scope)."
525def _list_backlog() -> int:
526 """Print every uncited access, worst file first, for burn-down work.
529 0 always -- listing is a report, not a verdict.
531 counts, _files, _lines = scan_tree()
532 for path, n
in sorted(counts.items(), key=
lambda kv: (-kv[1], kv[0])):
533 print(f
"{n:5d} {path}")
534 print(f
"total: {sum(counts.values())} uncited access(es) across {len(counts)} file(s)")
538def _do_update(current: Counter[str], files: int) -> int:
539 """Rewrite the baseline, refusing to grow any existing bucket.
542 current: The freshly measured ``file -> count`` map.
543 files: How many source files the scan opened, for the summary line.
546 0 when the baseline was written, 1 when the update was refused.
548 baseline = load_baseline()
551 seeding =
not BASELINE_FILE.is_file()
552 grew = []
if seeding
else [p
for p, n
in current.items()
if n > baseline.get(p, 0)]
555 f
"refusing to --update: {len(grew)} file(s) would GROW. "
556 "The baseline is a burn-down; write the missing HUM citations instead.",
559 for path
in grew[:MAX_DETAIL_LINES]:
560 print(f
" {path}", file=sys.stderr)
562 write_baseline(current)
564 f
"baseline updated: {sum(current.values())} uncited access(es) recorded "
565 f
"across {len(current)} file(s) ({files} file(s) scanned)."
571 """Ratchet uncited MMIO accesses against the committed baseline.
573 A ratchet, not a floor: ``--check`` fails when a count RISES, and
574 ``--update`` lowers the baseline once citations are written. The baseline
575 can therefore only move downward, which is what stops a large legacy count
576 from being permanently accepted while still letting CI block a new one
580 0 when every count is at or below the baseline, 1 when one grew or the
581 scan's scope was not credible.
583 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
584 parser.add_argument(
"--check", action=
"store_true", help=
"gate against the baseline")
585 parser.add_argument(
"--update", action=
"store_true", help=
"rewrite the baseline")
586 parser.add_argument(
"--list", action=
"store_true", help=
"print the whole backlog")
587 parser.add_argument(
"--selftest", action=
"store_true", help=
"assert this gate still fires")
588 args = parser.parse_args()
593 return _list_backlog()
594 if not (args.check
or args.update):
595 parser.error(
"one of --check / --update / --list / --selftest is required")
597 current, files, access_lines = scan_tree()
601 broken = scope_reason(files, access_lines)
603 verb =
"--update" if args.update
else "--check"
604 print(f
"refusing to {verb}: {broken}", file=sys.stderr)
608 return _do_update(current, files)
610 print(f
"cite ratchet: scanned {files} file(s), {access_lines} MMIO access line(s).")
611 return report(current, load_baseline())
614if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.