ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_selftest_coverage.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"""Enforce the ``--selftest`` requirement that nothing used to enforce.
5
6The repo's stated remedy for its dominant defect class -- a detector that has
7quietly stopped matching -- is a ``--selftest`` asserting BOTH directions.
8``scripts/ci.sh`` says so and ``CLAUDE.md`` says so. Nothing checked it, so a
9new checker with no selftest, or one whose gate never ran it, landed clean
10(#531). Two of the checkers that turned out to have no selftest were the two
11that had silently stopped seeing their subject -- ``check_obsolete_standards``
12scanning 0 files and ``audit_init_order`` reaching 11 of 217 apps. That is not
13a coincidence, and it is why this gate exists.
14
15WHAT THE RULE IS, AND WHY IT IS NARROWED
16----------------------------------------
17
18An unenforceable rule in ``CLAUDE.md`` is itself the defect class, so the rule
19is scoped to where it can be both meaningful and true:
20
21 **Rule A (universal).** ANY first-party script a gate body invokes that
22 *has* a selftest must have it RUN by that gate. A selftest nobody executes
23 is documentation. This has no exceptions and no baseline.
24
25 **Rule B (detectors).** Every script under ``scripts/checks/`` -- plus the
26 ``scripts/ci/check_*.py`` meta-checkers -- that a gate body invokes must
27 ACCEPT a selftest.
28
29Rule B is keyed on the taxonomy ``CLAUDE.md`` already documents, in which
30``scripts/`` is organised by the QUESTION a script answers: ``checks/`` is
31"Is the tree OK?" -- read-only, exits non-zero when it is not. Those are the
32detectors, and a detector is exactly the thing that can stop detecting.
33``builders/`` produce a build output, ``report/`` "never fails on content",
34and ``hil/`` drives the bench; demanding a both-directions selftest of
35``scripts/builders/docs.sh`` would be ceremony, and a gate that demands
36ceremony gets disabled. Scoping by the repo's own stated organisation is a
37principle, not an allowlist.
38
39THE BACKLOG IS RETIRED, NOT WAIVED
40----------------------------------
41
42Turning Rule B on found a real backlog, and issue #790 closed every row. The
43former ``.github/selftest-baseline.txt`` must remain absent: a NEW gate-wired
44detector with no selftest fails immediately, and recreating even an empty
45baseline fails too. There is no update mode because a detector regression is
46fixed by restoring its genuine both-direction selftest, never by freezing it.
47
48Run::
49
50 check_selftest_coverage.py # the gate
51 check_selftest_coverage.py --list # what is scanned, and its status
52 check_selftest_coverage.py --selftest # prove both directions
53
54Exit 0 if clean, 1 on a violation, 2 when the scan itself collapsed.
55"""
56
57from __future__ import annotations
58
59import argparse
60import ast
61import re
62import shlex
63import sys
64from collections.abc import Callable
65from pathlib import Path
66
67REPO_ROOT = Path(__file__).resolve().parents[2]
68GATE_DIR = REPO_ROOT / "scripts" / "ci" / "gates"
69BASELINE_FILE = REPO_ROOT / ".github" / "selftest-baseline.txt"
70
71# A first-party script path as one shell argv token. An optional prefix covers
72# the normal ``$REPO_ROOT/scripts/...`` spelling without treating prose in a
73# quoted argument as an invocation.
74SCRIPT_TOKEN_RE = re.compile(r"(?:^|.*/)(scripts/[\w./-]+\.(?:py|sh))$")
75SHELL_CONTROL = frozenset({";", "&&", "||", "|", "&", "(", ")"})
76# The image policy has a genuine runtime-free variant for gates that execute
77# inside the image and therefore cannot safely start a nested container runtime.
78SELFTEST_ARGS = frozenset({"--selftest", "--selftest-offline", "selftest"})
79
80# Directories whose scripts are DETECTORS and therefore owe a selftest under
81# Rule B. Derived from the scripts/ taxonomy documented in CLAUDE.md.
82DETECTOR_DIRS = ("scripts/checks/",)
83# The sibling meta-checkers, which live one directory up from checks/.
84DETECTOR_META_RE = re.compile(r"^scripts/ci/check_[\w.]+\.py$") # PATHREF-OK: a regex, not a path
85
86# A tree with no gate-invoked scripts means the parser stopped matching, not
87# that the gates stopped calling checkers. Refuse to report clean against
88# nothing. Measured 94 gate-invoked first-party scripts on 2026-07-28.
89MIN_INVOKED = 40
90
91EXIT_OK = 0
92EXIT_VIOLATION = 1
93EXIT_VACUOUS = 2
94
95
96def is_detector(rel: str) -> bool:
97 """Report whether `rel` is a detector owing a selftest under Rule B.
98
99 Args:
100 rel: Repo-relative script path.
101
102 Returns:
103 True for ``scripts/checks/*`` and the ``scripts/ci/check_*.py`` meta
104 checkers, False for builders, reports, generators and bench drivers.
105 """
106 return rel.startswith(DETECTOR_DIRS) or bool(DETECTOR_META_RE.match(rel))
107
108
109def _shell_segments(text: str) -> list[list[str]]:
110 """Tokenize shell source into simple-command segments.
111
112 Quoting and comments are handled by :mod:`shlex`; control operators end a
113 segment so a selftest argument on a neighboring command cannot confer
114 credit on a detector that did not receive it.
115 """
116 logical = text.replace("\\\n", " ")
117 segments: list[list[str]] = []
118 for line in logical.splitlines():
119 lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|()")
120 lexer.commenters = "#"
121 lexer.whitespace_split = True
122 current: list[str] = []
123 try:
124 tokens = list(lexer)
125 except ValueError:
126 continue
127 for token in tokens:
128 if token in SHELL_CONTROL or (token and set(token) <= set(";&|()")):
129 if current:
130 segments.append(current)
131 current = []
132 else:
133 current.append(token)
134 if current:
135 segments.append(current)
136 return segments
137
138
139def _segment_invocations(tokens: list[str]) -> list[tuple[str, bool]]:
140 """Return scripts and exact selftest argv association for one command."""
141 scripts: list[tuple[int, str]] = []
142 for index, token in enumerate(tokens):
143 match = SCRIPT_TOKEN_RE.match(token)
144 if match:
145 scripts.append((index, match.group(1)))
146 found: list[tuple[str, bool]] = []
147 for position, (index, rel) in enumerate(scripts):
148 end = scripts[position + 1][0] if position + 1 < len(scripts) else len(tokens)
149 found.append((rel, any(token in SELFTEST_ARGS for token in tokens[index + 1 : end])))
150 return found
151
152
153def scan_gate_invocations(text: str) -> dict[str, bool]:
154 """Map every first-party script invoked in `text` to whether a selftest ran.
155
156 Args:
157 text: A gate fragment's source.
158
159 Returns:
160 ``script path -> True`` when at least one invocation of that script in
161 this text carried a selftest.
162 """
163 found: dict[str, bool] = {}
164 for segment in _shell_segments(text):
165 for rel, ran in _segment_invocations(segment):
166 found[rel] = found.get(rel, False) or ran
167 return found
168
169
170def _python_has_selftest(text: str) -> bool:
171 """Recognize an actual Python argv branch or argparse declaration."""
172 try:
173 tree = ast.parse(text)
174 except SyntaxError:
175 return False
176 for node in ast.walk(tree):
177 if isinstance(node, ast.Call):
178 function = node.func
179 is_add_argument = (
180 isinstance(function, ast.Attribute) and function.attr == "add_argument"
181 )
182 if is_add_argument and any(
183 isinstance(arg, ast.Constant) and arg.value == "--selftest" for arg in node.args
184 ):
185 return True
186 if isinstance(node, ast.Compare) and any(
187 isinstance(child, ast.Constant) and child.value == "--selftest"
188 for child in ast.walk(node)
189 ):
190 return True
191 return False
192
193
194def _shell_has_selftest(text: str) -> bool:
195 """Recognize a shell case arm or argument-test dispatch for selftest."""
196 for line in text.replace("\\\n", " ").splitlines():
197 lexer = shlex.shlex(line, posix=True, punctuation_chars="()")
198 lexer.commenters = "#"
199 lexer.whitespace_split = True
200 try:
201 tokens = list(lexer)
202 except ValueError:
203 continue
204 if not tokens:
205 continue
206 if tokens[0] in SELFTEST_ARGS and ")" in tokens[1:]:
207 return True
208 if tokens[0] in {"if", "[", "[["} and "--selftest" in tokens[1:]:
209 return True
210 return False
211
212
213def source_has_selftest(rel: str, text: str) -> bool:
214 """Recognize an implemented selftest from source syntax, not token prose."""
215 if rel.endswith(".py"):
216 return _python_has_selftest(text)
217 if rel.endswith(".sh"):
218 return _shell_has_selftest(text)
219 return False
220
221
222def collect() -> dict[str, bool]:
223 """Gather every gate-invoked script and whether any gate runs its selftest.
224
225 Gate bodies delegate. ``gate_format`` invokes ``format_code.sh``, and
226 *that* is what drives ``check_comment_format.py`` -- a detector every bit as
227 gate-wired as one named in the fragment itself. Reading only the fragments
228 made such a script invisible: it was neither credited as invoked nor asked
229 for its selftest, so the checker reported clean over a detector no gate ever
230 proved. The walk therefore follows first-party shell helpers to a fixed
231 point, `seen` guarding against a helper cycle.
232
233 Returns:
234 ``script path -> selftest is invoked somewhere along the gate's reach``.
235 """
236 direct: dict[str, bool] = {}
237 for fragment in sorted(GATE_DIR.glob("*.sh")):
238 for rel, ran in scan_gate_invocations(fragment.read_text(encoding="utf-8")).items():
239 direct[rel] = direct.get(rel, False) or ran
240
241 def read(rel: str) -> str | None:
242 path = REPO_ROOT / rel
243 return path.read_text(encoding="utf-8") if path.is_file() else None
244
245 return expand_helpers(direct, read)
246
247
248def expand_helpers(
249 direct: dict[str, bool], read_text: Callable[[str], str | None]
250) -> dict[str, bool]:
251 """Extend `direct` with the scripts its shell helpers invoke, transitively.
252
253 Args:
254 direct: ``script path -> a selftest ran`` as read from the gate bodies.
255 read_text: Returns a script's source, or None when it cannot be read.
256
257 Returns:
258 The same mapping, plus every script reachable through a ``.sh`` helper.
259 `seen` makes a helper cycle terminate rather than spin.
260 """
261 out = dict(direct)
262 queue = list(direct)
263 seen: set[str] = set()
264 while queue:
265 rel = queue.pop()
266 if rel in seen or not rel.endswith(".sh"):
267 continue
268 seen.add(rel)
269 text = read_text(rel)
270 if text is None:
271 continue
272 for sub, ran in scan_gate_invocations(text).items():
273 out[sub] = out.get(sub, False) or ran
274 queue.append(sub)
275 return out
276
277
278def has_selftest(rel: str) -> bool:
279 """Report whether the script at `rel` implements a selftest.
280
281 Args:
282 rel: Repo-relative script path.
283
284 Returns:
285 True when either selftest spelling appears in the file; False when the
286 file cannot be read (a stale invocation is caught separately).
287 """
288 path = REPO_ROOT / rel
289 if not path.is_file():
290 return False
291 return source_has_selftest(rel, path.read_text(encoding="utf-8", errors="replace"))
292
293
294def evaluate(invoked: dict[str, bool]) -> tuple[list[str], list[str]]:
295 """Split the gate-invoked scripts into Rule A and Rule B offenders.
296
297 Args:
298 invoked: Output of `collect`.
299
300 Returns:
301 ``(rule_a, rule_b)`` -- scripts with an unrun selftest, and detectors
302 with no selftest at all. Both sorted.
303 """
304 rule_a: list[str] = []
305 rule_b: list[str] = []
306 for rel, ran in sorted(invoked.items()):
307 if not (REPO_ROOT / rel).is_file():
308 continue
309 if has_selftest(rel):
310 if not ran:
311 rule_a.append(rel)
312 elif is_detector(rel):
313 rule_b.append(rel)
314 return rule_a, rule_b
315
316
317def _report(rule_a: list[str], rule_b: list[str]) -> None:
318 """Print every violation with the fix spelled out.
319
320 Args:
321 rule_a: Scripts whose selftest no gate runs.
322 rule_b: Gate-wired detectors missing a selftest.
323 """
324 for rel in rule_a:
325 sys.stderr.write(
326 f" {rel}: implements a selftest that NO gate body runs.\n"
327 " A selftest nobody executes is documentation. Add it to the gate,\n"
328 f" before the scan: python3 {rel} --selftest\n\n"
329 )
330 for rel in rule_b:
331 sys.stderr.write(
332 f" {rel}: a gate-wired detector with NO --selftest.\n"
333 " A detector that has quietly stopped matching is indistinguishable\n"
334 " from a clean tree. Add a --selftest asserting BOTH directions (a\n"
335 " must-fire case and a must-stay-quiet case) and run it in the gate.\n"
336 " Do NOT recreate .github/selftest-baseline.txt; that debt authority\n"
337 " is retired and must remain absent.\n\n"
338 )
339
340
341def run_check() -> int:
342 """Apply Rule A and Rule B with no remaining baseline authority.
343
344 Returns:
345 0 clean, 1 on a violation, 2 when the scan collapsed.
346 """
347 invoked = collect()
348 if len(invoked) < MIN_INVOKED:
349 sys.stderr.write(
350 f"check_selftest_coverage.py: FATAL -- only {len(invoked)} gate-invoked "
351 f"script(s) found, floor is {MIN_INVOKED}.\n"
352 " A collapsed scan reports full selftest coverage because it saw nothing.\n"
353 )
354 return EXIT_VACUOUS
355
356 rule_a, rule_b = evaluate(invoked)
357 retired_baseline_present = BASELINE_FILE.is_file()
358
359 if rule_a or rule_b or retired_baseline_present:
360 sys.stderr.write("check_selftest_coverage.py: selftest requirement violated\n\n")
361 _report(rule_a, rule_b)
362 if retired_baseline_present:
363 sys.stderr.write(
364 " .github/selftest-baseline.txt: the debt reached zero, so the "
365 "retired baseline must be deleted.\n\n"
366 )
367 total = len(rule_a) + len(rule_b) + int(retired_baseline_present)
368 sys.stderr.write(f"{total} violation(s).\n")
369 return EXIT_VIOLATION
370
371 print(
372 f"check_selftest_coverage.py: clean -- {len(invoked)} gate-invoked script(s); "
373 "every selftest present is run; zero detector selftest debt; "
374 "retirement baseline absent."
375 )
376 return EXIT_OK
377
378
379def run_list() -> int:
380 """Print every gate-invoked script with its selftest status.
381
382 Returns:
383 Always 0.
384 """
385 invoked = collect()
386 for rel, ran in sorted(invoked.items()):
387 impl = has_selftest(rel)
388 kind = "detector" if is_detector(rel) else "other "
389 print(f"{kind} impl={'Y' if impl else 'n'} run={'Y' if ran else 'n'} {rel}")
390 print(f"total: {len(invoked)}")
391 return EXIT_OK
392
393
394def _selftest_cases() -> list[tuple[str, str, bool]]:
395 """Return ``(label, gate fragment text, must_fire)`` fixtures.
396
397 Both directions are covered deliberately: a meta-checker that only ever
398 sees compliant gate bodies cannot tell "compliant" from "stopped matching".
399
400 Returns:
401 The fixture list.
402 """
403 return [
404 (
405 "a detector whose selftest the gate runs (flag form)",
406 "gate_x() (\n set -e\n python3 scripts/checks/check_asm.py --selftest\n"
407 " python3 scripts/checks/check_asm.py\n)\n",
408 False,
409 ),
410 (
411 "a detector whose selftest the gate SKIPS",
412 "gate_x() (\n set -e\n python3 scripts/checks/check_asm.py\n)\n",
413 True,
414 ),
415 (
416 "the subcommand selftest spelling counts as running one",
417 "gate_x() (\n set -e\n bash scripts/ci/monitor.sh selftest\n)\n",
418 False,
419 ),
420 (
421 "the runtime-free selftest spelling counts as running one",
422 "gate_x() (\n set -e\n bash scripts/ci/devcontainer_image.sh --selftest-offline\n)\n",
423 False,
424 ),
425 (
426 "a commented-out invocation is not an invocation",
427 "gate_x() (\n set -e\n # python3 scripts/checks/check_asm.py\n true\n)\n",
428 False,
429 ),
430 (
431 "a selftest word in a later command does not confer credit",
432 "gate_x() (\n python3 scripts/checks/check_asm.py; echo --selftest\n)\n",
433 True,
434 ),
435 (
436 "a neighboring detector's selftest does not confer credit",
437 "gate_x() (\n python3 scripts/checks/check_asm.py && "
438 "python3 scripts/checks/check_c23_headers.py --selftest\n)\n",
439 True,
440 ),
441 (
442 "quoted prose naming a detector is not an invocation",
443 'gate_x() (\n echo "scripts/checks/check_asm.py --selftest"\n)\n',
444 False,
445 ),
446 ]
447
448
449def _rule_a_cases() -> list[tuple[str, bool]]:
450 """Rule A over the fixture gate bodies: it must fire on an unrun selftest.
451
452 Returns:
453 ``(label, held)`` per fixture, the label carrying which direction the
454 fixture asserts so a failure names it.
455 """
456 out: list[tuple[str, bool]] = []
457 for label, text, must_fire in _selftest_cases():
458 invoked = scan_gate_invocations(text)
459 fired = any(
460 has_selftest(rel) and not ran
461 for rel, ran in invoked.items()
462 if (REPO_ROOT / rel).is_file()
463 )
464 expectation = "must fire" if must_fire else "must stay quiet"
465 out.append((f"{label} ({expectation})", fired == must_fire))
466 return out
467
468
469def _helper_walk_cases() -> list[tuple[str, bool]]:
470 """The helper walk, driven off a fixture filesystem.
471
472 Without the walk a detector invoked through a gate's shell helper is
473 invisible in both directions: never credited as gate-wired, never asked for
474 its selftest.
475
476 Returns:
477 ``(label, held)`` per case.
478 """
479 helper = "scripts/checks/helper.sh" # PATHREF-OK: selftest fixture, not a real script
480 detector = "scripts/checks/check_thing.py" # PATHREF-OK: selftest fixture, not a real script
481 other = "scripts/checks/other.sh" # PATHREF-OK: selftest fixture, not a real script
482 quiet_helper = {helper: f"python3 {detector}\n"}
483 loud_helper = {helper: f"python3 {detector} --selftest\npython3 {detector}\n"}
484 cyclic = {helper: f"bash {other}\n", other: f"bash {helper}\n"}
485
486 reached_quiet = expand_helpers({helper: False}, quiet_helper.get)
487 reached_loud = expand_helpers({helper: False}, loud_helper.get)
488 reached_cycle = expand_helpers({helper: False}, cyclic.get)
489
490 return [
491 (
492 "a detector reached through a gate helper is seen",
493 detector in reached_quiet,
494 ),
495 (
496 "its selftest going unrun through that helper is reported",
497 reached_quiet.get(detector) is False,
498 ),
499 (
500 "its selftest being run through that helper counts",
501 reached_loud.get(detector) is True,
502 ),
503 ("a helper cycle terminates", other in reached_cycle),
504 ]
505
506
507def _taxonomy_cases() -> list[tuple[str, bool]]:
508 """Which directories count as detectors, and both selftest spellings.
509
510 Returns:
511 ``(label, held)`` per case.
512 """
513 return [
514 ("scripts/checks/ is classified as a detector", is_detector("scripts/checks/check_asm.py")),
515 (
516 "scripts/ci/check_*.py is classified as a detector",
517 is_detector("scripts/ci/check_ci_parity.py"),
518 ),
519 ("scripts/builders/ is NOT a detector", not is_detector("scripts/builders/docs.sh")),
520 ("scripts/report/ is NOT a detector", not is_detector("scripts/report/roadmap_stats.py")),
521 ("the flag selftest spelling is detected", has_selftest("scripts/checks/check_asm.py")),
522 ("the subcommand selftest spelling is detected", has_selftest("scripts/ci/monitor.sh")),
523 ]
524
525
526def _implementation_cases() -> list[tuple[str, bool]]:
527 """Prove implementation credit requires executable dispatch syntax."""
528 python_probe = "scripts/checks/probe.py" # PATHREF-OK: nonexistent selftest fixture
529 shell_probe = "scripts/checks/probe.sh" # PATHREF-OK: nonexistent selftest fixture
530 return [
531 (
532 "Python argparse selftest declaration is implemented",
533 source_has_selftest(
534 python_probe,
535 'parser.add_argument("--selftest", action="store_true")\n',
536 ),
537 ),
538 (
539 "Python argv comparison is implemented",
540 source_has_selftest(
541 python_probe,
542 'if "--selftest" in argv[1:]:\n run_selftest()\n',
543 ),
544 ),
545 (
546 "Python docstring token alone is not implemented",
547 not source_has_selftest(
548 python_probe,
549 '"""Run this checker with --selftest."""\n',
550 ),
551 ),
552 (
553 "shell case arm is implemented",
554 source_has_selftest(
555 shell_probe,
556 'case "$1" in\n --selftest) run_selftest ;;\nesac\n',
557 ),
558 ),
559 (
560 "shell echo token alone is not implemented",
561 not source_has_selftest(
562 shell_probe,
563 'echo "probe.sh --selftest: PASS"\n',
564 ),
565 ),
566 (
567 "shell comment token alone is not implemented",
568 not source_has_selftest(
569 shell_probe,
570 "# --selftest) run_selftest ;;\n",
571 ),
572 ),
573 ]
574
575
576def _live_scan_cases() -> list[tuple[str, bool]]:
577 """The two properties that can only be asserted against the real tree.
578
579 Returns:
580 ``(label, held)`` for the non-vacuity floor, zero debt, and retired
581 baseline.
582 """
583 live = collect()
584 _, rule_b = evaluate(live)
585 return [
586 (
587 f"live scan sees {len(live)} gate-invoked script(s) (floor {MIN_INVOKED})",
588 len(live) >= MIN_INVOKED,
589 ),
590 (
591 "the retired baseline is absent instead of being recreated",
592 not BASELINE_FILE.is_file(),
593 ),
594 ("the live tree carries zero detector selftest debt", not rule_b),
595 ]
596
597
598def _report_cases(cases: list[tuple[str, bool]]) -> int:
599 """Print one line per case; return how many did not hold.
600
601 Args:
602 cases: ``(label, held)`` pairs.
603
604 Returns:
605 The number of cases that failed.
606 """
607 failures = 0
608 for label, ok in cases:
609 failures += 0 if ok else 1
610 print(f" [{'ok' if ok else 'FAIL'}] {label}")
611 return failures
612
613
614def selftest() -> int:
615 """Prove Rule A fires on an unrun selftest and spares a run one.
616
617 Returns:
618 0 when every case holds, 1 otherwise.
619 """
620 families = (
621 _rule_a_cases,
622 _helper_walk_cases,
623 _taxonomy_cases,
624 _implementation_cases,
625 _live_scan_cases,
626 )
627 failures = sum(_report_cases(family()) for family in families)
628 if failures:
629 sys.stderr.write(f"check_selftest_coverage.py --selftest: {failures} case(s) failed.\n")
630 return EXIT_VIOLATION
631 print("check_selftest_coverage.py --selftest: all cases pass (both directions).")
632 return EXIT_OK
633
634
635def main() -> int:
636 """Parse arguments and dispatch.
637
638 Returns:
639 A process exit status.
640 """
641 parser = argparse.ArgumentParser(description="enforce the --selftest requirement")
642 parser.add_argument("--check", action="store_true", help="apply the rules (the gate mode)")
643 parser.add_argument("--list", action="store_true", help="print the scanned set and status")
644 parser.add_argument("--selftest", action="store_true", help="prove both directions, then exit")
645 args = parser.parse_args()
646
647 if not GATE_DIR.is_dir():
648 sys.stderr.write(
649 f"check_selftest_coverage.py: FATAL -- {GATE_DIR} does not exist; the gate "
650 "bodies moved and this checker is scanning nothing.\n"
651 )
652 return EXIT_VACUOUS
653 if args.selftest:
654 return selftest()
655 if args.list:
656 return run_list()
657 if not args.check:
658 parser.error("one of --check / --list / --selftest is required")
659 return run_check()
660
661
662if __name__ == "__main__":
663 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298