checkin 2026-10-17 (volt): six tools -- unreadable input no longer reads as clean
Five tracebacked on input they could not read (rc 1, the code this corpus uses for "a finding"); two -- citation_hash_class.py, citation_resolve_check.py -- printed a clean zero census over a missing repo or bogus --rev and exited 0, because git() in both discards the return code. Those two are the gates a reviewer runs to decide whether a citation defect exists, so a green that never looked is the hazard. queue_index.py / queue_claim_check.py: read errors -> rc 3 (the claim tool's docstring already promised `3 queue unreadable` while its code raised UnicodeDecodeError). citation_hash_class.py / citation_resolve_check.py: precondition(repo, rev) -> rc 2, matching doc_census_check.py and landing_check.py. check_lint.py / census_row_delta.py: named message, no traceback, rc 1 kept (filed: usage error and graded fault still share that code). 175 insertions(+), 14 deletions(-). Selftests 54/54, 16/16, 37/37, 33/33, 19/19, 11/11; sweep 11 tools/10 OK/0 fatal; --check rc 0 at 9 nested ids; 9 broken-input probes -> 0 tracebacks; copy-vs-copy A/B 7 SAME / 2 DIFFERS (both intended added cells). run.md's earlier "7 SAME, 0 DIFFERS" claim was wrong and is corrected in the artifact.
This commit is contained in:
1 parent
676be46ef8
commit
016d26eae7
7 files changed
+236
-14
No files matched your search
@@ -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 <missing>` rc 3,
|
||||
`--queue <non-UTF-8>` rc 3, `queue_claim_check --queue <non-UTF-8>` rc 3,
|
||||
both citation scanners on a missing repo **rc 2** (and rc 2 on `--rev deadbeef`),
|
||||
`check_lint --log <missing>` and `census_row_delta <non-census>` 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`.
|
||||
@@ -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"]:
|
||||
|
||||
+8
-2
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+66
-6
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user