4"""Gate: ``tests/README.md`` documents exactly the subdirectories that exist.
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
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.
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
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/``.
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.
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".
36from __future__
import annotations
43from dataclasses
import dataclass
44from pathlib
import Path
46sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
48from git_environment
import isolated_git_environment, trusted_git_executable
50REPO_ROOT = Path(__file__).resolve().parents[2]
51TESTS_DIR = REPO_ROOT /
"tests"
52README = TESTS_DIR /
"README.md"
57ROW_NAME_RE = re.compile(
r"^`([A-Za-z0-9._-]+)/`$")
68def _ignored_names(tests_dir: Path, names: set[str]) -> set[str]:
69 """Return the subset of ``names`` git ignores under ``tests_dir``.
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.
80 tests_dir: The ``tests/`` directory being scanned.
81 names: Candidate subdirectory names found on disk.
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.
90 probe = subprocess.run(
92 trusted_git_executable(),
98 input=
"\n".join(sorted(names)) +
"\n",
103 return {line.strip()
for line
in probe.stdout.splitlines()
if line.strip()}
106def immediate_subdirs(tests_dir: Path) -> set[str]:
107 """Return the names of the directories directly under `tests_dir`.
110 tests_dir: The ``tests/`` directory to scan.
113 Every immediate child directory name, excluding dot-directories (which
114 are tooling rather than test content) and gitignored build output.
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)
120def documented_subdirs(readme_text: str) -> set[str]:
121 """Return the subdirectory names the README's table claims to describe.
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.
128 readme_text: The full contents of ``tests/README.md``.
131 Every ``name`` written as ```name/``` in a table's first column.
133 names: set[str] = set()
134 for line
in readme_text.splitlines():
135 stripped = line.strip()
136 if not stripped.startswith(
"|"):
138 first_cell = stripped.strip(
"|").split(
"|", 1)[0].strip()
139 match = ROW_NAME_RE.match(first_cell)
141 names.add(match.group(1))
145def drift_problems(actual: set[str], documented: set[str]) -> list[str]:
146 """Return every way the tree and the README disagree.
149 actual: Subdirectory names that exist under ``tests/``.
150 documented: Subdirectory names the README describes.
153 One message per undocumented subdirectory and per documented-but-absent
154 entry; empty when the two sets are equal.
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)
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)
170 tests_dir: Path, readme: Path, min_subdirs: int = MIN_SUBDIRS
171) -> tuple[int, list[str]]:
172 """Compare a ``tests/`` tree against its README.
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.
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.
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"
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
195@dataclass(frozen=True)
197 """One selftest fixture and the verdict it must produce."""
201 documented: list[str]
207def _selftest_cases() -> list[_Case]:
208 """Return the fixtures the selftest asserts, one per direction.
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
216 trio = [
"alpha",
"beta",
"gamma"]
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),
225def _write_fixture(root: Path, case: _Case) -> tuple[Path, Path]:
226 """Build a throwaway ``tests/`` tree and README for one selftest case.
229 root: Temporary directory to build inside.
230 case: The fixture to materialise.
233 The ``(tests_dir, readme_path)`` pair to hand to :func:`evaluate`.
235 tests_dir = root /
"tests"
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"
242 "# tests/\n\n| Subdirectory | What it holds |\n|---|---|\n" + rows,
245 return tests_dir, readme
248def _run_case(root: Path, case: _Case) -> list[str]:
249 """Run one selftest case and return the ways it misbehaved.
252 root: Fresh temporary directory for this case.
253 case: The fixture and its expected verdict.
256 A message per assertion the case failed; empty when it behaved.
258 tests_dir, readme = _write_fixture(root, case)
259 code, messages = evaluate(tests_dir, readme, min_subdirs=case.floor)
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}")
271def _selftest_ignored() -> list[str]:
272 """Assert the gitignore carve-out, in both directions.
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.
281 One message per way the carve-out misbehaved; empty when it is correct.
283 failures: list[str] = []
284 git = trusted_git_executable()
285 with tempfile.TemporaryDirectory()
as tmp:
288 [git,
"init",
"--quiet", str(root)], capture_output=
True, text=
True, check=
False
290 (root /
".gitignore").write_text(
"build/\n", encoding=
"utf-8")
291 tests_dir = root /
"tests"
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"))
298 "# tests/\n\n| Subdirectory | What it holds |\n|---|---|\n" + rows,
301 code, messages = evaluate(tests_dir, readme, min_subdirs=3)
303 failures.append(f
" ignored build/ still demanded a README row: exit {code} {messages}")
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}")
312def _selftest_body() -> int:
313 """Prove both drift directions fire, a clean tree stays quiet, the floor holds.
316 0 when every fixture behaves, 1 otherwise.
318 cases = _selftest_cases()
319 failures: list[str] = []
321 with tempfile.TemporaryDirectory()
as tmp:
322 failures += _run_case(Path(tmp), case)
323 failures += _selftest_ignored()
325 print(
"check_tests_readme selftest FAILED:", file=sys.stderr)
326 print(
"\n".join(failures), file=sys.stderr)
329 f
"selftest OK: {len(cases)} cases plus the gitignore carve-out "
330 "(both drift directions + non-vacuity floor)"
335def _selftest() -> int:
336 """Run README inventory fixtures without inheriting the caller's repo."""
337 with isolated_git_environment():
338 return _selftest_body()
341def main(argv: list[str] |
None =
None) -> int:
345 argv: Command line, defaulting to ``sys.argv[1:]``.
348 0 when the README matches the tree, 1 on drift, 2 on a collapsed scan.
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)
355 code, messages = evaluate(TESTS_DIR, README)
357 count = len(immediate_subdirs(TESTS_DIR))
358 print(f
"tests/README.md OK: {count} subdirectory(ies) documented, none stale")
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)
367if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.