Files
troupe-standup/tools/doc_census_check.py
T
volt f051659581 tools (volt): a sha256 prefix in a block's prose is not that block's revision
The two-leg tool read every REV_LONG token in a docstring block as a revision of
this repository. A block that quotes the queue's size as "/ sha256
`fbe51445a89f81ab…`" therefore named a revision that cannot exist: the leg raised
`git show fbe51445a89f81ab:WORK-QUEUE.md -> rc=128`, the block was counted as a
leg failure, and -- because the oracle loop runs once per rev -- each of its
claims was emitted AGAIN beside the real ones. Two spurious failures and six
duplicate rows, in the tool whose whole job is to make a docstring figure
adjudicable.

WHAT IS LANDED (908 -> 952 lines, 45,034 -> 47,501 B, sha256 5c69c2d2 ->
c90ffab4; the diff is one new helper, three call sites, three selftest cells and
the docstring paragraph that states the rule):

1. `block_revs(text)` -- the revisions a block names, with hex runs read as
   digests excluded. Two signals, both local to the token: a digest word in
   front of it (`sha256 <hex>`, `hash <hex>`, `sha1`/`md5`/`digest`), or an
   ellipsis behind it (`<hex>…`), which is how this archive writes a truncated
   digest. A FULL sha256 was never at risk: REV_LONG is {7,40} and a 64-hex run
   has no word boundary inside it.
2. `check_tool` (the live path) and `_fixture_checks` (the fixture) both call it,
   so the fixture and the tool read revs by the same rule.
3. Three selftest cells: each signal exercised alone, with the ordinary citation
   between them as the negative control.
4. The docstring says so. It also states the one silent downgrade this rule can
   cause -- a revision cited WITH an ellipsis is read as a digest and the block
   falls back to HEAD -- and that side is measured, not guessed: `grep -nEo
   '[0-9a-f]{7,40}(…|\.\.\.)' tools/*.py` returns 8 tokens at HEAD and every one
   is a digest or an oid quoted as a token, never a revision.

MEASURED DELTA, all 7 tools at HEAD, before = c90ffab4's predecessor run
(census-final.json) vs after (census-after.json), compare_rows.py:

  rows          111 -> 96   (removed 18, added 3, changed 0)
  leg failures    2 -> 0
  verdicts      8 AMBIGUOUS / 16 MATCHES / 75 NOT_PRINTED / 2 REV_UNCHECKED /
                10 SELF_NOT_RUN
            ->  8 AMBIGUOUS / 16 MATCHES / 60 NOT_PRINTED / 2 REV_UNCHECKED /
                10 SELF_NOT_RUN
  per tool      citation_hash_class.py 28 -> 19 rows, queue_claim_check.py
                34 -> 28 rows, the other five unchanged

Every removed row is a duplicate emitted under a digest; every added row is the
same claim re-scoped to the rev the block actually names. No verdict CHANGED --
the fix removes rows, it does not re-judge any. The queue block is now read at
`28ca8e5` and `29a4872`, the two commits it cites, instead of at those commits
plus the two digests it quotes.

TWO INSTRUMENTS, NOT ONE. `--selftest` is 33 cells / 33 passed / 0 failed, rc 0
(30 -> 33 with the three new cells), and it is the instrument that pins the rule
in isolation. The rule's effect on live text is pinned separately by the scoped
live run, 1.8 s: `--tools tools/queue_claim_check.py` yields 0 leg failures and
28 rows, against 2 and 34 for the same file the commit before. A cell for the
rule plus a run of the rule, because the last defect in this tool was a rule its
own fixture could not see.

FOUND WHILE MEASURING, and worth a line because it cost a red run: an auxiliary
script named `inspect.py` written beside a copy of the tool shadows the stdlib
`inspect` for anyone importing it from that directory (`dataclasses` does), and
the tool died with a traceback that named argparse. The copy is `census_rows.py`
now; the lesson is that the scratch directory is sys.path[0] for the scripts in
it.
2026-10-16 22:55:27 +00:00

953 lines
46 KiB
Python

#!/usr/bin/env python3
"""doc_census_check.py -- does a tool's own docstring census match the census
that tool PRINTS?
WHY THIS EXISTS
This archive's three scanners each carry a census table in their module
docstring: counts that a reader cites, that no command checks. The docstring
is prose; prose is not executed. On 2026-10-16 the control plane returned
exactly this defect against a sibling scanner, in its own words:
"The scanner's own docstring carries a number that does not match the
census it prints -- the exact class the tool exists to catch."
tools/citation_resolve_check.py said `471 UNRESOLVED` on one line and
`441 UNRESOLVED` nine lines later, over a census the tool prints as 471.
Nothing was checking the docstring against the output beside it. That is this
tool's whole job.
WHAT IT MEASURES (read-only: this tool only writes --json)
Two independent verdicts per tool, and they do not share evidence.
1. INTERNAL SPLIT -- no oracle, no run. Within one docstring, every numeric
claim of the form `<value> <unit>` is collected; two claims of ONE unit
that a single revision scope covers, carrying two different values, is a
split. This is the verdict that catches the 441/471 instance, and it is
why the tool is useful with no subprocess at all.
2. ORACLE -- the tool is RUN, at the revision its own docstring names, and
each claim is compared against the value that run prints for that unit.
MATCHES claim == printed value
STALE claim != printed value, and the printed unit is not
declared clone-local -- a defect claim
CLONE_LOCAL claim != printed value but the tool's own output marks
that unit clone-local ("blobs in THIS clone's local object
store"), so drift is the documented expectation, not a
defect. Reported, never counted as one.
AMBIGUOUS the tool prints that unit with 2+ values at that rev
(e.g. `citations` prints once per section), so there is no
single oracle value; both are printed and no claim is made
NOT_PRINTED the unit does not appear in this tool's output at all
A claim is `<digits> <unitword>` with the unit word ADJACENT, which is the
archive's own notation. A bare number is not a claim and is not checked.
TWO ORACLE LEGS, AND WHY THE SECOND ONE EXISTS
Leg 1 is the tool's own run at the block's revision, exactly as above. Leg 2
is the SAME TOOL'S `--selftest`, added 2026-10-16 after the control plane
returned a docstring claim of `13 cells` against a selftest that prints `18
cells` -- the defect class this tool exists to catch, sitting inside its own
default scope and still read as NOT_PRINTED, because no leg ever passed
`--selftest` and a cell count is printed by nothing else. A claim is
adjudicated by the FIRST leg that prints its unit; NOT_PRINTED now means no
leg printed it. A tool that takes no `--selftest` simply has one leg -- a fact
about the tool, not a defect in it.
A leg that exits non-zero having printed nothing is reported RUN_FAILED, never
read as an empty census, and it adjudicates nothing either: the block's claims
are still read off any surviving leg -- leg 2 -- and are left unadjudicated
only when no leg survives, because a failed run is not a reason to stop
looking. A revision that will not materialise fails the same way rather than
producing a census at all: an empty queue is a census of zeros, and reading it
as that revision's census is the phantom this rule refuses.
A HEX RUN INTRODUCED AS A DIGEST IS NOT A REVISION. A block that quotes a
truncated sha256 in its prose is a size, not a place: read as a rev it files
the block under a revision that cannot exist, fails that leg, and then
adjudicates every claim in the block again beside the real ones. Two signals
are read, both local to the token -- a digest word in front of it (`sha256
<hex>`, `hash <hex>`), or an ellipsis behind it (`<hex>…`), which is how this
archive writes a truncated digest. See `block_revs`.
The run leg is `<script> --repo R --rev REV`. A tool that takes no `--rev`
answers it with an argparse diagnosis and prints no census at all, so the leg
is re-run with the rejected flags DROPPED -- the exact tokens argparse named,
and only those -- and is tagged `no-rev`; a recipe's leg may carry the same tag
for the same reason. Such a tool measures the tree it is POINTED AT, so a claim
in a block that names a revision is reported REV_UNCHECKED -- never counted as
verified against that revision, and never counted as a defect -- while a block
that names no revision is checked normally, since the default is what the flag
means there.
THIS TOOL DOES NOT RUN ITSELF. The default scope is every `tools/*.py` in the
tree, and this tool is one of them. Running it on itself spawns it, which
spawns it again, without a floor -- measured, not reasoned, in the check-in of
2026-10-16. The self target is therefore excluded from the run leg and every
one of its claims is reported SELF_NOT_RUN. It is deliberately still listed in
the scanned set, so the exclusion is a printed row and not a silence.
SCOPE, STATED RATHER THAN IMPLIED
Only module docstrings of .py tools are scanned: a docstring is where a census
table lives. Only revisions NAMED IN THE BLOCK are used as oracles -- a block
naming no revision is checked against the tool's default (HEAD) and tagged, so
a stale rev is never silently attributed. A tool whose subject is not `--repo`
+ `--rev` is driven through a per-tool recipe declared below, with a temp file
that is removed: `queue_claim_check.py` takes a materialised QUEUE file and so
its leg IS the revision, while `append_only_owners.py` takes a tree scan that
no flag can pin to a revision, so its leg is tagged `no-rev`. A tool no recipe
covers, and that answers `--repo ... --rev ...` with an argparse diagnosis, is
re-run with the rejected flags dropped and tagged the same way -- measured at
HEAD on 2026-10-16 against `check_lint.py`, which takes neither flag and whose
default census is a real one.
WHAT THIS DOES NOT CHECK, and the measured size of the gap. A claim is only
adjudicated when the unit it names is a unit the tool PRINTS. Over the three
scanners at d355395 the run read ninety-two claims: twelve were verified
against the run they point at, eight were in units the tool prints with two
values (SECTION_SPLIT_BY_TOOL -- no oracle), and seventy-one were in units the
tool never prints at all. So the oracle's reach is narrow and its precision is
the reason: it reported exactly one bad claim on that corpus, and that one was
the returned defect. A wider rule was tried first and falsified by its own
first run -- num_agreement_check.py flagged a hundred and seventeen
restatements across the archive, too coarse to be read -- so the narrow rule
is the one that ships. Two claim forms are read (`<value> <unit>` and
`<unit>: <value>`); a claim written in neither form is not seen, and that is a
stated gap, not a silent one.
NO CENSUS OF THIS TOOL'S OWN VERDICTS APPEARS IN THIS DOCSTRING, deliberately:
this scanner scans itself, so a count of its own output written here would
have to agree with the output of the run that the same commit changes. That is
the limit citation_hash_class.py records in the same words -- a commit cannot
measure the file it introduces -- and here it is avoided rather than
discovered.
USAGE
python3 doc_census_check.py [--repo DIR] [--rev HEAD] [--tools NAME...]
[--json OUT] [--no-run] [--selftest]
"""
import argparse
import ast
import collections
import json
import os
import re
import subprocess
import sys
import tempfile
DEFAULT_REPO = "/root/troupe-standup"
# A claim, in either of the two forms this archive writes:
# `<value> <unit>` -- "471 UNRESOLVED", "1,612 citations"
# `<unit> [: =] <value>` -- "ACS = 72", "overlap = 20" (markdown bold allowed)
# A bare number is not a claim. Deliberately not "any word": `471 cannot be
# resolved` is prose, and a checker that flags prose is a checker nobody reads.
CLAIM = re.compile(
r"(?<![0-9A-Za-z_.])(?P<num>[0-9][0-9,]*)(?P<sep>[^A-Za-z0-9\n]{0,2})"
r"(?P<unit>[A-Za-z_][A-Za-z0-9_]*|[A-Z][A-Z_]{2,})\b")
CLAIM_EQ = re.compile(
r"(?P<unit>[A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*\**\s*(?P<num>[0-9][0-9,]*)")
REV_LONG = re.compile(r"\b[0-9a-f]{7,40}\b")
# A hex run introduced as a DIGEST is not a revision, and reading it as one costs
# more than a wrong label: the block is filed under a revision that cannot exist,
# its leg fails, and then every claim in the block is adjudicated once per
# spurious rev beside the real ones. Measured 2026-10-16 at HEAD on
# `tools/queue_claim_check.py`, whose UNITS block quotes the queue's size as
# "/ sha256 `fbe51445a89f81ab…`": three claims emitted FOUR times, once per
# REV_LONG token, and two `git show <sha256-prefix>:WORK-QUEUE.md` leg failures.
# Two signals, both local to the token: a digest word in front of it (sha256,
# sha1, md5, digest, hash), or an ellipsis behind it -- the archive's own way of
# writing a truncated digest. A revision cited in the ordinary way carries
# neither, and a FULL sha256 is already invisible to REV_LONG: the run has no
# word boundary to bite on.
DIGEST_BEFORE = re.compile(
r"(?:sha[-\s]?256|sha[-\s]?1|md5|digest|hash)\s*[:=]?\s*[`'\"]?\s*$", re.I)
DIGEST_AFTER = re.compile(r"^[`'\"]?\s*(?:…|\.\.\.)")
def block_revs(text):
"""The revisions a block names -- hex runs read as digests excluded."""
out = set()
for m in REV_LONG.finditer(text):
if DIGEST_BEFORE.search(text[:m.start()]) or \
DIGEST_AFTER.match(text[m.end():]):
continue
out.add(m.group(0))
return sorted(out)
# Function words and units this tool does not adjudicate. `at` in "2026-10-15 at
# rev 9188dd3" is not a census quantity; neither is `B` (bytes differ by
# revision by design, and "1,201,550 B" is a size, not a count). Stated so the
# exclusion is a rule rather than a habit.
STOPWORDS = {
"at", "the", "of", "in", "is", "are", "was", "and", "or", "to", "for", "on",
"by", "as", "it", "its", "that", "this", "these", "those", "with", "from",
"no", "not", "one", "two", "three", "all", "any", "be", "been", "has",
"have", "had", "his", "her", "our", "your", "their", "but", "if", "then",
"than", "so", "up", "out", "off", "over", "into", "run", "runs", "may",
"can", "same", "each", "both", "per", "via", "b", "kb", "mb", "gb",
}
# How to run each tool at a revision. Any tool not listed here is run as
# `<script> --repo R --rev REV`. A recipe returns `(argv, tag)` and the tag says
# whether the recipe could materialise the revision the block names.
#
# queue_claim_check.py takes no --rev: its subject is a FILE, and the file is
# materialised at the revision and handed to it -- so the leg IS that rev.
# A revision that will NOT materialise is a raised failure, never an empty
# file: an empty queue is a census of zeros, and a census of zeros reads as
# that revision's census. Measured 2026-10-16 -- `git show
# c3b4a290dc64bd24:WORK-QUEUE.md` (a sha256 prefix REV_LONG read as a rev)
# exits 128, and the leg it fed printed `0 opening records` at rc 0.
# append_only_owners.py takes a positional AUDIT.json and nothing else: its
# input is a tree scan, which its sibling produces over the WORKING TREE
# (`append_only_audit.py` has no --rev either). This scanner is read-only, so
# it will not add a git worktree to the repository it is only allowed to
# read: the leg is tagged no-rev and every claim in a block that names a
# revision is reported REV_UNCHECKED, never adjudicated against a tree it
# did not stand on.
def _recipe_queue(repo, rev, tmpdir):
dst = os.path.join(tmpdir, f"WORK-QUEUE.{rev}.md")
p = subprocess.run(["git", "-C", repo, "show", f"{rev}:WORK-QUEUE.md"],
capture_output=True)
if p.returncode != 0:
raise RuntimeError("git show %s:WORK-QUEUE.md -> rc=%d: %s"
% (rev, p.returncode,
p.stderr.decode("utf-8", "replace").strip()[:120]))
with open(dst, "wb") as fh:
fh.write(p.stdout)
return ["--queue", dst, "--census"], ""
def _recipe_owners(repo, rev, tmpdir):
dst = os.path.join(tmpdir, f"append-only-audit.{rev}.json")
subprocess.run([sys.executable,
os.path.join(repo, "tools", "append_only_audit.py"),
"--repo", repo, "--json", dst], capture_output=True)
return [dst], "no-rev"
RECIPES = {"queue_claim_check.py": _recipe_queue,
"append_only_owners.py": _recipe_owners}
def git(repo, *a):
return subprocess.run(["git", "-C", repo, *a], capture_output=True).stdout
def docstring_span(path, text):
try:
tree = ast.parse(text)
except SyntaxError:
return None
if not tree.body or not isinstance(tree.body[0], ast.Expr):
return None
node = tree.body[0]
if not isinstance(node.value, ast.Constant) or not isinstance(node.value.value, str):
return None
return node.lineno, node.end_lineno
def blocks(lines, lo, hi):
"""Blank-line-delimited paragraphs -- the same scope the sibling scanners
use (citation_resolve_check.py for inheritance, num_agreement_check.py for
quantity agreement), so one paragraph is one scope in all three."""
start, buf = lo, []
for ln in range(lo, hi + 1):
raw = lines[ln - 1]
if raw.strip() == "":
if buf:
yield start, buf
buf, start = [], ln + 1
else:
buf.append((ln, raw))
if buf:
yield start, buf
# ------------------------------------------------- printed census -> oracle
def parse_printed(stdout):
"""stdout -> {unit: {value: [line, ...]}} plus the set of clone-local units.
Four line shapes are read, all of them the scanners' own:
` RESOLVES 113` indented label then count (family rows)
` citations: 410` label then count
`corpus: 307 files at ...` count then unit
`overlap A&B = 20 ; ...` inline assignments, split on the separators
A unit whose printed line (or the line above it, for a wrapped phrase) says
"clone" is clone-local: its count is expected to move with the clone.
"""
units = collections.defaultdict(lambda: collections.defaultdict(list))
clone = set()
lines = stdout.split("\n")
def add(unit, value, ln, cloneline):
u = unit.strip().lower()
if len(u) < 3 or not re.fullmatch(r"[a-z_][a-z0-9_]*", u) or u in STOPWORDS:
return
units[u][value].append(ln)
if "clone" in cloneline.lower():
clone.add(u)
for i, raw in enumerate(lines, 1):
ctx = raw + " " + (lines[i - 2] if i >= 2 else "")
m = re.match(r"^\s{2,}([A-Za-z_][A-Za-z0-9_]*)\s+([0-9][0-9,]*)\s*$", raw)
if m: # family row: label count
add(m.group(1), int(m.group(2).replace(",", "")), i, ctx)
continue
m = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*([0-9][0-9,]*)\b", raw)
if m: # label: count
add(m.group(1), int(m.group(2).replace(",", "")), i, ctx)
for m in re.finditer(r"([0-9][0-9,]*)\s+(?:[a-z]+ )?([A-Za-z_][A-Za-z0-9_]*)\b", raw):
add(m.group(2), int(m.group(1).replace(",", "")), i, ctx)
for seg in re.split(r"[;]", raw): # inline `X = N`
m = re.match(r"^\s*(.*?)\s*[:=]\s*([0-9][0-9,]*)\s*$", seg)
if not m or not m.group(1):
continue
words = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", m.group(1))
if not words:
continue
val = int(m.group(2).replace(",", ""))
add("_".join(w.lower() for w in words), val, i, ctx) # whole phrase
add(words[-1], val, i, ctx) # the nearest label: `overlap A&B`
# the scanners' own unit declaration: `... 6961 (unit: occurrences)`.
# The unit is named AFTER its number, which no other rule reads.
for m in re.finditer(r"\(\s*unit\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)", raw):
left = raw[:m.start()]
nums = re.findall(r"([0-9][0-9,]*)\s*$", left.rstrip())
if nums:
add(m.group(1), int(nums[-1].replace(",", "")), i, ctx)
return {k: dict(v) for k, v in units.items()}, clone
def claims_in(blk):
"""[(line, value, unit, token, offset)] for one block.
Both claim forms are read and a claim found by both is reported once. The
offset is measured on the block text so a RESTATEMENT (far apart) can be
told from an ENUMERATION of neighbouring sub-counts, the same
discrimination num_agreement_check.py applies.
"""
out, off = [], 0
for ln, raw in blk:
found = {}
for m in CLAIM.finditer(raw):
u = m.group("unit")
if len(u) < 3 or u.lower() in STOPWORDS or re.fullmatch(r"[qQ]\d+", u):
continue
found[m.start()] = (int(m.group("num").replace(",", "")), u, m.group(0))
for m in CLAIM_EQ.finditer(raw):
u = m.group("unit")
if (len(u) < 3 or u.lower() in STOPWORDS
or re.fullmatch(r"[qQ]\d+", u) or m.start() in found):
continue
if m.start() and re.match(r"[0-9A-Za-z_.]", raw[m.start() - 1]):
continue # `WORK-QUEUE.md:8586` is a citation
found[m.start()] = (int(m.group("num").replace(",", "")), u, m.group(0))
for pos in sorted(found):
val, u, tok = found[pos]
out.append((ln, val, u, tok, off + pos))
off += len(raw) + 1
return out
def is_label(unit):
"""An uppercase census label -- the vocabulary a split is checked in when
the tool has not been run (no oracle available)."""
return len(unit) >= 3 and re.fullmatch(r"[A-Z][A-Z_]*", unit) is not None
def keys_for(unit, printed):
"""Candidate oracle keys for a claimed unit word."""
cands = [unit, unit.lower(), unit.lower().rstrip("s"), unit.upper()]
seen, out = set(), []
for c in cands:
if c.lower() in printed and c.lower() not in seen:
seen.add(c.lower())
out.append(c.lower())
return out
def oracle(printed, clone, unit, value, tag=""):
"""(verdict, detail) for one claim; `printed` is one revision's map.
`tag` names the leg a verdict came from when it is not leg 1 ("no-rev",
"selftest"): a reader can then tell which census the number was read off.
"""
keys = keys_for(unit, printed)
vals = set()
for k in keys:
vals |= set(printed[k])
if not vals:
return "NOT_PRINTED", ""
if len(vals) > 1:
return "AMBIGUOUS", "printed: " + ", ".join(str(v) for v in sorted(vals))
printed_val = vals.pop()
if printed_val == value:
return "MATCHES", ""
if any(k in clone for k in keys):
return "CLONE_LOCAL", f"printed {printed_val}" + (f" [{tag}]" if tag else "")
return "STALE", f"printed {printed_val}" + (f" [{tag}]" if tag else "")
def adjudicate(legs, unit, value, block_revs):
"""The first leg that prints the unit adjudicates the claim.
`legs` is [(printed, clone, tag)] in priority order: leg 1 (the run) first,
then the selftest leg. NEITHER a `no-rev` leg NOR the selftest leg is a
verifier for a block that names a revision: one measured a different tree,
the other measured the tool's own fixtures. Such a claim is reported
REV_UNCHECKED -- visible, counted as neither a match nor a defect.
"""
for printed, clone, tag in legs:
if not printed or not keys_for(unit, printed):
continue
if block_revs and tag == "no-rev":
return "REV_UNCHECKED", ("block names %s; the tool takes no --rev, so this "
"leg measured the working tree"
% ",".join(block_revs))
if block_revs and tag == "selftest":
return "REV_UNCHECKED", ("block names %s; the selftest leg prints this "
"tool's own fixtures, not a census at that "
"revision" % ",".join(block_revs))
return oracle(printed, clone, unit, value, tag)
return "NOT_PRINTED", ""
# --------------------------------------------------------------- self-test
# Synthetic stdout, so the parsers and the verdicts are executed, not asserted.
FAKE_STDOUT = """\
corpus: 297 markdown files at babdadc in /root/troupe-standup
--- EXPLICIT `<path>:<line>` -- a CLAIM (unit: citations) ---
citations: 410
RESOLVES 113
STALE_LINE 0
--- BARE `:<line>` -- a CANDIDATE, never a claim (unit: citations) ---
citations: 1612
UNRESOLVED 471
file mentions with no line: 6961 (unit: occurrences) -- these seed the scope
index: 871 blobs (unit: blobs in THIS clone's local object store), 680 commits
overlap A&B = 20 ; ACS-only = 52
"""
FAKE_DOC = """\
THE CENSUS AT HEAD, WITH ITS UNITS (measured 2026-10-16, rev babdadc, 297 .md)
BARE 1612 citations: 255 EXTERNAL_NAMED / 471 UNRESOLVED.
Read every figure as a citation count, never as a defect count: 441 UNRESOLVED
is not 441 errors.
A SECOND BLOCK, no revision named, quoting 471 UNRESOLVED and nothing else.
"""
# Leg 2's shape: a selftest summary line, and nothing else. The cell count is
# printed here and nowhere else, which is why a claim about it needs this leg.
FAKE_SELFTEST = """\
c1 a NEW byte PASS
selftest: 18 cells, 18 passed, 0 failed
"""
def _fixture_checks():
printed, clone = parse_printed(FAKE_STDOUT)
ok = True
cells, bad = [0], [0]
def check(name, got, want):
nonlocal ok
good = got == want
cells[0] += 1
bad[0] += 0 if good else 1
ok = ok and good
print(f"{'PASS' if good else 'FAIL'} {name}")
if not good:
print(f" got {got}")
print(f" want {want}")
check("parse: UNRESOLVED single", sorted(printed.get("unresolved", {})), [471])
check("parse: citations ambiguous", sorted(printed.get("citations", {})), [410, 1612])
check("parse: count-then-unit", sorted(printed.get("occurrences", {})), [6961])
check("parse: one word skipped", sorted(printed.get("files", {})), [297])
check("parse: clone-local marked", "blobs" in clone, True)
# the phrase rule: `ACS-only = 52` must NOT register as the unit `acs`
check("parse: inline phrase key", sorted(printed.get("acs_only", {})), [52])
lines = FAKE_DOC.split("\n")
got, first_block, revs_seen = [], [], set()
for first, blk in blocks(lines, 1, len(lines)):
revs = block_revs("\n".join(r for _, r in blk))
revs_seen |= set(revs)
for ln, val, unit, _tok, _pos in claims_in(blk):
got.append((unit, val))
if first == 1:
first_block.append((unit, val))
# the fixture that matters: one unit, one rev scope, two values -> a split
check("claims: both UNRESOLVED values in one block",
[uv for uv in first_block if uv[0] == "UNRESOLVED"],
[("UNRESOLVED", 471), ("UNRESOLVED", 441)])
check("claims: `471 cannot be resolved` is not a claim",
any(u in ("cannot", "resolved") for u, _ in got), False)
check("claims: the block's rev is read", "babdadc" in revs_seen, True)
# the digest rule, one signal per cell, and the negative control between
# them -- the text of each is the text of the defect, measured at HEAD
check("revs: `sha256 <hex>` is a digest, not the block's revision",
block_revs("queue 1,170,483 B / sha256 `c3b4a290dc64bd24`"), [])
check("revs: an ellipsis-truncated hex run is a digest, not a revision",
block_revs("at `28ca8e5` (1,193,681 B / `c3b4a290dc64bd24…`)"),
["28ca8e5"])
check("revs: an ordinarily cited revision is still the block's revision",
block_revs("Run 37 read (`29a4872`, queue 1,170,483 B) and `28ca8e5`"),
["28ca8e5", "29a4872"])
check("oracle: 471 matches", oracle(printed, clone, "UNRESOLVED", 471),
("MATCHES", ""))
check("oracle: 441 is stale", oracle(printed, clone, "UNRESOLVED", 441),
("STALE", "printed 471"))
check("oracle: ambiguous unit makes no claim",
oracle(printed, clone, "citations", 1612),
("AMBIGUOUS", "printed: 410, 1612"))
check("oracle: clone-local is not a defect",
oracle(printed, clone, "blobs", 1234), ("CLONE_LOCAL", "printed 871"))
check("oracle: unprinted unit", oracle(printed, clone, "wombats", 3),
("NOT_PRINTED", ""))
# ---- leg 2 (--selftest) and the leg rules. The first check is the defect
# the leg was added for: a docstring cell count the run leg cannot see.
s2, s2c = parse_printed(FAKE_SELFTEST)
check("selftest leg: the cell count is a printed unit", sorted(s2.get("cells", {})), [18])
legs = [(printed, clone, "")] + [(s2, s2c, "selftest")]
check("adjudicate: a cell count leg 1 cannot see is STALE, not NOT_PRINTED",
adjudicate(legs, "cells", 13, []), ("STALE", "printed 18 [selftest]"))
check("adjudicate: the corrected cell count matches",
adjudicate(legs, "cells", 18, []), ("MATCHES", ""))
check("adjudicate: leg 1 wins when both legs print the unit",
adjudicate([(s2, s2c, ""), ({**s2, "cells": {99: [1]}}, s2c, "selftest")],
"cells", 99, []), ("STALE", "printed 18"))
check("adjudicate: a no-rev leg does not verify a block that names a revision",
adjudicate([(s2, s2c, "no-rev")], "cells", 18, ["f5403b2"]),
("REV_UNCHECKED", "block names f5403b2; the tool takes no --rev, so this "
"leg measured the working tree"))
check("adjudicate: the selftest leg is not a verifier at a named revision",
adjudicate([(s2, s2c, "selftest")], "cells", 18, ["f5403b2"]),
("REV_UNCHECKED", "block names f5403b2; the selftest leg prints this "
"tool's own fixtures, not a census at that revision"))
check("adjudicate: a no-rev leg IS an oracle for a block naming no revision",
adjudicate([(s2, s2c, "no-rev")], "cells", 13, []),
("STALE", "printed 18 [no-rev]"))
check("adjudicate: no leg prints the unit -> NOT_PRINTED",
adjudicate([(printed, clone, "")], "wombats", 3, []), ("NOT_PRINTED", ""))
# ---- the recipe contract, exercised rather than asserted: run_tool takes a
# recipe's (argv, tag) and the tag must survive to the leg, because the
# tag is what decides whether a claim may be adjudicated at all. A stub
# stands in for a tool so this stays hermetic and instant; the real
# owners recipe costs a 107 s tree scan and is exercised by the run.
with tempfile.TemporaryDirectory(prefix="doccensus-selftest-") as td:
stub = os.path.join(td, "stub.py")
with open(stub, "w") as fh:
fh.write("print('cells: 7')\n")
for want_tag in ("no-rev", ""):
RECIPES["stub.py"] = (lambda t: lambda repo, rev, tmp: (["--probe"], t))(want_tag)
try:
out, rc, tag = run_tool(td, stub, "HEAD", td)
finally:
RECIPES.pop("stub.py", None)
check(f"recipe: run_tool carries a recipe's tag {want_tag!r} to the leg",
(rc, tag, "cells: 7" in out), (0, want_tag, True))
def _boom(repo_, rev_, tmp_):
raise RuntimeError("boom")
RECIPES["stub.py"] = _boom
raised = ""
try:
try:
run_tool(td, stub, "HEAD", td)
except RuntimeError as exc:
raised = str(exc)
finally:
RECIPES.pop("stub.py", None)
check("recipe: an unmaterialisable revision raises, never an empty census",
raised, "boom")
# A tool that rejects `--repo`/`--rev` but has a perfectly good default
# census. The retry must drop exactly the tokens argparse named: the
# first cut re-ran it as `<script> --repo R`, which was rejected again,
# and a working tool was reported RUN_FAILED. Both halves are pinned --
# the retry happens, and it happens only for an argparse rejection.
apstub = os.path.join(td, "stub_argparse.py")
with open(apstub, "w") as fh:
fh.write("import argparse\n"
"ap = argparse.ArgumentParser()\n"
'ap.add_argument("--selftest", action="store_true")\n'
"ap.parse_args()\n"
"print('cells: 7')\n")
out, rc, tag = run_tool(td, apstub, "HEAD", td)
check("run_tool: argparse rejection retried with the rejected flags dropped",
(rc, tag, "cells: 7" in out), (0, "no-rev", True))
rcstub = os.path.join(td, "stub_rc2.py")
with open(rcstub, "w") as fh:
fh.write("import sys\n"
"sys.stderr.write('boom\\n')\n"
"sys.exit(2)\n")
out, rc, tag = run_tool(td, rcstub, "HEAD", td)
check("run_tool: a non-argparse failure is not re-tagged no-rev",
(rc, tag, "rc=2" in out), (2, "", True))
# ---- a failed run leg does not silence its block. This path lives in
# check_tool and needs a subprocess, so it is exercised end to end with
# a stub tool rather than asserted: the stub rejects every census
# invocation with an argparse diagnosis and answers only `--selftest`.
with tempfile.TemporaryDirectory(prefix="doccensus-selftest-") as td:
stub = os.path.join(td, "stub_failing_run.py")
with open(stub, "w") as fh:
fh.write("'''stub: a run leg that fails, a selftest that answers.\n"
"\n7 cells\n'''\n"
"import sys\n"
'if "--selftest" in sys.argv:\n'
' print("selftest: 7 cells, 7 passed, 0 failed")\n'
"else:\n"
' sys.stderr.write("usage: stub_failing_run.py [-h] [--selftest]\\n")\n'
" sys.exit(2)\n")
rep = check_tool(td, stub, "stub_failing_run.py", {}, run=True, tmpdir=td)
check("check_tool: a failed run leg is reported, and leg 2 still adjudicates",
([f["verdict"] for f in rep["failures"]],
[(v["unit"], v["claim"], v["verdict"]) for v in rep["verdicts"]]),
(["RUN_FAILED"], [("cells", 7, "MATCHES")]))
# ---- the banner's arithmetic, which is what a reader actually reads: a
# RUN_FAILED leg and a STALE claim are counted in two columns, so four
# failed runs cannot read as four bad claims.
synthetic = {"tool": "t", "splits": [], "unchecked": {}, "selftest_leg": True,
"failures": [{"verdict": "RUN_FAILED"}],
"verdicts": [{"verdict": "MATCHES"}, {"verdict": "STALE"}]}
check("counts: a leg failure is not a claim defect", counts(synthetic)[:4],
(0, 1, 1, 2))
check("counts: an absent leg 2 is noted",
counts({**synthetic, "selftest_leg": False})[4],
["no --selftest: leg 2 absent"])
print(f"selftest: {cells[0]} cells, {cells[0] - bad[0]} passed, {bad[0]} failed")
return 0 if ok else 1
# ------------------------------------------------------------------- driver
def run_tool(repo, script, rev, tmpdir, timeout=600):
"""(stdout, rc, tag) for the tool's own run at `rev`.
`tag` is "" when the leg stood on the revision it was asked for and "no-rev"
when it did not: either the tool rejected `--rev` and was re-run without it,
or a recipe in RECIPES declared that it could not materialise the revision.
Such a leg measures the tree it is pointed at, so a claim in a block that
names a revision is reported REV_UNCHECKED rather than adjudicated (see
check_tool). stdout is returned; stderr is only folded in when there is no
stdout at all, so an argparse diagnosis is visible as a failure instead of
parsed as a census.
"""
recipe = RECIPES.get(os.path.basename(script), None)
if recipe is None:
argv, tag = [sys.executable, script, "--repo", repo, "--rev", rev], ""
else:
args, tag = recipe(repo, rev, tmpdir)
argv = [sys.executable, script] + args
p = subprocess.run(argv, capture_output=True, timeout=timeout)
out = p.stdout.decode("utf-8", "replace")
err = p.stderr.decode("utf-8", "replace")
# A tool that takes neither `--repo` nor `--rev` answers with an argparse
# diagnosis naming the flags it rejected, and NOTHING else: measured
# 2026-10-16, `check_lint.py --repo R --rev HEAD` exits 2 with stdout empty
# and `unrecognized arguments: --repo R --rev HEAD`, and the first cut of
# this rule re-ran it as `<script> --repo R` -- still rejected, still rc 2,
# so a tool with a perfectly good default census was reported RUN_FAILED.
# The retry therefore drops the tokens argparse itself named, and only
# those; a tool whose values collide with a dropped flag is a stated
# limitation, not a silent one.
m = re.search(r"unrecognized arguments:\s*(.+)$", err, re.M)
if tag != "no-rev" and p.returncode != 0 and m:
drop = set(m.group(1).split())
argv = [x for x in argv if x not in drop]
p = subprocess.run(argv, capture_output=True, timeout=timeout)
out = p.stdout.decode("utf-8", "replace")
err = p.stderr.decode("utf-8", "replace")
tag = "no-rev"
if not out.strip():
out = "<no stdout; rc=%d> %s" % (p.returncode, err[:200].replace("\n", " "))
return out, p.returncode, tag
def run_selftest(script, timeout=300):
"""Leg 2: the tool's own `--selftest`, or None if it takes no such flag.
A cell count is printed here and nowhere else, which is the whole reason
this leg exists. `None` means the tool has no selftest to offer -- reported
as one leg, not as a failure.
"""
try:
p = subprocess.run([sys.executable, script, "--selftest"],
capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
return "<--selftest timed out>"
out = p.stdout.decode("utf-8", "replace")
err = p.stderr.decode("utf-8", "replace")
if "unrecognized arguments" in err and "--selftest" in err:
return None
if not out.strip():
return "<no stdout; rc=%d> %s" % (p.returncode, err[:200].replace("\n", " "))
return out
def unit_scope(unit, legs, no_run):
"""Is a split in this unit checkable, and why not if it is not?
With the oracle legs in hand a unit is checkable when some leg prints it
with exactly ONE value: the docstring and the output then talk about one
number. A unit printed with 2+ values is SECTION-SPLIT BY THE TOOL'S OWN
DESIGN (`citations` prints once under EXPLICIT and once under BARE), so a
docstring that mirrors both is correct and is reported as such, not flagged.
A unit no leg prints is not a census unit here. Without a run, only
uppercase census labels are checked.
"""
if no_run or not legs:
return ("LABEL_ONLY" if is_label(unit) else "UNCHECKED_NO_ORACLE")
saw_split = False
for printed, clone, _tag in legs:
vals = set()
for k in keys_for(unit, printed or {}):
vals |= set((printed or {})[k])
if not vals:
continue
if len(vals) > 1:
saw_split = True
continue
return "CHECKABLE"
if saw_split:
return "SECTION_SPLIT_BY_TOOL"
return "NOT_A_CENSUS_UNIT"
def check_tool(repo, script, name, cache, run=True, tmpdir=".", isolation=240):
text = open(script, encoding="utf-8", errors="replace").read()
span = docstring_span(script, text)
if span is None:
return None
lines = text.split("\n")
lo, hi = span
blk_claims = []
for first, blk in blocks(lines, lo, hi):
btext = "\n".join(raw for _, raw in blk)
revs = block_revs(btext)
cl = claims_in(blk)
if cl:
blk_claims.append((first, revs, cl, btext))
# ---- the legs first. Leg 1: the tool's own run, once per revision the
# docstring names. Leg 2: its --selftest, once. The tool under test is
# never this tool: running this scanner on itself spawns it, which
# spawns it again, without a floor.
is_self = os.path.basename(script) == os.path.basename(os.path.abspath(__file__))
printed_by_rev, verdicts, failures = {}, [], []
selftest_leg = None
if run and not is_self:
for first, revs, cl, _b in blk_claims:
for rev in (revs or ["HEAD"]):
key = (name, rev)
if key not in cache:
try:
out, rc, tag = run_tool(repo, script, rev, tmpdir)
cache[key] = (parse_printed(out), rc, out, tag)
except Exception as exc: # noqa: BLE001
cache[key] = (None, None, f"<run failed: {exc}>", "")
printed_by_rev.setdefault((first, rev), cache[key])
skey = (name, "<selftest>")
if skey not in cache:
try:
sout = run_selftest(script)
except Exception as exc: # noqa: BLE001
sout = f"<selftest failed: {exc}>"
cache[skey] = (parse_printed(sout) if sout else None,
0 if sout else None, sout or "", "selftest")
selftest_leg = cache[skey]
def legs_for(first, rev):
"""Priority-ordered [(printed, clone, tag)] for one block scope."""
out = []
entry = printed_by_rev.get((first, rev))
if entry and entry[0]:
out.append((entry[0][0], entry[0][1], entry[3]))
if selftest_leg and selftest_leg[0]:
out.append((selftest_leg[0][0], selftest_leg[0][1], "selftest"))
return out
# 1. INTERNAL SPLIT: one unit, one revision scope, two values, far enough
# apart to be a restatement rather than a neighbouring sub-count.
splits, unchecked = [], collections.Counter()
by_unit = collections.defaultdict(list)
for first, revs, cl, _b in blk_claims:
scope = tuple(revs)
for ln, val, unit, _t, pos in cl:
by_unit[(unit.upper() if unit.isupper() else unit.lower(), scope)].append(
(first, ln, val, pos))
for (unit, scope), items in sorted(by_unit.items()):
vals = {v for _, _, v, _ in items}
if len(vals) < 2:
continue
# a restatement is a pair far apart; an enumeration is neighbours
far = any(abs(b[3] - a[3]) >= isolation
for a, b in [(x, y) for x in items for y in items]
if a[1] != b[1] and a[2] != b[2])
if not far:
continue
rev_for_oracle = (items[0][0], scope[0] if len(scope) == 1 else "HEAD")
why = ("SELF_NOT_RUN" if is_self and run
else unit_scope(unit, legs_for(*rev_for_oracle), not run))
if why != "CHECKABLE" and why != "LABEL_ONLY":
unchecked[why] += 1
continue
splits.append({"unit": unit, "revs": list(scope),
"values": sorted(vals), "how": why,
"rows": [{"line": ln, "value": v, "block": b}
for b, ln, v, _ in items]})
# 2. ORACLE: every claim compared against what the tool prints, leg 1 first.
if run:
for first, revs, cl, _b in blk_claims:
for rev in (revs or ["HEAD"]):
if is_self:
for ln, val, unit, tok, _pos in cl:
verdicts.append({"file": name, "block": first, "line": ln,
"rev": rev, "unit": unit, "claim": val,
"token": tok, "verdict": "SELF_NOT_RUN",
"detail": "the tool under test is this tool"})
continue
pr, rc, out, _tag = printed_by_rev[(first, rev)]
printed, clone = pr if pr else (None, None)
legs = legs_for(first, rev)
# A leg with no census -- raised, or exited non-zero having
# printed nothing -- adjudicates nothing either: drop it and let
# whichever leg survives answer instead. Only when none survives
# is the block left unadjudicated.
no_census = pr is None or (rc not in (0, None)
and not any(printed.values()))
if pr is None:
failures.append({"file": name, "block": first, "rev": rev,
"unit": "-", "claim": None,
"verdict": "RUN_FAILED", "detail": str(out)[:90]})
elif no_census:
failures.append({"file": name, "block": first, "rev": rev,
"unit": "-", "claim": None,
"verdict": "RUN_FAILED",
"detail": "rc=%s, nothing printed: %s"
% (rc, str(out)[:80])})
if no_census:
legs = [l for l in legs if l[2] == "selftest"]
if not legs:
continue
for ln, val, unit, tok, _pos in cl:
v, detail = adjudicate(legs, unit, val, revs)
verdicts.append({"file": name, "block": first, "line": ln,
"rev": rev, "unit": unit, "claim": val,
"token": tok, "verdict": v, "detail": detail})
return {"tool": name, "splits": splits, "unchecked": dict(unchecked),
"failures": failures, "verdicts": verdicts,
"self": is_self and run, "selftest_leg": (not run) or bool(selftest_leg and selftest_leg[0]),
"blocks_with_claims": len(blk_claims)}
def counts(rep):
"""(splits, claim_defects, leg_failures, claims, notes) -- the banner's rows.
A LEG FAILURE IS NOT A CLAIM DEFECT. RUN_FAILED says no census came back and
such a leg adjudicates nothing (see check_tool); STALE says a claim was read
against a census and disagreed. The first live run of the two-leg tool mixed
them in one `N STALE/failed` column, so four failed runs read as four bad
claims -- measured 2026-10-16 at HEAD, check_lint.py 2 and
queue_claim_check.py 2, none of them a claim disagreement. Two columns, so
the distinction survives the summary a reader actually reads.
"""
stale = [v for v in rep["verdicts"] if v["verdict"] == "STALE"]
notes = []
if rep.get("self"):
notes.append("self: no leg run (SELF_NOT_RUN)")
elif not rep.get("selftest_leg"):
notes.append("no --selftest: leg 2 absent")
return (len(rep["splits"]), len(stale), len(rep["failures"]),
len(rep["verdicts"]), notes)
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("--tools", nargs="*")
ap.add_argument("--json")
ap.add_argument("--no-run", action="store_true",
help="internal-split verdicts only; no subprocess")
ap.add_argument("--selftest", action="store_true")
a = ap.parse_args()
if a.selftest:
return _fixture_checks()
store = git(a.repo, "ls-tree", "-r", "--name-only", a.rev)
tree = {p for p in store.decode("utf-8", "replace").split("\n") if p}
tools = a.tools or sorted(p for p in tree
if p.startswith("tools/") and p.endswith(".py"))
if not tools:
print(f"no tools found at {a.rev}", file=sys.stderr)
return 2
cache, reports = {}, []
with tempfile.TemporaryDirectory(prefix="doccensus-") as tmpdir:
for t in tools:
src = subprocess.run(["git", "-C", a.repo, "show", f"{a.rev}:{t}"],
capture_output=True).stdout
local = os.path.join(tmpdir, os.path.basename(t))
with open(local, "wb") as fh:
fh.write(src)
try:
rep = check_tool(a.repo, local, t, cache, run=not a.no_run,
tmpdir=tmpdir)
except subprocess.TimeoutExpired:
rep = {"tool": t, "splits": [], "blocks_with_claims": 0,
"verdicts": [{"file": t, "verdict": "TIMEOUT", "rev": "-",
"unit": "-", "claim": None, "block": 0}]}
if rep:
reports.append(rep)
print(f"tools scanned at {a.rev}: {len(reports)} "
f"({'internal-split only, label vocabulary' if a.no_run else 'docstring vs printed census'})")
tot = collections.Counter()
for rep in reports:
bad = rep["failures"] + [v for v in rep["verdicts"] if v["verdict"] == "STALE"]
nsplits, nstale, nfail, nclaims, notes = counts(rep)
print(f"\n{rep['tool']}: {nsplits} INTERNAL SPLIT, "
f"{nstale} STALE claim(s), {nfail} leg failure(s), "
f"{nclaims} claims checked"
+ (" [" + "; ".join(notes) + "]" if notes else ""))
for s in rep["splits"]:
vals = ", ".join(f"{r['value']}@{r['line']}" for r in s["rows"])
scope = ",".join(s["revs"]) or "no rev named"
print(f" SPLIT {s['unit']} [{scope}] ({s['how']}): {vals}")
for k, c in sorted(rep["unchecked"].items()):
print(f" (not flagged: {c} split(s) in units {k})")
for v in bad:
print(f" {v['verdict']:10s} {v['file']}:{v.get('line', '-')} "
f"{v['unit']} claimed {v['claim']} at {v['rev']} -- {v['detail']}")
for k in sorted({v["verdict"] for v in rep["verdicts"]}):
if k in ("REV_UNCHECKED", "SELF_NOT_RUN", "CLONE_LOCAL"):
n = sum(1 for v in rep["verdicts"] if v["verdict"] == k)
print(f" ({n} {k}: reported, never counted as a defect)")
for k, c in collections.Counter(v["verdict"] for v in rep["verdicts"]).items():
tot[k] += c
print("\n--- verdict totals (unit: claims) ---")
for k in sorted(tot):
print(f" {k:12s} {tot[k]}")
if a.json:
with open(a.json, "w") as fh:
json.dump({"rev": a.rev, "repo": a.repo, "reports": reports}, fh, indent=1)
print(f"wrote {a.json}")
return 0
if __name__ == "__main__":
sys.exit(main())