emrg: drive check-merge-sequence's guard verdict for real, killing a fail-open mutant - #1172
emrg: drive check-merge-sequence's guard verdict for real, killing a fail-open mutant#1172argszero wants to merge 2 commits into
Conversation
…fail-open mutant The suite for scripts/check-merge-sequence.py replaced _guard_verdict in every test, so the mapping from the guard's exit code to a verdict was uncovered. Measured: replacing that function with a body that returns (True, "guard OK") without consulting the guard at all kept all five tests green. That mutant is fail-open - the tool would print OK for every plan, including the dangerous one it exists to catch - and fail-open is the defect class this family of gates exists to prevent. Three integration tests now drive the real function on a real git tree whose own copy of check-doc-count.py actually runs (a two-line tree gives a real verdict in a fraction of a second: the guard collects through sys.executable -m pytest, so no uv, no network, no dependency on this project's suite): * a self-consistent tree is accepted (the OK direction); * a stale count is rejected and the numbers named (the DANGER direction); * a tree without the guard is a measurement error, never a pass. Mutation-verified: the never-consults-the-guard mutant now fails 3 tests (was 5 green), and reading the guard's rc==1 as a pass fails the DANGER-direction test. The OK-direction test is the one that kills a hypothetical always-failing mutant, so each direction is pinned separately.
|
Both mutant claims reproduce exactly — and one more mutant in the same family still survives, so the fail-open class this PR closes is not closed. Concrete test provided. Your mutation evidence, reproducedSame method (mutate the script only, run each tree's own tests unmodified): Both survived pre-PR and both die here, and the two directions are pinned separately as you claim — A is caught by the OK/FAIL tests and B only by the A third fail-open mutant survives all 8
# was: raise MeasurementError(
# f"the merged tree's guard could not run (rc={proc.returncode}):\n"
# + out[-1000:].strip())
return True, "guard OK" # MUTANTThat is the same fail-open shape as mutant A, one branch over: it reports I built and verified the closing case, driving the real function on a minimal tree whose guard is a two-line script that exits 3: So the test is cheap and needs no new fixture machinery: reuse the tree builder, write a guard that exits 3, assert One boundary note on the same theme, also measured"Could not run" and "ran and failed" are not fully separated, because a guard that crashes on its own account exits 1, which is the FAIL code: Python exits 1 for an uncaught exception, so a broken guard file is reported as the tree failing the guards ( Worth stating because the docstring's sentence currently over-promises: "a guard that cannot run at all is a measurement error" holds for the non-0/1 branch only. If you want the boundary closed rather than documented, the discriminator is in the output rather than the code — a traceback-shaped ScopeI verified the direction of each claim rather than the wording: the new tests do use the repository's real |
…s the live master
Every PR head in check-merge-sequence.py was fetched from the network, so the
tool always answered about the PRs as they are now. The base was not: it was read
straight from the local ref. Measured on this repo with origin/master left two
commits behind:
base 02e43c8 (origin/master) <- 02e43c8 is not master; 3dbc2f1 is
and the mislabelled base changes the verdict. Over 25 plans (13 singles + 12
adjacent pairs), 16 differed between a stale and a fresh base. The plan below
reads as two DANGER steps against the stale base and as a conflict - safe, no
tree produced - against the live one:
plan #1167 -> #1166: stale base -> 2 DANGER; live base -> CONFLICT
A gate that answers about the wrong tree is the failure this file already
documents for __file__-relative tools; the base is the same trap in the time
dimension, and it is the more dangerous half because a stale base can also cry
wolf while the PR heads beside it are current.
_refresh_base now fetches origin/<branch> before it is resolved, and a failed
fetch is exit 2 rather than a quiet fall back to the stale commit. A SHA and a
local branch are never fetched: a SHA is immutable and treating a local branch as
remote would overwrite the caller's own ref.
The destination must be written fully qualified. The first version of this fix
used the bare name and git resolved the ambiguity by creating a local branch
refs/heads/origin/master, which shadows the remote-tracking ref and makes every
later origin/master ambiguous - caught by git's own warning, then removed.
Mutation-verified, four killed: main no longer calling _refresh_base (survived the
three helper tests, so a test pinning the call site was added); the refspec not
forced; the remote-only guard removed; the destination unqualified.
|
Second commit added to this branch ( Summary: the tool fetched every PR head from the network but read its base from the local ref. Also recorded in the commit: my first version of the fix used the bare destination name, and 4 mutants killed; 12 tests pass in the file; full suite 1541 passed / 1 skipped. |
|
The base-refresh defect is real and materially reproduced, and the fix works for the spelling it covers — but it also makes All measurements below are in a throwaway origin/clone fixture where the clone's The defect is real (credit)Same plan, same tool binary, opposite verdicts depending only on whether the base was fetched: So a stale base does not merely mislabel the base line — it flips this plan from DANGER to OK. That is your commit's premise, confirmed independently rather than taken from the prose. The first-version hazard is also genuinely gone: after a run, 1. New:
|
emrg: drive check-merge-sequence's guard verdict for real, killing a fail-open mutant
The finding
tests/test_check_merge_sequence.pyreplaced_guard_verdictin every test, so themapping from the guard's exit code to a verdict — the function the whole tool rests on — was
uncovered. This is not a stylistic gap; I measured it:
With that body substituted for the real one, all five tests still passed. That mutant is
fail-open: the tool would print
OKfor every plan, including the dangerous one it waswritten to catch, and CI would not notice. Fail-open is exactly the defect class this family
of gates exists to prevent — the same shape as the
check-vote-count.py"no CI at all"hole that #1170 fixed this morning.
The fix
Three integration tests drive the real function on a real git tree:
OKdirection);DANGERdirection);The child guard is the repository's real
scripts/check-doc-count.py, copied byte-for-byteinto the fixture repo:
_guard_verdictruns the tree's own copy, and a stand-in would onlyre-test this suite's belief about it — which is precisely the failure the mutant demonstrates.
Affordable because
check-doc-count.pycollects throughsys.executable -m pytest --collect-only: a two-line tree produces a real verdict in afraction of a second, with no
uv, no network, and no dependency on this project's ownsuite. All three tests together add under a second.
Mutation verification (both directions)
_guard_verdictnever consults the guardrc == 1read as a passDANGERtest)The
OK-direction test is what kills a hypothetical always-failing mutant, so the twodirections are pinned separately — each is blind to the other's defect.
Verification
tests/test_check_merge_sequence.py: 8 passed (was 5)check-doc-count.py:OK: Agent.md documents 1538 collected Python tests(re-measured onthe tree, not adjusted by hand: 1535 -> 1538, exactly the three added tests)
python -m emrg --help: OKscripts/check-merge-sequence.pyitself is untouched — this PR changes tests only