4"""Enforce the ``--selftest`` requirement that nothing used to enforce.
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.
15WHAT THE RULE IS, AND WHY IT IS NARROWED
16----------------------------------------
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:
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.
25 **Rule B (detectors).** Every script under ``scripts/checks/`` -- plus the
26 ``scripts/ci/check_*.py`` meta-checkers -- that a gate body invokes must
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.
39THE BACKLOG IS RETIRED, NOT WAIVED
40----------------------------------
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.
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
54Exit 0 if clean, 1 on a violation, 2 when the scan itself collapsed.
57from __future__
import annotations
64from collections.abc
import Callable
65from pathlib
import Path
67REPO_ROOT = Path(__file__).resolve().parents[2]
68GATE_DIR = REPO_ROOT /
"scripts" /
"ci" /
"gates"
69BASELINE_FILE = REPO_ROOT /
".github" /
"selftest-baseline.txt"
74SCRIPT_TOKEN_RE = re.compile(
r"(?:^|.*/)(scripts/[\w./-]+\.(?:py|sh))$")
75SHELL_CONTROL = frozenset({
";",
"&&",
"||",
"|",
"&",
"(",
")"})
78SELFTEST_ARGS = frozenset({
"--selftest",
"--selftest-offline",
"selftest"})
82DETECTOR_DIRS = (
"scripts/checks/",)
84DETECTOR_META_RE = re.compile(
r"^scripts/ci/check_[\w.]+\.py$")
96def is_detector(rel: str) -> bool:
97 """Report whether `rel` is a detector owing a selftest under Rule B.
100 rel: Repo-relative script path.
103 True for ``scripts/checks/*`` and the ``scripts/ci/check_*.py`` meta
104 checkers, False for builders, reports, generators and bench drivers.
106 return rel.startswith(DETECTOR_DIRS)
or bool(DETECTOR_META_RE.match(rel))
109def _shell_segments(text: str) -> list[list[str]]:
110 """Tokenize shell source into simple-command segments.
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.
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] = []
128 if token
in SHELL_CONTROL
or (token
and set(token) <= set(
";&|()")):
130 segments.append(current)
133 current.append(token)
135 segments.append(current)
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)
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])))
153def scan_gate_invocations(text: str) -> dict[str, bool]:
154 """Map every first-party script invoked in `text` to whether a selftest ran.
157 text: A gate fragment's source.
160 ``script path -> True`` when at least one invocation of that script in
161 this text carried a selftest.
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
170def _python_has_selftest(text: str) -> bool:
171 """Recognize an actual Python argv branch or argparse declaration."""
173 tree = ast.parse(text)
176 for node
in ast.walk(tree):
177 if isinstance(node, ast.Call):
180 isinstance(function, ast.Attribute)
and function.attr ==
"add_argument"
182 if is_add_argument
and any(
183 isinstance(arg, ast.Constant)
and arg.value ==
"--selftest" for arg
in node.args
186 if isinstance(node, ast.Compare)
and any(
187 isinstance(child, ast.Constant)
and child.value ==
"--selftest"
188 for child
in ast.walk(node)
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
206 if tokens[0]
in SELFTEST_ARGS
and ")" in tokens[1:]:
208 if tokens[0]
in {
"if",
"[",
"[["}
and "--selftest" in tokens[1:]:
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)
222def collect() -> dict[str, bool]:
223 """Gather every gate-invoked script and whether any gate runs its selftest.
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.
234 ``script path -> selftest is invoked somewhere along the gate's reach``.
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
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
245 return expand_helpers(direct, read)
249 direct: dict[str, bool], read_text: Callable[[str], str |
None]
251 """Extend `direct` with the scripts its shell helpers invoke, transitively.
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.
258 The same mapping, plus every script reachable through a ``.sh`` helper.
259 `seen` makes a helper cycle terminate rather than spin.
263 seen: set[str] = set()
266 if rel
in seen
or not rel.endswith(
".sh"):
269 text = read_text(rel)
272 for sub, ran
in scan_gate_invocations(text).items():
273 out[sub] = out.get(sub,
False)
or ran
278def has_selftest(rel: str) -> bool:
279 """Report whether the script at `rel` implements a selftest.
282 rel: Repo-relative script path.
285 True when either selftest spelling appears in the file; False when the
286 file cannot be read (a stale invocation is caught separately).
288 path = REPO_ROOT / rel
289 if not path.is_file():
291 return source_has_selftest(rel, path.read_text(encoding=
"utf-8", errors=
"replace"))
294def evaluate(invoked: dict[str, bool]) -> tuple[list[str], list[str]]:
295 """Split the gate-invoked scripts into Rule A and Rule B offenders.
298 invoked: Output of `collect`.
301 ``(rule_a, rule_b)`` -- scripts with an unrun selftest, and detectors
302 with no selftest at all. Both sorted.
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():
309 if has_selftest(rel):
312 elif is_detector(rel):
314 return rule_a, rule_b
317def _report(rule_a: list[str], rule_b: list[str]) ->
None:
318 """Print every violation with the fix spelled out.
321 rule_a: Scripts whose selftest no gate runs.
322 rule_b: Gate-wired detectors missing a selftest.
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"
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"
341def run_check() -> int:
342 """Apply Rule A and Rule B with no remaining baseline authority.
345 0 clean, 1 on a violation, 2 when the scan collapsed.
348 if len(invoked) < MIN_INVOKED:
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"
356 rule_a, rule_b = evaluate(invoked)
357 retired_baseline_present = BASELINE_FILE.is_file()
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:
364 " .github/selftest-baseline.txt: the debt reached zero, so the "
365 "retired baseline must be deleted.\n\n"
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
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."
379def run_list() -> int:
380 """Print every gate-invoked script with its selftest status.
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)}")
394def _selftest_cases() -> list[tuple[str, str, bool]]:
395 """Return ``(label, gate fragment text, must_fire)`` fixtures.
397 Both directions are covered deliberately: a meta-checker that only ever
398 sees compliant gate bodies cannot tell "compliant" from "stopped matching".
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",
411 "a detector whose selftest the gate SKIPS",
412 "gate_x() (\n set -e\n python3 scripts/checks/check_asm.py\n)\n",
416 "the subcommand selftest spelling counts as running one",
417 "gate_x() (\n set -e\n bash scripts/ci/monitor.sh selftest\n)\n",
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",
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",
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",
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",
442 "quoted prose naming a detector is not an invocation",
443 'gate_x() (\n echo "scripts/checks/check_asm.py --selftest"\n)\n',
449def _rule_a_cases() -> list[tuple[str, bool]]:
450 """Rule A over the fixture gate bodies: it must fire on an unrun selftest.
453 ``(label, held)`` per fixture, the label carrying which direction the
454 fixture asserts so a failure names it.
456 out: list[tuple[str, bool]] = []
457 for label, text, must_fire
in _selftest_cases():
458 invoked = scan_gate_invocations(text)
460 has_selftest(rel)
and not ran
461 for rel, ran
in invoked.items()
462 if (REPO_ROOT / rel).is_file()
464 expectation =
"must fire" if must_fire
else "must stay quiet"
465 out.append((f
"{label} ({expectation})", fired == must_fire))
469def _helper_walk_cases() -> list[tuple[str, bool]]:
470 """The helper walk, driven off a fixture filesystem.
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
477 ``(label, held)`` per case.
479 helper =
"scripts/checks/helper.sh"
480 detector =
"scripts/checks/check_thing.py"
481 other =
"scripts/checks/other.sh"
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"}
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)
492 "a detector reached through a gate helper is seen",
493 detector
in reached_quiet,
496 "its selftest going unrun through that helper is reported",
497 reached_quiet.get(detector)
is False,
500 "its selftest being run through that helper counts",
501 reached_loud.get(detector)
is True,
503 (
"a helper cycle terminates", other
in reached_cycle),
507def _taxonomy_cases() -> list[tuple[str, bool]]:
508 """Which directories count as detectors, and both selftest spellings.
511 ``(label, held)`` per case.
514 (
"scripts/checks/ is classified as a detector", is_detector(
"scripts/checks/check_asm.py")),
516 "scripts/ci/check_*.py is classified as a detector",
517 is_detector(
"scripts/ci/check_ci_parity.py"),
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")),
526def _implementation_cases() -> list[tuple[str, bool]]:
527 """Prove implementation credit requires executable dispatch syntax."""
528 python_probe =
"scripts/checks/probe.py"
529 shell_probe =
"scripts/checks/probe.sh"
532 "Python argparse selftest declaration is implemented",
535 'parser.add_argument("--selftest", action="store_true")\n',
539 "Python argv comparison is implemented",
542 'if "--selftest" in argv[1:]:\n run_selftest()\n',
546 "Python docstring token alone is not implemented",
547 not source_has_selftest(
549 '"""Run this checker with --selftest."""\n',
553 "shell case arm is implemented",
556 'case "$1" in\n --selftest) run_selftest ;;\nesac\n',
560 "shell echo token alone is not implemented",
561 not source_has_selftest(
563 'echo "probe.sh --selftest: PASS"\n',
567 "shell comment token alone is not implemented",
568 not source_has_selftest(
570 "# --selftest) run_selftest ;;\n",
576def _live_scan_cases() -> list[tuple[str, bool]]:
577 """The two properties that can only be asserted against the real tree.
580 ``(label, held)`` for the non-vacuity floor, zero debt, and retired
584 _, rule_b = evaluate(live)
587 f
"live scan sees {len(live)} gate-invoked script(s) (floor {MIN_INVOKED})",
588 len(live) >= MIN_INVOKED,
591 "the retired baseline is absent instead of being recreated",
592 not BASELINE_FILE.is_file(),
594 (
"the live tree carries zero detector selftest debt",
not rule_b),
598def _report_cases(cases: list[tuple[str, bool]]) -> int:
599 """Print one line per case; return how many did not hold.
602 cases: ``(label, held)`` pairs.
605 The number of cases that failed.
608 for label, ok
in cases:
609 failures += 0
if ok
else 1
610 print(f
" [{'ok' if ok else 'FAIL'}] {label}")
614def selftest() -> int:
615 """Prove Rule A fires on an unrun selftest and spares a run one.
618 0 when every case holds, 1 otherwise.
624 _implementation_cases,
627 failures = sum(_report_cases(family())
for family
in families)
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).")
636 """Parse arguments and dispatch.
639 A process exit status.
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()
647 if not GATE_DIR.is_dir():
649 f
"check_selftest_coverage.py: FATAL -- {GATE_DIR} does not exist; the gate "
650 "bodies moved and this checker is scanning nothing.\n"
658 parser.error(
"one of --check / --list / --selftest is required")
662if __name__ ==
"__main__":
663 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.