4"""check_init_order_freshness.py -- gate docs/INIT_ORDER_AUDIT.md against a regenerate.
6``docs/INIT_ORDER_AUDIT.md`` is a COMMITTED, GENERATED artefact:
7``just docs::audit_init`` writes it with
8``audit_init_order.py --report docs/INIT_ORDER_AUDIT.md``.
9Nothing re-ran that generator and byte-compared the committed copy, so it
10silently drifted -- it claimed 11 apps audited while the tree held 217, for as
11long as the discovery glob was depth-capped (#190). A generated doc that
12nothing regenerates is a claim with no mechanism behind it, and this one is
13cited from ``docs/qualification/`` (#537).
15This is the same "regenerate and gate" shape ``check_generated_artefacts.py``
16uses for the MC/DC and Doxygen gap docs. That gate is ``slow`` because its
17MC/DC half consumes the ``mcdc`` build output; this generator is hardware-free
18and reads a sorted glob, so it is byte-stable across runs and lives in its own
19``fast`` gate rather than that group.
21The comparison is against the tracked candidate bytes. In CI that is the clean
22checkout; the pre-commit policy runs this checker inside its staged snapshot.
23Comparing to ``HEAD`` would make the remediation unverifiable until after the
24commit it is meant to guard. ``--selftest`` drives the verdict logic in both
25directions and floors the real regenerate at ``audit_init_order``'s app-discovery
26floor, so a collapsed generator fails instead of reporting a clean, empty tree.
29from __future__
import annotations
36from pathlib
import Path
38sys.path.insert(0, str(Path(__file__).resolve().parent))
39sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
41import audit_init_order
42from git_environment
import isolated_git_environment, trusted_git_executable
43from selftest_assert
import expect, report
45REPO_ROOT = Path(__file__).resolve().parents[2]
46ARTEFACT =
"docs/INIT_ORDER_AUDIT.md"
48 "Run `just docs::audit_init` (or `python3 scripts/checks/audit_init_order.py "
49 "--report docs/INIT_ORDER_AUDIT.md`) and commit docs/INIT_ORDER_AUDIT.md."
51GENERATOR =
"scripts/checks/audit_init_order.py"
58def regenerate(repo_root: Path) -> bytes:
59 """Return the report ``audit_init_order`` would write for ``repo_root``.
61 Built from the generator's own public helpers rather than by shelling out,
62 so the gate and the generator cannot render differently.
65 repo_root: Repository root whose ``examples/`` tree is audited.
68 The rendered Markdown report as ASCII bytes.
70 apps = audit_init_order.collect_apps(repo_root)
71 audits = [audit_init_order.audit_app(app, main)
for app, main
in apps]
72 return audit_init_order.render_markdown(audits, repo_root).encode(
"ascii")
75def tracked_candidate(repo_root: Path, rel: str) -> bytes |
None:
76 """Return tracked candidate bytes for ``rel``, or None when unavailable.
79 repo_root: Repository whose candidate is read.
80 rel: Repo-relative POSIX path of the tracked artefact.
83 Candidate bytes, or None when the path is untracked or absent.
85 result = subprocess.run(
86 [trusted_git_executable(),
"-C", str(repo_root),
"ls-files",
"--error-unmatch",
"--", rel],
90 path = repo_root / rel
91 if result.returncode != 0
or not path.is_file():
93 return path.read_bytes()
96def diff_excerpt(committed: bytes, fresh: bytes) -> str:
97 """Return a bounded unified diff from the committed copy to the regenerate.
100 committed: Bytes committed at ``HEAD``.
101 fresh: Bytes the generator just produced.
104 At most ``MAX_DIFF_LINES`` lines of unified diff, with a trailing count
105 of whatever was suppressed.
108 difflib.unified_diff(
109 committed.decode(
"ascii", errors=
"replace").splitlines(),
110 fresh.decode(
"ascii", errors=
"replace").splitlines(),
111 fromfile=f
"{ARTEFACT} (tracked candidate)",
112 tofile=f
"{ARTEFACT} (fresh regenerate)",
116 shown = lines[:MAX_DIFF_LINES]
117 if len(lines) > MAX_DIFF_LINES:
118 shown.append(f
" ... {len(lines) - MAX_DIFF_LINES} further diff line(s) suppressed")
119 return "\n".join(shown)
122def drift(committed: bytes |
None, fresh: bytes) -> str |
None:
123 """Describe how ``committed`` differs from ``fresh``, or None when identical.
125 This is the verdict the gate turns into its exit code; the selftest drives
126 it in both directions.
128 The description carries a real diff, not just the two byte counts. Drift in
129 this report is routinely SIZE-IDENTICAL: deleting one line from an example's
130 file header moves an init call from ``L402`` to ``L401``, which is the same
131 width, so the old wording read "committed 31141 bytes differ from the
132 31141-byte regenerate" and looked like a paradox. Three separate green-up
133 attempts diagnosed that as generator nondeterminism instead of the ordinary
134 staleness it was. A verdict that cannot be acted on is a verdict that gets
135 explained away, so the drift names itself now.
138 committed: Bytes committed at ``HEAD``, or None when untracked.
139 fresh: Bytes the generator just produced.
142 A drift description, or None when the two are byte-identical.
144 if committed
is None:
145 return f
"candidate copy is not tracked or readable; cannot verify freshness. {REMEDIATION}"
146 if committed == fresh:
149 f
"stale: candidate {len(committed)} bytes differ from the "
150 f
"{len(fresh)}-byte regenerate. {REMEDIATION}\n"
151 f
"{diff_excerpt(committed, fresh)}"
155def generate_via_cli(repo_root: Path, env_overrides: dict[str, str]) -> bytes:
156 """Run the generator's own CLI under ``env_overrides`` and return its report.
159 repo_root: Repository root passed through to the generator.
160 env_overrides: Environment entries layered over the current environment.
163 The bytes the CLI wrote to its ``--report`` path.
166 RuntimeError: When the CLI wrote no report at all.
168 env = dict(os.environ)
169 env.update(env_overrides)
170 with tempfile.TemporaryDirectory()
as tmp:
171 out = Path(tmp) /
"INIT_ORDER_AUDIT.md"
173 [
"/usr/bin/python3",
"-I", str(repo_root / GENERATOR),
"--report", str(out)],
179 if not out.is_file():
180 msg = f
"{GENERATOR} wrote no report under {env_overrides}"
181 raise RuntimeError(msg)
182 return out.read_bytes()
185def check(repo_root: Path = REPO_ROOT) -> int:
186 """Fail when the tracked candidate report differs from a fresh regenerate.
189 repo_root: Repository to audit and whose candidate copy is compared.
192 0 when the committed copy is byte-identical to the regenerate, 1
195 fresh = regenerate(repo_root)
196 verdict = drift(tracked_candidate(repo_root, ARTEFACT), fresh)
198 print(f
"check_init_order_freshness.py: clean -- {ARTEFACT} matches a fresh regenerate.")
200 sys.stderr.write(f
"check_init_order_freshness.py: {ARTEFACT}: {verdict}\n")
204def selftest_generator_stability(fresh: bytes, failures: list[str]) ->
None:
205 """Assert the generator is non-vacuous and stable across environments.
208 fresh: The bytes ``regenerate`` just produced for ``REPO_ROOT``.
209 failures: Accumulator every ``expect`` here appends its misses to.
214 app_count = len(audit_init_order.collect_apps(REPO_ROOT))
216 app_count >= audit_init_order.APP_FLOOR,
217 f
"live discovery feeds the report {app_count} app(s) (floor {audit_init_order.APP_FLOOR})",
220 expect(regenerate(REPO_ROOT) == fresh,
"the generator is byte-deterministic", failures)
233 cli_c = generate_via_cli(REPO_ROOT, {
"LC_ALL":
"C",
"PYTHONHASHSEED":
"0"})
234 cli_utf8 = generate_via_cli(REPO_ROOT, {
"LC_ALL":
"C.UTF-8",
"PYTHONHASHSEED":
"1"})
237 "the generator's CLI renders identically under two locales and hash seeds",
242 "the CLI's report is byte-identical to the bytes this gate demands",
247def selftest_candidate_bytes(failures: list[str]) ->
None:
248 """Prove tracked worktree bytes win while untracked paths stay absent."""
249 with isolated_git_environment(), tempfile.TemporaryDirectory()
as raw_tmp:
251 (root /
"docs").mkdir()
252 candidate = root / ARTEFACT
253 candidate.write_bytes(b
"before\n")
255 [trusted_git_executable(),
"init",
"-q"], cwd=root, check=
True
258 [trusted_git_executable(),
"add", ARTEFACT], cwd=root, check=
True
261 tracked_candidate(root, ARTEFACT) == b
"before\n",
262 "a tracked candidate is readable before commit",
265 candidate.write_bytes(b
"after\n")
267 tracked_candidate(root, ARTEFACT) == b
"after\n",
268 "the checker reads candidate bytes instead of stale HEAD bytes",
272 tracked_candidate(root,
"docs/untracked.md")
is None,
273 "an untracked candidate is rejected",
278def selftest_verdict_directions(fresh: bytes, failures: list[str]) ->
None:
279 """Assert the drift verdict fires, stays quiet, and localises what drifted.
282 fresh: The bytes ``regenerate`` just produced for ``REPO_ROOT``.
283 failures: Accumulator every ``expect`` here appends its misses to.
287 drift(fresh, fresh)
is None,
288 "a committed copy equal to the regenerate is clean",
292 drift(fresh + b
"tampered\n", fresh)
is not None,
293 "a drifted committed copy is reported",
296 expect(drift(
None, fresh)
is not None,
"an untracked committed copy is reported", failures)
303 same_size = fresh.replace(b
"(rank 100)", b
"(rank 101)", 1)
305 len(same_size) == len(fresh)
and same_size != fresh,
306 "the mutated copy is the same size as the regenerate but not equal to it",
309 size_verdict = drift(same_size, fresh)
310 expect(size_verdict
is not None,
"a size-identical drift is still reported", failures)
312 size_verdict
is not None and "rank 101" in size_verdict,
313 "the verdict shows the differing line, not just the two byte counts",
318def selftest() -> int:
319 """Prove the verdict fires on drift, stays quiet on a match, and is non-vacuous.
322 0 when every assertion held in both directions, 1 otherwise.
324 failures: list[str] = []
325 fresh = regenerate(REPO_ROOT)
326 selftest_generator_stability(fresh, failures)
327 selftest_verdict_directions(fresh, failures)
328 selftest_candidate_bytes(failures)
329 return report(failures)
333 """Dispatch to the freshness check or its selftest.
336 The exit code of whichever mode ran.
338 if "--selftest" in sys.argv[1:]:
343if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.