sweep: ECHOES_USAGE -- a refusal that echoes the invocation is not an input refusal (Q97)
This commit is contained in:
1 parent
9a5367be0b
commit
58895d9513
6 files changed
+380
-16
No files matched your search
@@ -27,7 +27,7 @@ queue_append_audit.py 25 cells
|
||||
queue_claim_check.py 24 fixtures
|
||||
queue_index.py 80 cells
|
||||
reply_check.py 49 cells
|
||||
selftest_sweep.py 44 cells
|
||||
selftest_sweep.py 49 cells
|
||||
served_drift_check.py 25 cells
|
||||
tool_map.py 20 cells
|
||||
verify.py 59 cells
|
||||
|
||||
|
+135
-4
@@ -105,6 +105,33 @@ WHAT A REFUSAL IS GRADED ON, FIXED 2026-10-27 (Q93)
|
||||
table's own comment: the contract keeps "refused, legibly, at the documented
|
||||
code" and stops keeping "refused for the reason the table remembers".
|
||||
|
||||
WHAT A REFUSAL MUST NOT BE, ADDED 2026-10-29 (Q97)
|
||||
The contract above has one blind spot and it is not theoretical: Python's
|
||||
`argparse` writes `usage: ...` and `prog: error: unrecognized arguments: ...`
|
||||
to stderr and exits 2, which satisfies "rc 2, no traceback, a message"
|
||||
exactly. Every probe invokes its tool with flags, so a tool whose flag is
|
||||
renamed -- or removed -- never reads the input at all, and an axis that
|
||||
exists to grade malformed-input handling reads clean. Measured at 9878f12:
|
||||
renaming `--repo` to `--repo-dir` in `citation_hash_class.py` alone left
|
||||
`malformed-input: 26 probes, 26 ok` and `verdict: rc 0 -- clean`.
|
||||
A refusal is now ALSO required to be about the input: a message that leads
|
||||
with `usage:` or carries an argparse failure shape (`unrecognized
|
||||
arguments`, `error: argument`, `the following arguments are required`,
|
||||
`invalid choice:`, `ambiguous option:`, `no such option`) is ECHOES_USAGE --
|
||||
fatal on both input axes, the read axis included, because that axis has had
|
||||
the same hole since it was written (it pinned no prose at all). The check is
|
||||
negative on purpose: requiring the message to name the probe's path would
|
||||
redden tools that refuse correctly with a boundary line, which is the Q93
|
||||
defect coming back the other way.
|
||||
One measurement the filed record does not carry: its mutant renames `--repo`
|
||||
to `--repo-dir` with the same dest, and that rename is INVISIBLE to
|
||||
argparse -- a long option may be abbreviated to any unambiguous prefix, so
|
||||
`--repo` still parses as `--repo-dir` and the tool reads its input
|
||||
normally (measured: same refusal line, rc 2, flag and renamed flag alike).
|
||||
The filed mutant therefore stays green for a reason other than the one the
|
||||
item names. Cell c48 and the verification below use a rename that is not a
|
||||
prefix (`--tree`), which is the shape that actually reaches the parser.
|
||||
|
||||
ONE VERDICT LINE, ADDED 2026-10-27 (Q93)
|
||||
The leg printed `sweep: 22 tools, 22 OK, 0 fatal` and returned **rc 1**: the
|
||||
sweep line is about the TOOLS, the exit code is about the RUN, and nothing
|
||||
@@ -163,7 +190,7 @@ USAGE:
|
||||
python3 selftest_sweep.py --repo /root/troupe-standup
|
||||
python3 selftest_sweep.py --repo /root/troupe-standup --write-baseline tools/selftest_baseline.tsv
|
||||
python3 selftest_sweep.py --tools-dir /tmp/fake --json /tmp/sweep.json
|
||||
python3 selftest_sweep.py --selftest # 44 cells, parser, sweep, pin and both input axes
|
||||
python3 selftest_sweep.py --selftest # 49 cells, parser, sweep, pin and both input axes
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -238,9 +265,23 @@ READ_AXIS_UNIT = REFUSAL_RC
|
||||
# green gate. An ungraded tool may not be reported clean; the tables are the
|
||||
# gate's only cover, so a gap in them has to fail like a fault in a tool.
|
||||
FATAL_READ = ("TRACEBACK", "RC_NOT_2", "SILENT", "NO_TOOL", "RC_WRONG",
|
||||
"UNEXPLAINED", "NO_PROBE")
|
||||
"UNEXPLAINED", "NO_PROBE", "ECHOES_USAGE")
|
||||
NO_PROBE = "NO_PROBE" # fatal: a tool with no probe defined
|
||||
|
||||
# Q97 (2026-10-29): the shapes argparse prints when it cannot parse the
|
||||
# INVOCATION. A refusal carrying one of these is the tool telling the operator
|
||||
# the probe used a flag it does not have -- the input was never read, so the
|
||||
# probe graded nothing. Checked on both input axes; the usage-line lead is
|
||||
# checked separately by `echoes_invocation` because argparse always prints one.
|
||||
ARGV_ECHO_MARKERS = (
|
||||
"unrecognized arguments",
|
||||
"error: argument",
|
||||
"the following arguments are required",
|
||||
"invalid choice:",
|
||||
"ambiguous option:",
|
||||
"no such option",
|
||||
)
|
||||
|
||||
# {nope} = a path that does not exist; {bin} = a file that is not UTF-8;
|
||||
# {bindir} = a directory holding one non-UTF-8 *.md (added 2026-10-18 with
|
||||
# checkin_check.py, whose input is a directory of UTF-8 files -- a {bin} file
|
||||
@@ -737,6 +778,26 @@ def _first_line(text: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def echoes_invocation(text):
|
||||
"""True when a rc-2 message is argparse failing to PARSE THE INVOCATION
|
||||
rather than the tool refusing the input (Q97, 2026-10-29).
|
||||
|
||||
Two shapes, both measured from real argparse output rather than invented:
|
||||
the message's first non-empty line leads with `usage:`, or it carries one
|
||||
of ARGV_ECHO_MARKERS. The test is negative -- a refusal is red for what it
|
||||
IS, not for what a table remembers it saying (that was Q93's defect) --
|
||||
and it is deliberately not "must name the probe's path", because two tools
|
||||
in this directory refuse correctly with a boundary line that names no
|
||||
path."""
|
||||
low = (text or "").lower()
|
||||
if any(m in low for m in ARGV_ECHO_MARKERS):
|
||||
return True
|
||||
for line in (text or "").splitlines():
|
||||
if line.strip():
|
||||
return line.strip().lower().startswith("usage:")
|
||||
return False
|
||||
|
||||
|
||||
def judge_read(rc, text):
|
||||
"""One probe's verdict. Three classes, in this order on purpose: a
|
||||
traceback is a defect whatever its exit code, and a silent rc 2 is a
|
||||
@@ -748,6 +809,10 @@ def judge_read(rc, text):
|
||||
"detail": _first_line(text) or f"rc {rc}, no output"}
|
||||
if not (text or "").strip():
|
||||
return {"verdict": "SILENT", "rc": rc, "detail": "rc 2 with no message"}
|
||||
if echoes_invocation(text):
|
||||
return {"verdict": "ECHOES_USAGE", "rc": rc,
|
||||
"detail": "argparse failure, not an input refusal: "
|
||||
+ _first_line(text)}
|
||||
return {"verdict": "OK", "rc": rc, "detail": _first_line(text)}
|
||||
|
||||
|
||||
@@ -826,7 +891,9 @@ def judge_malformed(rc, out, err, expect_rc, need):
|
||||
|
||||
A REFUSAL (expect_rc == 2) is graded on the contract, not on prose (Q93):
|
||||
the documented code, no traceback, one non-empty message on either stream.
|
||||
Which stream is not part of the contract -- measured 2026-10-27,
|
||||
Since Q97 (2026-10-29) the message must also NOT be argparse failing to
|
||||
parse the invocation -- that is ECHOES_USAGE, fatal, because the input was
|
||||
never read. Which stream is not part of the contract -- measured 2026-10-27,
|
||||
`tool_map.py` writes its refusal to stdout and `citation_hash_class.py` to
|
||||
stderr, both at rc 2, both correct. `need` is only consulted where the tool
|
||||
GRADED the malformed input (rc 0/1): there the word is its verdict."""
|
||||
@@ -840,6 +907,11 @@ def judge_malformed(rc, out, err, expect_rc, need):
|
||||
if not text.strip():
|
||||
return {"verdict": "SILENT", "rc": rc, "detail": "rc %s with no message" % rc}
|
||||
if expect_rc == REFUSAL_RC:
|
||||
if echoes_invocation(text):
|
||||
# Q97: rc 2 from the argument parser is not a refusal of the input.
|
||||
return {"verdict": "ECHOES_USAGE", "rc": rc, "contract": "refusal",
|
||||
"detail": "argparse failure, not an input refusal: "
|
||||
+ _first_line(text)}
|
||||
# a refusal: the code and a readable line are the whole contract
|
||||
return {"verdict": "OK", "rc": rc, "contract": "refusal",
|
||||
"detail": "refusal rc 2: " + _first_line(text)}
|
||||
@@ -953,8 +1025,10 @@ def run_verdict(tool_fatal, read_fatal, malformed_fatal, pin_rc, pin_fatal):
|
||||
|
||||
def selftest():
|
||||
bad = []
|
||||
ran = []
|
||||
|
||||
def cell(name, ok, detail=""):
|
||||
ran.append(name)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}"
|
||||
+ (f" [{detail}]" if (detail and not ok) else ""))
|
||||
if not ok:
|
||||
@@ -1228,7 +1302,64 @@ def selftest():
|
||||
and "pin ungradable" in bad_line,
|
||||
f"{ok_line} | {red_line} | {bad_line}")
|
||||
|
||||
print(f"selftest: 44 cells, {44 - len(bad)} passed, {len(bad)} failed")
|
||||
# c45-c49: Q97 -- a refusal that is argparse failing to parse the
|
||||
# INVOCATION is not a refusal of the input. c45/c46 are the judge on both
|
||||
# axes; c47 is the control (a refusal that names its input stays OK); c48
|
||||
# is end to end through run_read_probe against a stub whose REAL argparse
|
||||
# renames the flag the probe passes -- the filed mutant, shrunk to one
|
||||
# cell; c49 is the summary line naming the fault.
|
||||
jm = judge_malformed(
|
||||
2, "", "usage: t.py [-h] --repo-dir REPO_DIT\nt.py: error: "
|
||||
"unrecognized arguments: --repo /tmp/x\n", 2, None)
|
||||
cell("c45 argparse usage at rc 2 on the malformed axis -> ECHOES_USAGE, fatal",
|
||||
jm["verdict"] == "ECHOES_USAGE" and axis_faults([jm]) == [jm],
|
||||
jm["verdict"])
|
||||
jr = judge_read(2, "usage: t.py [-h] --queue QUEUE\nt.py: error: the "
|
||||
"following arguments are required: --queue\n")
|
||||
cell("c46 argparse usage at rc 2 on the read axis -> ECHOES_USAGE, fatal",
|
||||
jr["verdict"] == "ECHOES_USAGE" and axis_faults([jr]) == [jr],
|
||||
jr["verdict"])
|
||||
jm = judge_malformed(2, "", "cannot read /tmp/nope: [Errno 2] No such file "
|
||||
"or directory\n", 2, None)
|
||||
jr = judge_read(2, "cannot read /tmp/nope: [Errno 2] No such file or directory")
|
||||
cell("c47 a refusal that names its input stays OK on both axes",
|
||||
jm["verdict"] == "OK" and jr["verdict"] == "OK",
|
||||
f"{jm['verdict']}/{jr['verdict']}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
stub = os.path.join(td, "renamed_flag.py")
|
||||
with open(stub, "w", encoding="utf-8") as fh:
|
||||
fh.write("import argparse\n"
|
||||
"ap = argparse.ArgumentParser()\n"
|
||||
# deliberately NOT a prefix of --repo: argparse accepts an
|
||||
# unambiguous abbreviation, so --repo -> --repo-dir still
|
||||
# parses and reads the input (measured, Q97).
|
||||
"ap.add_argument('--tree', dest='repo')\n"
|
||||
"a = ap.parse_args()\n"
|
||||
"print('cannot read ' + a.repo)\n"
|
||||
"raise SystemExit(2)\n")
|
||||
rc_m, out_m, err_m = run_read_probe(stub, ["--repo", "{nope}"], 30)
|
||||
jm_bad = judge_malformed(rc_m, out_m, err_m, 2, None)
|
||||
rc_c, out_c, err_c = run_read_probe(stub, ["--tree", "{nope}"], 30)
|
||||
jm_ok = judge_malformed(rc_c, out_c, err_c, 2, None)
|
||||
cell("c48 end to end: a renamed flag reads ECHOES_USAGE, its real flag OK",
|
||||
rc_m == 2 and jm_bad["verdict"] == "ECHOES_USAGE"
|
||||
and rc_c == 2 and jm_ok["verdict"] == "OK",
|
||||
f"{rc_m}:{jm_bad['verdict']} / {rc_c}:{jm_ok['verdict']}")
|
||||
|
||||
axis_echo = [{"tool": "t.py", "probe": "not-a-repo", "verdict": "ECHOES_USAGE",
|
||||
"rc": 2, "expect_rc": 2, "detail": "argparse failure"}]
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
erc, ehard, esoft = report_read(axis_echo, quiet=True)
|
||||
line_e = buf.getvalue()
|
||||
cell("c49 the summary names ECHOES_USAGE with its probe, and the run is red",
|
||||
erc == 1 and (ehard, esoft) == (0, 1)
|
||||
and "malformed-input: 1 probes, 0 ok, 1 fault "
|
||||
"(ECHOES_USAGE t.py/not-a-repo)" in line_e,
|
||||
line_e.strip().splitlines()[-1] if line_e.strip() else "no output")
|
||||
|
||||
print(f"selftest: {len(ran)} cells, {len(ran) - len(bad)} passed, {len(bad)} failed")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user