4"""Gate: every disambiguation README's machine-checkable claims still hold.
6The tree carries several pairs of things a newcomer can plausibly pick the wrong
7one of -- two filesystems, two firmware-update mechanisms, a facade and the
8driver underneath it. Each pair gets one very small README answering "which do I
9use, and why do both exist". Prose like that is exactly what rots: the library it
10names gets renamed, the symbol it cites disappears, the "two apps use this one"
11count silently becomes eleven, and nothing notices.
13So each of those READMEs carries its load-bearing claims in a machine-readable
14block, and this gate re-derives every one of them from the tree::
20 symbol: ra8_io_vfs_open
23 files: libs/ra8_fs/src/*.c = 32
26(abridged -- the real block carries more ``symbol`` rows, and the counts
27shown are whatever the tree held when this was written. Only the block in
28the README is checked; this one is here to show the syntax.)
30``this`` the directory this README lives in and speaks for. Exactly one.
31``that`` the thing it is being distinguished FROM. One or more.
32``symbol`` an identifier the prose cites. Must still occur inside one of the
33 declared paths, so a rename or a deletion fails the gate.
34``users`` an identifier plus the number of ``examples/**/CMakeLists.txt`` that
35 reference it. Re-counted here, so a number in the prose cannot drift.
36``files`` a repo-relative glob plus how many files match it. The other way a
37 number gets into this kind of prose ("116 bench apps"), and the other
40Registration is the block itself: a README containing the marker is in scope the
41moment it is committed. There is no second list to keep in sync -- that is the
42drift this gate exists to prevent, and a registry of anti-drift READMEs would
43reintroduce it one level up.
45Like every detector here it carries a **non-vacuity floor**. A scan that finds
46almost no blocks, or almost no claims, did not walk the tree it meant to, and
47"no drift found" against nothing is the silent pass this gate exists to stop.
49``--selftest`` asserts both directions over throwaway trees: a broken path, a
50vanished symbol, a stale count and a misfiled ``this`` each FIRE, an in-sync
51tree stays QUIET, and a collapsed scan is caught.
54from __future__
import annotations
60from pathlib
import Path
62REPO_ROOT = Path(__file__).resolve().parents[2]
64MARKER =
"<!-- disambig"
65"""Opening line of the claim block. Its presence is what puts a README in scope."""
67BLOCK_RE = re.compile(
r"<!--\s*disambig\s*\n(.*?)-->", re.DOTALL)
68"""The claim block body, between the marker and the comment terminator."""
70CLAIM_RE = re.compile(
r"^\s*(this|that|symbol|users|files)\s*:\s*(.+?)\s*$")
71"""One claim line. Anything else inside the block is a syntax error, not a comment."""
73USERS_RE = re.compile(
r"^(?P<token>[A-Za-z0-9_]+)\s*=\s*(?P<count>\d+)$")
74"""Right-hand side of a ``users:`` claim: an identifier and the expected count."""
76FILES_RE = re.compile(
r"^(?P<glob>\S+)\s*=\s*(?P<count>\d+)$")
77"""Right-hand side of a ``files:`` claim: a repo-relative glob and its file count."""
79NEVER_WALK = {
".git",
"build",
"build-cov",
"_deps",
"__pycache__",
"node_modules"}
80"""Directories with no authored content; never walked for any purpose."""
82NOT_OURS = {
"third_party",
"ra8_fonts"}
83"""Vendored and generated trees. Excluded from README DISCOVERY so a vendored
84README can never be pulled into scope, but NOT from symbol resolution: a block
85that says ``that: libs/third_party/filex`` is deliberately pointing there, and a
86symbol claim about the vendored side has to be allowed to find its target."""
103"""Suffixes searched when resolving a ``symbol:`` claim.
105``.md`` is deliberately absent. With it in, a symbol claim was satisfied by the
106very README that made it -- the claim block sits inside a ``.md`` file under the
107``this:`` path, so ``symbol: anything_at_all`` matched itself and the check could
108never fail. The selftest's rename case caught exactly that. A symbol claim has to
109be backed by source, not by the prose citing it."""
112"""Non-vacuity floor: fewer blocks than this means the scan did not walk the tree."""
115"""Non-vacuity floor on total claims, so a tree of empty blocks cannot pass."""
122def _walk_files(root: Path, *, ours_only: bool) -> list[Path]:
123 """Every file under ``root``, skipping directories with no authored content.
126 root: Directory to walk.
127 ours_only: When True, also skip vendored and generated trees.
130 Paths of regular files, in a stable sorted order.
132 skip = NEVER_WALK | NOT_OURS
if ours_only
else NEVER_WALK
133 if not root.is_dir():
137 for path
in sorted(root.rglob(
"*"))
138 if not any(part
in skip
for part
in path.parts)
and path.is_file()
142def _read(path: Path) -> str:
143 """Read a file as text, tolerating undecodable bytes.
149 The file's contents, with undecodable bytes replaced.
152 return path.read_text(encoding=
"utf-8", errors=
"replace")
157def _word_re(token: str) -> re.Pattern[str]:
158 """Compile an identifier-boundary match for ``token``.
161 token: Identifier to match.
164 A pattern matching the token only at identifier boundaries.
166 return re.compile(rf
"(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])")
169def find_readmes(root: Path) -> list[Path]:
170 """Locate every first-party README carrying a claim block.
173 root: Repository root to search.
176 Paths of in-scope READMEs, sorted.
178 files = _walk_files(root, ours_only=
True)
179 return [p
for p
in files
if p.name ==
"README.md" and MARKER
in _read(p)]
182def parse_block(text: str) -> tuple[list[tuple[str, str]], list[str]]:
183 """Extract the claim list from one README's text.
186 text: Full README contents.
189 A ``(claims, errors)`` pair. ``claims`` are ``(kind, value)`` in file
190 order; ``errors`` describe malformed blocks.
192 match = BLOCK_RE.search(text)
194 return [], [
"claim block opens but never closes with -->"]
195 claims: list[tuple[str, str]] = []
196 errors: list[str] = []
197 for raw
in match.group(1).splitlines():
200 claim = CLAIM_RE.match(raw)
202 errors.append(f
"unparsable claim line: {raw.strip()!r}")
204 claims.append((claim.group(1), claim.group(2)))
205 return claims, errors
208def count_example_users(root: Path, token: str) -> int:
209 """Count example listfiles that reference ``token`` as a whole identifier.
212 root: Repository root.
213 token: Identifier to look for.
216 Number of ``examples/**/CMakeLists.txt`` files mentioning it.
218 pattern = _word_re(token)
220 for path
in _walk_files(root /
"examples", ours_only=
True):
221 if path.name ==
"CMakeLists.txt" and pattern.search(_read(path)):
226def count_glob(root: Path, pattern: str) -> int:
227 """Count files matching a repo-relative glob.
230 root: Repository root.
231 pattern: Glob such as ``examples/x/*/hil.conf``.
234 Number of matching regular files.
236 return sum(1
for p
in root.glob(pattern)
if p.is_file())
239def symbol_occurs(root: Path, paths: list[str], symbol: str) -> bool:
240 """Report whether ``symbol`` still occurs inside any declared path.
243 root: Repository root.
244 paths: Repo-relative ``this``/``that`` paths from the block.
245 symbol: Identifier the README cites.
248 True when at least one occurrence is found.
250 pattern = _word_re(symbol)
253 candidates = [target]
if target.is_file()
else _walk_files(target, ours_only=
False)
254 for path
in candidates:
255 if path.suffix
in TEXT_SUFFIXES
and pattern.search(_read(path)):
260def _structure_problems(root: Path, readme: Path, this: list[str], that: list[str]) -> list[str]:
261 """Check a block's shape: one owner, at least one counterpart, live paths.
264 root: Repository root.
265 readme: The README being checked.
266 this: Values of the ``this:`` claims.
267 that: Values of the ``that:`` claims.
270 Problems with the block's structure.
272 problems: list[str] = []
274 problems.append(f
"needs exactly one 'this:' claim, found {len(this)}")
276 problems.append(
"needs at least one 'that:' claim")
277 owner = readme.parent.relative_to(root).as_posix()
278 if this
and this[0] != owner:
279 problems.append(f
"'this: {this[0]}' but the README lives in {owner}")
281 f
"declared path does not exist: {p}" for p
in this + that
if not (root / p).exists()
286def _symbol_problem(root: Path, live_paths: list[str], value: str) -> str |
None:
287 """Check one ``symbol:`` claim.
290 root: Repository root.
291 live_paths: Declared paths that exist.
292 value: The claimed identifier.
295 A problem string, or None when the claim holds.
298 return f
"symbol '{value}' has no live path to search"
299 if not symbol_occurs(root, live_paths, value):
300 return f
"symbol no longer occurs in the declared paths: {value}"
304def _count_problem(root: Path, kind: str, value: str) -> str |
None:
305 """Check one ``users:`` or ``files:`` claim by recomputing it.
308 root: Repository root.
309 kind: Either ``users`` or ``files``.
310 value: The claim's right-hand side.
313 A problem string, or None when the recomputed number matches.
315 pattern, shape = (USERS_RE,
"TOKEN = N")
if kind ==
"users" else (FILES_RE,
"GLOB = N")
316 spec = pattern.match(value)
318 return f
"{kind} claim must read '{shape}', got {value!r}"
319 subject = spec.group(1)
320 claimed = int(spec.group(
"count"))
321 actual = count_example_users(root, subject)
if kind ==
"users" else count_glob(root, subject)
322 if actual != claimed:
324 f
"{kind} '{subject}' claims {claimed}, tree has {actual}"
325 " -- update the README (and any number in its prose)"
330def check_readme(root: Path, readme: Path) -> list[str]:
331 """Verify one README's claims against the tree.
334 root: Repository root.
335 readme: The README to check.
338 Human-readable problems, each prefixed with the README path; empty when
341 claims, syntax = parse_block(_read(readme))
342 this = [v
for k, v
in claims
if k ==
"this"]
343 that = [v
for k, v
in claims
if k ==
"that"]
344 problems = syntax + _structure_problems(root, readme, this, that)
346 live_paths = [p
for p
in this + that
if (root / p).exists()]
347 for kind, value
in claims:
350 found = _symbol_problem(root, live_paths, value)
351 elif kind
in (
"users",
"files"):
352 found = _count_problem(root, kind, value)
353 if found
is not None:
354 problems.append(found)
356 rel = readme.relative_to(root).as_posix()
357 return [f
"{rel}: {p}" for p
in problems]
360def evaluate(root: Path) -> tuple[int, list[str], int, int]:
361 """Check every disambiguation README under ``root``.
364 root: Repository root.
367 ``(exit_code, problems, readme_count, claim_count)``.
369 readmes = find_readmes(root)
370 problems: list[str] = []
372 for readme
in readmes:
373 claims, _ = parse_block(_read(readme))
374 claim_total += len(claims)
375 problems += check_readme(root, readme)
376 if len(readmes) < MIN_READMES
or claim_total < MIN_CLAIMS:
378 f
"collapsed scan: {len(readmes)} README(s) and {claim_total} claim(s)"
379 f
" (floor {MIN_READMES}/{MIN_CLAIMS}) -- the scan did not reach the tree"
381 return EXIT_VACUOUS, problems, len(readmes), claim_total
382 return (EXIT_DRIFT
if problems
else EXIT_OK), problems, len(readmes), claim_total
385_GOOD_README =
"""# libs/thing -- thing vs other
387Use `thing`. `other` exists because of history.
396files: examples/*/CMakeLists.txt = 1
401def _make_tree(root: Path, readme_body: str) ->
None:
402 """Build a throwaway repository the checker can walk.
405 root: Directory to populate.
406 readme_body: Contents of ``libs/thing/README.md``.
408 (root /
"libs" /
"thing").mkdir(parents=
True)
409 (root /
"libs" /
"other").mkdir(parents=
True)
410 (root /
"examples" /
"app").mkdir(parents=
True)
411 (root /
"libs" /
"thing" /
"thing.c").write_text(
"void thing_open(void) {}\n")
412 (root /
"libs" /
"other" /
"other.c").write_text(
"void other_open(void) {}\n")
413 (root /
"examples" /
"app" /
"CMakeLists.txt").write_text(
"LIBS thing\n")
414 (root /
"libs" /
"thing" /
"README.md").write_text(readme_body)
417def _selftest_cases() -> list[tuple[str, str, bool]]:
418 """Fixtures asserting both directions.
421 ``(name, readme_body, must_fire)`` triples.
424 (
"MUST NOT FIRE: every claim holds", _GOOD_README,
False),
426 "MUST FIRE: declared path is gone",
427 _GOOD_README.replace(
"that: libs/other",
"that: libs/vanished"),
431 "MUST FIRE: cited symbol was renamed away",
432 _GOOD_README.replace(
"symbol: thing_open",
"symbol: thing_opened"),
436 "MUST FIRE: user count drifted",
437 _GOOD_README.replace(
"users: thing = 1",
"users: thing = 7"),
441 "MUST FIRE: README misfiled against its own 'this'",
442 _GOOD_README.replace(
"this: libs/thing",
"this: libs/other"),
446 "MUST FIRE: claim line is not parsable",
447 _GOOD_README.replace(
"symbol: thing_open\n",
"symbol thing_open\n"),
451 "MUST FIRE: file-count claim drifted",
452 _GOOD_README.replace(
"examples/*/CMakeLists.txt = 1",
"examples/*/CMakeLists.txt = 9"),
455 (
"MUST FIRE: block never closes", _GOOD_README.replace(
"-->",
""),
True),
459def _selftest() -> int:
460 """Prove the gate fires on drift, stays quiet in sync, and rejects vacuity.
463 0 when every fixture behaves, 1 otherwise.
465 failures: list[str] = []
468 for name, body, must_fire
in _selftest_cases():
469 with tempfile.TemporaryDirectory()
as tmp:
471 _make_tree(root, body)
472 fired = bool(check_readme(root, root /
"libs" /
"thing" /
"README.md"))
473 if fired != must_fire:
474 failures.append(f
" {name}: fired={fired}, expected={must_fire}")
478 with tempfile.TemporaryDirectory()
as tmp:
480 (empty /
"examples").mkdir(parents=
True)
481 code, _, _, _ = evaluate(empty)
482 if code != EXIT_VACUOUS:
483 failures.append(f
" MUST FIRE: a tree with no blocks is vacuous, got exit {code}")
485 print(
" ok MUST FIRE: a scan that finds nothing is vacuous, not clean")
487 live, _, live_readmes, live_claims = evaluate(REPO_ROOT)
488 if live == EXIT_VACUOUS:
489 failures.append(
" the live tree trips the non-vacuity floor")
491 print(f
" ok live scope: {live_readmes} README(s), {live_claims} claim(s)")
494 print(
"check_disambig_readmes selftest FAILED:", file=sys.stderr)
495 print(
"\n".join(failures), file=sys.stderr)
497 print(
"check_disambig_readmes: selftest passed (both directions + floor).")
501def main(argv: list[str] |
None =
None) -> int:
505 argv: Command line, defaulting to ``sys.argv[1:]``.
508 0 when every claim holds, 1 on drift, 2 on a collapsed scan.
510 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
511 parser.add_argument(
"--selftest", action=
"store_true", help=
"prove both directions fire")
512 args = parser.parse_args(argv)
516 code, problems, readmes, claims = evaluate(REPO_ROOT)
518 print(f
"check_disambig_readmes: {readmes} README(s), {claims} claim(s) all still hold.")
520 label =
"collapsed scan" if code == EXIT_VACUOUS
else "stale claim(s)"
521 print(f
"check_disambig_readmes: {label}:", file=sys.stderr)
522 for problem
in problems:
523 print(f
" {problem}", file=sys.stderr)
527if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.