4"""Gate: versioned generated gap artefacts must match a fresh regenerate.
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).
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
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.
26Two artefact families, checked uniformly:
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
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
50from __future__
import annotations
58from collections.abc
import Callable
59from dataclasses
import dataclass
60from pathlib
import Path
61from types
import ModuleType
63sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
65from git_environment
import isolated_git_environment, trusted_git_executable
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"
74@dataclass(frozen=True)
76 """One generator and the versioned artefacts it owns.
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.
90 artefacts: tuple[str, ...]
91 regenerate: Callable[[], int]
92 requires: tuple[Path, ...]
96def _read_candidate(root: Path, rel: str) -> bytes |
None:
97 """Return tracked worktree bytes for ``rel``; reject untracked/deleted copies."""
107 result = subprocess.run(
112 if result.returncode != 0:
115 return path.read_bytes()
if path.is_file()
else None
118def _diff_artefact(candidate: bytes |
None, fresh: bytes) -> str |
None:
119 """Describe how a tracked candidate differs from a fresh regenerate.
122 candidate: Tracked worktree bytes, or None when absent or untracked.
123 fresh: Bytes the generator just produced.
126 A one-line drift description, or None when the two are byte-identical.
128 if candidate
is None:
129 return "candidate copy is absent or untracked; cannot verify retained evidence"
130 if candidate == fresh:
132 return f
"stale: candidate {len(candidate)} bytes differ from the {len(fresh)}-byte regenerate"
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():
140 path.unlink(missing_ok=
True)
142 path.write_bytes(data)
146 group: ArtefactGroup,
148 read_candidate: Callable[[Path, str], bytes |
None],
150 """Regenerate one group and return a drift message per stale artefact.
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.
156 missing = [str(p)
for p
in group.requires
if not p.exists()]
158 joined =
", ".join(missing)
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}"
164 candidate = {rel: read_candidate(root, rel)
for rel
in group.artefacts}
165 saved: dict[str, bytes |
None] = {}
166 for rel
in group.artefacts:
168 saved[rel] = path.read_bytes()
if path.exists()
else None
170 errors: list[str] = []
172 rc = group.regenerate()
174 errors.append(f
"{group.name}: regenerator exited {rc} (see its output above)")
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}")
181 _restore(root, saved)
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
191 with contextlib.redirect_stdout(io.StringIO()):
192 return regen_mcdc_gaps.main()
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))
201 with contextlib.redirect_stdout(io.StringIO()):
202 return doxy_report.run_report()
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
211 return gen_driver_status
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)
221def _production_groups() -> list[ArtefactGroup]:
222 """The versioned generated gap artefacts this gate enforces freshness on."""
227 "docs/MCDC_GAPS.csv",
229 "docs/MCDC_DEACTIVATIONS.md",
231 regenerate=_regenerate_mcdc,
232 requires=(MCDC_TXT,),
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."
241 artefacts=(
"docs/DOXYGEN_GAPS.csv",
"docs/DOXYGEN_GAPS.md"),
242 regenerate=_regenerate_doxygen,
245 "Run `python3 scripts/checks/doxy_audit.py` and commit the regenerated "
246 "docs/DOXYGEN_GAPS.csv and docs/DOXYGEN_GAPS.md."
250 name=
"driver-status",
251 artefacts=(
"docs/DRIVER_STATUS.md",),
252 regenerate=_regenerate_driver_status,
255 "Run `python3 scripts/gen/gen_driver_status.py` and commit the "
256 "regenerated docs/DRIVER_STATUS.md."
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))
268 sys.stderr.write(
"check_generated_artefacts.py: candidate gap artefacts are stale.\n\n")
270 sys.stderr.write(f
" {error}\n\n")
271 sys.stderr.write(f
"{len(errors)} stale artefact(s).\n")
274 "check_generated_artefacts.py: clean -- every tracked candidate gap artefact "
275 "matches a fresh regenerate."
280def _synthetic_generator(path: Path, content: bytes) -> Callable[[], int]:
281 """Build a regenerate() that writes fixed ``content`` to ``path`` and succeeds."""
283 def _regenerate() -> int:
284 path.write_bytes(content)
290def _counting_generator(sink: list[int], path: Path) -> Callable[[], int]:
291 """Build a regenerate() that records each call in ``sink`` before writing."""
293 def _regenerate() -> int:
295 path.write_bytes(b
"regenerated\n")
301def _run_git(root: Path, *args: str) ->
None:
302 """Run a git subcommand in ``root``, raising on non-zero exit."""
304 [trusted_git_executable(),
"-C", str(root), *args],
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()):
317 target = root / real.ARTEFACT
318 target.parent.mkdir(parents=
True, exist_ok=
True)
319 target.write_bytes((hal / real.ARTEFACT).read_bytes())
322def _driver_status_cases(root: Path) -> list[tuple[str, bool]]:
323 """Drive the REAL driver-status generator through the freshness comparator.
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.
332 root: The throwaway git repository seeded by ``_selftest_cases``.
335 The two ``(label, passed)`` tuples, in both directions.
337 real = _load_driver_status()
338 hal = root /
"hal-fixture"
339 driver_rel = real.ARTEFACT
341 def _regen_driver() -> int:
342 (root / driver_rel).write_bytes((hal / driver_rel).read_bytes())
345 group = ArtefactGroup(
"driver-real-match", (driver_rel,), _regen_driver, (),
"n/a")
346 clean = _check_group(group, root, _read_candidate) == []
348 (hal /
"libs" /
"ra8_hal" /
"inc" /
"ra8_extra.h").write_text(
349 "#pragma once\nra8_err_t ra8_extra_init(void);\n", encoding=
"ascii"
351 with contextlib.redirect_stdout(io.StringIO()):
353 drift = bool(_check_group(group, root, _read_candidate))
356 (
"driver-status: fresh regenerate matches candidate -> clean", clean),
357 (
"driver-status: a new driver changes the page -> drift", drift),
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")
370 root,
"-c",
"user.email=ci@localhost",
"-c",
"user.name=ci",
"commit",
"-q",
"-m",
"seed"
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."""
380 untracked =
"docs/NOT_COMMITTED.md"
382 "match": ArtefactGroup(
385 _synthetic_generator(root / tracked, b
"committed-AAA\n"),
389 "drift": ArtefactGroup(
392 _synthetic_generator(root / tracked, b
"regenerated-BBB\n"),
396 "candidate": ArtefactGroup(
399 _synthetic_generator(root / tracked, b
"candidate-CCC\n"),
403 "missing": ArtefactGroup(
406 _counting_generator(ran, root / tracked),
407 (root /
"absent-input.txt",),
410 "untracked": ArtefactGroup(
413 _synthetic_generator(root / untracked, b
"y\n"),
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)
426 match_clean = _check_group(groups[
"match"], root, _read_candidate) == []
427 drift_seen = bool(_check_group(groups[
"drift"], root, _read_candidate))
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")
435 missing_errors = _check_group(groups[
"missing"], root, _read_candidate)
436 untracked_drift = bool(_check_group(groups[
"untracked"], root, _read_candidate))
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),
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))
454 for label, passed
in cases:
455 status =
"ok" if passed
else "FAIL"
458 print(f
" [{status}] {label}")
460 sys.stderr.write(f
"check_generated_artefacts.py --selftest: {failures} case(s) failed.\n")
462 print(
"check_generated_artefacts.py --selftest: all cases pass.")
466def selftest() -> int:
467 """Run generated-artifact fixtures without inheriting the caller's repo."""
468 with isolated_git_environment():
469 return _selftest_body()
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."
480 help=
"run the detector's self-test (both directions) and exit",
482 args = parser.parse_args()
488if __name__ ==
"__main__":
489 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.