ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_disambig_readmes.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: every disambiguation README's machine-checkable claims still hold.
5
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.
12
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::
15
16 <!-- disambig
17 this: libs/ra8_fs
18 that: libs/ra8_io
19 symbol: ra8_fs_format
20 symbol: ra8_io_vfs_open
21 users: ra8_fs = 31
22 users: ra8_io = 15
23 files: libs/ra8_fs/src/*.c = 32
24 -->
25
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.)
29
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
38 way it goes stale.
39
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.
44
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.
48
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.
52"""
53
54from __future__ import annotations
55
56import argparse
57import re
58import sys
59import tempfile
60from pathlib import Path
61
62REPO_ROOT = Path(__file__).resolve().parents[2]
63
64MARKER = "<!-- disambig"
65"""Opening line of the claim block. Its presence is what puts a README in scope."""
66
67BLOCK_RE = re.compile(r"<!--\s*disambig\s*\n(.*?)-->", re.DOTALL)
68"""The claim block body, between the marker and the comment terminator."""
69
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."""
72
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."""
75
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."""
78
79NEVER_WALK = {".git", "build", "build-cov", "_deps", "__pycache__", "node_modules"}
80"""Directories with no authored content; never walked for any purpose."""
81
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."""
87
88TEXT_SUFFIXES = {
89 ".c",
90 ".h",
91 ".cpp",
92 ".hpp",
93 ".py",
94 ".sh",
95 ".cmake",
96 ".txt",
97 ".yml",
98 ".yaml",
99 ".ld",
100 ".dox",
101 ".conf",
102}
103"""Suffixes searched when resolving a ``symbol:`` claim.
104
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."""
110
111MIN_READMES = 4
112"""Non-vacuity floor: fewer blocks than this means the scan did not walk the tree."""
113
114MIN_CLAIMS = 15
115"""Non-vacuity floor on total claims, so a tree of empty blocks cannot pass."""
116
117EXIT_OK = 0
118EXIT_DRIFT = 1
119EXIT_VACUOUS = 2
120
121
122def _walk_files(root: Path, *, ours_only: bool) -> list[Path]:
123 """Every file under ``root``, skipping directories with no authored content.
124
125 Args:
126 root: Directory to walk.
127 ours_only: When True, also skip vendored and generated trees.
128
129 Returns:
130 Paths of regular files, in a stable sorted order.
131 """
132 skip = NEVER_WALK | NOT_OURS if ours_only else NEVER_WALK
133 if not root.is_dir():
134 return []
135 return [
136 path
137 for path in sorted(root.rglob("*"))
138 if not any(part in skip for part in path.parts) and path.is_file()
139 ]
140
141
142def _read(path: Path) -> str:
143 """Read a file as text, tolerating undecodable bytes.
144
145 Args:
146 path: File to read.
147
148 Returns:
149 The file's contents, with undecodable bytes replaced.
150 """
151 try:
152 return path.read_text(encoding="utf-8", errors="replace")
153 except OSError:
154 return ""
155
156
157def _word_re(token: str) -> re.Pattern[str]:
158 """Compile an identifier-boundary match for ``token``.
159
160 Args:
161 token: Identifier to match.
162
163 Returns:
164 A pattern matching the token only at identifier boundaries.
165 """
166 return re.compile(rf"(?<![A-Za-z0-9_]){re.escape(token)}(?![A-Za-z0-9_])")
167
168
169def find_readmes(root: Path) -> list[Path]:
170 """Locate every first-party README carrying a claim block.
171
172 Args:
173 root: Repository root to search.
174
175 Returns:
176 Paths of in-scope READMEs, sorted.
177 """
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)]
180
181
182def parse_block(text: str) -> tuple[list[tuple[str, str]], list[str]]:
183 """Extract the claim list from one README's text.
184
185 Args:
186 text: Full README contents.
187
188 Returns:
189 A ``(claims, errors)`` pair. ``claims`` are ``(kind, value)`` in file
190 order; ``errors`` describe malformed blocks.
191 """
192 match = BLOCK_RE.search(text)
193 if match is None:
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():
198 if not raw.strip():
199 continue
200 claim = CLAIM_RE.match(raw)
201 if claim is None:
202 errors.append(f"unparsable claim line: {raw.strip()!r}")
203 continue
204 claims.append((claim.group(1), claim.group(2)))
205 return claims, errors
206
207
208def count_example_users(root: Path, token: str) -> int:
209 """Count example listfiles that reference ``token`` as a whole identifier.
210
211 Args:
212 root: Repository root.
213 token: Identifier to look for.
214
215 Returns:
216 Number of ``examples/**/CMakeLists.txt`` files mentioning it.
217 """
218 pattern = _word_re(token)
219 hits = 0
220 for path in _walk_files(root / "examples", ours_only=True):
221 if path.name == "CMakeLists.txt" and pattern.search(_read(path)):
222 hits += 1
223 return hits
224
225
226def count_glob(root: Path, pattern: str) -> int:
227 """Count files matching a repo-relative glob.
228
229 Args:
230 root: Repository root.
231 pattern: Glob such as ``examples/x/*/hil.conf``.
232
233 Returns:
234 Number of matching regular files.
235 """
236 return sum(1 for p in root.glob(pattern) if p.is_file())
237
238
239def symbol_occurs(root: Path, paths: list[str], symbol: str) -> bool:
240 """Report whether ``symbol`` still occurs inside any declared path.
241
242 Args:
243 root: Repository root.
244 paths: Repo-relative ``this``/``that`` paths from the block.
245 symbol: Identifier the README cites.
246
247 Returns:
248 True when at least one occurrence is found.
249 """
250 pattern = _word_re(symbol)
251 for rel in paths:
252 target = root / rel
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)):
256 return True
257 return False
258
259
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.
262
263 Args:
264 root: Repository root.
265 readme: The README being checked.
266 this: Values of the ``this:`` claims.
267 that: Values of the ``that:`` claims.
268
269 Returns:
270 Problems with the block's structure.
271 """
272 problems: list[str] = []
273 if len(this) != 1:
274 problems.append(f"needs exactly one 'this:' claim, found {len(this)}")
275 if not that:
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}")
280 problems += [
281 f"declared path does not exist: {p}" for p in this + that if not (root / p).exists()
282 ]
283 return problems
284
285
286def _symbol_problem(root: Path, live_paths: list[str], value: str) -> str | None:
287 """Check one ``symbol:`` claim.
288
289 Args:
290 root: Repository root.
291 live_paths: Declared paths that exist.
292 value: The claimed identifier.
293
294 Returns:
295 A problem string, or None when the claim holds.
296 """
297 if not live_paths:
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}"
301 return None
302
303
304def _count_problem(root: Path, kind: str, value: str) -> str | None:
305 """Check one ``users:`` or ``files:`` claim by recomputing it.
306
307 Args:
308 root: Repository root.
309 kind: Either ``users`` or ``files``.
310 value: The claim's right-hand side.
311
312 Returns:
313 A problem string, or None when the recomputed number matches.
314 """
315 pattern, shape = (USERS_RE, "TOKEN = N") if kind == "users" else (FILES_RE, "GLOB = N")
316 spec = pattern.match(value)
317 if spec is None:
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:
323 return (
324 f"{kind} '{subject}' claims {claimed}, tree has {actual}"
325 " -- update the README (and any number in its prose)"
326 )
327 return None
328
329
330def check_readme(root: Path, readme: Path) -> list[str]:
331 """Verify one README's claims against the tree.
332
333 Args:
334 root: Repository root.
335 readme: The README to check.
336
337 Returns:
338 Human-readable problems, each prefixed with the README path; empty when
339 every claim holds.
340 """
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)
345
346 live_paths = [p for p in this + that if (root / p).exists()]
347 for kind, value in claims:
348 found = None
349 if kind == "symbol":
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)
355
356 rel = readme.relative_to(root).as_posix()
357 return [f"{rel}: {p}" for p in problems]
358
359
360def evaluate(root: Path) -> tuple[int, list[str], int, int]:
361 """Check every disambiguation README under ``root``.
362
363 Args:
364 root: Repository root.
365
366 Returns:
367 ``(exit_code, problems, readme_count, claim_count)``.
368 """
369 readmes = find_readmes(root)
370 problems: list[str] = []
371 claim_total = 0
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:
377 problems.append(
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"
380 )
381 return EXIT_VACUOUS, problems, len(readmes), claim_total
382 return (EXIT_DRIFT if problems else EXIT_OK), problems, len(readmes), claim_total
383
384
385_GOOD_README = """# libs/thing -- thing vs other
386
387Use `thing`. `other` exists because of history.
388
389<!-- disambig
390this: libs/thing
391that: libs/other
392symbol: thing_open
393symbol: other_open
394users: thing = 1
395users: other = 0
396files: examples/*/CMakeLists.txt = 1
397-->
398"""
399
400
401def _make_tree(root: Path, readme_body: str) -> None:
402 """Build a throwaway repository the checker can walk.
403
404 Args:
405 root: Directory to populate.
406 readme_body: Contents of ``libs/thing/README.md``.
407 """
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)
415
416
417def _selftest_cases() -> list[tuple[str, str, bool]]:
418 """Fixtures asserting both directions.
419
420 Returns:
421 ``(name, readme_body, must_fire)`` triples.
422 """
423 return [
424 ("MUST NOT FIRE: every claim holds", _GOOD_README, False),
425 (
426 "MUST FIRE: declared path is gone",
427 _GOOD_README.replace("that: libs/other", "that: libs/vanished"),
428 True,
429 ),
430 (
431 "MUST FIRE: cited symbol was renamed away",
432 _GOOD_README.replace("symbol: thing_open", "symbol: thing_opened"),
433 True,
434 ),
435 (
436 "MUST FIRE: user count drifted",
437 _GOOD_README.replace("users: thing = 1", "users: thing = 7"),
438 True,
439 ),
440 (
441 "MUST FIRE: README misfiled against its own 'this'",
442 _GOOD_README.replace("this: libs/thing", "this: libs/other"),
443 True,
444 ),
445 (
446 "MUST FIRE: claim line is not parsable",
447 _GOOD_README.replace("symbol: thing_open\n", "symbol thing_open\n"),
448 True,
449 ),
450 (
451 "MUST FIRE: file-count claim drifted",
452 _GOOD_README.replace("examples/*/CMakeLists.txt = 1", "examples/*/CMakeLists.txt = 9"),
453 True,
454 ),
455 ("MUST FIRE: block never closes", _GOOD_README.replace("-->", ""), True),
456 ]
457
458
459def _selftest() -> int:
460 """Prove the gate fires on drift, stays quiet in sync, and rejects vacuity.
461
462 Returns:
463 0 when every fixture behaves, 1 otherwise.
464 """
465 failures: list[str] = []
466 # The non-vacuity floor would swallow every single-README fixture, so the
467 # per-case runs measure PROBLEMS and the floor gets a dedicated case below.
468 for name, body, must_fire in _selftest_cases():
469 with tempfile.TemporaryDirectory() as tmp:
470 root = Path(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}")
475 else:
476 print(f" ok {name}")
477
478 with tempfile.TemporaryDirectory() as tmp:
479 empty = Path(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}")
484 else:
485 print(" ok MUST FIRE: a scan that finds nothing is vacuous, not clean")
486
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")
490 else:
491 print(f" ok live scope: {live_readmes} README(s), {live_claims} claim(s)")
492
493 if failures:
494 print("check_disambig_readmes selftest FAILED:", file=sys.stderr)
495 print("\n".join(failures), file=sys.stderr)
496 return 1
497 print("check_disambig_readmes: selftest passed (both directions + floor).")
498 return 0
499
500
501def main(argv: list[str] | None = None) -> int:
502 """Entry point.
503
504 Args:
505 argv: Command line, defaulting to ``sys.argv[1:]``.
506
507 Returns:
508 0 when every claim holds, 1 on drift, 2 on a collapsed scan.
509 """
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)
513 if args.selftest:
514 return _selftest()
515
516 code, problems, readmes, claims = evaluate(REPO_ROOT)
517 if code == EXIT_OK:
518 print(f"check_disambig_readmes: {readmes} README(s), {claims} claim(s) all still hold.")
519 return EXIT_OK
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)
524 return code
525
526
527if __name__ == "__main__":
528 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298