`committed()` built the directory set from `range(4, len(parts))`. Index 3 is `docs/re/captures` itself, so every SUB-directory was registered and the root never was. Eight pages cite the directory as a whole -- PROTOCOL's evidence table, CONSOLIDATION, CONTAINERS-AND-AGENTS, decoder-loop, both HANDOFFs, BLOCKED, verify-screen-blend-divergence -- and each of those looked like a citation of a file that does not exist. `check-capture-citations` exited 1 on a correct checkout, which is the failure mode its own comments warn about: "a gate that is red on every clean checkout" teaches people to ignore it. The selftest could not see this. It takes `next(iter(dirs))`, and an arbitrary member of that set is always a sub-directory -- the one case that works. It now asserts the root explicitly, with and without its trailing slash. Both new cases were confirmed to FAIL against the unfixed function before the fix went in; a selftest case that passes either way is decoration. Measured: full check 203 citations, 212 captures, 0 dangling, 0 tracked assets, exit 0. Before: exit 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
13 KiB
Python
Executable File
288 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Do the captures and the pages that cite them agree?
|
|
|
|
tools/re/check-capture-citations # assert
|
|
tools/re/check-capture-citations --selftest # can it fail?
|
|
tools/re/check-capture-citations --orphans # list the unreferenced files
|
|
|
|
`tools/port/check-citations` has checked `docs/port/*.md` since it was written.
|
|
**`docs/re/` has never had one**, and `docs/re/captures/` is 118 MB — the
|
|
largest thing in the repository, and the one place a file can be added, never
|
|
cited, and never noticed.
|
|
|
|
⚠️ 2026-09-19, issue #49: **game assets are no longer committed.** Screenshots
|
|
and savegame blobs live in the working tree and are gitignored, so the pages'
|
|
relative links still resolve on a machine that has them while nothing ships.
|
|
That inverts half of this check — "is it committed?" became "is it PRESENT?",
|
|
and a NEW failure appeared: an asset that IS tracked. Both are below.
|
|
|
|
Three failures, which are opposites and must not be conflated:
|
|
|
|
* a page cites a capture that **is not present** — a reader following it
|
|
gets nothing. That is an error, exactly as in `check-citations`.
|
|
* a game asset that **is tracked by git** — issue #49 says it must not be.
|
|
* a capture that **no page cites** — not an error. It may be evidence a page
|
|
should have cited, and deleting on that basis would silently ratify the
|
|
omission. Reported, counted, never failed on.
|
|
|
|
⚠️ THE NAIVE VERSION OF THIS CHECK REPORTS 10 DANGLING AND IS WRONG ABOUT 9.
|
|
A first pass with a plain path regex claimed ten; the real number is one. Eight
|
|
were **directory** references — `docs/re/captures/ui-layout/` — which resolve
|
|
perfectly well and are absent only from `git ls-tree`, which lists files. One
|
|
was a path at the end of a sentence, with the full stop pulled into the match.
|
|
Both are handled below, and both are in the selftest, because a checker that
|
|
cries wolf nine times out of ten is worse than none: it teaches you to ignore it.
|
|
"""
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = "docs/re/captures/"
|
|
# A capture path, optionally with the `docs/re/` prefix, optionally a directory.
|
|
CITE = re.compile(r"(?:docs/re/)?captures/[A-Za-z0-9._/-]+")
|
|
# ⚠️ `git grep -E` is POSIX ERE: it has no `(?:`, and a pattern carrying one
|
|
# matches NOTHING while exiting 0. That silence read as "no page cites any
|
|
# capture" — 257 orphans, 0 citations — which is the same shape as the export
|
|
# table that returned empty and let the build succeed.
|
|
CITE_ERE = r"(docs/re/)?captures/[A-Za-z0-9._/-]+"
|
|
# Where a citation may live. Prose AND code: tests and tools open these files.
|
|
SEARCH = ["docs", "crates", "tools", "port", "authored"]
|
|
# 🔴 AND NOT THIS FILE. `SEARCH` includes `tools`, so the scan read *itself* --
|
|
# and `selftest()`'s planted fixture below is a literal capture path. The check
|
|
# therefore reported a dangling citation to a file that will never exist, and
|
|
# exited 1 on a clean checkout, for ever. A gate that is red before anyone
|
|
# changes anything teaches people to ignore it.
|
|
# Excluding only this one file keeps every other tool in scope; a real citation
|
|
# belongs in a page or a tool that opens the capture, never in the checker.
|
|
SELF = ":!tools/re/check-capture-citations"
|
|
|
|
|
|
def git(*args: str) -> str:
|
|
return subprocess.run(["git", *args], capture_output=True, text=True).stdout
|
|
|
|
|
|
def committed() -> tuple[set[str], set[str]]:
|
|
"""Every capture in the WORKING TREE, and every directory they imply.
|
|
|
|
⚠️ This read `git ls-tree HEAD` first, and that made the check useless for
|
|
the thing it is for: restoring a missing capture left it still reporting the
|
|
citation as dangling, because the fix was in the index and the check was
|
|
looking at the last commit. A pre-merge check must see the change being
|
|
proposed. `tools/port/check-citations` uses the working tree for exactly
|
|
this reason; so does this.
|
|
"""
|
|
# 🔴 NOT `git ls-files`: since #49 the assets are deliberately untracked, so
|
|
# the index no longer knows they exist. Presence is a question about the
|
|
# working tree, and asking git would report every screenshot as missing and
|
|
# fail on all 203 citations.
|
|
files = set()
|
|
for dirpath, _dirnames, filenames in os.walk(ROOT):
|
|
for fn in filenames:
|
|
if fn == ".gitignore":
|
|
continue
|
|
files.add(os.path.join(dirpath, fn).replace(os.sep, "/"))
|
|
# 🔴 START AT 3, NOT 4 — 3 is `docs/re/captures` ITSELF. Starting at 4 built
|
|
# every sub-directory and never the root, so the eight pages that cite the
|
|
# directory as a whole ("evidence lives in `docs/re/captures/`") each looked
|
|
# like a citation of a file that does not exist, and the gate was red on a
|
|
# clean, correct tree. The selftest could not see it either: it took an
|
|
# arbitrary member of `dirs`, which is always a sub-directory.
|
|
dirs = set()
|
|
for f in files:
|
|
parts = f.split("/")
|
|
for i in range(3, len(parts)): # docs/re/captures[/<dir>/...]
|
|
dirs.add("/".join(parts[:i]) + "/")
|
|
return files, dirs
|
|
|
|
|
|
ASSET_SUFFIXES = (".png", ".jpg", ".jpeg", ".bin")
|
|
|
|
|
|
def tracked_assets() -> list[str]:
|
|
"""Game assets that are committed — forbidden since issue #49.
|
|
|
|
The repo carries code, tooling and docs. A screenshot that sneaks back in
|
|
is invisible in review (a binary shows as "Bin 0 -> 1234567 bytes") and is
|
|
permanent once merged, because removing it later needs a history rewrite.
|
|
So this is the half of the check that has to be loud.
|
|
"""
|
|
out = []
|
|
for l in git("ls-files").splitlines():
|
|
if l.lower().endswith(ASSET_SUFFIXES):
|
|
out.append(l)
|
|
return out
|
|
|
|
|
|
def cited() -> set[str]:
|
|
raw = git("grep", "-rhoE", CITE_ERE, "--", *SEARCH, SELF).splitlines()
|
|
out = set()
|
|
for line in raw:
|
|
c = line.split(":")[-1]
|
|
# ⚠️ A path at the end of a sentence takes the punctuation with it.
|
|
c = c.rstrip(".,;:)`")
|
|
if not c.startswith("docs/"):
|
|
c = "docs/re/" + c
|
|
out.add(c)
|
|
return out
|
|
|
|
|
|
def resolves(c: str, files: set[str], dirs: set[str]) -> bool:
|
|
if c in files:
|
|
return True
|
|
# A directory reference, written with or without its trailing slash.
|
|
return c.rstrip("/") + "/" in dirs
|
|
|
|
|
|
def named_bare() -> set[str]:
|
|
"""Basenames that appear anywhere outside the capture tree itself.
|
|
|
|
🔴 A CAPTURE CAN BE USED WITHOUT ITS PATH. `tools/re-capture/screen_match.py`
|
|
opens `movie-frame-attract-a.png` and `-b.png` by **filename**, building the
|
|
directory separately. A path-only scan calls both orphans, and deleting them
|
|
on that basis breaks the tool — which is what nearly happened. Anything
|
|
named at all counts as used.
|
|
"""
|
|
out = set()
|
|
for line in git("grep", "-rhoE",
|
|
r"[A-Za-z0-9._-]+\.(png|jpg|csv|log|txt|json|jsonl|bin|tsv)",
|
|
"--", *SEARCH, SELF).splitlines():
|
|
out.add(line.split(":")[-1].strip())
|
|
return out
|
|
|
|
|
|
def scan():
|
|
files, dirs = committed()
|
|
refs = cited()
|
|
bare = named_bare()
|
|
dangling = sorted(c for c in refs if not resolves(c, files, dirs))
|
|
# 🔴 A FILE INSIDE A CITED DIRECTORY IS CITED. A page that says "see
|
|
# `docs/re/captures/hud-runtime/`" is citing the whole directory, so every
|
|
# file in it is referenced and none is an orphan. Without this rule the
|
|
# first prune deleted the entire contents of two such directories and the
|
|
# check then went red on the very citations that made them evidence — it
|
|
# caught its own damage, which is the only reason this rule exists.
|
|
cited_dirs = {c.rstrip("/") + "/" for c in refs if resolves(c, files, dirs) and c not in files}
|
|
orphans = sorted(
|
|
f for f in files
|
|
if f not in refs
|
|
and os.path.basename(f) not in bare
|
|
and not any(f.startswith(d) for d in cited_dirs)
|
|
)
|
|
return files, refs, dangling, orphans
|
|
|
|
|
|
def selftest() -> int:
|
|
"""🔴 A CHECK THAT CANNOT FAIL IS NOT A CHECK — and this one has three ways
|
|
to be wrong, not one. It must catch a real dangling citation, and it must
|
|
NOT flag a directory reference or a path ending a sentence, which is how
|
|
the naive version produced nine false alarms."""
|
|
files, dirs = committed()
|
|
real_dir = next(iter(dirs), None)
|
|
real_file = next(iter(files), None)
|
|
if not real_dir or not real_file:
|
|
print("selftest: 🔴 no captures present — nothing to test against")
|
|
return 2
|
|
cases = [
|
|
("planted dangling caught", "docs/re/captures/no-such-file-anywhere.png", False),
|
|
("real file passed", real_file, True),
|
|
("directory reference passed", real_dir, True),
|
|
("directory without slash passed", real_dir.rstrip("/"), True),
|
|
# The root is the case the arbitrary `real_dir` above can never be.
|
|
("captures root itself passed", ROOT, True),
|
|
("captures root without slash passed", ROOT.rstrip("/"), True),
|
|
("sentence punctuation stripped", real_file + ".", True),
|
|
]
|
|
# 🔴 THE SELFTEST USED TO PASS WHILE THE SCAN RETURNED NOTHING. It exercised
|
|
# `resolves()` and never the gathering, so an ERE the grep could not compile
|
|
# produced "0 cited, 257 orphans" and five green ticks above it. A check
|
|
# whose selftest cannot see the failure that actually happened is decoration.
|
|
refs = cited()
|
|
gathering_ok = len(refs) > 50 and any(r.startswith(ROOT) for r in refs)
|
|
print(" %-34s %s (%d citations found)"
|
|
% ("citation gathering works", "ok" if gathering_ok else "🔴 FAILED", len(refs)))
|
|
|
|
# 🔴 REGRESSION GUARD FOR `SELF`. The planted fixture above is a literal
|
|
# capture path living in a file `SEARCH` covers. Before the exclusion
|
|
# existed the scan counted it as a real citation, so the check reported a
|
|
# dangling capture and exited 1 on a clean tree — permanently. If anyone
|
|
# drops `SELF`, this goes red here instead of in everybody else's run.
|
|
fixture_counted = cases[0][1] in refs
|
|
print(" %-34s %s"
|
|
% ("own fixtures not counted", "🔴 FAILED" if fixture_counted else "ok"))
|
|
|
|
# 🔴 THE #49 RULE NEEDS ITS OWN TICK. Presence now comes from the working
|
|
# tree, so a checkout with the captures present looks identical whether or
|
|
# not they are tracked — only this assertion can tell the difference.
|
|
stowaways = tracked_assets()
|
|
print(" %-34s %s%s"
|
|
% ("no game asset tracked", "ok" if not stowaways else "🔴 FAILED",
|
|
"" if not stowaways else " (%d tracked)" % len(stowaways)))
|
|
|
|
ok = gathering_ok and not fixture_counted and not stowaways
|
|
for name, path, want in cases:
|
|
got = resolves(path.rstrip(".,;:)`"), files, dirs)
|
|
mark = "ok" if got == want else "🔴 FAILED"
|
|
if got != want:
|
|
ok = False
|
|
print(" %-34s %s" % (name, mark))
|
|
print("selftest: %s" % ("ok" if ok else "🔴 BROKEN"))
|
|
return 0 if ok else 2
|
|
|
|
|
|
def main() -> int:
|
|
if "--selftest" in sys.argv:
|
|
return selftest()
|
|
files, refs, dangling, orphans = scan()
|
|
if "--orphans" in sys.argv:
|
|
for f in orphans:
|
|
print(f)
|
|
return 0
|
|
|
|
stowaways = tracked_assets()
|
|
print("captures present on disk : %d" % len(files))
|
|
print(" cited by a page or a tool : %d" % (len(files) - len(orphans)))
|
|
print(" cited by nothing : %d (reported, not failed —" % len(orphans))
|
|
print(" an orphan may be evidence a page owes)")
|
|
# 🔴 A BARE CLONE HAS NO CAPTURES AT ALL, and that is not a defect.
|
|
# Since #49 the assets are never committed, so a fresh clone, a worktree or
|
|
# a CI checkout legitimately has none — and the naive check calls all 134
|
|
# citations dangling and exits 1. A gate that is red on every clean checkout
|
|
# is one people learn to ignore, which is how the last wrong-by-default
|
|
# check in this file cost a session. Distinguish "none here" from "this one
|
|
# is missing": with some assets present, a gap is real and still fails.
|
|
assets_here = sum(1 for f in files if f.lower().endswith(ASSET_SUFFIXES))
|
|
if assets_here == 0 and dangling:
|
|
print(" ⓘ no captures on this checkout: %d citations unresolved" % len(dangling))
|
|
print(" Expected — captures are local-only (#49) and a fresh clone has none.")
|
|
print(" Copy them in, or read the evidence on the issue it is attached to.")
|
|
print(" 🔴 game assets TRACKED : %d" % len(stowaways))
|
|
return 1 if stowaways else 0
|
|
|
|
rc = 0
|
|
if dangling:
|
|
print(" 🔴 cited but NOT present : %d" % len(dangling))
|
|
for d in dangling:
|
|
print(" %s" % d)
|
|
print("\n🔴 a reader following those gets nothing. Restore the capture, fix the")
|
|
print(" path, or drop the citation.")
|
|
rc = 1
|
|
else:
|
|
print(" 🔴 cited but NOT present : 0")
|
|
|
|
if stowaways:
|
|
print(" 🔴 game assets TRACKED : %d (issue #49 — code, tooling, docs only)" % len(stowaways))
|
|
for a in stowaways[:10]:
|
|
print(" %s" % a)
|
|
if len(stowaways) > 10:
|
|
print(" … and %d more" % (len(stowaways) - 10))
|
|
print("\n🔴 run `git rm --cached` on those; they stay on disk and stay ignored.")
|
|
rc = 1
|
|
else:
|
|
print(" 🔴 game assets TRACKED : 0")
|
|
return rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|