Files
Sylpheed/tools/re/check-capture-citations
MechaCat02 43f0a905a0
Some checks failed
CI / Native — linux (pull_request) Failing after 1h1m57s
CI / WASM — Web (pull_request) Successful in 24m51s
CI / Formatting (pull_request) Successful in 28s
fix(re): the capture check was red on a clean tree
`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>
2026-09-21 18:12:27 +02:00

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())