ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_generated_artefacts.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"""Gate: versioned generated gap artefacts must match a fresh regenerate.
5
6The MC/DC and Doxygen gap reports under ``docs/`` are GENERATED, yet they are
7also COMMITTED, and nothing compared the committed copy against what the
8generator produces from the current tree. On a DO-178C Level B target these are
9the human-readable record of where the remaining structural-coverage and
10documentation gaps are; a committed copy that has silently drifted describes a
11tree that no longer exists (issue #380).
12
13This is the "regenerate and gate" resolution -- the same shape
14``check_ci_parity.py`` uses on the gate registry. For every versioned generated
15artefact this gate re-runs its generator and byte-compares the result against
16the tracked worktree candidate; any difference fails the gate and names the
17command that refreshes it. Reading the candidate rather than ``HEAD`` makes the
18gate useful before a refresh is committed, while the tracked-file check still
19rejects untracked or deleted evidence. Deleting the versioned copies
20(produce-in-CI-only)
21was the rejected alternative: the DO-178C qualification set
22(``docs/qualification/PSAC.md``, ``SVR.md``, ``SAS.md``, ``SQAP.md`` ...) cites
23these files by path as versioned, retained evidence, so the in-tree history is
24load-bearing and cannot move to an ephemeral CI artefact.
25
26Two artefact families, checked uniformly:
27
28* **mcdc-gaps** -- ``docs/MCDC_GAPS.csv``, ``docs/MCDC_GAPS.md`` and
29 ``docs/MCDC_DEACTIVATIONS.md``, regenerated by
30 ``scripts/fix/regen_mcdc_gaps.py`` from ``build/mcdc-report/mcdc.txt``. That
31 report is the (slow) output of the ``mcdc`` gate, so this gate CONSUMES it and
32 must be scheduled after ``mcdc`` in the same job; if the report is absent it
33 FAILS LOUDLY naming the dependency rather than skipping.
34* **doxygen-gaps** -- ``docs/DOXYGEN_GAPS.csv`` and ``docs/DOXYGEN_GAPS.md``,
35 regenerated by ``scripts/checks/doxy_audit.py`` from a static source parse
36 (no toolchain input required).
37* **driver-status** -- ``docs/DRIVER_STATUS.md``, regenerated by
38 ``scripts/gen/gen_driver_status.py`` from the HAL source inventory. This one
39 used to be hand-maintained, and by the time it was replaced 33 of its 109 rows
40 named a deleted file and it still described a library that had been removed
41 (issue #721). Deriving it is only half the fix; gating the derivation is the
42 other half.
43
44``--selftest`` proves the detector still fires in both directions -- a matching
45regenerate passes, a mismatching one fails, and a missing required input fails
46without running the generator -- so a gate that quietly stopped comparing cannot
47pass as clean.
48"""
49
50from __future__ import annotations
51
52import argparse
53import contextlib
54import io
55import subprocess
56import sys
57import tempfile
58from collections.abc import Callable
59from dataclasses import dataclass
60from pathlib import Path
61from types import ModuleType
62
63sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
64
65from git_environment import isolated_git_environment, trusted_git_executable
66
67REPO_ROOT = Path(__file__).resolve().parents[2]
68SCRIPTS_CHECKS = REPO_ROOT / "scripts" / "checks"
69SCRIPTS_FIX = REPO_ROOT / "scripts" / "fix"
70SCRIPTS_GEN = REPO_ROOT / "scripts" / "gen"
71MCDC_TXT = REPO_ROOT / "build" / "mcdc-report" / "mcdc.txt"
72
73
74@dataclass(frozen=True)
75class ArtefactGroup:
76 """One generator and the versioned artefacts it owns.
77
78 Attributes:
79 name: Short identifier printed in gate output.
80 artefacts: Repo-relative POSIX paths the generator writes and that are
81 versioned in the tree.
82 regenerate: Runs the generator in place and returns its exit code (0 on
83 success); it writes exactly ``artefacts``.
84 requires: Input files that must exist before ``regenerate`` can run. An
85 absent input fails the gate loudly instead of skipping the family.
86 remediation: The command a human runs to refresh the versioned copies.
87 """
88
89 name: str
90 artefacts: tuple[str, ...]
91 regenerate: Callable[[], int]
92 requires: tuple[Path, ...]
93 remediation: str
94
95
96def _read_candidate(root: Path, rel: str) -> bytes | None:
97 """Return tracked worktree bytes for ``rel``; reject untracked/deleted copies."""
98 argv = [
99 "git",
100 "-C",
101 str(root),
102 "ls-files",
103 "--error-unmatch",
104 "--",
105 rel,
106 ]
107 result = subprocess.run( # noqa: S603 -- fixed git argv, no shell
108 argv,
109 capture_output=True,
110 check=False,
111 )
112 if result.returncode != 0:
113 return None
114 path = root / rel
115 return path.read_bytes() if path.is_file() else None
116
117
118def _diff_artefact(candidate: bytes | None, fresh: bytes) -> str | None:
119 """Describe how a tracked candidate differs from a fresh regenerate.
120
121 Args:
122 candidate: Tracked worktree bytes, or None when absent or untracked.
123 fresh: Bytes the generator just produced.
124
125 Returns:
126 A one-line drift description, or None when the two are byte-identical.
127 """
128 if candidate is None:
129 return "candidate copy is absent or untracked; cannot verify retained evidence"
130 if candidate == fresh:
131 return None
132 return f"stale: candidate {len(candidate)} bytes differ from the {len(fresh)}-byte regenerate"
133
134
135def _restore(root: Path, saved: dict[str, bytes | None]) -> None:
136 """Put every artefact back to the bytes captured before regeneration."""
137 for rel, data in saved.items():
138 path = root / rel
139 if data is None:
140 path.unlink(missing_ok=True)
141 else:
142 path.write_bytes(data)
143
144
145def _check_group(
146 group: ArtefactGroup,
147 root: Path,
148 read_candidate: Callable[[Path, str], bytes | None],
149) -> list[str]:
150 """Regenerate one group and return a drift message per stale artefact.
151
152 Restores every artefact to its pre-run bytes afterwards, so the check never
153 leaves the working tree dirtier than it found it. A missing required input
154 is a hard failure -- the generator is never run -- rather than a skip.
155 """
156 missing = [str(p) for p in group.requires if not p.exists()]
157 if missing:
158 joined = ", ".join(missing)
159 return [
160 f"{group.name}: required input(s) absent: {joined}. This gate consumes them "
161 f"and must run after the gate that produces them. {group.remediation}"
162 ]
163
164 candidate = {rel: read_candidate(root, rel) for rel in group.artefacts}
165 saved: dict[str, bytes | None] = {}
166 for rel in group.artefacts:
167 path = root / rel
168 saved[rel] = path.read_bytes() if path.exists() else None
169
170 errors: list[str] = []
171 try:
172 rc = group.regenerate()
173 if rc != 0:
174 errors.append(f"{group.name}: regenerator exited {rc} (see its output above)")
175 else:
176 for rel in group.artefacts:
177 drift = _diff_artefact(candidate[rel], (root / rel).read_bytes())
178 if drift is not None:
179 errors.append(f"{rel}: {drift}. {group.remediation}")
180 finally:
181 _restore(root, saved)
182 return errors
183
184
185def _regenerate_mcdc() -> int:
186 """Rewrite docs/MCDC_GAPS.* from build/mcdc-report/mcdc.txt; return its rc."""
187 if str(SCRIPTS_FIX) not in sys.path:
188 sys.path.insert(0, str(SCRIPTS_FIX))
189 import regen_mcdc_gaps # noqa: PLC0415 -- lazy: adds scripts/fix to sys.path first
190
191 with contextlib.redirect_stdout(io.StringIO()):
192 return regen_mcdc_gaps.main()
193
194
195def _regenerate_doxygen() -> int:
196 """Rewrite docs/DOXYGEN_GAPS.* from a static source parse; return its rc."""
197 if str(SCRIPTS_CHECKS) not in sys.path:
198 sys.path.insert(0, str(SCRIPTS_CHECKS))
199 import doxy_report # noqa: PLC0415 -- lazy: adds scripts/checks to sys.path first
200
201 with contextlib.redirect_stdout(io.StringIO()):
202 return doxy_report.run_report()
203
204
205def _load_driver_status() -> ModuleType:
206 """Import gen_driver_status, putting scripts/gen on the path first."""
207 if str(SCRIPTS_GEN) not in sys.path:
208 sys.path.insert(0, str(SCRIPTS_GEN))
209 import gen_driver_status # noqa: PLC0415 -- lazy: adds scripts/gen to sys.path first
210
211 return gen_driver_status
212
213
214def _regenerate_driver_status() -> int:
215 """Rewrite docs/DRIVER_STATUS.md from the HAL inventory; return its rc."""
216 module = _load_driver_status()
217 with contextlib.redirect_stdout(io.StringIO()):
218 return module.write(REPO_ROOT)
219
220
221def _production_groups() -> list[ArtefactGroup]:
222 """The versioned generated gap artefacts this gate enforces freshness on."""
223 return [
224 ArtefactGroup(
225 name="mcdc-gaps",
226 artefacts=(
227 "docs/MCDC_GAPS.csv",
228 "docs/MCDC_GAPS.md",
229 "docs/MCDC_DEACTIVATIONS.md",
230 ),
231 regenerate=_regenerate_mcdc,
232 requires=(MCDC_TXT,),
233 remediation=(
234 "Run `just quality::local::mcdc` (or `just quality::gate::run mcdc`) and "
235 "commit the regenerated docs/MCDC_GAPS.csv, docs/MCDC_GAPS.md and "
236 "docs/MCDC_DEACTIVATIONS.md."
237 ),
238 ),
239 ArtefactGroup(
240 name="doxygen-gaps",
241 artefacts=("docs/DOXYGEN_GAPS.csv", "docs/DOXYGEN_GAPS.md"),
242 regenerate=_regenerate_doxygen,
243 requires=(),
244 remediation=(
245 "Run `python3 scripts/checks/doxy_audit.py` and commit the regenerated "
246 "docs/DOXYGEN_GAPS.csv and docs/DOXYGEN_GAPS.md."
247 ),
248 ),
249 ArtefactGroup(
250 name="driver-status",
251 artefacts=("docs/DRIVER_STATUS.md",),
252 regenerate=_regenerate_driver_status,
253 requires=(),
254 remediation=(
255 "Run `python3 scripts/gen/gen_driver_status.py` and commit the "
256 "regenerated docs/DRIVER_STATUS.md."
257 ),
258 ),
259 ]
260
261
262def check() -> int:
263 """Fail when any tracked candidate artefact differs from a fresh regenerate."""
264 errors: list[str] = []
265 for group in _production_groups():
266 errors.extend(_check_group(group, REPO_ROOT, _read_candidate))
267 if errors:
268 sys.stderr.write("check_generated_artefacts.py: candidate gap artefacts are stale.\n\n")
269 for error in errors:
270 sys.stderr.write(f" {error}\n\n")
271 sys.stderr.write(f"{len(errors)} stale artefact(s).\n")
272 return 1
273 print(
274 "check_generated_artefacts.py: clean -- every tracked candidate gap artefact "
275 "matches a fresh regenerate."
276 )
277 return 0
278
279
280def _synthetic_generator(path: Path, content: bytes) -> Callable[[], int]:
281 """Build a regenerate() that writes fixed ``content`` to ``path`` and succeeds."""
282
283 def _regenerate() -> int:
284 path.write_bytes(content)
285 return 0
286
287 return _regenerate
288
289
290def _counting_generator(sink: list[int], path: Path) -> Callable[[], int]:
291 """Build a regenerate() that records each call in ``sink`` before writing."""
292
293 def _regenerate() -> int:
294 sink.append(1)
295 path.write_bytes(b"regenerated\n")
296 return 0
297
298 return _regenerate
299
300
301def _run_git(root: Path, *args: str) -> None:
302 """Run a git subcommand in ``root``, raising on non-zero exit."""
303 subprocess.run( # noqa: S603 -- fixed argv, no shell
304 [trusted_git_executable(), "-C", str(root), *args],
305 check=True,
306 capture_output=True,
307 )
308
309
310def _seed_driver_fixture(root: Path) -> None:
311 """Generate the driver-status fixture so git commits it with the seed."""
312 real = _load_driver_status()
313 hal = root / "hal-fixture"
314 with contextlib.redirect_stdout(io.StringIO()):
315 real._seed_tree(hal) # noqa: SLF001 -- the generator's own fixture builder
316 real.write(hal)
317 target = root / real.ARTEFACT
318 target.parent.mkdir(parents=True, exist_ok=True)
319 target.write_bytes((hal / real.ARTEFACT).read_bytes())
320
321
322def _driver_status_cases(root: Path) -> list[tuple[str, bool]]:
323 """Drive the REAL driver-status generator through the freshness comparator.
324
325 The synthetic groups prove the comparator; they say nothing about whether a
326 real generator is wired to it. A freshness check whose selftest only ever
327 exercised a stand-in would keep passing after the real one stopped producing
328 what it used to. So this regenerates against the throwaway versioned HAL at
329 seed time and asserts clean, then adds a driver and asserts drift.
330
331 Args:
332 root: The throwaway git repository seeded by ``_selftest_cases``.
333
334 Returns:
335 The two ``(label, passed)`` tuples, in both directions.
336 """
337 real = _load_driver_status()
338 hal = root / "hal-fixture"
339 driver_rel = real.ARTEFACT
340
341 def _regen_driver() -> int:
342 (root / driver_rel).write_bytes((hal / driver_rel).read_bytes())
343 return 0
344
345 group = ArtefactGroup("driver-real-match", (driver_rel,), _regen_driver, (), "n/a")
346 clean = _check_group(group, root, _read_candidate) == []
347
348 (hal / "libs" / "ra8_hal" / "inc" / "ra8_extra.h").write_text(
349 "#pragma once\nra8_err_t ra8_extra_init(void);\n", encoding="ascii"
350 )
351 with contextlib.redirect_stdout(io.StringIO()):
352 real.write(hal)
353 drift = bool(_check_group(group, root, _read_candidate))
354
355 return [
356 ("driver-status: fresh regenerate matches candidate -> clean", clean),
357 ("driver-status: a new driver changes the page -> drift", drift),
358 ]
359
360
361def _seed_selftest_repo(root: Path) -> str:
362 """Create and commit the synthetic freshness candidate."""
363 tracked = "docs/SYNTH_GAPS.md"
364 (root / "docs").mkdir(parents=True, exist_ok=True)
365 (root / tracked).write_bytes(b"committed-AAA\n")
366 _seed_driver_fixture(root)
367 _run_git(root, "init", "-q")
368 _run_git(root, "add", "-A")
369 _run_git(
370 root, "-c", "user.email=ci@localhost", "-c", "user.name=ci", "commit", "-q", "-m", "seed"
371 )
372 return tracked
373
374
375def _synthetic_selftest_groups(
376 root: Path, tracked: str
377) -> tuple[dict[str, ArtefactGroup], list[int]]:
378 """Build the synthetic group variants and the missing-input run probe."""
379 ran: list[int] = []
380 untracked = "docs/NOT_COMMITTED.md"
381 groups = {
382 "match": ArtefactGroup(
383 "synth-match",
384 (tracked,),
385 _synthetic_generator(root / tracked, b"committed-AAA\n"),
386 (),
387 "n/a",
388 ),
389 "drift": ArtefactGroup(
390 "synth-drift",
391 (tracked,),
392 _synthetic_generator(root / tracked, b"regenerated-BBB\n"),
393 (),
394 "n/a",
395 ),
396 "candidate": ArtefactGroup(
397 "synth-candidate",
398 (tracked,),
399 _synthetic_generator(root / tracked, b"candidate-CCC\n"),
400 (),
401 "n/a",
402 ),
403 "missing": ArtefactGroup(
404 "synth-missing",
405 (tracked,),
406 _counting_generator(ran, root / tracked),
407 (root / "absent-input.txt",),
408 "n/a",
409 ),
410 "untracked": ArtefactGroup(
411 "synth-untracked",
412 (untracked,),
413 _synthetic_generator(root / untracked, b"y\n"),
414 (),
415 "n/a",
416 ),
417 }
418 return groups, ran
419
420
421def _selftest_cases(root: Path) -> list[tuple[str, bool]]:
422 """Exercise every freshness direction against a throwaway git repo."""
423 tracked = _seed_selftest_repo(root)
424 groups, ran = _synthetic_selftest_groups(root, tracked)
425
426 match_clean = _check_group(groups["match"], root, _read_candidate) == []
427 drift_seen = bool(_check_group(groups["drift"], root, _read_candidate))
428
429 (root / tracked).write_bytes(b"candidate-CCC\n")
430 candidate_clean = _check_group(groups["candidate"], root, _read_candidate) == []
431 (root / tracked).unlink()
432 deleted_drift = bool(_check_group(groups["match"], root, _read_candidate))
433 (root / tracked).write_bytes(b"committed-AAA\n")
434
435 missing_errors = _check_group(groups["missing"], root, _read_candidate)
436 untracked_drift = bool(_check_group(groups["untracked"], root, _read_candidate))
437
438 return [
439 ("fresh matches tracked candidate -> clean", match_clean),
440 ("fresh differs from tracked candidate -> drift", drift_seen),
441 ("unstaged generated refresh matching fresh output -> clean", candidate_clean),
442 ("deleted tracked candidate -> drift", deleted_drift),
443 ("missing required input -> fail, generator not run", bool(missing_errors) and not ran),
444 ("untracked candidate copy -> drift", untracked_drift),
445 *_driver_status_cases(root),
446 ]
447
448
449def _selftest_body() -> int:
450 """Prove the detector fires in both directions and never silently skips."""
451 with tempfile.TemporaryDirectory() as tmp:
452 cases = _selftest_cases(Path(tmp))
453 failures = 0
454 for label, passed in cases:
455 status = "ok" if passed else "FAIL"
456 if not passed:
457 failures += 1
458 print(f" [{status}] {label}")
459 if failures:
460 sys.stderr.write(f"check_generated_artefacts.py --selftest: {failures} case(s) failed.\n")
461 return 1
462 print("check_generated_artefacts.py --selftest: all cases pass.")
463 return 0
464
465
466def selftest() -> int:
467 """Run generated-artifact fixtures without inheriting the caller's repo."""
468 with isolated_git_environment():
469 return _selftest_body()
470
471
472def main() -> int:
473 """Parse arguments and dispatch to the freshness check or its selftest."""
474 parser = argparse.ArgumentParser(
475 description="Verify tracked candidate gap artefacts match a fresh regenerate."
476 )
477 parser.add_argument(
478 "--selftest",
479 action="store_true",
480 help="run the detector's self-test (both directions) and exit",
481 )
482 args = parser.parse_args()
483 if args.selftest:
484 return selftest()
485 return check()
486
487
488if __name__ == "__main__":
489 raise SystemExit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298