From 26056d2a21758ddd3331c3e51f202179d8982f00 Mon Sep 17 00:00:00 2001 From: Volt Date: Sun, 18 Oct 2026 07:35:53 +0000 Subject: [PATCH] tools (volt): doc_census_check -- the gate, and a hyphenated unit is not a unit `--fail` moves the verdict into the exit code: rc 0 ran and graded no fault, rc 1 ran and graded a STALE claim or an INTERNAL SPLIT, rc 2 could not grade (usage, a --tools filter matching nothing tracked, unreadable input). Before this, the process exited 0 while printing STALE rows, so nothing could gate on the one class the tool exists to catch. Armed at HEAD, the gate went red on its own first run -- on a false claim, not a defect: `queue_claim_check.py`'s axis list says `39 ACS-only lines carry no Q token` (and 12, and 1) and the token read as a unit was the adjective `ACS` in `ACS-only`; with no oracle to test the label against, --no-run split 39/12/1. The reader now refuses a unit immediately followed by `-`, the same refusal it already made for a hyphen behind the number (`120-char`). Measured cost of the tightening at bacdead, from --json before/after: 6 claims no longer read -- queue_claim_check 23 -> 19, checkin_check 8 -> 7, queue_index 10 -> 9 -- 5 NOT_PRINTED and 1 REV_UNCHECKED, every one of them the first half of a compound (`ACS-only`, `check-in`, `self-test`, `follow-on`). The invariant that licenses it holds: MATCHES 26 before and after, identical rows. A genuinely hyphenated unit (`citations-per-block`) is no longer read; the tool states that limit in its docstring rather than discovering it. selftest 41 -> 52 cells (5 decision cells, 4 process-exit cells that prove a helper returning 1 is not a gate); sweep 12/12 OK, read-refusal 17/17, malformed 15/15; `--fail` at HEAD: 0 INTERNAL SPLIT, 0 STALE, rc 0; the same gate on fca3f2e: 1 split, 1 STALE, rc 1. --- tools/doc_census_check.py | 150 +++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/tools/doc_census_check.py b/tools/doc_census_check.py index 6ad4f38..645d07a 100644 --- a/tools/doc_census_check.py +++ b/tools/doc_census_check.py @@ -122,9 +122,44 @@ SCOPE, STATED RATHER THAN IMPLIED measure the file it introduces -- and here it is avoided rather than discovered. +FAILING ON THE CLASS IT CATCHES (`--fail`) + Without it the process exits 0 while printing STALE rows, so no gate could + act on the one class this tool exists to catch without parsing its text. + `--fail` makes the exit code carry the verdict. The cost of the old silence + is measured twice, and the two are NOT the same measurement: + + - STRUCTURAL -- reproducible right here, no archive needed. A stub tree + whose docstring says `99 widgets` beside a printed `widgets: 7` exits 0 + without `--fail` and 1 with it (`gate e2e` cells in `--selftest`). + + - HISTORICAL -- true only of the pre-tightening separator (`ac0e211^`). + That build at rev 4ce1aed, `--tools tools/checkin_check.py`, exits 0 + with two STALE verdicts on `checkin_check.py:59`: `Never claimed 8` and + `never claimed 1`, both `printed 2 [selftest]` -- the digits in "Never 1, + never a traceback". Reproduced 2026-10-18 from this host. The tightened + separator stops reading those digits as a census: the same rev now + adjudicates 7 claims, 0 STALE. So the historical run is cited with its + build and never as current behaviour -- a claim that says "rc 0 with two + STALE" without naming the build goes stale the moment the reader is + tightened, which is the failure this tool grades other files for. + + The code means: + + 0 ran, and graded no fault (every claim adjudicated agrees) + 1 ran, and graded a fault (an INTERNAL SPLIT or a STALE claim) + 2 could not grade (usage, a --tools filter that matches + nothing tracked, unreadable input) + + rc 2 keeps its meaning from the tools/ contract (6ee8fce) and is decided + before the gate runs, so "could not grade" never reads as "graded clean" or + as "graded a fault". A LEG FAILURE (RUN_FAILED) is reported in its own column + and does not fail the gate, for the reason counts() states: a leg that + produced no census adjudicated no claim. A gate that counted it would fail + runs it never checked. + USAGE python3 doc_census_check.py [--repo DIR] [--rev HEAD] [--tools NAME...] - [--json OUT] [--no-run] [--selftest] # selftest: 41 cells, one per claim class + [--json OUT] [--no-run] [--fail] [--selftest] # selftest: 52 cells, one per claim class """ import argparse import ast @@ -157,9 +192,22 @@ DEFAULT_REPO = "/root/troupe-standup" # this archive grades lanes for. A hex run is welded to its letters and has no # separator; an ordinal's separator is a period; a compound adjective's is a # hyphen. None of the three is ` `. +# +# A HYPHEN AFTER THE UNIT IS THE SAME REFUSAL, made 2026-10-18 after the +# --fail gate went red at HEAD on a false claim: `queue_claim_check.py`'s axis +# list says `39 ACS-only lines carry no Q token`, `12 ACS-only lines +# are indented`, `1 ACS-only line carries a BQ exclusion phrase` -- three +# sub-counts of ONE population of 52, and the token read as a unit was the +# adjective `ACS` in `ACS-only`. With no oracle to test the label against, a +# `--no-run` split fired on 39/12/1. A hyphen immediately behind the unit word +# marks a compound adjective, not a unit noun, exactly as a hyphen behind the +# number does (`120-char`). The cost is stated: a genuinely hyphenated unit +# (`citations-per-block`) is no longer read, and the invariant that licenses +# this tightening is that the 26 MATCHES do not move -- measured before and +# after, same 26 rows. CLAIM = re.compile( r"(?[0-9]+(?:,[0-9]{3})*)(?P\s{1,2}|[)\]}\"'`*]\s)" - r"(?P[A-Za-z_][A-Za-z0-9_]*|[A-Z][A-Z_]{2,})\b") + r"(?P[A-Za-z_][A-Za-z0-9_]*|[A-Z][A-Z_]{2,})\b(?!-)") CLAIM_EQ = re.compile( r"(?P[A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*\**\s*(?P[0-9]+(?:,[0-9]{3})*)") REV_LONG = re.compile(r"\b[0-9a-f]{7,40}\b") @@ -530,6 +578,12 @@ def _fixture_checks(): claims_of("read as UTF-8. Never 1, never a traceback"), []) check("claims: a compound adjective is not a claim", claims_of("a 120-char window and a 1-based index"), []) + # the same refusal on the unit side, which is where the 2026-10-18 false + # split came from: `ACS-only` is a compound adjective, not a unit + check("claims: a hyphenated unit word is a compound adjective, not a claim", + claims_of("39 ACS-only lines carry no Q token"), []) + check("claims: the same unit unhyphenated is still a claim", + claims_of("39 ACS lines carry no Q token"), [("ACS", 39)]) check("claims: the tightening keeps a count and its unit", claims_of("471 UNRESOLVED and 1,612 citations"), [("UNRESOLVED", 471), ("citations", 1612)]) @@ -680,6 +734,55 @@ def _fixture_checks(): resolve_tools(None, {"tools/b.py", "tools/a.py", "README.md"}), (["tools/a.py", "tools/b.py"], [])) + # ---- the gate. Before `--fail` this tool exited 0 while printing STALE + # rows -- the two historical STALEs at 4ce1aed require the + # pre-tightening reader, `ac0e211^`, so they are cited with that build + # and not as current behaviour (see FAILING ON THE CLASS IT CATCHES) -- + # so the one class it exists to catch could not fail anything. The + # first five cells pin the decision; the last four pin the PROCESS exit + # code, because a helper returning 1 proves nothing about a gate. + stale_rep = {"tool": "t", "splits": [], "unchecked": {}, "failures": [], + "verdicts": [{"verdict": "STALE"}], "selftest_leg": True} + split_rep = {**stale_rep, "splits": [{"unit": "UNRESOLVED", "rows": []}], + "verdicts": [{"verdict": "MATCHES"}]} + # a leg failure alone: reported, never spent as a defect + clean_rep = {**stale_rep, "failures": [{"verdict": "RUN_FAILED"}], + "verdicts": [{"verdict": "MATCHES"}, + {"verdict": "NOT_PRINTED"}, + {"verdict": "AMBIGUOUS"}, + {"verdict": "REV_UNCHECKED"}]} + check("gate: off is rc 0 even with a STALE claim", gate_rc([stale_rep], False), 0) + check("gate: on and a STALE claim -> rc 1", gate_rc([stale_rep], True), 1) + check("gate: on and an INTERNAL SPLIT alone -> rc 1", gate_rc([split_rep], True), 1) + check("gate: on and a leg failure alone -> rc 0 (reported, not spent)", + gate_rc([clean_rep], True), 0) + check("gate: the two columns sum across tools", gate_faults([stale_rep, split_rep]), + (1, 1)) + + with tempfile.TemporaryDirectory(prefix="doccensus-gate-") as td: + os.makedirs(os.path.join(td, "tools")) + for name, claim in (("stub_stale.py", 99), ("stub_ok.py", 7)): + with open(os.path.join(td, "tools", name), "w") as fh: + fh.write(f'"""A stub whose docstring says {claim} widgets."""\n' + "print('widgets: 7')\n") + genv = dict(os.environ, GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@t", + GIT_COMMITTER_NAME="t", GIT_COMMITTER_EMAIL="t@t") + for sub in (["init", "-q"], ["add", "-A"], ["commit", "-qm", "fixture"]): + subprocess.run(["git", "-C", td] + sub, capture_output=True, env=genv) + + def run_gate(*extra): + return subprocess.run([sys.executable, os.path.abspath(__file__), + "--repo", td, "--rev", "HEAD"] + list(extra), + capture_output=True, env=genv).returncode + + check("gate e2e: a STALE claim fails the process (rc 1)", run_gate("--fail"), 1) + check("gate e2e: the same tree without --fail is rc 0 (the old silence)", + run_gate(), 0) + check("gate e2e: a matching claim passes the gate (rc 0)", + run_gate("--tools", "tools/stub_ok.py", "--fail"), 0) + check("gate e2e: a --tools filter matching nothing is rc 2, not 1", + run_gate("--tools", "tools/nope.py", "--fail"), 2) + print(f"selftest: {cells[0]} cells, {cells[0] - bad[0]} passed, {bad[0]} failed") return 0 if ok else 1 @@ -927,6 +1030,34 @@ def counts(rep): len(rep["verdicts"]), notes) +def gate_faults(reports): + """(splits, stale) -- the two things `--fail` refuses to let pass. + + The class this tool exists to catch is a docstring claim that disagrees + with the census printed beside it (STALE), and two claims of one unit that + disagree with each other (INTERNAL SPLIT). A LEG FAILURE IS NOT IN THIS + COUNT, for the reason counts() gives: a leg that produced no census + adjudicated no claim, so counting it would fail a run on a claim never + checked. rc 2, decided in main(), covers "could not grade" separately. + """ + splits = stale = 0 + for rep in reports: + splits += len(rep.get("splits") or []) + stale += sum(1 for v in (rep.get("verdicts") or []) + if v.get("verdict") == "STALE") + return splits, stale + + +def gate_rc(reports, fail): + """0 when the gate is off or nothing was graded faulty; 1 when it is on and + a defect claim was graded. Never 2: "could not grade" is main()'s call, + made before any report exists, and this function cannot see it.""" + if not fail: + return 0 + splits, stale = gate_faults(reports) + return 1 if (splits or stale) else 0 + + def resolve_tools(requested, tree): """(tools, missing). A requested name that is not tracked at the rev is REPORTED, never silently dropped: `--tools checkin_check.py` (a bare @@ -947,6 +1078,10 @@ def main(): ap.add_argument("--json") ap.add_argument("--no-run", action="store_true", help="internal-split verdicts only; no subprocess") + ap.add_argument("--fail", action="store_true", + help="rc 1 if any tool reports an INTERNAL SPLIT or a STALE " + "claim; leg failures are reported, never spent as " + "defects (rc 2 stays 'could not grade')") ap.add_argument("--selftest", action="store_true") a = ap.parse_args() @@ -1016,7 +1151,16 @@ def main(): 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 a.fail: + nsplit, nstale = gate_faults(reports) + if nsplit or nstale: + print(f"\nFAIL: {nsplit} INTERNAL SPLIT + {nstale} STALE claim(s) " + f"at {a.rev}" + + (" (internal-split only: --no-run)" if a.no_run else "")) + return 1 + print(f"\nOK: 0 INTERNAL SPLIT, 0 STALE claim(s) at {a.rev}" + + (" (internal-split only: --no-run)" if a.no_run else "")) + return gate_rc(reports, a.fail) if __name__ == "__main__":