fix(gate): run-tests.sh could not check its own most important precondition - #310
Conversation
…dition
Two measured holes, plus an honest correction to a third.
1. REQUIRED_TOOLS could not express the precondition that matters most.
It is a list of BINARIES checked with `command -v`. pytest is not a binary
this runner calls — it is a MODULE (`python -m pytest`) — so the one guard
whose entire job is "the thing that runs the tests is present" was
structurally unable to check pytest. It also asserted `python3` while the
runner actually invokes `python`.
MEASURED on the dev host with every REQUIRED_TOOLS binary present but no
pytest importable: all 17 targets printed
run-tests: ERROR — could not parse pytest's summary for <dir>.
and the run ended `TOTAL collected=0 … RESULT: FAIL`, exit 1.
CORRECTION, stated because it changes the severity: the gate did NOT go
green, and the briefed "reports per-target PASS with collected=0" does not
exist on this revision — GUARD 4 (unparseable summary) and GUARD 3 (the
collected floor) both fire. The defect is DIAGNOSTIC: seventeen copies of a
message blaming pytest's OUTPUT FORMAT for a missing dependency, pointing at
the wrong subsystem. That is the #276 shape — a real finding that reads like
an environment fault. Now one named FATAL, exit 2, before any suite runs.
2. `declare -a RESULTS` / `declare -a SKIP_LINES` leave the arrays DECLARED BUT
UNSET. Under `set -u` the first `${#arr[@]}` on a still-empty array aborts
the command with "unbound variable" (measured, bash 5.3.15). With zero skips
this printed a raw
scripts/run-tests.sh: line 479: SKIP_LINES: unbound variable
where GUARD 2's skip list belonged, and the unpinned-skip loop below it never
executed. No `set -e`, so the script continued and the skip-TOTAL accounting
still fired — the damage was confined to the DIAGNOSTIC path, at exactly the
moment someone is reading why the gate is red. Fixed with `NAME=()`.
3. "printed RESULT: FAIL and exited 0" did NOT reproduce.
Measured: exit 1. The structure forbids it — `RESULT: FAIL` is printed only
when `fail != 0` and the next statement is `exit "$fail"`, whose only
non-zero value is 1. The likely origin is reading the status through a
pipeline (`| tail`), which yields the last command's status rather than the
runner's. Pinned end-to-end anyway by
`test_a_failing_run_never_exits_zero`, which forces a red run and asserts the
two can never disagree — and pytest.fail()s if it could not force one, so it
cannot pass vacuously.
Known-bad states, each proven LOUD by before/after rather than by reading code:
no pytest module before: 17 misleading errors, exit 1
after: one named FATAL, exit 2, no suite started
empty target dir fails on the per-directory `collected 0 tests` floor
typo'd target GUARD 5 aborts naming the entry and saying "does not exist"
The last two were already guarded before this PR; nothing had ever proven those
paths could fire, so they are labelled REACHABILITY proofs, not regression
coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed shebang The repo-wide runtime-shebang scanner (#306, scripts/tests/ test_runtime_shebangs.py) failed this file IN THE SANDBOX ONLY. The dev-host run was green because I ran this ONE file, and the scanner lives in another — a per-file run structurally cannot see a repo-wide guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gated on the MERGED tree, not just this branchPer RULES.md — a PR green on its own branch proves nothing about the tree its merge creates. Built
Combined exit Delta accounted exactly: 5792 (baseline) + 10 (#309's
|
… 0 on failure (#312) #308 asserted that scripts/run-tests.sh "can exit 0 while printing RESULT: FAIL / collected=0". That does not reproduce and is structurally impossible on this revision: `RESULT: FAIL` prints only inside `if [ "$fail" -ne 0 ]`, and the very next statement is `exit "$fail"`. Measured on 13bc8bd, a run without pytest importable prints 17 x "could not parse pytest's summary", RESULT: FAIL, and exits 1 (2 in a second probe). The original report most likely read a status through a pipeline -- the rc=$? trap RULES.md already names. The real defect, fixed in #310, is narrower: REQUIRED_TOOLS checks BINARIES via `command -v`, but pytest is a MODULE, so a missing pytest yielded a diagnostic blaming pytest's output format instead of a clean precondition failure. It also asserted `python3` while the runner calls `python`. Bad diagnostics, not a false green. This was relayed from a subagent report and repeated to the operator twice without being tested -- the same class of error as the four corrections the doc itself records, except this one reached a merged document before anyone checked it. Left visible as a strikethrough rather than deleted, because the doc's purpose is recording what was believed and then disproved. Also updates three items now closed: - #309 gated the node suites: 529 ungated tests, not the 508 first reported (scripts/collector/browser-ext/tests was ungated too). Node gate 468 -> 997. - #311 shipped the telemetry deadman, including the verified property that "cannot tell" cannot render as "all healthy" -- deadman returns count=0 for both ok and unreachable, so a consumer reading count alone would be wrong. - session-create's cause is now known: `session.created` is a bus EVENT TYPE, not a plugin hook name, on opencode 1.18.4. Consequence: 2,736 of 2,799 tool-call rows carry session=''. Not fixed -- that file's last edit killed 11 hours of telemetry. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1.
REQUIRED_TOOLScould not express its own most important preconditionIt is a list of binaries checked with
command -v. pytest is not a binary this runner calls — it is a module (python -m pytest) — so the one guard whose entire job is "the thing that runs the tests is present" was structurally unable to check pytest. It also assertedpython3while the runner actually invokespython.Measured on the dev host with every
REQUIRED_TOOLSbinary present but no pytest importable — all 17 targets printed:and the run ended
TOTAL collected=0 … RESULT: FAIL, exit 1.Correction to the briefed severity
The gate did not go green, and the briefed "reports per-target
PASSwithcollected=0" does not exist on this revision — GUARD 4 (unparseable summary) and GUARD 3 (the collected floor) both fire. The defect is diagnostic: seventeen copies of a message blaming pytest's output format for a missing dependency, pointing at the wrong subsystem entirely. That is the #276 shape — a real finding that reads like an environment fault.Now a single named FATAL, exit 2, before any suite starts.
2.
declare -a+set -u— a guard's own error path was brokendeclare -a RESULTS/declare -a SKIP_LINESleave the arrays declared but unset. Underset -uthe first${#arr[@]}on a still-empty array aborts the command withunbound variable(measured, bash 5.3.15). With zero skips this printed a rawwhere GUARD 2's skip list belonged, and the unpinned-skip loop below it never executed. There is no
set -e, so the script continued and the skip-total accounting still fired — the damage was confined to the diagnostic path, at exactly the moment someone is reading why the gate is red. Fixed withNAME=().Not in the brief; found while reproducing hole 1.
3. "printed
RESULT: FAILand exited 0" — did NOT reproduceMeasured: exit 1. The structure forbids it —
RESULT: FAILis printed only whenfail != 0, and the very next statement isexit "$fail", whose only non-zero value is1. The most likely origin is reading the status through a pipeline (| tail), which yields the last command's status rather than the runner's — the trap the brief itself warns about.Pinned end-to-end anyway by
test_a_failing_run_never_exits_zero, which forces a red run and asserts the two can never disagree — andpytest.fail()s if it could not force one, so it cannot pass vacuously.Known-bad states — proven LOUD by before/after, not by reading code
collected 0 testsfloor →RESULT: FAILThe last two were already guarded before this PR — nothing had ever proven those paths could fire. Labelled REACHABILITY proofs, not regression coverage.
Measured — both tiers
nix build .#checks.x86_64-linux.pytestsRESULT: PASS, exit 0scripts/tests/test_run_tests_preconditions.py+test_runtime_shebangs.pyunder nix-shellDelta accounted exactly: baseline 5792 (
origin/main13bc8bd, sandbox) + 8 new tests = 5800. Skips unchanged at 1.The sandbox caught a defect the dev host could not
First sandbox run was RED (
5800 collected / 1 failed). The repo-wide runtime-shebang scanner added by #306 rejected this file: the shim helper wrote its own#!{shutil.which("bash")}. My dev-host run was green because I ran this one file, and the scanner lives in another — a per-file run structurally cannot see a repo-wide guard. Fixed by routing the shim throughscripts/testlib/mockbin.write_exec(ec63d11), which is exactly the helper #306 created for this.Recording it because it is the two-tier lesson landing on the PR that is about two-tier honesty, and because the green I would otherwise have reported was a claim about a subset.
Red/green matrix
scripts/tests/test_run_tests_preconditions.py, base reforigin/main(13bc8bd), measured by swappingorigin/main's runner into place:origin/mainrunnerThe 3 reds are exactly the three regression tests (
test_python_is_a_required_tool,test_missing_pytest_module_is_named,test_empty_arrays_are_initialised_assigned). The 5 greens at base are the reachability proofs and controls, which should pass at base because those guards pre-existed — labelled as such in the module docstring rather than counted as coverage for this change.Control pairs
test_the_runner_copy_helper_actually_works— positive control on the mutation helper. Every known-bad-state test rewritesHERMETIC_TARGETSin a copy; if that regex stopped matching, the copies would silently be the unmodified runner and each test would exercise the real 17-target list instead. It asserts the rewrite both applies and takes effect (all 1 hermetic target(s) resolve).test_set_u_empty_array_mechanism_holds— positive control on the bash behaviour the source pin describes. Asserts both halves:declare -aerrors,=()succeeds. Without it the pin could guard a non-issue on a bash where neither form errors.test_a_failing_run_never_exits_zerofails loudly if it cannot force a red run, rather than passing on an absence.Also found, deliberately NOT fixed
scripts/claude-hooks/tests/test_bash_guard.pyprints its own verdict line asRESULT: all good, which collides with the runner's ownRESULT: PASS/RESULT: FAIL. Anygrep -E "RESULT:"over a gate log returns two different subsystems' verdicts interleaved, and the hook test's line appears before the runner's. It cost real confusion while measuring this PR. Left alone because renaming a hook test's output is orthogonal to this change and belongs with whoever owns those scripts — flagging rather than silently widening the diff.Audit of the remaining exit paths
Asked the wider question — what else can this runner do that produces reassuring output without having tested anything? Findings beyond the above:
run_pytestis called in the parent shell (not a subshell), sofail=1genuinely persists; no lost-status hazard there.HOOK_TESTSloop's|| continuewas already converted to a loud failure by a prior PR; verified still loud._count_ofreturns0for an absent word, so a summary naming onlyfailedstill yieldscollected >= 1and cannot silently pass the per-directory floor.--check-targetsdeliberately zeroesmissing_toolsand now also bypasses the pytest precondition — correct (it runs no tests), and pinned bytest_check_targets_is_cheap_and_runs_no_testsfrom the earlier PR.Files touched
scripts/run-tests.shscripts/tests/test_run_tests_preconditions.py(new)No overlap with
nix/home.nix,scripts/bar-status-poll, orscripts/collector/**.🤖 Generated with Claude Code