4"""check_roadmap_dashboard_freshness.py -- gate docs/ROADMAP_DASHBOARD.md against a regenerate.
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.
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
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.
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.
33from __future__
import annotations
38from pathlib
import Path
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"))
44import roadmap_dashboard
45from git_environment
import isolated_git_environment, trusted_git_executable
46from selftest_assert
import expect, report
48REPO_ROOT = Path(__file__).resolve().parents[2]
49ARTEFACT =
"docs/ROADMAP_DASHBOARD.md"
50SOURCE =
"docs/ROADMAP.md"
58REMEDIATION =
"Run `just docs::dashboard` and commit docs/ROADMAP_DASHBOARD.md."
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(
65 [git_bin,
"-C", str(repo_root), *args],
71def count_drivers(repo_root: Path) -> int:
72 """Return how many drivers the generator parses from ``repo_root``'s ROADMAP.md.
75 repo_root: Repository root whose ``docs/ROADMAP.md`` is parsed.
78 The number of ``### <driver>`` sections the generator recognises.
80 text = (repo_root / SOURCE).read_text(encoding=
"utf-8")
81 return len(roadmap_dashboard.parse_roadmap(text))
84def regenerate(repo_root: Path) -> bytes:
85 """Return the dashboard ``roadmap_dashboard`` would write for ``repo_root``.
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.
93 repo_root: Repository root whose ``docs/ROADMAP.md`` is rendered.
96 The rendered Markdown dashboard as bytes.
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")
103def tracked_candidate(repo_root: Path, rel: str) -> bytes |
None:
104 """Return tracked candidate bytes for ``rel``, or None when unavailable.
107 repo_root: Repository whose ``HEAD`` is read.
108 rel: Repo-relative POSIX path of the committed artefact.
111 Candidate bytes, or None when the path is untracked or absent.
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():
117 return path.read_bytes()
120def drift(committed: bytes |
None, fresh: bytes) -> str |
None:
121 """Describe how ``committed`` differs from ``fresh``, or None when identical.
123 This is the verdict the gate turns into its exit code; the selftest drives
124 it in both directions.
127 committed: Bytes committed at ``HEAD``, or None when untracked.
128 fresh: Bytes the generator just produced.
131 A one-line drift description, or None when the two are byte-identical.
133 if committed
is None:
134 return f
"committed copy is not tracked at HEAD; cannot verify freshness. {REMEDIATION}"
135 if committed == fresh:
138 f
"stale: committed {len(committed)} bytes differ from the "
139 f
"{len(fresh)}-byte regenerate of {SOURCE}. {REMEDIATION}"
143def check(repo_root: Path = REPO_ROOT) -> int:
144 """Fail when the committed dashboard differs from a fresh regenerate.
147 repo_root: Repository to render and whose ``HEAD`` copy is compared.
150 0 when the committed copy is byte-identical to the regenerate, 1
153 fresh = regenerate(repo_root)
154 verdict = drift(tracked_candidate(repo_root, ARTEFACT), fresh)
157 f
"check_roadmap_dashboard_freshness.py: clean -- {ARTEFACT} "
158 f
"matches a fresh regenerate of {SOURCE}."
161 sys.stderr.write(f
"check_roadmap_dashboard_freshness.py: {ARTEFACT}: {verdict}\n")
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:
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)
175 init
is not None and init.returncode == 0
and add
is not None and add.returncode == 0
177 expect(git_ready,
"the selftest Git fixture initializes", failures)
180 tracked_candidate(root, ARTEFACT) == b
"before\n",
181 "a tracked candidate is readable before commit",
184 candidate.write_bytes(b
"after\n")
186 tracked_candidate(root, ARTEFACT) == b
"after\n",
187 "the checker reads candidate bytes instead of stale HEAD bytes",
191 tracked_candidate(root,
"docs/untracked.md")
is None,
192 "an untracked candidate is rejected",
197def selftest() -> int:
198 """Prove the verdict fires on drift, stays quiet on a match, and is non-vacuous.
201 0 when every assertion held in both directions, 1 otherwise.
203 failures: list[str] = []
204 fresh = regenerate(REPO_ROOT)
209 drivers = count_drivers(REPO_ROOT)
211 drivers >= DRIVER_FLOOR,
212 f
"live parse feeds the dashboard {drivers} driver(s) (floor {DRIVER_FLOOR})",
215 expect(regenerate(REPO_ROOT) == fresh,
"the generator is byte-deterministic", failures)
219 drift(fresh, fresh)
is None,
220 "a committed copy equal to the regenerate is clean",
224 drift(fresh + b
"tampered\n", fresh)
is not None,
225 "a drifted committed copy is reported",
228 expect(drift(
None, fresh)
is not None,
"an untracked committed copy is reported", failures)
230 _check_candidate_cases(failures)
232 return report(failures)
236 """Dispatch to the freshness check or its selftest.
239 The exit code of whichever mode ran.
241 if "--selftest" in sys.argv[1:]:
246if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.