c30f372landed tools/import_resolve_check.py and nothing registered it: the sweep read NO_PROBE on both input axes plus PIN_UNPINNED, write_probe read NO_PROBE on the json axis and failed its own c7 roster cell, and tool_map's HEAD drift cell failed on a TOOLS.md that did not name the tool. Measured at7ffd177: verify.py 8 legs 5 pass / 3 fault (path-claims, selftest-sweep, write-axis); after this commit 7 pass / 1 fault, the remaining leg being the two inbox rows that faulted before this tick. Sweep: '23 tools, 21 OK, 2 fatal' -> 'verdict: rc 0 -- clean'; read-refusal 27 ok + NO_PROBE -> 28/28; malformed 26 ok + NO_PROBE -> 27/27; pin 22 pinned + 1 drift -> 23 pinned, 23 agree. write_probe: selftest 14/15 (c7 tool-only=['import_resolve_check.py']) -> 15/15, live axis 27 probed / 27 ok. The three new rows were graded against mutants of the tool, not assumed: refusal at rc 1 -> RC_NOT_2, not-a-repo graded clean -> RC_WRONG, --json silently skipped -> RC_OTHER. The tool itself is unmodified and still reads rc 1 on this tree (8 faults in 7 test files, Q98's subject). No other lane's file edited.
736 lines
38 KiB
Python
736 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""Measure the WRITE end of every tool in tools/: `--json PATH` at a target the
|
|
host cannot write.
|
|
|
|
WHY THIS EXISTS
|
|
`selftest_sweep.py`'s read axis grades what a tool does with an input it
|
|
cannot read. Nothing graded the other end. Measured at `67f94cc`, the last
|
|
revision before the guards landed, over the 20 tools then tracked: **18
|
|
answered a `--json` target inside a nonexistent directory with an uncaught
|
|
FileNotFoundError at rc 1**; `gate_blame.py` returned rc 2 from its own input
|
|
and never reached the write (the UNNAMED class below, ungraded rather than
|
|
passed); `queue_append_audit.py` already refused (rc 2, "cannot write json").
|
|
A traceback is what this repo's contract calls fatal, and rc 1 is the code
|
|
that means a graded fault, so those 18 made "the grade is bad"
|
|
indistinguishable from "the artifact was never written". Two earlier drafts
|
|
of this paragraph said "five" and "13 of 20"; neither is what the instrument
|
|
below prints, and the number that counts is the one a reader can reproduce:
|
|
`git worktree add --detach <dir> 67f94cc && cp tools/write_probe.py
|
|
<dir>/tools/ && cd <dir> && python3 tools/write_probe.py --repo <dir>`. The
|
|
guards landed that day; this instrument is what proves them and what a new
|
|
tool has to pass -- including this one, which probes itself. The recipe names
|
|
the tracked path rather than the copied absolute one so that the command a
|
|
reader types is also a command `doc_command_check.py` can resolve: addressed
|
|
through the scratch copy's absolute path it graded TOOL_MISSING at 7da4283,
|
|
the only red row that instrument had.
|
|
|
|
THE CONTRACT IT GRADES
|
|
The grade runs first and its stdout report stands. The write is wrapped, so a
|
|
target this host cannot write prints one line naming the path on stderr and
|
|
returns 2 -- "could not grade": the artifact was asked for and not delivered.
|
|
rc 2 outranks the 0/1 the report may have earned, because a caller who asked
|
|
for a file and got a stack trace cannot know a grade happened at all.
|
|
|
|
VERDICTS, IN THIS ORDER (a traceback is a defect whatever the exit code)
|
|
TRACEBACK uncaught exception text in either stream
|
|
RC_OTHER clean stream, rc != 2 -- the write failed and the tool did not
|
|
say so in the one code that means "could not grade"
|
|
SILENT rc 2 with no message at all -- a refusal nobody can read
|
|
UNNAMED rc 2, a message, but neither stream names the target. What this
|
|
grades is the TEXT, not the guard's own sentence: `str(exc)` on
|
|
an OSError carries the path it failed on, so a guard that drops
|
|
its explicit path but keeps the exception still reads OK. That is
|
|
measured, not assumed -- it is the M4b case of the mutation study
|
|
in wizard-outputs/volt/2026-10-21-write-axis-bites/
|
|
mutation_study.py, and UNNAMED is reachable only when no stream
|
|
carries the path at all.
|
|
OK rc 2, one line naming the target, no traceback
|
|
NO_REACH the tool never got as far as the write on this probe: its own
|
|
rc 2 came from the input, so the write path is UNGRADED here
|
|
(counted, never passed -- an ungraded path is not a clean one)
|
|
NO_JSON no --json argument in the source: nothing to probe
|
|
|
|
A fault is printed as a `FAULT <tool> ...` line ABOVE the table, not only as
|
|
a row inside it: verify.py's detail line takes the first reason-shaped line of
|
|
a leg's report, and the first such line used to be the table's first healthy
|
|
row. Measured 2026-10-21 on a one-defect worktree: the leg said
|
|
`FAULT: append_only_audit.py OK 2 cannot write --json ...` while the tool that
|
|
carried the defect was doc_census_check.py.
|
|
|
|
THE SECOND AXIS -- caller-path write flags other than --json
|
|
Until 2026-10-21 this instrument graded `--json` and nothing else. Every tool
|
|
that writes a caller-supplied path through a flag of its own
|
|
(`queue_index --write`, `tool_map --write`, `doc_census_check --save-vocab`)
|
|
was outside the gate: its guard could be deleted and `verify.py` would still
|
|
print `gate: OK`. The control plane named that as this lane's next tool change
|
|
(reply 46 to volt: "the next tool to add a `--write` is ungraded until the
|
|
table grows a second axis"), and the fix is a second axis, not three rows
|
|
bolted onto the first.
|
|
|
|
Discovery is by name and the coverage requirement is derived from it: every
|
|
`<parser>.add_argument("--write...")` / `("--save...")` call in a tracked tool
|
|
is a caller-path write flag, and every one discovered must have an entry in
|
|
FLAG_PROBES. The rule is name-based on purpose, and its boundary is stated
|
|
rather than hidden: `--emit-baseline` is `store_true` and writes stdout (its
|
|
one file write is inside `checkin_check.py`'s selftest, at a path that
|
|
selftest owns), so it is not in the class; a future flag named outside the
|
|
`--write`/`--save` families would be missed by discovery -- which is why every
|
|
run prints the discovered set with its size, so a short list is visible on the
|
|
report instead of absent from it.
|
|
|
|
MEASURED, not asserted (2026-10-21, six throwaway copies of this clone, one
|
|
defect each, verdicts read out of this tool's own --json):
|
|
wizard-outputs/volt/2026-10-21-write-flag-axis/mutation_study.py. `CTL` the
|
|
clean tree -> the three flag rows OK, 24 probes, rc 0, and `verify.py --fast`
|
|
write-axis PASS; `M1` `queue_index --write`'s guard narrowed to
|
|
`PermissionError` -> TRACEBACK, probe rc 1, leg FAULT, while that same tool's
|
|
`--json` row still reads OK (the axis grades a path the old one could not see);
|
|
`M2` the refusal returning 1 -> RC_OTHER; `M3b` the message deleted from a tool
|
|
that prints nothing before it writes -> SILENT; `M4` a `--save-extra` flag on a
|
|
tracked tool with no table entry -> UNAXED. One prediction was wrong and the
|
|
case records the measurement rather than the expectation: `M3a` deletes the
|
|
same message from `doc_census_check --save-vocab`, which prints its census
|
|
report to stdout before it writes, so the judge still finds text and the class
|
|
is UNNAMED, not SILENT -- on that tool the two classes are not interchangeable,
|
|
and both are faults.
|
|
|
|
Two verdicts are this axis's own, and both are faults:
|
|
UNAXED a write flag discovered in the source with no FLAG_PROBES entry --
|
|
the path exists and nothing probes it (the NO_PROBE rule, applied
|
|
to a flag rather than a tool)
|
|
STALE_FLAG FLAG_PROBES names a flag the tool no longer defers -- the table
|
|
has outlived the code it describes
|
|
|
|
FIXTURES
|
|
Every probe runs in a throwaway tree, so the grade each tool performs is over
|
|
a few bytes rather than over this repository: the write path is what is being
|
|
graded, not the tool's subject matter. `{target}` is the only path that does
|
|
not exist -- it must not.
|
|
|
|
READ-ONLY on the repo: the only files created are under a TemporaryDirectory.
|
|
|
|
USAGE
|
|
python3 tools/write_probe.py [--repo DIR] [--only TOOL] [--timeout S]
|
|
[--json PATH] [--verbose]
|
|
python3 tools/write_probe.py --selftest # 15 cells: judge, table,
|
|
# coverage, fault block,
|
|
# fixtures, both axes
|
|
EXIT
|
|
0 every probed tool is OK; 1 at least one is not (including NO_REACH and
|
|
NO_JSON: an ungraded write path may not be reported clean); 2 could not
|
|
grade -- no tracked tools discovered, or --json itself unwritable.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections import Counter
|
|
|
|
JSON_FLAG = re.compile(r"add_argument\(\s*[\"']--json[\"']")
|
|
|
|
# Same rule as every other tool here: the default target is the checkout this
|
|
# file lives in, so a bare run in a clone grades the clone rather than a fixed
|
|
# path on this host.
|
|
DEFAULT_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
# A caller-path write flag other than --json. The class is the two name families
|
|
# this repo uses for "write the thing you were given a path for"; see THE SECOND
|
|
# AXIS above for the boundary and why --emit-baseline is not in it.
|
|
#
|
|
# The receiver is part of the pattern on purpose: every call site in this repo is
|
|
# `<parser>.add_argument("--flag")` on one line (21/21 tools at 2026-10-21), and
|
|
# requiring the receiver is what keeps this instrument from discovering its own
|
|
# fixtures -- `'ap.add_argument("--write")\n'` inside a selftest cell is a string
|
|
# literal at the start of a line, and a match there would have this tool
|
|
# reporting itself UNAXED for a flag it does not define.
|
|
WRITE_FLAG = re.compile(
|
|
r"^[ \t]*[A-Za-z_][A-Za-z0-9_]*\.add_argument\(\s*[\"'](--(?:write|save)(?:-[a-z0-9]+)*)[\"']",
|
|
re.M)
|
|
TRACEBACK = "Traceback (most recent call last)"
|
|
|
|
# (tool, argv) -- `{...}` placeholders are filled by fixture_paths(). One entry
|
|
# per tracked tool: a tool the table does not name is NO_PROBE (fatal), on the
|
|
# same rule as the read axis -- an ungraded path may not pass.
|
|
WRITE_PROBES = (
|
|
("append_only_audit.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("append_only_owners.py", ["{audit}", "--json", "{target}"]),
|
|
("census_row_delta.py", ["{census_a}", "{census_b}", "--json", "{target}"]),
|
|
("check_lint.py", ["--check", "echo ok", "--json", "{target}"]),
|
|
("checkin_check.py", ["--dir", "{checkins}", "--roster", "volt",
|
|
"--json", "{target}"]),
|
|
("citation_hash_class.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("citation_resolve_check.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("doc_census_check.py", ["--repo", "{tinyrepo}", "--no-run",
|
|
"--json", "{target}"]),
|
|
("doc_command_check.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("gate_blame.py", ["--repo", "{tinyrepo}", "--leg", "selftest-sweep",
|
|
"--range", "1", "--json", "{target}"]),
|
|
("import_resolve_check.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("landing_check.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("manifest_audit.py", ["--root", "{tinyrepo}", "--json", "{target}"]),
|
|
("path_claim_check.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("queue_append_audit.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("queue_claim_check.py", ["--queue", "{queue}", "--json", "{target}"]),
|
|
("queue_index.py", ["--queue", "{queue}", "--json", "{target}"]),
|
|
("reply_check.py", ["--dir", "{replies}", "--json", "{target}"]),
|
|
("selftest_sweep.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("served_drift_check.py", ["--root", "{tinyrepo}", "--base-url", "{baseurl}",
|
|
"--json", "{target}"]),
|
|
("tool_map.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
("verify.py", ["--repo", "{tinyrepo}", "--fast", "--json", "{target}"]),
|
|
# It grades itself: run over the one-tool fixture repo, this probe reports
|
|
# that fixture tool as NO_PROBE (rc 1), then turns to its own --json write,
|
|
# which is the path being graded. Committing the instrument without this
|
|
# row would make the table's own NO_PROBE rule fail on its author.
|
|
("write_probe.py", ["--repo", "{tinyrepo}", "--json", "{target}"]),
|
|
)
|
|
|
|
# SECOND AXIS: caller-path write flags per tool -> (flag, argv template).
|
|
# `{flagtarget}` is a path whose parent does not exist, one per (tool, flag), so
|
|
# two tools' rows can never be confused in a fault line. Coverage is REQUIRED and
|
|
# discovered from the source (see flag_coverage): a tool that grows a `--write`
|
|
# and is not added here is UNAXED -- a fault -- not silently invisible.
|
|
FLAG_PROBES = {
|
|
"doc_census_check.py": ("--save-vocab",
|
|
["--repo", "{tinyrepo}", "--save-vocab", "{flagtarget}"]),
|
|
"queue_index.py": ("--write",
|
|
["--repo", "{tinyrepo}", "--queue", "{queue}",
|
|
"--write", "{flagtarget}"]),
|
|
"selftest_sweep.py": ("--write-baseline",
|
|
["--repo", "{tinyrepo}", "--write-baseline",
|
|
"{flagtarget}"]),
|
|
"tool_map.py": ("--write",
|
|
["--repo", "{tinyrepo}", "--write", "{flagtarget}"]),
|
|
}
|
|
|
|
|
|
def _write(path, text):
|
|
d = os.path.dirname(path)
|
|
if d and not os.path.isdir(d):
|
|
os.makedirs(d, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
|
|
|
|
def build_fixtures(td, real_repo):
|
|
"""A one-commit git repo, one fake instrument, one reply, one check-in, one
|
|
log, one queue and two census files: enough for every tool in the table to
|
|
earn a 0 or a 1 and then turn to its write. Sizes are deliberately tiny."""
|
|
p = {}
|
|
|
|
tiny = os.path.join(td, "tiny")
|
|
os.makedirs(os.path.join(tiny, "docs"), exist_ok=True)
|
|
os.makedirs(os.path.join(tiny, "checkins"), exist_ok=True)
|
|
os.makedirs(os.path.join(tiny, "replies"), exist_ok=True)
|
|
os.makedirs(os.path.join(tiny, "tools"), exist_ok=True)
|
|
_write(os.path.join(tiny, "README.md"), "# tiny\n\nA fixture repo.\n")
|
|
_write(os.path.join(tiny, "WORK-QUEUE.md"),
|
|
"# Work queue\n\n**Q1 -- OPEN (pipe, 2026-01-01). One item.**\n\n"
|
|
"Body.\n")
|
|
_write(os.path.join(tiny, "docs", "queue-index.md"),
|
|
"# Index\n\n| id | state |\n| --- | --- |\n| Q1 | OPEN |\n")
|
|
_write(os.path.join(tiny, "tools", "fake_tool.py"),
|
|
'#!/usr/bin/env python3\n"""fake_tool.py -- a fake instrument.\n\n'
|
|
'USAGE:\n python3 fake_tool.py --selftest # 1 cells\n"""\n'
|
|
'import sys\nif "--selftest" in sys.argv:\n'
|
|
' print("selftest: 1 cells, 1 passed, 0 failed")\n'
|
|
' sys.exit(0)\nprint("fake")\n')
|
|
_write(os.path.join(tiny, "checkins", "2026-01-01-volt.md"),
|
|
"# volt check-in 2026-01-01\n\n"
|
|
"- Built: the fixture.\n- Next: nothing.\n")
|
|
_write(os.path.join(tiny, "replies", "2026-01-01-to-merlin-01.md"),
|
|
"# reply 01 to merlin\n\n## 1. Subject\n\nBody text, one finding.\n\n"
|
|
"-- volt\n")
|
|
for cmd in (["init", "-q", "-b", "main"], ["add", "-A"],
|
|
["-c", "user.email=v@example.invalid",
|
|
"-c", "user.name=volt", "commit", "-q", "-m", "fixture"]):
|
|
subprocess.run(["git", "-C", tiny] + cmd, capture_output=True, text=True)
|
|
p["tinyrepo"] = tiny
|
|
p["queue"] = os.path.join(tiny, "WORK-QUEUE.md")
|
|
p["index"] = os.path.join(tiny, "docs", "queue-index.md")
|
|
p["replies"] = os.path.join(tiny, "replies")
|
|
p["checkins"] = os.path.join(tiny, "checkins")
|
|
p["toolsdir"] = os.path.join(tiny, "tools")
|
|
|
|
p["audit"] = os.path.join(td, "audit.json")
|
|
_write(p["audit"], json.dumps({"rows": []}) + "\n")
|
|
p["log"] = os.path.join(td, "log.txt")
|
|
_write(p["log"], "checked 2026-01-01 Q1 clean\n")
|
|
for key in ("census_a", "census_b"):
|
|
p[key] = os.path.join(td, key + ".json")
|
|
_write(p[key], json.dumps({"rev": "abc1234", "repo": tiny,
|
|
"reports": [], "rows": []}) + "\n")
|
|
|
|
p["baseurl"] = "file://" + tiny
|
|
|
|
p["target"] = os.path.join(td, "no-such-dir", "out.json")
|
|
# The probe is meaningless if the target's parent exists: an existing
|
|
# directory would let the write succeed and grade the wrong thing.
|
|
assert not os.path.isdir(os.path.dirname(p["target"]))
|
|
# Second axis: one directory for every caller-path write flag's target, also
|
|
# nonexistent, so each flag probe grades a refusal rather than a delivery.
|
|
p["flagdir"] = os.path.join(td, "no-such-dir-flag")
|
|
assert not os.path.isdir(p["flagdir"])
|
|
p["repo"] = real_repo
|
|
return p
|
|
|
|
|
|
def flag_target(flagdir, tool, flag):
|
|
"""A per-(tool, flag) target inside the nonexistent flag directory."""
|
|
return os.path.join(flagdir, "%s%s" % (tool.replace(".py", ""), flag))
|
|
|
|
|
|
def flag_coverage(src, name):
|
|
"""-> (flags to probe, [(flag, verdict, detail)]) for one tool's source.
|
|
|
|
Pure, and that is the point: the UNAXED and STALE_FLAG verdicts are decided
|
|
from the source text alone, so the selftest can drive them without spawning
|
|
a process. A discovered flag with a table entry is probed; one without is
|
|
UNAXED; an entry the source has stopped defining is STALE_FLAG.
|
|
"""
|
|
disc = sorted(set(WRITE_FLAG.findall(src)))
|
|
cov = FLAG_PROBES.get(name)
|
|
covflag = cov[0] if cov else None
|
|
probe, faults = [], []
|
|
for f in disc:
|
|
if f == covflag:
|
|
probe.append(f)
|
|
else:
|
|
faults.append((f, "UNAXED",
|
|
"write flag discovered with no FLAG_PROBES entry -- ungraded"))
|
|
if covflag and covflag not in disc:
|
|
faults.append((covflag, "STALE_FLAG",
|
|
"FLAG_PROBES names a flag this tool no longer defines"))
|
|
return probe, faults
|
|
|
|
|
|
def probe_flags(name, src, path, fx, timeout, cwd, runner=None):
|
|
"""Every caller-path write flag this tool defines -> rows (axis `flag`)."""
|
|
runner = runner or run_probe
|
|
probe, faults = flag_coverage(src, name)
|
|
rows = [{"tool": name, "axis": "flag", "flag": f, "verdict": v, "rc": None,
|
|
"detail": d} for f, v, d in faults]
|
|
cov = FLAG_PROBES.get(name)
|
|
for flag in probe:
|
|
# probe is non-empty only when the discovered flag IS the covered one,
|
|
# so cov is never None here; the guard keeps that visible to a reader.
|
|
if not cov or cov[0] != flag:
|
|
continue
|
|
target = flag_target(fx["flagdir"], name, flag)
|
|
fx2 = dict(fx)
|
|
fx2["flagtarget"] = target
|
|
argv = [re.sub(r"\{(\w+)\}", lambda m: fx2[m.group(1)], x) for x in cov[1]]
|
|
rc, out, err = runner(path, argv, timeout, cwd)
|
|
rec = {"tool": name, "axis": "flag", "flag": flag, "probe": argv}
|
|
rec.update(judge(rc, out, err, target))
|
|
rec["detail"] = "%s: %s" % (flag, rec["detail"])
|
|
rows.append(rec)
|
|
return rows
|
|
|
|
|
|
def judge(rc, out, err, target):
|
|
text = (out or "") + (err or "")
|
|
if TRACEBACK in text:
|
|
tail = [l for l in text.splitlines() if l.strip()][-1:]
|
|
return {"verdict": "TRACEBACK", "rc": rc,
|
|
"detail": (tail[0] if tail else "")[:110]}
|
|
if rc != 2:
|
|
first = next((l.strip() for l in (err or out or "").splitlines()
|
|
if l.strip()), "")
|
|
return {"verdict": "RC_OTHER", "rc": rc,
|
|
"detail": (first or "rc %s, no message" % rc)[:110]}
|
|
msg = [l for l in (err or "").splitlines() if l.strip()] or \
|
|
[l for l in (out or "").splitlines() if l.strip()]
|
|
if not msg:
|
|
return {"verdict": "SILENT", "rc": rc, "detail": "rc 2 with no message"}
|
|
if target not in text:
|
|
return {"verdict": "UNNAMED", "rc": rc,
|
|
"detail": "rc 2 but the message never names the target: "
|
|
+ msg[-1][:64]}
|
|
if "cannot write" not in text and "cannot be written" not in text:
|
|
# rc 2 and the path is named, but not as a write refusal: the tool
|
|
# refused its INPUT. The write path is therefore ungraded here.
|
|
return {"verdict": "NO_REACH", "rc": rc,
|
|
"detail": "rc 2 on the input, never reached the write: "
|
|
+ msg[-1][:64]}
|
|
return {"verdict": "OK", "rc": rc, "detail": msg[-1][:110]}
|
|
|
|
|
|
def fault_lines(rows, width):
|
|
"""One line per row that is not OK, naming the tool and its class.
|
|
|
|
Printed ABOVE the table by main(). The position is the point: verify.py's
|
|
detail line takes the FIRST reason-shaped line a leg prints, and every row
|
|
of the table reads `cannot write --json <path>: [Errno 2] ...`, so before
|
|
this block the detail on a red write-axis leg was the first HEALTHY row.
|
|
Measured 2026-10-21 (M1-leg of the mutation study): the row said
|
|
`FAULT: append_only_audit.py OK 2 cannot write --json ...` on a worktree
|
|
whose only defect was in doc_census_check.py -- the one tool a reader would
|
|
not need to look at. The word FAULT is carried for the same reason: it is in
|
|
verify.py's STRONG_HINTS, so a fault whose own detail has no diagnostic
|
|
marker (SILENT: "rc 2 with no message") is still the line a reader sees.
|
|
"""
|
|
out = []
|
|
for r in rows:
|
|
if r.get("verdict") == "OK":
|
|
continue
|
|
out.append("FAULT %-*s %-9s %4s %s"
|
|
% (width, r.get("tool", "?"), r.get("verdict", "?"),
|
|
"-" if r.get("rc") is None else r["rc"],
|
|
(r.get("detail") or "")[:110]))
|
|
return out
|
|
|
|
|
|
def run_probe(path, argv, timeout, cwd):
|
|
try:
|
|
p = subprocess.run([sys.executable, path] + argv, cwd=cwd,
|
|
capture_output=True, text=True, errors="replace",
|
|
timeout=timeout)
|
|
return p.returncode, p.stdout or "", p.stderr or ""
|
|
except subprocess.TimeoutExpired:
|
|
return None, "", "TIMEOUT after %ss" % timeout
|
|
except OSError as exc:
|
|
return None, "", "<exec failed: %s>" % exc
|
|
|
|
|
|
def tracked_tools(repo):
|
|
p = subprocess.run(["git", "-C", repo, "ls-tree", "-r", "--name-only",
|
|
"HEAD", "tools/"], capture_output=True, text=True)
|
|
if p.returncode != 0:
|
|
return None, (p.stderr or "git ls-tree failed").strip()
|
|
return [os.path.basename(x) for x in p.stdout.splitlines()
|
|
if x.endswith(".py")], "git ls-tree HEAD tools/"
|
|
|
|
|
|
def selftest(repo):
|
|
"""Eleven cells: six for the judge's ordered verdicts, five for the table,
|
|
the fixture and the fault block. The judge cells matter because the order is
|
|
the contract -- a traceback outranks a right code, and a rc-2 input refusal
|
|
(NO_REACH) must never read as a passed write path. The fault block gets its
|
|
own cell because its position above the table is load-bearing for verify.py's
|
|
detail line, and a formatting change would otherwise regress it silently.
|
|
"""
|
|
bad = []
|
|
|
|
# Cells below read `repo` (the tool table comes from its tracked tools/), so
|
|
# an unreadable repo turns three of them into FAIL -- "your table is wrong"
|
|
# when the truth is "I never saw your tree". Refuse at rc 2, the same code
|
|
# and the same sentence main() gives this input.
|
|
names, why = tracked_tools(repo)
|
|
if names is None:
|
|
print("cannot read: " + why, file=sys.stderr)
|
|
return 2
|
|
|
|
def cell(name, ok, why=""):
|
|
print((" ok " if ok else " FAIL ") + name
|
|
+ ("" if ok else " [%s]" % why))
|
|
if not ok:
|
|
bad.append(name)
|
|
|
|
t = os.path.join("no-such-dir", "out.json")
|
|
tb = TRACEBACK + "\n File \"x.py\", line 1\nFileNotFoundError: [Errno 2]"
|
|
cell("c1 a traceback is TRACEBACK whatever the exit code",
|
|
judge(2, "", tb, t)["verdict"] == "TRACEBACK")
|
|
cell("c2 a clean rc 1 is RC_OTHER, not a pass",
|
|
judge(1, "", "cannot write --json " + t + "\n", t)["verdict"] == "RC_OTHER")
|
|
cell("c3 rc 2 with no message is SILENT",
|
|
judge(2, "", "", t)["verdict"] == "SILENT")
|
|
cell("c4 rc 2 that never names the target is UNNAMED",
|
|
judge(2, "", "gate_blame: cannot grade: all 1 commit(s) UNGRADABLE\n",
|
|
t)["verdict"] == "UNNAMED")
|
|
cell("c5 rc 2 naming the target but refusing INPUT is NO_REACH (ungraded)",
|
|
judge(2, "", "cannot read " + t + ": no such file\n",
|
|
t)["verdict"] == "NO_REACH")
|
|
cell("c6 rc 2 naming the target as a WRITE refusal is OK",
|
|
judge(2, "", "cannot write --json " + t + ": [Errno 2]\n",
|
|
t)["verdict"] == "OK")
|
|
|
|
names, source = tracked_tools(repo)
|
|
table = dict(WRITE_PROBES)
|
|
dupes = len([n for n, _ in WRITE_PROBES]) - len(table)
|
|
cell("c7 the table names every tracked tool once, and no tool it cannot see",
|
|
names is not None and not dupes and set(table) == set(names),
|
|
"%s: table-only=%s tool-only=%s dupes=%s"
|
|
% (source, sorted(set(table) - set(names or [])),
|
|
sorted(set(names or []) - set(table)), dupes))
|
|
|
|
with tempfile.TemporaryDirectory(prefix="write-selftest-") as td:
|
|
fx = build_fixtures(td, repo)
|
|
cell("c8 the probe target's parent does not exist (or the write would succeed)",
|
|
not os.path.isdir(os.path.dirname(fx["target"])), fx["target"])
|
|
if names and "check_lint.py" in names:
|
|
rc, out, err = run_probe(os.path.join(repo, "tools", "check_lint.py"),
|
|
["--check", "echo ok", "--json", fx["target"]],
|
|
120, fx["tinyrepo"])
|
|
v = judge(rc, out, err, fx["target"])
|
|
cell("c9 a real tool, probed: rc 2, one line naming the target, no traceback",
|
|
v["verdict"] == "OK", str(v))
|
|
else:
|
|
cell("c9 a real tool, probed: rc 2, one line naming the target", False,
|
|
"check_lint.py not discovered under %s" % repo)
|
|
|
|
rows = [{"tool": "x", "verdict": "OK"}, {"tool": "y", "verdict": "NO_PROBE"}]
|
|
cell("c10 NO_PROBE counts as a fault, so an ungraded path cannot pass",
|
|
len([r for r in rows if r["verdict"] != "OK"]) == 1)
|
|
|
|
# c11 exists because the fault block's POSITION is the whole point of it:
|
|
# the block is what verify.py's detail line reads, and a block that named a
|
|
# healthy tool (or came after the table) would put the wrong filename in
|
|
# front of a reader on every red write-axis leg.
|
|
rows2 = [{"tool": "healthy.py", "verdict": "OK", "rc": 0, "detail": "wrote"},
|
|
{"tool": "hurt.py", "verdict": "SILENT", "rc": 2, "detail": ""},
|
|
{"tool": "worse.py", "verdict": "TRACEBACK", "rc": 1,
|
|
"detail": "FileNotFoundError: [Errno 2] no such file"}]
|
|
blk = fault_lines(rows2, 10)
|
|
cell("c11 the fault block names every faulting tool, never a healthy one",
|
|
len(blk) == 2 and "hurt.py" in blk[0] and "worse.py" in blk[1]
|
|
and "healthy.py" not in " ".join(blk)
|
|
and all(ln.startswith("FAULT ") for ln in blk),
|
|
str(blk))
|
|
|
|
# ---- the second axis (2026-10-21) -------------------------------------
|
|
# c12 is the coverage RULE, decided from source text alone: the class is
|
|
# discovered, so a tool that grows a --write is a fault until it is probed.
|
|
probe_ok, faults_ok = flag_coverage('ap.add_argument("--write", metavar="PATH")\n',
|
|
"queue_index.py")
|
|
probe_un, faults_un = flag_coverage('ap.add_argument("--save-vocab")\n',
|
|
"fake_tool.py")
|
|
probe_no, faults_no = flag_coverage(
|
|
'ap.add_argument("--json")\nap.add_argument("--emit-baseline", '
|
|
'action="store_true")\n', "queue_index.py")
|
|
cell("c12 flag coverage: covered -> probed, uncovered -> UNAXED, gone -> "
|
|
"STALE_FLAG, and --json/--emit-baseline are outside the class",
|
|
probe_ok == ["--write"] and not faults_ok
|
|
and not probe_un and [f[1] for f in faults_un] == ["UNAXED"]
|
|
and not probe_no and [f[1] for f in faults_no] == ["STALE_FLAG"],
|
|
"covered=%s/%s uncovered=%s/%s outside-class=%s/%s"
|
|
% (probe_ok, faults_ok, probe_un, faults_un, probe_no, faults_no))
|
|
|
|
# c13 is the integration cell: every caller-path write flag this repo's
|
|
# table names is probed by the same code path main() uses, over the same
|
|
# fixtures. The roster is READ FROM FLAG_PROBES, not listed here: a cell
|
|
# with its own hardcoded three would keep passing while the table grew,
|
|
# which is the same blind spot the UNAXED rule closes one level down.
|
|
real_rows = []
|
|
with tempfile.TemporaryDirectory(prefix="write-selftest-flag-") as tdf:
|
|
fxf = build_fixtures(tdf, repo)
|
|
for nm in sorted(FLAG_PROBES):
|
|
pf = os.path.join(repo, "tools", nm)
|
|
try:
|
|
with open(pf, encoding="utf-8") as fh:
|
|
s = fh.read()
|
|
except OSError as exc:
|
|
cell("c13 every caller-path write flag in the table probes OK",
|
|
False, "unreadable %s: %s" % (pf, exc))
|
|
real_rows = None
|
|
break
|
|
real_rows.extend(probe_flags(nm, s, pf, fxf, 300, fxf["tinyrepo"]))
|
|
if real_rows is not None:
|
|
cell("c13 every caller-path write flag in the table probes OK",
|
|
len(real_rows) == len(FLAG_PROBES)
|
|
and all(r["verdict"] == "OK" for r in real_rows),
|
|
str([(r["tool"], r["flag"], r["verdict"]) for r in real_rows]))
|
|
|
|
# c14/c15 are the blind spot the axis exists for, measured on a fixture tool
|
|
# rather than argued: the SAME tool passes the json axis and fails the flag
|
|
# axis, and its guarded twin passes both. Without c14 the axis would be three
|
|
# green rows with nothing to show it can go red.
|
|
for label, wrap, want in (
|
|
("c14 a bare --write is TRACEBACK on the flag axis while the json "
|
|
"axis alone reads clean", False, "TRACEBACK"),
|
|
("c15 a guarded --write is OK on the flag axis (the axis can pass)", True,
|
|
"OK")):
|
|
with tempfile.TemporaryDirectory(prefix="write-selftest-bare-") as tdb:
|
|
fxb = build_fixtures(tdb, repo)
|
|
body = ['#!/usr/bin/env python3', 'import argparse, sys, json',
|
|
'ap = argparse.ArgumentParser()',
|
|
'ap.add_argument("--json")',
|
|
'ap.add_argument("--write", metavar="PATH")',
|
|
'a = ap.parse_args()',
|
|
# The json half is GUARDED in both fixtures: c14's point is a
|
|
# tool that reads clean on the axis this instrument already
|
|
# had, and is ungraded on the axis it did not.
|
|
'if a.json:',
|
|
' try:',
|
|
' with open(a.json, "w", encoding="utf-8") as fh:',
|
|
' fh.write("{}")',
|
|
' except OSError as exc:',
|
|
' print("cannot write --json " + a.json + ": "',
|
|
' + str(exc), file=sys.stderr)',
|
|
' sys.exit(2)']
|
|
if wrap:
|
|
body += ['if a.write:',
|
|
' try:',
|
|
' with open(a.write, "w", encoding="utf-8") as fh:',
|
|
' fh.write("x")',
|
|
' except OSError as exc:',
|
|
' print("cannot write --write " + a.write + ": "',
|
|
' + str(exc), file=sys.stderr)',
|
|
' sys.exit(2)']
|
|
else:
|
|
body += ['if a.write:',
|
|
' with open(a.write, "w", encoding="utf-8") as fh:',
|
|
' fh.write("x")']
|
|
src = "\n".join(body) + "\n"
|
|
st = os.path.join(fxb["toolsdir"], "bare_write.py")
|
|
_write(st, src)
|
|
saved = dict(FLAG_PROBES)
|
|
FLAG_PROBES["bare_write.py"] = ("--write",
|
|
["--write", "{flagtarget}"])
|
|
try:
|
|
jrc, jout, jerr = run_probe(st, ["--json", fxb["target"]], 120,
|
|
fxb["tinyrepo"])
|
|
json_v = judge(jrc, jout, jerr, fxb["target"])["verdict"]
|
|
frows = probe_flags("bare_write.py", src, st, fxb, 120,
|
|
fxb["tinyrepo"])
|
|
finally:
|
|
FLAG_PROBES.clear()
|
|
FLAG_PROBES.update(saved)
|
|
got_v = frows[0]["verdict"] if len(frows) == 1 else "ROWCOUNT:%d" % len(frows)
|
|
ok = got_v == want and frows[0].get("axis") == "flag"
|
|
if want == "TRACEBACK":
|
|
ok = ok and json_v == "OK"
|
|
cell(label, ok, "flag=%s json=%s rc=%s" % (got_v, json_v,
|
|
frows[0].get("rc")))
|
|
|
|
print("selftest: 15 cells, %d passed, %d failed" % (15 - len(bad), len(bad)))
|
|
return 1 if bad else 0
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--repo", default=DEFAULT_REPO)
|
|
ap.add_argument("--only", default=None, metavar="TOOL")
|
|
ap.add_argument("--timeout", type=int, default=180, metavar="S")
|
|
ap.add_argument("--json", default=None, metavar="PATH")
|
|
ap.add_argument("--selftest", action="store_true",
|
|
help="prove the judge and the table, then exit")
|
|
ap.add_argument("--verbose", action="store_true")
|
|
a = ap.parse_args(argv)
|
|
|
|
repo = os.path.abspath(a.repo)
|
|
if a.selftest:
|
|
return selftest(repo)
|
|
names, source = tracked_tools(repo)
|
|
if names is None:
|
|
print("cannot read: " + source, file=sys.stderr)
|
|
return 2
|
|
if not names:
|
|
print("cannot read: no tracked tools under " + repo, file=sys.stderr)
|
|
return 2
|
|
|
|
table = dict(WRITE_PROBES)
|
|
rows = []
|
|
disc = {}
|
|
with tempfile.TemporaryDirectory(prefix="write-probe-") as td:
|
|
fx = build_fixtures(td, repo)
|
|
for name in names:
|
|
if a.only and name != a.only:
|
|
continue
|
|
path = os.path.join(repo, "tools", name)
|
|
try:
|
|
with open(path, encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
except OSError as exc:
|
|
rows.append({"tool": name, "axis": "json", "verdict": "UNREADABLE",
|
|
"rc": None, "detail": str(exc)[:110]})
|
|
continue
|
|
if name not in table:
|
|
rows.append({"tool": name, "axis": "json", "verdict": "NO_PROBE",
|
|
"rc": None,
|
|
"detail": "no write probe in the table -- ungraded"})
|
|
elif not JSON_FLAG.search(src):
|
|
rows.append({"tool": name, "axis": "json", "verdict": "NO_JSON",
|
|
"rc": None, "detail": "no --json argument in source"})
|
|
else:
|
|
argv = [re.sub(r"\{(\w+)\}", lambda m: fx[m.group(1)], x)
|
|
for x in table[name]]
|
|
rc, out, err = run_probe(path, argv, a.timeout, fx["tinyrepo"])
|
|
rec = {"tool": name, "axis": "json", "probe": table[name]}
|
|
rec.update(judge(rc, out, err, fx["target"]))
|
|
rows.append(rec)
|
|
if a.verbose:
|
|
print(" . %-24s %-9s rc=%s" % (name, rec["verdict"], rc))
|
|
# Second axis: every caller-path write flag this tool defines.
|
|
found = set(WRITE_FLAG.findall(src))
|
|
if found:
|
|
disc[name] = sorted(found)
|
|
for rec in probe_flags(name, src, path, fx, a.timeout, fx["tinyrepo"]):
|
|
rows.append(rec)
|
|
if a.verbose:
|
|
print(" . %-24s %-9s rc=%s %s"
|
|
% (name, rec["verdict"], rec.get("rc"), rec["flag"]))
|
|
for name in sorted(set(FLAG_PROBES) - set(names)):
|
|
rows.append({"tool": name, "axis": "flag",
|
|
"flag": FLAG_PROBES[name][0], "verdict": "STALE_FLAG",
|
|
"rc": None,
|
|
"detail": "%s: FLAG_PROBES names a tool that is not tracked"
|
|
% FLAG_PROBES[name][0]})
|
|
if not rows:
|
|
print("cannot read: no tool matched --only " + str(a.only),
|
|
file=sys.stderr)
|
|
return 2
|
|
|
|
bad = [r for r in rows if r["verdict"] != "OK"]
|
|
width = max([len(r["tool"]) for r in rows] + [4])
|
|
print("write probe -- %d probe(s) over %d tracked tool(s) under %s (%s)"
|
|
% (len(rows), len(names), os.path.join(repo, "tools"), source))
|
|
print()
|
|
faults = fault_lines(rows, width)
|
|
if faults:
|
|
for ln in faults:
|
|
print(ln)
|
|
print()
|
|
print("%-*s %-4s %-9s %4s detail" % (width, "tool", "axis", "verdict", "rc"))
|
|
for r in rows:
|
|
print("%-*s %-4s %-9s %4s %s"
|
|
% (width, r["tool"], r.get("axis", "json"), r["verdict"],
|
|
"-" if r.get("rc") is None else r["rc"], r["detail"]))
|
|
print()
|
|
# One pass over `rows` replaces two `len([r for r in rows if r.get("axis") ==
|
|
# X])` scans that differed only by the axis string constant. Counter buckets
|
|
# each row once by its axis value; a row with no "axis" key yields None, and
|
|
# axis_counts["json"]/["flag"] read 0 for it -- identical to the two-scans
|
|
# form, which left a None-axis row in neither column.
|
|
axis_counts = Counter(r.get("axis") for r in rows)
|
|
n_json = axis_counts["json"]
|
|
n_flag = axis_counts["flag"]
|
|
print("write axis: %d probed, %d ok, %d fault(s) (json %d, flag %d)"
|
|
% (len(rows), len(rows) - len(bad), len(bad), n_json, n_flag)
|
|
+ ("" if not bad else " (" + ", ".join(sorted({r["verdict"]
|
|
for r in bad})) + ")"))
|
|
# The discovered set is printed, not just counted: the coverage requirement is
|
|
# derived from the source, so a reader must be able to see what it found. A
|
|
# name outside the --write/--save families is the class's stated blind spot,
|
|
# and it is invisible here -- which is why the list itself is on the report.
|
|
n_cov = sum(1 for n, fs in disc.items() for f in fs
|
|
if (FLAG_PROBES.get(n) or ("",))[0] == f)
|
|
print("write flags discovered: %d tool(s) -- %s; %d of %d flag(s) covered"
|
|
" by FLAG_PROBES"
|
|
% (len(disc),
|
|
", ".join("%s %s" % (n, "/".join(f)) for n, f in sorted(disc.items()))
|
|
or "none",
|
|
n_cov, sum(len(f) for f in disc.values())))
|
|
|
|
if a.json:
|
|
payload = {"repo": repo, "discovery": source, "probed": len(rows),
|
|
"ok": len(rows) - len(bad), "faults": len(bad),
|
|
"axes": {"json": n_json, "flag": n_flag},
|
|
"write_flags": {n: f for n, f in sorted(disc.items())},
|
|
"tools": rows}
|
|
try:
|
|
with open(a.json, "w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh, indent=1, sort_keys=True)
|
|
fh.write("\n")
|
|
except OSError as exc:
|
|
print("cannot write --json " + a.json + ": " + str(exc),
|
|
file=sys.stderr)
|
|
return 2
|
|
return 1 if bad else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|