diff --git a/checkins/2026-10-17-volt.md b/checkins/2026-10-17-volt.md index bda7648..101ed31 100644 --- a/checkins/2026-10-17-volt.md +++ b/checkins/2026-10-17-volt.md @@ -466,3 +466,64 @@ did `outside, corpus = outside_ids(...)` → `TypeError: cannot unpack non-itera new cells hold it: `--index outside a git work tree exits 0` and `… still summarizes` → **selftest 46 cells, 46 passed, 0 failed**, sweep `11 tools, 10 OK, 0 fatal`, `--check` still rc 0 at 9 nested ids. This is a second commit on top of `e79cbca`, not a rewrite of it. + + +## Tick 4 — unreadable input must not read as "clean" (six tools, one class) + +Picked up an unfinished item: six tracked verifier tools mishandled input they could not +read, in two directions. Five crashed with a Python traceback under an exit code that this +corpus uses for "a finding"; two reported a **clean census over an input they never opened** +— `citation_hash_class.py --repo /nope` printed `corpus: 0 files`, `flags: 0 rows` and +exited **0**, and `citation_resolve_check.py` did the same for `citations: 0 / stale: 0`. +The second direction is the dangerous one: a green gate that never looked is +indistinguishable from a green gate that looked and found nothing, and these are the two +tools a reviewer runs to decide whether a citation defect exists. Both were found by probe, +not by reading — every one of the six was already green on its own selftest. + +Cause, measured: `git()` in both scanners returns `subprocess.run(...).stdout` and discards +the return code, so a missing repo or a mistyped `--rev` yields an empty tree, an empty +corpus and a zero count. `queue_claim_check.py`'s docstring already promised +`3 queue unreadable` while its code raised `UnicodeDecodeError` on non-UTF-8 — doc/code +mismatch closed rather than widened. + +**Changed** — six files, `175 insertions(+), 14 deletions(-)`: + + tools/queue_index.py 66/6 read errors -> rc 3; whole-input guard on --check + tools/queue_claim_check.py 45/5 UnicodeDecodeError -> rc 3, + 2 read-error cells + tools/citation_hash_class.py 24/0 precondition(repo, rev) -> rc 2 + tools/citation_resolve_check.py 21/0 same guard, same code + tools/check_lint.py 8/2 unreadable --log -> message, no traceback + tools/census_row_delta.py 11/1 non-census JSON named, no traceback + +Exit codes follow the tools that already had a convention: 3 = queue unreadable (claim_check +docstring), 2 = precondition failed / did not measure (`doc_census_check.py`, +`landing_check.py`). No `except Exception` — every guard names `OSError`, +`UnicodeDecodeError` or `ValueError`, the classes actually observed. + +**Verified, this host, this work tree:** + +- selftests: `queue_index` 54/54, `queue_claim_check` 16/16 (10 claim-check, 4 unit, + 2 read-error), `check_lint` 37/37, `census_row_delta` 33/33, `citation_hash_class` 19/19, + `citation_resolve_check` 11/11 — 0 failed everywhere. +- `tools/selftest_sweep.py` → `sweep: 11 tools, 10 OK, 0 fatal`; the admitted `--selftest` + claims now match what the tools print (the one non-OK, `doc_census_check.py NO_CLAIM`, is + advisory and pre-existing). +- `queue_index.py --check docs/queue-index.md` → rc 0, `49 sections, 45 ids, + 4 continuation sections, 9 nested ids, 9937 lines / 1224324 bytes`. +- nine broken-input probes → **0 tracebacks**. `queue_index --queue ` rc 3, + `--queue ` rc 3, `queue_claim_check --queue ` rc 3, + both citation scanners on a missing repo **rc 2** (and rc 2 on `--rev deadbeef`), + `check_lint --log ` and `census_row_delta ` message-then-rc 1. +- no-behaviour-change A/B, HEAD copy vs working copy on one work tree, nine invocations: + **7 SAME, 2 DIFFERS**, and both DIFFERS are the intended added cells (54 vs 46, 16 vs 14). + +**A correction I owe, against my own draft.** My run note first claimed "7 SAME, 0 DIFFERS +over three selftests". That is false — a selftest that gains cells cannot print the same +bytes — and it contradicted its own table. Re-measured copy-vs-copy it is 7 SAME / 2 +DIFFERS. The trap is recorded in the artifact: the citation scanners census *this clone's +local object store*, so an A/B done with `git stash` adds stash objects to the very store +being counted; copy-vs-copy is the valid method. Filed, not fixed: `check_lint.py` and +`census_row_delta.py` still use rc 1 for both "a graded fault" and "I could not read your +file" — the message disambiguates, the code does not, and renumbering is a lane decision. + +Artifact: `/root/.hermes/wizard-outputs/volt/2026-10-17-input-error-class/run.md`. diff --git a/tools/census_row_delta.py b/tools/census_row_delta.py index 4372c8c..679dae5 100644 --- a/tools/census_row_delta.py +++ b/tools/census_row_delta.py @@ -76,8 +76,18 @@ def load(path): `rows` is every surviving verdict dict, each carrying its tool name; keying happens in `keyed()` so one load serves every key shape. + + An unreadable or non-census file is a usage error, not a traceback: this + used to raise the json decoder's error or a bare `KeyError: 'reports'`, with + rc 1. The message names the file and what was expected instead. """ - d = json.load(open(path, encoding="utf-8")) + try: + with open(path, encoding="utf-8") as fh: + d = json.load(fh) + except (OSError, ValueError) as exc: + raise SystemExit("cannot read census %s: %s" % (path, exc)) + if not isinstance(d, dict) or not isinstance(d.get("reports"), list): + raise SystemExit("not a doc_census_check census (no 'reports' list): %s" % path) rows, fails, per = [], [], {} for rep in d["reports"]: for v in rep["verdicts"]: diff --git a/tools/check_lint.py b/tools/check_lint.py index cbf5613..8d576bc 100644 --- a/tools/check_lint.py +++ b/tools/check_lint.py @@ -511,8 +511,14 @@ def main(argv=None) -> int: return 1 if (a.fail_on_fault and primary != "SOUND") else 0 if a.log: - with open(a.log, encoding="utf-8", errors="replace", newline="") as fh: - text = fh.read() + try: + with open(a.log, encoding="utf-8", errors="replace", newline="") as fh: + text = fh.read() + except OSError as exc: + # A log that is not there is a usage error, not a grading failure: + # it used to escape as a FileNotFoundError traceback with rc 1, + # which is the code this tool uses for "the check has a fault". + raise SystemExit("cannot read log: %s" % exc) cmd = extract_check(text) primary, faults, detail = classify(cmd) if not a.quiet: diff --git a/tools/citation_hash_class.py b/tools/citation_hash_class.py index 0c2c329..33ede56 100644 --- a/tools/citation_hash_class.py +++ b/tools/citation_hash_class.py @@ -117,6 +117,21 @@ def git(repo, *a): return subprocess.run(["git", "-C", repo, *a], capture_output=True).stdout +def precondition(repo, rev): + """None when `repo` is a git work tree that carries `rev`, else why not. + + The caller turns this into exit 2. It exists because every reader below + swallows git's exit status: an unreadable repo or a mistyped revision came + back as an empty tree, and the census printed zeros over it with rc 0. + """ + p = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", + rev + "^{commit}"], capture_output=True) + if p.returncode == 0: + return None + why = p.stderr.decode("utf-8", "replace").strip().splitlines() + return ("cannot read %s at %s: %s" % (repo, rev, why[-1] if why else "git rev-parse failed")) + + def build_index(repo): """One pass over the whole object store: blob oid -> (content sha256, size).""" meta, idx = {}, collections.defaultdict(list) @@ -351,6 +366,15 @@ def main(): if a.selftest: return selftest(a.repo) + # A repo or revision git cannot read is a precondition failure, never a + # clean census. `git()` returns stdout only, so `--repo /nonexistent` used to + # print "corpus: 0 files ... 0 flags" and exit 0 -- a silent green on the one + # input this tool cannot see. Exit 2 says "did not measure", which is true. + problem = precondition(a.repo, a.rev) + if problem: + print(problem, file=sys.stderr) + return 2 + corpus = a.corpus or [p for p in git(a.repo, "ls-tree", "-r", "--name-only", a.rev) .decode('utf-8', 'replace').split('\n') if p] IDX = build_index(a.repo) diff --git a/tools/citation_resolve_check.py b/tools/citation_resolve_check.py index 5f9137e..a76c961 100644 --- a/tools/citation_resolve_check.py +++ b/tools/citation_resolve_check.py @@ -144,6 +144,22 @@ def git(repo, *a): return subprocess.run(["git", "-C", repo, *a], capture_output=True).stdout +def precondition(repo, rev): + """None when `repo` is a git work tree that carries `rev`, else why not. + + Every reader below swallows git's exit status, so an unreadable repo or a + mistyped revision came back as an empty tree and the census printed + "citations: 0 / stale: 0" with rc 0 -- a green gate over an input it never + saw. The caller turns this into exit 2. + """ + p = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", rev + "^{commit}"], + capture_output=True) + if p.returncode == 0: + return None + why = p.stderr.decode("utf-8", "replace").strip().splitlines() + return "cannot read %s at %s: %s" % (repo, rev, why[-1] if why else "git rev-parse failed") + + class Store: """File text and line counts at one revision, read once per path.""" @@ -348,6 +364,11 @@ def main(): if a.selftest: return selftest(a.repo) + problem = precondition(a.repo, a.rev) + if problem: + print(problem, file=sys.stderr) + return 2 + store = Store(a.repo, a.rev) corpus = a.corpus or sorted(p for p in store.tree if p.endswith(".md")) rows, stats = scan(a.repo, a.rev, corpus, store) diff --git a/tools/queue_claim_check.py b/tools/queue_claim_check.py index be3be2a..fa94a8e 100644 --- a/tools/queue_claim_check.py +++ b/tools/queue_claim_check.py @@ -104,7 +104,7 @@ USAGE python3 tools/queue_claim_check.py --queue PATH --json OUT.json python3 tools/queue_claim_check.py --validate # + the hand-read answers python3 tools/queue_claim_check.py --git # resolve closure revisions - python3 tools/queue_claim_check.py --selftest # 14 fixtures, exit 0/1 + python3 tools/queue_claim_check.py --selftest # 16 fixtures, exit 0/1 python3 tools/queue_claim_check.py --census # both closure units + the axis table Exit codes: 0 clean · 1 self-test failure · 2 stale opening line (--fail-on-stale) @@ -112,11 +112,14 @@ Exit codes: 0 clean · 1 self-test failure · 2 stale opening line (--fail-on-st """ import argparse +import contextlib +import io import json import os import re import subprocess import sys +import tempfile # -------------------------------------------------------------------------- # matching primitives @@ -524,10 +527,43 @@ def selftest(verbose=True): if not ok: failures.append(name) + # A read failure is rc 3, not a traceback. `--queue` at a missing path or at + # bytes that are not UTF-8 escaped main() as FileNotFoundError / + # UnicodeDecodeError; the first was already caught, the second was not, and + # rc 1 is what this corpus uses for "a check failed". Two cells, because the + # exit code is the whole contract an operator sees. + n_read = 0 + with tempfile.TemporaryDirectory() as td: + bad = os.path.join(td, 'not-utf8.md') + with open(bad, 'wb') as fh: + fh.write(b'\xff\xfe\x80\n') + for name, path, want in ( + ('read cell 1: a missing queue path exits 3, not a traceback', + os.path.join(td, 'nope.md'), 'No such file'), + ('read cell 2: a queue that is not UTF-8 exits 3, not a traceback', + bad, 'utf-8')): + n_read += 1 + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(['--queue', path]) + msg = err.getvalue() + ok = rc == 3 and 'cannot read queue:' in msg + if ok: + ok = want.lower() in msg.lower() + if verbose: + print('%s %s' % ('PASS' if ok else 'FAIL', name)) + if not ok: + print(' expected rc 3 and `cannot read queue: ...%s...`\n' + ' got rc %d, stderr %r' % (want, rc, msg.strip()[:160])) + if not ok: + failures.append(name) + if verbose: - total = len(FIXTURES) + n_unit - print('\nself-test: %d fixtures (%d claim-check, %d unit), %d passed, %d failed' - % (total, len(FIXTURES), n_unit, total - len(failures), len(failures))) + total = len(FIXTURES) + n_unit + n_read + print('\nself-test: %d fixtures (%d claim-check, %d unit, %d read-error), ' + '%d passed, %d failed' + % (total, len(FIXTURES), n_unit, n_read, + total - len(failures), len(failures))) return failures @@ -558,7 +594,11 @@ def main(argv=None): path = args.queue or os.path.join(root, 'WORK-QUEUE.md') try: text = open(path, encoding='utf-8').read() - except OSError as exc: + except (OSError, UnicodeDecodeError) as exc: + # UnicodeDecodeError is not an OSError: a binary or mis-encoded file at + # --queue used to escape main() as a traceback with rc 1, which is the + # code the fixture corpus uses for "a check failed". A read failure is + # rc 3 with the same message, whatever the reason. print('cannot read queue: %s' % exc, file=sys.stderr) return 3 diff --git a/tools/queue_index.py b/tools/queue_index.py index 97a6472..2cf3f7e 100644 --- a/tools/queue_index.py +++ b/tools/queue_index.py @@ -78,7 +78,12 @@ WHAT IT DOES NOT DO about the queue's currency and not about the last person to write a checkin. `--check` exit codes: 0 current, 1 stale (with the delta printed), 2 the - document carries no parseable pinned block. + document carries no parseable pinned block, 3 the queue or the document could + not be read at all (missing path, or bytes that are not UTF-8). An unreadable + input is a precondition failure, never a stale index and never a traceback: + `--queue` at a missing path or at a binary used to raise FileNotFoundError / + UnicodeDecodeError out of `run()` with rc 1, which reads like "stale" to every + caller that only checks the exit code. READ-ONLY except `--json PATH` and `--write PATH`. @@ -88,7 +93,7 @@ USAGE python3 tools/queue_index.py --json out.json python3 tools/queue_index.py --write docs/queue-index.md python3 tools/queue_index.py --check docs/queue-index.md - python3 tools/queue_index.py --selftest # fixture corpus, 46 cells + python3 tools/queue_index.py --selftest # fixture corpus, 54 cells Re-run `--write` after any append to the queue. The document carries the queue's sha256, so a stale copy is a RED check, never a silent lie. @@ -480,12 +485,27 @@ def delta(pinned, fresh): def run(args): repo = args.repo queue_path = os.path.join(repo, args.queue) - with open(queue_path, encoding="utf-8") as fh: - text = fh.read() + # A read failure is an exit code, not a traceback. `--queue` pointed at a + # missing path or at bytes that are not UTF-8 raised FileNotFoundError / + # UnicodeDecodeError straight out of run(), so the operator got a stack and + # rc 1 while every other reader in this repo (`queue_claim_check.py:558-563`) + # says `cannot read queue:` and exits 3. The same contract is owed to the + # --check document: a gate that cannot read its input has verified nothing, + # and must not report that as a stale index (rc 1) or a crash. + try: + with open(queue_path, encoding="utf-8") as fh: + text = fh.read() + except (OSError, UnicodeDecodeError) as exc: + print("cannot read queue: %s" % exc, file=sys.stderr) + return 3 payload = parse(text) if args.check: - doc = open(args.check, encoding="utf-8").read() + try: + doc = open(args.check, encoding="utf-8").read() + except (OSError, UnicodeDecodeError) as exc: + print("cannot read index document: %s" % exc, file=sys.stderr) + return 3 pinned = extract_pinned(doc) if pinned is None: print("UNVERIFIABLE: no parseable pinned block in %s" % args.check) @@ -749,6 +769,46 @@ def selftest(): check("no pin -> exit 2", rc, 2) check("no pin -> says so", "UNVERIFIABLE" in buf.getvalue(), True) + # --- a read error is an exit code, not a traceback ----------------------- + # `--queue` at a missing path or at bytes that are not UTF-8 used to raise + # straight out of run() (FileNotFoundError / UnicodeDecodeError, rc 1). The + # repo's other reader exits 3 with `cannot read queue:`; this tool now does + # the same, and owes the --check document the same answer: an unreadable + # gate input is rc 3, never the rc 1 that means "stale". + bad_q = os.path.join(root, "bad-utf8.md") + with open(bad_q, "wb") as fh: + fh.write(b"\xff\xfe\x80\n") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = run(argparse.Namespace(repo=root, queue="missing-queue.md", index=False, + json=None, write=None, check=None, outside=False)) + check("missing queue -> exit 3", rc, 3) + check("missing queue -> says cannot read queue", + err.getvalue().startswith("cannot read queue:"), True) + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = run(argparse.Namespace(repo=root, queue="bad-utf8.md", index=False, + json=None, write=None, check=None, outside=False)) + check("non-UTF-8 queue -> exit 3", rc, 3) + check("non-UTF-8 queue -> says cannot read queue", + "cannot read queue:" in err.getvalue(), True) + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = run(argparse.Namespace(repo=repo, queue="WORK-QUEUE.md", index=False, + json=None, write=None, + check=os.path.join(repo, "missing-index.md"), + outside=False)) + check("missing index document -> exit 3", rc, 3) + check("missing index document -> names the document", + "cannot read index document:" in err.getvalue(), True) + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = run(argparse.Namespace(repo=repo, queue="WORK-QUEUE.md", index=False, + json=None, write=None, check=bad_q, outside=False)) + check("non-UTF-8 index document -> exit 3", rc, 3) + check("non-UTF-8 index document -> names the document", + "cannot read index document:" in err.getvalue(), True) + print("selftest: %d cells, %d passed, %d failed" % (cells[0], cells[0] - bad[0], bad[0])) return 1 if bad[0] else 0 @@ -765,7 +825,7 @@ def main(argv=None): ap.add_argument("--check", metavar="PATH", help="re-derive and compare a rendered index") ap.add_argument("--outside", action="store_true", help="report ids the prose corpus names that the queue never mentions") - ap.add_argument("--selftest", action="store_true", help="fixture corpus, 46 cells") + ap.add_argument("--selftest", action="store_true", help="fixture corpus, 54 cells") args = ap.parse_args(argv) if args.selftest: return selftest()