ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_init_order_freshness.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"""check_init_order_freshness.py -- gate docs/INIT_ORDER_AUDIT.md against a regenerate.
5
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).
14
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.
20
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.
27"""
28
29from __future__ import annotations
30
31import difflib
32import os
33import subprocess
34import sys
35import tempfile
36from pathlib import Path
37
38sys.path.insert(0, str(Path(__file__).resolve().parent))
39sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
40
41import audit_init_order
42from git_environment import isolated_git_environment, trusted_git_executable
43from selftest_assert import expect, report
44
45REPO_ROOT = Path(__file__).resolve().parents[2]
46ARTEFACT = "docs/INIT_ORDER_AUDIT.md"
47REMEDIATION = (
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."
50)
51GENERATOR = "scripts/checks/audit_init_order.py"
52
53# How much of the unified diff the verdict prints. Enough to name the drifting
54# apps without turning a wholesale regeneration into a wall of log.
55MAX_DIFF_LINES = 24
56
57
58def regenerate(repo_root: Path) -> bytes:
59 """Return the report ``audit_init_order`` would write for ``repo_root``.
60
61 Built from the generator's own public helpers rather than by shelling out,
62 so the gate and the generator cannot render differently.
63
64 Args:
65 repo_root: Repository root whose ``examples/`` tree is audited.
66
67 Returns:
68 The rendered Markdown report as ASCII bytes.
69 """
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")
73
74
75def tracked_candidate(repo_root: Path, rel: str) -> bytes | None:
76 """Return tracked candidate bytes for ``rel``, or None when unavailable.
77
78 Args:
79 repo_root: Repository whose candidate is read.
80 rel: Repo-relative POSIX path of the tracked artefact.
81
82 Returns:
83 Candidate bytes, or None when the path is untracked or absent.
84 """
85 result = subprocess.run( # noqa: S603 -- fixed argv, no shell
86 [trusted_git_executable(), "-C", str(repo_root), "ls-files", "--error-unmatch", "--", rel],
87 capture_output=True,
88 check=False,
89 )
90 path = repo_root / rel
91 if result.returncode != 0 or not path.is_file():
92 return None
93 return path.read_bytes()
94
95
96def diff_excerpt(committed: bytes, fresh: bytes) -> str:
97 """Return a bounded unified diff from the committed copy to the regenerate.
98
99 Args:
100 committed: Bytes committed at ``HEAD``.
101 fresh: Bytes the generator just produced.
102
103 Returns:
104 At most ``MAX_DIFF_LINES`` lines of unified diff, with a trailing count
105 of whatever was suppressed.
106 """
107 lines = list(
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)",
113 lineterm="",
114 )
115 )
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)
120
121
122def drift(committed: bytes | None, fresh: bytes) -> str | None:
123 """Describe how ``committed`` differs from ``fresh``, or None when identical.
124
125 This is the verdict the gate turns into its exit code; the selftest drives
126 it in both directions.
127
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.
136
137 Args:
138 committed: Bytes committed at ``HEAD``, or None when untracked.
139 fresh: Bytes the generator just produced.
140
141 Returns:
142 A drift description, or None when the two are byte-identical.
143 """
144 if committed is None:
145 return f"candidate copy is not tracked or readable; cannot verify freshness. {REMEDIATION}"
146 if committed == fresh:
147 return None
148 return (
149 f"stale: candidate {len(committed)} bytes differ from the "
150 f"{len(fresh)}-byte regenerate. {REMEDIATION}\n"
151 f"{diff_excerpt(committed, fresh)}"
152 )
153
154
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.
157
158 Args:
159 repo_root: Repository root passed through to the generator.
160 env_overrides: Environment entries layered over the current environment.
161
162 Returns:
163 The bytes the CLI wrote to its ``--report`` path.
164
165 Raises:
166 RuntimeError: When the CLI wrote no report at all.
167 """
168 env = dict(os.environ)
169 env.update(env_overrides)
170 with tempfile.TemporaryDirectory() as tmp:
171 out = Path(tmp) / "INIT_ORDER_AUDIT.md"
172 subprocess.run( # noqa: S603 -- fixed argv, no shell
173 ["/usr/bin/python3", "-I", str(repo_root / GENERATOR), "--report", str(out)],
174 cwd=str(repo_root),
175 env=env,
176 capture_output=True,
177 check=False,
178 )
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()
183
184
185def check(repo_root: Path = REPO_ROOT) -> int:
186 """Fail when the tracked candidate report differs from a fresh regenerate.
187
188 Args:
189 repo_root: Repository to audit and whose candidate copy is compared.
190
191 Returns:
192 0 when the committed copy is byte-identical to the regenerate, 1
193 otherwise.
194 """
195 fresh = regenerate(repo_root)
196 verdict = drift(tracked_candidate(repo_root, ARTEFACT), fresh)
197 if verdict is None:
198 print(f"check_init_order_freshness.py: clean -- {ARTEFACT} matches a fresh regenerate.")
199 return 0
200 sys.stderr.write(f"check_init_order_freshness.py: {ARTEFACT}: {verdict}\n")
201 return 1
202
203
204def selftest_generator_stability(fresh: bytes, failures: list[str]) -> None:
205 """Assert the generator is non-vacuous and stable across environments.
206
207 Args:
208 fresh: The bytes ``regenerate`` just produced for ``REPO_ROOT``.
209 failures: Accumulator every ``expect`` here appends its misses to.
210 """
211 # Non-vacuity floor: the real generator must see the whole app tree, not a
212 # collapsed glob. audit_init_order owns the floor; reuse it so a re-capped
213 # discovery fails here instead of reporting a clean, tiny report.
214 app_count = len(audit_init_order.collect_apps(REPO_ROOT))
215 expect(
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})",
218 failures,
219 )
220 expect(regenerate(REPO_ROOT) == fresh, "the generator is byte-deterministic", failures)
221
222 # ...and deterministic ACROSS environments, not merely within one process.
223 # Repeating regenerate() in the same interpreter cannot see a locale-
224 # dependent collation, a hash-seed-dependent iteration order or anything
225 # else the environment fixes once at start-up, so on its own it licenses a
226 # "cross-machine nondeterminism" theory it can never refute. Two CLI runs
227 # under deliberately different settings can. The CLI is also what
228 # `just docs::audit_init` invokes, so this pins the second half of
229 # the contract too: the bytes the gate DEMANDS are the bytes the documented
230 # remediation PRODUCES. Nothing proved that before -- regenerate() builds
231 # the report from the generator's helpers, and a divergence there would
232 # have left the gate asking for a file no command in the tree could write.
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"})
235 expect(
236 cli_c == cli_utf8,
237 "the generator's CLI renders identically under two locales and hash seeds",
238 failures,
239 )
240 expect(
241 cli_c == fresh,
242 "the CLI's report is byte-identical to the bytes this gate demands",
243 failures,
244 )
245
246
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:
250 root = Path(raw_tmp)
251 (root / "docs").mkdir()
252 candidate = root / ARTEFACT
253 candidate.write_bytes(b"before\n")
254 subprocess.run( # noqa: S603 -- fixed Git authority and fixture-only argv
255 [trusted_git_executable(), "init", "-q"], cwd=root, check=True
256 )
257 subprocess.run( # noqa: S603 -- fixed tracked artefact from this module
258 [trusted_git_executable(), "add", ARTEFACT], cwd=root, check=True
259 )
260 expect(
261 tracked_candidate(root, ARTEFACT) == b"before\n",
262 "a tracked candidate is readable before commit",
263 failures,
264 )
265 candidate.write_bytes(b"after\n")
266 expect(
267 tracked_candidate(root, ARTEFACT) == b"after\n",
268 "the checker reads candidate bytes instead of stale HEAD bytes",
269 failures,
270 )
271 expect(
272 tracked_candidate(root, "docs/untracked.md") is None,
273 "an untracked candidate is rejected",
274 failures,
275 )
276
277
278def selftest_verdict_directions(fresh: bytes, failures: list[str]) -> None:
279 """Assert the drift verdict fires, stays quiet, and localises what drifted.
280
281 Args:
282 fresh: The bytes ``regenerate`` just produced for ``REPO_ROOT``.
283 failures: Accumulator every ``expect`` here appends its misses to.
284 """
285 # Both directions of the verdict the gate turns into its exit code.
286 expect(
287 drift(fresh, fresh) is None,
288 "a committed copy equal to the regenerate is clean",
289 failures,
290 )
291 expect(
292 drift(fresh + b"tampered\n", fresh) is not None,
293 "a drifted committed copy is reported",
294 failures,
295 )
296 expect(drift(None, fresh) is not None, "an untracked committed copy is reported", failures)
297
298 # The drift that actually happens here is SIZE-IDENTICAL -- an init call
299 # sliding from L402 to L401 when an unrelated commit deletes a header line.
300 # Assert the verdict both FIRES on it and LOCALISES it; a message that only
301 # reports two equal byte counts is what sent three green-up attempts after
302 # a nondeterminism that was never there.
303 same_size = fresh.replace(b"(rank 100)", b"(rank 101)", 1)
304 expect(
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",
307 failures,
308 )
309 size_verdict = drift(same_size, fresh)
310 expect(size_verdict is not None, "a size-identical drift is still reported", failures)
311 expect(
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",
314 failures,
315 )
316
317
318def selftest() -> int:
319 """Prove the verdict fires on drift, stays quiet on a match, and is non-vacuous.
320
321 Returns:
322 0 when every assertion held in both directions, 1 otherwise.
323 """
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)
330
331
332def main() -> int:
333 """Dispatch to the freshness check or its selftest.
334
335 Returns:
336 The exit code of whichever mode ran.
337 """
338 if "--selftest" in sys.argv[1:]:
339 return selftest()
340 return check()
341
342
343if __name__ == "__main__":
344 sys.exit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298