ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_roadmap_dashboard_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_roadmap_dashboard_freshness.py -- gate docs/ROADMAP_DASHBOARD.md against a regenerate.
5
6``docs/ROADMAP_DASHBOARD.md`` is a COMMITTED, GENERATED historical artefact:
7``just docs::dashboard`` writes it with ``scripts/report/roadmap_dashboard.py``, which
8renders it purely from the closed evidence in ``docs/ROADMAP.md``. Current work
9is tracked in GitHub issues and the project board. Previously, nothing
10re-ran that generator and byte-compared the committed copy against ``HEAD``, so
11it could silently drift out of step with ``ROADMAP.md`` the same way
12``docs/INIT_ORDER_AUDIT.md`` did (#537) -- a generated doc that nothing
13regenerates is a claim with no mechanism behind it.
14
15This is the same "regenerate and gate" shape ``check_init_order_freshness.py``
16uses. Like that generator, this one is hardware-free and reads a committed
17markdown file, so it is byte-stable across runs and lives in its own ``fast``
18gate rather than the ``slow`` ``artefact-freshness`` group that consumes build
19output.
20
21The comparison is against the tracked candidate bytes. In CI that is the clean
22checkout; the pre-commit policy runs inside its staged snapshot. Comparing to
23``HEAD`` would make a corrected generated document fail until after the commit
24that this gate is meant to guard. ``--selftest`` drives the verdict logic in
25both directions and floors the real regenerate at ``DRIVER_FLOOR`` drivers, so
26a collapsed parser fails instead of reporting a clean, empty dashboard.
27
28Scope note: the generator also writes ``docs/badges/*.svg`` from the same
29source; this gate owns the markdown dashboard named in #537. The badges are a
30separate artefact and out of scope here.
31"""
32
33from __future__ import annotations
34
35import subprocess
36import sys
37import tempfile
38from pathlib import Path
39
40sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts" / "report"))
41sys.path.insert(0, str(Path(__file__).resolve().parent))
42sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
43
44import roadmap_dashboard
45from git_environment import isolated_git_environment, trusted_git_executable
46from selftest_assert import expect, report
47
48REPO_ROOT = Path(__file__).resolve().parents[2]
49ARTEFACT = "docs/ROADMAP_DASHBOARD.md"
50SOURCE = "docs/ROADMAP.md"
51
52# Non-vacuity floor: the historical record contains 45 drivers. A parse that
53# sees fewer has collapsed (an empty or truncated ROADMAP.md, or a broken
54# heading regex), and a dashboard rendered from it would be quietly wrong. The
55# floor is below the archived count so its only job is to reject a vacuous scan.
56DRIVER_FLOOR = 30
57
58REMEDIATION = "Run `just docs::dashboard` and commit docs/ROADMAP_DASHBOARD.md."
59
60
61def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[bytes] | None:
62 """Run a fixed Git operation with the resolved executable and no shell."""
63 git_bin = trusted_git_executable()
64 return subprocess.run( # noqa: S603 -- resolved executable, fixed argv, no shell
65 [git_bin, "-C", str(repo_root), *args],
66 capture_output=True,
67 check=False,
68 )
69
70
71def count_drivers(repo_root: Path) -> int:
72 """Return how many drivers the generator parses from ``repo_root``'s ROADMAP.md.
73
74 Args:
75 repo_root: Repository root whose ``docs/ROADMAP.md`` is parsed.
76
77 Returns:
78 The number of ``### <driver>`` sections the generator recognises.
79 """
80 text = (repo_root / SOURCE).read_text(encoding="utf-8")
81 return len(roadmap_dashboard.parse_roadmap(text))
82
83
84def regenerate(repo_root: Path) -> bytes:
85 """Return the dashboard ``roadmap_dashboard`` would write for ``repo_root``.
86
87 Built from the generator's own public helpers (``parse_roadmap`` +
88 ``render_dashboard``) rather than by shelling out, so the gate and the
89 generator cannot render differently, and encoded ``utf-8`` to match the
90 bytes the generator writes to disk.
91
92 Args:
93 repo_root: Repository root whose ``docs/ROADMAP.md`` is rendered.
94
95 Returns:
96 The rendered Markdown dashboard as bytes.
97 """
98 text = (repo_root / SOURCE).read_text(encoding="utf-8")
99 drivers = roadmap_dashboard.parse_roadmap(text)
100 return roadmap_dashboard.render_dashboard(drivers).encode("utf-8")
101
102
103def tracked_candidate(repo_root: Path, rel: str) -> bytes | None:
104 """Return tracked candidate bytes for ``rel``, or None when unavailable.
105
106 Args:
107 repo_root: Repository whose ``HEAD`` is read.
108 rel: Repo-relative POSIX path of the committed artefact.
109
110 Returns:
111 Candidate bytes, or None when the path is untracked or absent.
112 """
113 result = _git(repo_root, "ls-files", "--error-unmatch", "--", rel)
114 path = repo_root / rel
115 if result is None or result.returncode != 0 or not path.is_file():
116 return None
117 return path.read_bytes()
118
119
120def drift(committed: bytes | None, fresh: bytes) -> str | None:
121 """Describe how ``committed`` differs from ``fresh``, or None when identical.
122
123 This is the verdict the gate turns into its exit code; the selftest drives
124 it in both directions.
125
126 Args:
127 committed: Bytes committed at ``HEAD``, or None when untracked.
128 fresh: Bytes the generator just produced.
129
130 Returns:
131 A one-line drift description, or None when the two are byte-identical.
132 """
133 if committed is None:
134 return f"committed copy is not tracked at HEAD; cannot verify freshness. {REMEDIATION}"
135 if committed == fresh:
136 return None
137 return (
138 f"stale: committed {len(committed)} bytes differ from the "
139 f"{len(fresh)}-byte regenerate of {SOURCE}. {REMEDIATION}"
140 )
141
142
143def check(repo_root: Path = REPO_ROOT) -> int:
144 """Fail when the committed dashboard differs from a fresh regenerate.
145
146 Args:
147 repo_root: Repository to render and whose ``HEAD`` copy is compared.
148
149 Returns:
150 0 when the committed copy is byte-identical to the regenerate, 1
151 otherwise.
152 """
153 fresh = regenerate(repo_root)
154 verdict = drift(tracked_candidate(repo_root, ARTEFACT), fresh)
155 if verdict is None:
156 print(
157 f"check_roadmap_dashboard_freshness.py: clean -- {ARTEFACT} "
158 f"matches a fresh regenerate of {SOURCE}."
159 )
160 return 0
161 sys.stderr.write(f"check_roadmap_dashboard_freshness.py: {ARTEFACT}: {verdict}\n")
162 return 1
163
164
165def _check_candidate_cases(failures: list[str]) -> None:
166 """Prove candidate tracking reads working bytes and rejects untracked files."""
167 with isolated_git_environment(), tempfile.TemporaryDirectory() as raw_tmp:
168 root = Path(raw_tmp)
169 (root / "docs").mkdir()
170 candidate = root / ARTEFACT
171 candidate.write_bytes(b"before\n")
172 init = _git(root, "init", "-q")
173 add = _git(root, "add", ARTEFACT)
174 git_ready = (
175 init is not None and init.returncode == 0 and add is not None and add.returncode == 0
176 )
177 expect(git_ready, "the selftest Git fixture initializes", failures)
178 if git_ready:
179 expect(
180 tracked_candidate(root, ARTEFACT) == b"before\n",
181 "a tracked candidate is readable before commit",
182 failures,
183 )
184 candidate.write_bytes(b"after\n")
185 expect(
186 tracked_candidate(root, ARTEFACT) == b"after\n",
187 "the checker reads candidate bytes instead of stale HEAD bytes",
188 failures,
189 )
190 expect(
191 tracked_candidate(root, "docs/untracked.md") is None,
192 "an untracked candidate is rejected",
193 failures,
194 )
195
196
197def selftest() -> int:
198 """Prove the verdict fires on drift, stays quiet on a match, and is non-vacuous.
199
200 Returns:
201 0 when every assertion held in both directions, 1 otherwise.
202 """
203 failures: list[str] = []
204 fresh = regenerate(REPO_ROOT)
205
206 # Non-vacuity floor: the real parse must see the whole roadmap, not a
207 # collapsed heading scan. A dashboard rendered from an empty parse would
208 # otherwise read as clean.
209 drivers = count_drivers(REPO_ROOT)
210 expect(
211 drivers >= DRIVER_FLOOR,
212 f"live parse feeds the dashboard {drivers} driver(s) (floor {DRIVER_FLOOR})",
213 failures,
214 )
215 expect(regenerate(REPO_ROOT) == fresh, "the generator is byte-deterministic", failures)
216
217 # Both directions of the verdict the gate turns into its exit code.
218 expect(
219 drift(fresh, fresh) is None,
220 "a committed copy equal to the regenerate is clean",
221 failures,
222 )
223 expect(
224 drift(fresh + b"tampered\n", fresh) is not None,
225 "a drifted committed copy is reported",
226 failures,
227 )
228 expect(drift(None, fresh) is not None, "an untracked committed copy is reported", failures)
229
230 _check_candidate_cases(failures)
231
232 return report(failures)
233
234
235def main() -> int:
236 """Dispatch to the freshness check or its selftest.
237
238 Returns:
239 The exit code of whichever mode ran.
240 """
241 if "--selftest" in sys.argv[1:]:
242 return selftest()
243 return check()
244
245
246if __name__ == "__main__":
247 sys.exit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298