#!/usr/bin/env python3 """citation_resolve_check.py -- does every `:` citation in this archive's prose actually resolve, and to which file? WHY THIS EXISTS This archive cites itself by line number. Its most-recurring review defect is not a wrong count and not a wrong hash family -- it is a citation that does not point where the sentence says it points. Logged in the ledger's own words: WORK-QUEUE.md:8586 "the identifiers printed for Zephyrus's newest file are the 11th's ... Filename and *Blocked on* text were right, the identifiers wrong." Third instance of this class in one week (reply 51 §3 -> :8467; reply 53 §0). WORK-QUEUE.md:8003 cites to :2264/:2684/:6847 "read back and checked by hand", because nothing checked them automatically. WORK-QUEUE.md:8009 "The hygiene count confirmed at two revisions, not one" -- the same read repeated by hand for want of a tool. citation_hash_class.py settles a token's FAMILY. queue_claim_check.py settles an item's CLOSURE. Neither settles whether a `path:line` lands on the line the citation is offered as evidence for. That is the third axis, and this is it. WHAT IT MEASURES (read-only; the only write path is --json) Two citation forms, in one left-to-right pass per file, paragraph by paragraph. EXPLICIT `:`, and `:-` for a range. The file is named AT the citation, so the verdict is a CLAIM: RESOLVES path in the tree at --rev, 1 <= line <= line_count STALE_LINE path in the tree, line > line_count -- the citation points past the end of the file it names EXTERNAL_PATH path not in this tree at --rev (a host path under /root, or another repository's file). OUT OF SCOPE and counted separately: the citation may be perfectly true, this tool cannot read it, and saying so is the honest verdict. BARE `:`, the troupe's inheritance idiom -- "the file last named". This is NOT a claim and this tool refuses to make it one. It was written to resolve the inherited file, and the first run FALSIFIED that rule at scale (see below). Two candidates are offered for every bare cite and each is resolved separately: candidate 1 the nearest preceding path NAMED in the same blank-line-delimited block (the idiom as written) candidate 2 the file the citation is written IN AS_NAMED only candidate 1 has that line AS_SELF only candidate 2 has that line -- the named-file rule mis-binds and the citation is still resolvable AMBIGUOUS both have it, so the number does not decide EXTERNAL_NAMED candidate 1 is not in this tree at all (a host artifact); out of scope, same class as EXTERNAL_PATH -- and ALSO the shape a mis-bind takes when the last-named path is a host file UNRESOLVED no candidate in this tree has the line and nothing external was named: no outside reader can resolve this citation at --rev. This is the defect claim. Both candidates and both resolutions are printed for every row, so a reader never has to trust the classifier. WHY BARE CITES ARE NOT RESOLVED BY THE NAMED-FILE RULE -- measured, not argued The rule was implemented as a verdict first, and the run falsified it. Three of the largest inherited-"stale" groups were mis-binds, each verified by reading the cited text at HEAD: WORK-QUEUE.md:9804 ":9622's own code span" -- 9622 is a WORK-QUEUE.md line (the queue row describing the tool); the rule had it bound to tools/queue_claim_check.py, named 40 chars earlier. WORK-QUEUE.md:8023 the hygiene table's `:6348` column -- WORK-QUEUE.md lines in a row whose LAST column names the artifact the row is ABOUT. A table row names a file for a different purpose than the one the line number belongs to. checkins/2026-10-15-merlin.md:739 "`:2264` / `:2684` / `:6847`" -- again WORK-QUEUE.md lines, this time bound to the host file root/crucible/q51/helm-selftest-q51.py named in an earlier sentence of the same block (EXTERNAL_NAMED). So "nearest named file" is a candidate; candidate 2 is what rescues the residue. AS_SELF is the count of citations the named-file rule alone would have reported as defects and that in fact resolve against the file they are in. THE CENSUS AT REV babdadc, WITH ITS UNITS (297 .md; measured 2026-10-16, re-measured 2026-10-19 with the fence-aware build. The fence excludes quoted `path:line` rows, so this build reads one AS_SELF and five UNRESOLVED lower than the 2026-10-16 build did; the rev is the 16th's, the reading is today's.) EXPLICIT 410 citations: 113 RESOLVES / 0 STALE_LINE / 297 EXTERNAL_PATH. Every explicit citation whose file is in this repo resolves. BARE 1606 citations: 119 AS_NAMED / 715 AS_SELF / 51 AMBIGUOUS / 255 EXTERNAL_NAMED / 466 UNRESOLVED. Only 119 of 1606 bare cites are resolvable by the file-naming idiom the archive documents; 466 cannot be resolved by any reader at this rev without inferring the referent from context. That is the number this tool exists to make checkable, and it is the honest form of the ledger's own "a token quoted without its class". mentions 6613 occurrences carrying no line -- these seed the scope. Read every figure as a citation count, never as a defect count: 466 UNRESOLVED is not 466 errors. The tool's own catch is the worked example below. WORKED CATCH -- this tool's first finding is one of MY OWN artifacts checkins/2026-10-15-volt.md:9 reads "the three open items belong to other lanes (Q37 `:5278` the pipe's, `:8530` Mercury's, the career-lane apply queue `:7303` Alexander's)". Read at HEAD: that file is 281 lines, so neither candidate has line 5278; the block's last-named path is tools/queue_claim_check.py, which is 695 lines. Verdict UNRESOLVED, and it is correct -- the three numbers are WORK-QUEUE.md lines (5278 is Q37's Status line, 8530 is Q53's, 7303 is the Q53 opening), and the citation does not say so. Any troupe reader infers it; a stranger cannot check it. Reproduce with: python3 citation_resolve_check.py --only unresolved --context | grep volt.md NOT in scope, stated rather than implied: whether the cited LINE contains the evidence the sentence claims. That needs the claim, not just the pointer. A citation reproduced inside INLINE quotes is still a citation to this scanner, which does not parse quotation marks -- so a document that reports a defective citation verbatim appears to contain it. Measured instance: the landing check-in for this tool reproduces the defective citations it is reporting, verbatim, and the scanner counts every one of them. A FENCED BLOCK IS QUOTED MATERIAL, NOT THIS DOCUMENT'S PROSE -- made 2026-10-19 `tools/checkin_check.py` and `tools/queue_index.py` both refuse to read structure out of a ``` fence, on the rule that a quoted heading is not a heading. This scanner had no fence state, so a fenced listing that carries `path:line` rows was graded as the containing document's own citation. The question was left open on 2026-10-19 ("a quoted tool output is not a claim") and is closed here by measurement rather than taste: over the whole corpus at HEAD the fenced rows are 0.26% of the census -- six out of 2,273. Every one is a bare `:line` inside a code listing -- a number the listing quotes, not one the document asserts. The exclusion removes no prose verdict (pre/post row diff: exactly those six rows, no verdict changed) and the count is PRINTED rather than silently dropped, so a reader sees what was set aside. USAGE python3 citation_resolve_check.py [--repo DIR] [--rev HEAD] [--corpus PATH...] [--json OUT] [--only stale|asself|unresolved|ambiguous|external|externalnamed|named|all] [--context] [--selftest] # 14 fixtures """ import argparse import collections import json import re import subprocess import sys DEFAULT_REPO = "/root/troupe-standup" # The extensions this archive actually cites by line. Deliberately a closed list: # an open `\.[a-z]+` would swallow clock times and domain names. EXT = (r"md|markdown|py|json|txt|js|mjs|cjs|sh|bash|yml|yaml|html|css|csv|tsv|" r"jsonl|sql|db|sqlite|age|wav|mp3|mp4|gz|tgz|toml|cfg|ini|log|out|bak") # Path branch first, so a named path always wins over the bare branch at the same # offset. The bare branch requires the colon NOT to follow a digit or a letter -- # without that, `12:08:10Z`, `09:44:40Z` and `2026-10-11-0:0:2` are citations. # The whole `:line` part is optional, so a bare file MENTION is visible and can # seed the inheritance scope. (Getting this wrong the first time -- making the # cite group mandatory -- hid every mention and made every bare cite # unresolvable; the self-test fixture caught it before the tool was used.) PAT = re.compile( r"(?P[A-Za-z0-9_][A-Za-z0-9_.@/+-]*\.(?:%s))" r"(?:`?:(?P[0-9]{1,7})(?:\s*[-\u2013,]\s*(?P[0-9]{1,7}))?)?" r"|(?[0-9]{3,7})(?![0-9])" % EXT) # A FENCED BLOCK IS QUOTED MATERIAL, NOT THIS DOCUMENT'S PROSE (2026-10-19). # The same rule `queue_index.py` and `checkin_check.py` already apply to headings: # a quoted structure is not structure. Here the quoted thing is a citation. The # mask is computed per FILE (not per block) because a fence can contain blank # lines and so straddle two of `blocks()`' paragraphs; line numbers stay absolute, # which is what makes the join sound. The count is reported, never silently # dropped: `fenced skipped` is printed on every run. FENCE = re.compile(r"^\s*(```|~~~)") def fence_mask(text): """1-based line numbers that are a fence marker or sit inside a fence.""" out, in_fence = set(), False for i, raw in enumerate(text.split("\n"), 1): if FENCE.match(raw): out.add(i) in_fence = not in_fence continue if in_fence: out.add(i) return out def git(repo, *a): return subprocess.run(["git", "-C", repo, *a], capture_output=True).stdout def precondition(repo, rev): """None when `repo` is a git work tree that carries `rev`, else why not. Every reader below swallows git's exit status, so an unreadable repo or a mistyped revision came back as an empty tree and the census printed "citations: 0 / stale: 0" with rc 0 -- a green gate over an input it never saw. The caller turns this into exit 2. """ p = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", rev + "^{commit}"], capture_output=True) if p.returncode == 0: return None why = p.stderr.decode("utf-8", "replace").strip().splitlines() return "cannot read %s at %s: %s" % (repo, rev, why[-1] if why else "git rev-parse failed") class Store: """File text and line counts at one revision, read once per path.""" def __init__(self, repo, rev): self.repo, self.rev = repo, rev raw = git(repo, "ls-tree", "-r", "--name-only", rev).decode("utf-8", "replace") self.tree = {p for p in raw.split("\n") if p} self._text = {} def text(self, path): if path not in self._text: b = subprocess.run(["git", "-C", self.repo, "show", f"{self.rev}:{path}"], capture_output=True).stdout self._text[path] = b.decode("utf-8", "replace") return self._text[path] def nlines(self, path): t = self.text(path) if t == "": return 0 return t.count("\n") + (0 if t.endswith("\n") else 1) def has(self, path, line): return path is not None and path in self.tree and 1 <= line <= self.nlines(path) def blocks(text): """Yield (first_line_no, block_text). Paragraphs are the inheritance scope: the archive restarts its "the file named here" convention at a blank line.""" ln, start, buf = 1, 1, [] for raw in text.split("\n"): if raw.strip() == "": yield start, "\n".join(buf) buf, start = [], ln + 1 else: buf.append(raw) ln += 1 if buf: yield start, "\n".join(buf) def classify_explicit(store, target, want): if target not in store.tree: return "EXTERNAL_PATH", None have = store.nlines(target) return ("RESOLVES" if 1 <= want <= have else "STALE_LINE"), have def classify_bare(store, here, inherited, want): """Two candidates, each resolved separately; never collapsed into one verdict.""" named = store.has(inherited, want) self_ok = store.has(here, want) external = inherited is not None and inherited not in store.tree if named and self_ok and inherited != here: v = "AMBIGUOUS" elif named: v = "AS_NAMED" elif self_ok: v = "AS_SELF" elif external: v = "EXTERNAL_NAMED" else: v = "UNRESOLVED" return v, named, self_ok def scan(repo, rev, corpus, store): rows, stats = [], collections.Counter() for p in corpus: if p not in store.tree: stats["corpus_missing"] += 1 continue text = store.text(p) mask = fence_mask(text) for blk_first, blk in blocks(text): last = None for m in PAT.finditer(blk): abs_line = blk_first + blk.count("\n", 0, m.start()) if abs_line in mask: # quoted, not asserted stats["fenced_mentions" if m.group("path") and m.group("line") is None else "fenced_citations"] += 1 continue ctx = blk[max(0, m.start() - 70):m.end() + 70].replace("\n", " ") if m.group("path"): last = m.group("path") if m.group("line") is None: # a mention, no cite stats["mentions"] += 1 continue want = int(m.group("line")) kind = "explicit" verdict, have = classify_explicit(store, last, want) named = self_ok = None else: kind = "bare" want = int(m.group("bline")) verdict, named, self_ok = classify_bare(store, p, last, want) have = None stats[kind] += 1 stats[verdict] += 1 rows.append({"file": p, "line": abs_line, "kind": kind, "target": last, "want": want, "end": int(m.group("end")) if m.group("end") else None, "verdict": verdict, "have": have, "named_has": named, "self_has": self_ok, "context": ctx}) return rows, stats # --------------------------------------------------------------- self-test # Each fixture names its own CONTAINING file, because "candidate 2" is a property # of where the citation is written, not of the text alone. Line counts at HEAD: # WORK-QUEUE.md 9,856 / checkins/2026-10-15-volt.md 281 / the reply file 83. QUEUE = "WORK-QUEUE.md" VOLT = "checkins/2026-10-15-volt.md" FIXTURES = [ # (containing file, corpus text, [(kind, verdict, target, want)...]) (QUEUE, "see checkins/2026-10-15-volt.md:12 for the table", [("explicit", "RESOLVES", "checkins/2026-10-15-volt.md", 12)]), (QUEUE, "WORK-QUEUE.md:999999 is past the end", [("explicit", "STALE_LINE", "WORK-QUEUE.md", 999999)]), (QUEUE, "a host path, not this repo: mercury-task.txt:24", [("explicit", "EXTERNAL_PATH", "mercury-task.txt", 24)]), # the idiom as written: the bare cite takes the file named in its own block, # and when that IS the containing file it is not ambiguity (QUEUE, "The queue is long.\nWORK-QUEUE.md is the file.\n" "Its rows sit at :2264 and a bad one at :99999.", [("bare", "AS_NAMED", "WORK-QUEUE.md", 2264), ("bare", "UNRESOLVED", "WORK-QUEUE.md", 99999)]), # a blank line restarts the scope -> the named-file rule has nothing, and the # containing-file candidate is what resolves it. This is the AS_SELF class. (QUEUE, "WORK-QUEUE.md at head.\n\nand the same rows at :2264", [("bare", "AS_SELF", None, 2264)]), # two candidates, both with the line -> the number does not decide (QUEUE, "checkins/2026-10-15-volt.md is a file.\nand the queue's own rows at :123", [("bare", "AMBIGUOUS", "checkins/2026-10-15-volt.md", 123)]), # a named candidate that is not in this tree is out of scope, not a defect (QUEUE, "root/crucible/q51/helm-selftest-q51.py is his gate.\n" "the queue's row at :99999", [("bare", "EXTERNAL_NAMED", "root/crucible/q51/helm-selftest-q51.py", 99999)]), # the tool's own worked catch, replayed as a fixture: the real block names # tools/queue_claim_check.py (695 lines) and the containing check-in is 281 # lines, so neither candidate holds the queue's own line numbers (VOLT, "the claim check, and now `tools/queue_claim_check.py` run here, leaves " "the executable set empty: Q37 `:5278` the pipe's, `:8530` Mercury's", [("bare", "UNRESOLVED", "tools/queue_claim_check.py", 5278), ("bare", "UNRESOLVED", "tools/queue_claim_check.py", 8530)]), # STATED LIMITATION: a bare cite under 3 digits is out of scope by design -- # at 1-2 digits a line number is indistinguishable from a clock, a ratio or a # version, and :08 inside 12:08:10Z is exactly what that floor excludes. (QUEUE, "and the queue's own rows at :12", []), # a clock is not a citation (QUEUE, "read at 12:08:10Z and 09:44:40Z", []), # a range is reported with its end line (QUEUE, "recoveries/helm-pin-hash-convention.md:85-90", [("explicit", "RESOLVES", "recoveries/helm-pin-hash-convention.md", 85)]), # a fenced block is quoted material, not the containing document's prose -- # the same rule queue_index.py and checkin_check.py apply to headings (QUEUE, "```\ncheckins/2026-10-15-volt.md:12\n```", []), # ... and the fence CLOSES: the same citation in prose is graded again, the # positive control that shows the mask is a state and not a blanket refusal (QUEUE, "```\ncheckins/2026-10-15-volt.md:12\n```\n" "see checkins/2026-10-15-volt.md:12", [("explicit", "RESOLVES", "checkins/2026-10-15-volt.md", 12)]), # an unterminated fence quotes to end of file, the fence's documented cost (QUEUE, "```\nWORK-QUEUE.md:999999", []), ] def _expect(here, text, store): got = [] mask = fence_mask(text) for first, blk in blocks(text): last = None for m in PAT.finditer(blk): if first + blk.count("\n", 0, m.start()) in mask: continue # quoted, not asserted if m.group("path"): last = m.group("path") if m.group("line") is None: continue v, _ = classify_explicit(store, last, int(m.group("line"))) got.append(("explicit", v, last, int(m.group("line")))) else: want = int(m.group("bline")) v, _n, _s = classify_bare(store, here, last, want) got.append(("bare", v, last, want)) return got def selftest(repo): store = Store(repo, "HEAD") n = bad = 0 for here, text, want in FIXTURES: got = _expect(here, text, store) n += 1 ok = got == want bad += 0 if ok else 1 print(f"{'PASS' if ok else 'FAIL'} in {here[:26]}: {text[:44]!r}") if not ok: print(f" got {got}") print(f" want {want}") print(f"self-test: {n} fixtures, {n - bad} passed, {bad} failed") return 0 if bad == 0 else 1 def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--repo", default=DEFAULT_REPO) ap.add_argument("--rev", default="HEAD") ap.add_argument("--corpus", nargs="*") ap.add_argument("--json") ap.add_argument("--only", choices=["stale", "asself", "unresolved", "ambiguous", "external", "externalnamed", "named", "all"], default="stale") ap.add_argument("--context", action="store_true") ap.add_argument("--selftest", action="store_true") a = ap.parse_args() if a.selftest: return selftest(a.repo) problem = precondition(a.repo, a.rev) if problem: print(problem, file=sys.stderr) return 2 store = Store(a.repo, a.rev) corpus = a.corpus or sorted(p for p in store.tree if p.endswith(".md")) rows, stats = scan(a.repo, a.rev, corpus, store) print(f"corpus: {len(corpus)} markdown files at {a.rev} in {a.repo}") print("--- EXPLICIT `:` -- a CLAIM (unit: citations) ---") print(f" citations: {stats['explicit']}") for v in ("RESOLVES", "STALE_LINE", "EXTERNAL_PATH"): print(f" {v:15s} {stats[v]}") print("--- BARE `:` -- a CANDIDATE, never a claim (unit: citations) ---") print(f" citations: {stats['bare']}") for v in ("AS_NAMED", "AS_SELF", "AMBIGUOUS", "EXTERNAL_NAMED", "UNRESOLVED"): print(f" {v:15s} {stats[v]}") print(f" ({stats['AS_SELF']} of the bare cites resolve ONLY as the file they " f"are written in -- the named-file rule alone would have called them " f"defects; {stats['UNRESOLVED']} resolve as neither, so no reader can " f"check them without inferring the referent)") print(f"file mentions with no line: {stats['mentions']} (unit: occurrences) " f"-- these seed the inheritance scope") print(f"fenced skipped: {stats['fenced_citations']} citations, " f"{stats['fenced_mentions']} mentions (unit: rows) -- a fenced block is " f"quoted material, not this document's prose") by_target = collections.Counter(r["target"] for r in rows if r["kind"] == "explicit" and r["target"]) print(f"distinct explicitly cited targets: {len(by_target)} (unit: distinct paths)") for t, c in by_target.most_common(8): print(f" {c:6d} {t}" f"{'' if t in store.tree else ' [not in tree at ' + a.rev + ']'}") sel = {"stale": lambda r: r["kind"] == "explicit" and r["verdict"] == "STALE_LINE", "external": lambda r: r["verdict"] == "EXTERNAL_PATH", "externalnamed": lambda r: r["verdict"] == "EXTERNAL_NAMED", "asself": lambda r: r["verdict"] == "AS_SELF", "unresolved": lambda r: r["verdict"] == "UNRESOLVED", "ambiguous": lambda r: r["verdict"] == "AMBIGUOUS", "named": lambda r: r["verdict"] == "AS_NAMED", "all": lambda r: True}[a.only] show = [r for r in rows if sel(r)] print(f"--- {a.only}: {len(show)} citations (showing first 300) ---") for r in show[:300]: if r["kind"] == "bare": extra = (f" [named={r['target'] or '-'}:" f"{'y' if r['named_has'] else 'n'} self=" f"{'y' if r['self_has'] else 'n'}]") else: extra = f" [have={r['have']}]" print(f" {r['file']}:{r['line']} {r['kind']:8s} -> " f"{r['target'] or '-'}:{r['want']} {r['verdict']}{extra}") if a.context: print(f" ...{r['context']}...") if a.json: with open(a.json, "w") as fh: json.dump({"rev": a.rev, "repo": a.repo, "corpus": corpus, "stats": dict(stats), "citations": rows}, fh, indent=1) print(f"wrote {a.json}") return 0 if __name__ == "__main__": sys.exit(main())