Files
troupe-standup/tools/citation_resolve_check.py
volt c3d70567a6 volt: verify gains a seventh leg -- doc-commands, graded at WORKTREE
The gate had six legs and none of them read a docstring's own command line.
doc_command_check.py existed since 838750c but only as a tool: nothing on the
gate ran it, so a documented flag its parser never learned could land and
nobody read it. This is that leg.

  doc-commands   tools/doc_command_check.py --rev WORKTREE --fail
                 WORKTREE, not HEAD: the commit window is where a docstring is
                 edited. Measured at the worktree this lands in: 67 commands
                 over 19 oracle tools, 67 OK, rc 0.

doc_command_check.py learns --rev WORKTREE: tracked paths from `git ls-files`
(an untracked tools/x.py is not part of the commit this mode grades), bytes
from disk. Outside a work tree it is rc 2, not an empty clean run. Selftest
15 -> 18 cells: one document edited on disk but not committed is rc 0 at HEAD
and rc 1 at WORKTREE, and the flag it names is the edited one.

Three tools stopped defaulting to a fixed path on this host. verify.py,
selftest_sweep.py, write_probe.py, citation_resolve_check.py,
path_claim_check.py and doc_census_check.py now resolve DEFAULT_REPO from the
file's own location, so a bare run in a clone grades the clone.

Three selftests refuse unreadable input at rc 2 instead of reporting it as
failed content: tool_map (not a work tree), write_probe (no table), and
citation_resolve_check (no corpus). Each cell below them graded `repo`, so an
absent tree read as "your table is wrong".

Evidence, all measured on this tree before the commit:
  verify.py --selftest              55 cells, 55 passed   (docstring says 55)
  doc_command_check.py --selftest   18 cells, 18 passed   (baseline pinned 18)
  tool_map.py --repo . --rev WORKTREE --check docs/TOOLS.md   rc 0
  verify.py --repo .                7 legs, 6 pass, 1 fault -- selftest-sweep,
                                    tool_map's drift cell, which renders HEAD
                                    and so cannot pass inside a commit window
tools/selftest_baseline.tsv re-pins doc_command_check.py 15 -> 18 cells.
2026-10-22 22:05:55 +00:00

514 lines
25 KiB
Python

#!/usr/bin/env python3
"""citation_resolve_check.py -- does every `<file>:<line>` 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 `<path>:<line>`, and `<path>:<line>-<line>` 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 `:<line>`, 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 os
import re
import subprocess
import sys
DEFAULT_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 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<path>[A-Za-z0-9_][A-Za-z0-9_.@/+-]*\.(?:%s))"
r"(?:`?:(?P<line>[0-9]{1,7})(?:\s*[-\u2013,]\s*(?P<end>[0-9]{1,7}))?)?"
r"|(?<![\dA-Za-z]):(?P<bline>[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
Cite = collections.namedtuple("Cite", [
"abs_line", "in_fence", "kind", "target", "want",
"verdict", "have", "named_has", "self_has", "end", "ctx"])
def iter_citations(text, here, store):
"""Yield one Cite per citation match in `text`, in source order.
Single source of truth for how citations are walked and classified -- shared
by scan() (the production census) and _expect() (the selftest fixture grader).
Both inlined this same blocks/fence/mention/classify loop; the two copies had
already begun to drift: scan() tallies fenced+mention stats while _expect()
silently drops them, and only the accumulator differed -- the classification
itself was duplicated. One generator, one rule.
"""
mask = fence_mask(text)
for first, blk in blocks(text):
last = None
for m in PAT.finditer(blk):
abs_line = first + blk.count("\n", 0, m.start())
in_fence = abs_line in mask
ctx = blk[max(0, m.start() - 70):m.end() + 70].replace("\n", " ")
target = m.group("path")
if target:
last = target
if m.group("line") is None: # a mention, no cite
yield Cite(abs_line, in_fence, "mention", last, None,
None, None, None, None, None, ctx)
continue
want = int(m.group("line"))
verdict, have = classify_explicit(store, last, want)
end = int(m.group("end")) if m.group("end") else None
yield Cite(abs_line, in_fence, "explicit", last, want,
verdict, have, None, None, end, ctx)
else:
want = int(m.group("bline"))
verdict, named, self_ok = classify_bare(store, here, last, want)
yield Cite(abs_line, in_fence, "bare", last, want,
verdict, None, named, self_ok, None, ctx)
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)
for c in iter_citations(text, p, store):
if c.in_fence:
stats["fenced_mentions" if c.kind == "mention"
else "fenced_citations"] += 1
continue
if c.kind == "mention":
stats["mentions"] += 1
continue
stats[c.kind] += 1
stats[c.verdict] += 1
rows.append({"file": p, "line": c.abs_line, "kind": c.kind,
"target": c.target, "want": c.want,
"end": c.end, "verdict": c.verdict, "have": c.have,
"named_has": c.named_has, "self_has": c.self_has,
"context": c.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 = []
for c in iter_citations(text, here, store):
if c.in_fence or c.kind == "mention":
continue
got.append((c.kind, c.verdict, c.target, c.want))
return got
def selftest(repo):
# The fixtures grade real citations out of `repo`, so a repo that cannot be
# read turns every fixture into a mismatch -- eight failures that say "your
# content is wrong" when the truth is "I never saw your content". Refuse at
# rc 2, the code this tool's own main() uses for the same condition.
problem = precondition(repo, "HEAD")
if problem:
print(problem, file=sys.stderr)
return 2
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 `<path>:<line>` -- 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 `:<line>` -- 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:
try:
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)
except OSError as exc:
print("cannot write --json " + a.json + ": " + str(exc), file=sys.stderr)
return 2
print(f"wrote {a.json}")
return 0
if __name__ == "__main__":
sys.exit(main())