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.
953 lines
46 KiB
Python
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())
|