ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_tests_readme.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: ``tests/README.md`` documents exactly the subdirectories that exist.
5
6The README explains what each subdirectory of ``tests/`` is for. A plain prose
7README rots the moment someone adds ``tests/newthing/`` and forgets to describe
8it, or deletes ``tests/oldthing/`` and leaves a paragraph describing a directory
9that is gone -- and nothing notices. This gate makes that impossible in both
10directions:
11
121. **Every immediate subdirectory of ``tests/`` is described.** A new one that
13 the README does not name fails the gate, so it cannot be added silently.
14
152. **Every subdirectory the README names still exists.** A row describing a
16 directory that has been removed or renamed fails the gate, so a stale entry
17 cannot linger.
18
19The README is machine-read the same way it is human-read: only the FIRST cell
20of each Markdown table row counts, written as a code span with a trailing slash
21(```bench/```). Keying on the first column -- never the prose, never a
22description cell -- is what lets a description mention ``epub/real/`` without the
23checker mistaking ``epub`` for a top-level subdirectory of ``tests/``.
24
25Like every other detector in this tree it carries a **non-vacuity floor**: the
26real ``tests/`` has eight subdirectories, so a scan that finds almost none did
27not walk the tree it meant to, and reporting "no drift" against nothing would be
28the exact silent-pass failure this gate exists to prevent.
29
30``--selftest`` runs first in the gate. It builds throwaway ``tests/`` trees and
31asserts an undocumented subdirectory fires, a stale README entry fires, an
32in-sync tree stays quiet, and a collapsed scan is caught by the floor. Without
33it, "0 problems" is indistinguishable from "checked nothing".
34"""
35
36from __future__ import annotations
37
38import argparse
39import re
40import subprocess
41import sys
42import tempfile
43from dataclasses import dataclass
44from pathlib import Path
45
46sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
47
48from git_environment import isolated_git_environment, trusted_git_executable
49
50REPO_ROOT = Path(__file__).resolve().parents[2]
51TESTS_DIR = REPO_ROOT / "tests"
52README = TESTS_DIR / "README.md"
53
54# A documented subdirectory is the first cell of a table row, written as a code
55# span ending in a slash: `name/`. Reading only the first column is deliberate
56# -- a name that appears in a row's description or in prose is never counted.
57ROW_NAME_RE = re.compile(r"^`([A-Za-z0-9._-]+)/`$")
58
59# The real tree carries eight subdirectories. A scan that finds far fewer walked
60# the wrong root or a tree that never checked out; refuse to certify it clean.
61MIN_SUBDIRS = 5
62
63EXIT_OK = 0
64EXIT_DRIFT = 1
65EXIT_VACUOUS = 2
66
67
68def _ignored_names(tests_dir: Path, names: set[str]) -> set[str]:
69 """Return the subset of ``names`` git ignores under ``tests_dir``.
70
71 A filesystem scan cannot tell test content from BUILD OUTPUT: the moment a
72 developer runs the host suite, ``tests/build/`` and ``tests/build-cov/``
73 appear and this gate demands a README row for each of them. Both are
74 gitignored artefacts that never exist in a CI checkout, so the gate failed
75 only on the machines where it was run by hand -- the mirror image of the
76 ``git ls-files`` scope trap, and just as good at training people to ignore
77 it. Git owns the tracked/ignored distinction, so ask git.
78
79 Args:
80 tests_dir: The ``tests/`` directory being scanned.
81 names: Candidate subdirectory names found on disk.
82
83 Returns:
84 The names git reports as ignored. Empty when ``tests_dir`` is not
85 inside a git work tree -- which is exactly the selftest`s throwaway
86 fixture, so the fixtures keep behaving as plain directory scans.
87 """
88 if not names:
89 return set()
90 probe = subprocess.run( # noqa: S603 -- fixed argv, paths built from tests_dir
91 [
92 trusted_git_executable(),
93 "-C",
94 str(tests_dir),
95 "check-ignore",
96 "--stdin",
97 ],
98 input="\n".join(sorted(names)) + "\n",
99 capture_output=True,
100 text=True,
101 check=False,
102 )
103 return {line.strip() for line in probe.stdout.splitlines() if line.strip()}
104
105
106def immediate_subdirs(tests_dir: Path) -> set[str]:
107 """Return the names of the directories directly under `tests_dir`.
108
109 Args:
110 tests_dir: The ``tests/`` directory to scan.
111
112 Returns:
113 Every immediate child directory name, excluding dot-directories (which
114 are tooling rather than test content) and gitignored build output.
115 """
116 found = {p.name for p in tests_dir.iterdir() if p.is_dir() and not p.name.startswith(".")}
117 return found - _ignored_names(tests_dir, found)
118
119
120def documented_subdirs(readme_text: str) -> set[str]:
121 """Return the subdirectory names the README's table claims to describe.
122
123 Only the first cell of each table row is read, so a name appearing in a
124 row's description or in surrounding prose is never mistaken for a documented
125 top-level subdirectory.
126
127 Args:
128 readme_text: The full contents of ``tests/README.md``.
129
130 Returns:
131 Every ``name`` written as ```name/``` in a table's first column.
132 """
133 names: set[str] = set()
134 for line in readme_text.splitlines():
135 stripped = line.strip()
136 if not stripped.startswith("|"):
137 continue
138 first_cell = stripped.strip("|").split("|", 1)[0].strip()
139 match = ROW_NAME_RE.match(first_cell)
140 if match:
141 names.add(match.group(1))
142 return names
143
144
145def drift_problems(actual: set[str], documented: set[str]) -> list[str]:
146 """Return every way the tree and the README disagree.
147
148 Args:
149 actual: Subdirectory names that exist under ``tests/``.
150 documented: Subdirectory names the README describes.
151
152 Returns:
153 One message per undocumented subdirectory and per documented-but-absent
154 entry; empty when the two sets are equal.
155 """
156 problems = [
157 f"tests/{name}/ exists but is not documented in tests/README.md -- "
158 "add a table row whose first cell is `" + name + "/`"
159 for name in sorted(actual - documented)
160 ]
161 problems += [
162 f"tests/README.md documents tests/{name}/ but no such subdirectory "
163 "exists -- remove or rename that row"
164 for name in sorted(documented - actual)
165 ]
166 return problems
167
168
169def evaluate(
170 tests_dir: Path, readme: Path, min_subdirs: int = MIN_SUBDIRS
171) -> tuple[int, list[str]]:
172 """Compare a ``tests/`` tree against its README.
173
174 Args:
175 tests_dir: The ``tests/`` directory to scan.
176 readme: The README that must describe every subdirectory.
177 min_subdirs: Non-vacuity floor; a scan below it is treated as collapsed.
178
179 Returns:
180 An ``(exit_code, messages)`` pair: ``EXIT_VACUOUS`` when fewer than
181 ``min_subdirs`` directories were found, ``EXIT_DRIFT`` on any
182 disagreement, ``EXIT_OK`` when the README matches the tree.
183 """
184 actual = immediate_subdirs(tests_dir)
185 if len(actual) < min_subdirs:
186 return EXIT_VACUOUS, [
187 f"only {len(actual)} subdirectory(ies) found under {tests_dir} "
188 f"(floor is {min_subdirs}); the scan collapsed rather than the tree"
189 ]
190 readme_text = readme.read_text(encoding="utf-8") if readme.exists() else ""
191 problems = drift_problems(actual, documented_subdirs(readme_text))
192 return (EXIT_DRIFT if problems else EXIT_OK), problems
193
194
195@dataclass(frozen=True)
196class _Case:
197 """One selftest fixture and the verdict it must produce."""
198
199 name: str
200 subdirs: list[str]
201 documented: list[str]
202 floor: int
203 want_code: int
204 needle: str | None
205
206
207def _selftest_cases() -> list[_Case]:
208 """Return the fixtures the selftest asserts, one per direction.
209
210 Returns:
211 A case for the in-sync tree, the undocumented-subdir direction, the
212 stale-entry direction, and the non-vacuity floor. ``needle`` is a
213 substring one message must contain, or ``None`` when the case must
214 produce no messages.
215 """
216 trio = ["alpha", "beta", "gamma"]
217 return [
218 _Case("in-sync stays quiet", trio, trio, 3, EXIT_OK, None),
219 _Case("undocumented subdir fires", trio, ["alpha", "beta"], 3, EXIT_DRIFT, "tests/gamma/"),
220 _Case("stale doc entry fires", trio, [*trio, "ghost"], 3, EXIT_DRIFT, "ghost"),
221 _Case("collapsed scan is vacuous", ["alpha"], ["alpha"], 3, EXIT_VACUOUS, None),
222 ]
223
224
225def _write_fixture(root: Path, case: _Case) -> tuple[Path, Path]:
226 """Build a throwaway ``tests/`` tree and README for one selftest case.
227
228 Args:
229 root: Temporary directory to build inside.
230 case: The fixture to materialise.
231
232 Returns:
233 The ``(tests_dir, readme_path)`` pair to hand to :func:`evaluate`.
234 """
235 tests_dir = root / "tests"
236 tests_dir.mkdir()
237 for name in case.subdirs:
238 (tests_dir / name).mkdir()
239 rows = "".join(f"| `{name}/` | fixture description |\n" for name in case.documented)
240 readme = tests_dir / "README.md"
241 readme.write_text(
242 "# tests/\n\n| Subdirectory | What it holds |\n|---|---|\n" + rows,
243 encoding="utf-8",
244 )
245 return tests_dir, readme
246
247
248def _run_case(root: Path, case: _Case) -> list[str]:
249 """Run one selftest case and return the ways it misbehaved.
250
251 Args:
252 root: Fresh temporary directory for this case.
253 case: The fixture and its expected verdict.
254
255 Returns:
256 A message per assertion the case failed; empty when it behaved.
257 """
258 tests_dir, readme = _write_fixture(root, case)
259 code, messages = evaluate(tests_dir, readme, min_subdirs=case.floor)
260 failures = []
261 if code != case.want_code:
262 failures.append(f" {case.name}: exit {code}, expected {case.want_code}")
263 if case.needle is None:
264 if case.want_code == EXIT_OK and messages:
265 failures.append(f" {case.name}: expected no messages, got {messages}")
266 elif not any(case.needle in message for message in messages):
267 failures.append(f" {case.name}: no message mentioned '{case.needle}': {messages}")
268 return failures
269
270
271def _selftest_ignored() -> list[str]:
272 """Assert the gitignore carve-out, in both directions.
273
274 Builds a real throwaway git work tree so ``git check-ignore`` has something
275 to answer, then checks that an IGNORED subdirectory needs no README row
276 while an identically-shaped TRACKABLE one still does. Without the second
277 half a carve-out that had widened to swallow every directory would report
278 the cleanest tree it has ever seen.
279
280 Returns:
281 One message per way the carve-out misbehaved; empty when it is correct.
282 """
283 failures: list[str] = []
284 git = trusted_git_executable()
285 with tempfile.TemporaryDirectory() as tmp:
286 root = Path(tmp)
287 subprocess.run( # noqa: S603 -- fixed argv, temp path
288 [git, "init", "--quiet", str(root)], capture_output=True, text=True, check=False
289 )
290 (root / ".gitignore").write_text("build/\n", encoding="utf-8")
291 tests_dir = root / "tests"
292 tests_dir.mkdir()
293 for name in ("alpha", "beta", "gamma", "build"):
294 (tests_dir / name).mkdir()
295 readme = tests_dir / "README.md"
296 rows = "".join(f"| `{name}/` | fixture |\n" for name in ("alpha", "beta", "gamma"))
297 readme.write_text(
298 "# tests/\n\n| Subdirectory | What it holds |\n|---|---|\n" + rows,
299 encoding="utf-8",
300 )
301 code, messages = evaluate(tests_dir, readme, min_subdirs=3)
302 if code != EXIT_OK:
303 failures.append(f" ignored build/ still demanded a README row: exit {code} {messages}")
304 # ...and the must-fire half: a directory git does NOT ignore still does.
305 (tests_dir / "delta").mkdir()
306 code, messages = evaluate(tests_dir, readme, min_subdirs=3)
307 if code != EXIT_DRIFT or not any("tests/delta/" in message for message in messages):
308 failures.append(f" a trackable subdir stopped firing: exit {code} {messages}")
309 return failures
310
311
312def _selftest_body() -> int:
313 """Prove both drift directions fire, a clean tree stays quiet, the floor holds.
314
315 Returns:
316 0 when every fixture behaves, 1 otherwise.
317 """
318 cases = _selftest_cases()
319 failures: list[str] = []
320 for case in cases:
321 with tempfile.TemporaryDirectory() as tmp:
322 failures += _run_case(Path(tmp), case)
323 failures += _selftest_ignored()
324 if failures:
325 print("check_tests_readme selftest FAILED:", file=sys.stderr)
326 print("\n".join(failures), file=sys.stderr)
327 return 1
328 print(
329 f"selftest OK: {len(cases)} cases plus the gitignore carve-out "
330 "(both drift directions + non-vacuity floor)"
331 )
332 return 0
333
334
335def _selftest() -> int:
336 """Run README inventory fixtures without inheriting the caller's repo."""
337 with isolated_git_environment():
338 return _selftest_body()
339
340
341def main(argv: list[str] | None = None) -> int:
342 """Entry point.
343
344 Args:
345 argv: Command line, defaulting to ``sys.argv[1:]``.
346
347 Returns:
348 0 when the README matches the tree, 1 on drift, 2 on a collapsed scan.
349 """
350 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
351 parser.add_argument("--selftest", action="store_true", help="prove both drift directions fire")
352 args = parser.parse_args(argv)
353 if args.selftest:
354 return _selftest()
355 code, messages = evaluate(TESTS_DIR, README)
356 if code == EXIT_OK:
357 count = len(immediate_subdirs(TESTS_DIR))
358 print(f"tests/README.md OK: {count} subdirectory(ies) documented, none stale")
359 return EXIT_OK
360 label = "collapsed scan" if code == EXIT_VACUOUS else "drift"
361 print(f"tests/README.md {label}: {len(messages)} problem(s):", file=sys.stderr)
362 for message in messages:
363 print(f" {message}", file=sys.stderr)
364 return code
365
366
367if __name__ == "__main__":
368 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298