Repository navigation
fix(falsify): a mutation naming a test that does not exist is refused, not passed (#923) - #942
Merged
Merged
Conversation
…, not passed (#923) `go test -run <name> <pkg>` over a package holding no such test runs nothing and exits 0, and the harness read that zero as "the guard is not held". It printed TEST STILL PASSED for a guard that was held all along: measured on 2026-11-13, a mutation of a-transport-fault-is-bounded.json declared ./internal/core/emulator/ and named a test living in internal/cli. A verdict over an empty population, in the instrument this repository built to catch exactly that. It fails towards alarm, which is the less dangerous direction, and it has a silent half: a mutation whose test was renamed keeps the old name, matches nothing, and reports TEST STILL PASSED forever, until somebody "fixes" it by deleting the mutation. The guard then has no falsification and nothing says so. The check asks `go test -list` whether each declared name exists in the package its mutation declares, once per run and grouped by package, and refuses the spec otherwise. It runs inside falsify:lint, so prepush catches a stale name, and again before the tree is copied, so a void run costs half a second instead of a copy plus a suite. The new state is kept apart from the three verdicts the harness already draws: the mutation was never applied and the test was never run, which is none of them. It also asks at the second depth, and that was found by falsification rather than foreseen. `go test -run 'TestX/no-such-sub'` runs TestX, no subtest matches, the parent passes, exit 0 - the same defect one level down, and 13 committed mutations name a subtest, nine of them rows of one table in s3-bucket-configuration.json. Renaming one row would have silenced nine mutations with nothing to say so. Three real parent runs, 0.9 s, so there was no cost argument for leaving it out. Twelve selftest assertions hold the seven controls, and each was proved by mutating it in a copy outside the repository: the package filter (which the first version of its own case could not see, because a one-package module makes "in this package" and "anywhere" the same set), the untrustworthy listing of a tree that does not build, the pre-copy wiring in main(), the subtest check and its accepting half, the parent that cannot answer, and an unusable pattern - that last one a false proof rather than a void one, since `go test` rejects the pattern, exits non-zero, and the replay reads non-zero as "the test bit". Sweep of the committed specs: 1665 names over 251 specs, 36 package patterns, 13 subtests really entered under their parents, no offending triple at either depth. Three stale sentences went with it: testplan's spec count, falsify:selftest's "three mutations" where its own output prints four, and lint()'s docstring naming one half of what it now checks. The published costs name the invocation that produced them, because mise runs the pinned Python 3.12.11 and a bare python3 resolves 3.12.13 on this station, which is a factor of two on a sub-second task. Left to their own issues: #936 (falsify:lint is run by no pull request, and CONTRIBUTING says it is), #937 (five hand-written counts nothing recomputes), #938 (a selftest case can pass for the wrong reason, 2 being the answer for seven of them, with the four behaviours no case holds). Assisted-by: Claude Code (claude-opus-5)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
go test -run <name> <pkg>over a package holding no such test runs nothing and exits 0, and the harness read that zero as "the guard is not held". Sofalsifyprinted TEST STILL PASSED for guards that were held all along: a verdict over an empty population, in the instrument this repository built to catch exactly that.Measured on 2026-11-13: a mutation of
a-transport-fault-is-bounded.jsondeclared./internal/core/emulator/and named a test living ininternal/cli. Run by hand against the package that holds the test, the same mutation failed in 5.02 s.It fails towards alarm, which is the less dangerous direction, and it has a silent half: a mutation whose test was renamed keeps the old name, matches nothing, and reports TEST STILL PASSED for ever, until somebody "fixes" it by deleting the mutation. The guard then has no falsification and nothing says so.
What changed
Every declared
testmust appear in ago test -listof its declared package, or the spec is refused. One listing per run, grouped by package, memoised for the process. It runs insidefalsify:lint, soprepushcatches a stale name, and again before the tree is copied, so a void run costs half a second instead of a copy plus a suite.The new state is kept apart from the three verdicts the harness already draws, and the comment at the call site says so in those words: a copy red before any mutation measured no guard; a refused spec measured no guard either; a mutation that applied and left its test green is a verdict. This one is none of them — the mutation was never applied and the test was never run.
It also asks at the second depth, and that was found by falsification rather than foreseen.
go test -run 'TestX/no-such-sub'runsTestX, no subtest matches, the parent passes, exit 0 — the same defect one level down. 13 committed mutations name a subtest, nine of them rows of a single table ins3-bucket-configuration.json. Renaming one row would have silenced nine mutations with nothing to say so. Cost: three real parent runs, 0.9 s, so there was no cost argument for leaving it out.Sweep of the committed specs
Exit 0. No offending triple at either depth. The issue asked for this sweep explicitly, because nobody knew whether a committed spec already named a test that does not exist.
Type of change
What the contradicteurs found, and what it cost
Two roles, two cycles each, which is the maximum the project allows. Four BLOCKERs, all the same shape: the guard this lot adds was not held by its own tests.
covered_paths' package filter could be replaced byreturn set(known)and the selftest stayed green, because a one-package module makes "in this package" and "anywhere in the listing" the same set. That is the very triple falsify reads "no tests to run" as a guard that is not held #923 exists for._unbuildable_case()passed without the branch it exists for, because a broken package makes the name missing anyway.main()was unmeasured:unknown = []reprinted TEST STILL PASSED verbatim, measured end to end.Twelve selftest assertions now hold seven controls, and each was proved by mutating it in a copy outside the repository: the package filter, the untrustworthy listing of a tree that does not build, the pre-copy wiring, the subtest check and its accepting half, a parent that cannot answer, and an unusable pattern. That last one is a false proof rather than a void one:
go testrejects the pattern, exits non-zero, and the replay reads non-zero as "the test bit".The most instructive result is not in the diff. The author's first versions of two of those cases were green under both mutants, because the specs named
filerelatively whilelintresolves it against the process's working directory, so both answered through the stale-fragment path and never reached the new check. It was found by mutating the new code, not by reading it. That class is #938.Runs
mise run prepush2ab0c24mise run falsify:selftestpython3 tools/falsify/falsify.py --lint tools/falsify/specsmise run conformance:leg -- probe--vm offmise run testplannamesMode:
--vm offthroughout. Nothing here touches a machine runtime, so no leg underFEINT_VMis owed.Costs, with the invocation that produced them
Measured on 2026-11-14 on the maintainer's station. The invocation is part of the figure, because
mise runuses the pinned Python 3.12.11 while a barepython3resolves 3.12.13 on that station, which is a factor of two on a sub-second task:falsify:selftest: 1.37 to 1.44 s throughmise run, 2.92 to 3.04 s through a barepython3, same 32 assertions either way, against 0.03 s for the build-free version.falsify:lint: 2.54 to 2.63 s throughmise run, 2.77 to 2.89 s bare; the three subtest parent runs are 0.9 s of it.An earlier draft published 3.0 s for the selftest and explained it with a mechanism the code does not have ("the module path is new on every run", where it is a constant). Both were withdrawn and remeasured; the counterfactual was measured too, and a module name the build cache had never seen costs the same, because the cache keys on the bytes compiled and not on the path.
Runs not played
mise run falsify:all, which belongs to the night. What it would add over the lint is that the new pre-copy refusal changes no existing verdict across all 251 specs. The five specstestplannames are the subset this diff can have broken, and they are green.Three stale sentences went with it
tools/testplan/specs.goclaimed 128 specs and 186 files, measured now at 251 and 295;falsify:selftestsaid "three mutations" where its own output prints four;lint()'s docstring named one half of what it now checks. The wider version of that is #937.Left to their own issues
falsify:lintandfalsify:selftestare run by no pull request, whileCONTRIBUTING.mdsaysprepushis what a pull request refuses. This lot makes the lint the only thing between a renamed test and a false TEST STILL PASSED, and it lives in a hook a fresh clone does not have.2being the answer for seven of them, plus four behaviours of this file that no case holds.Checklist
Always
mise run checkpasses (insideprepush, on the rebase)internal/corestill knows no providermise run testplanwas run, and what it printed was run; the one run not played is named aboveWhen a model wrote a substantive part of this
Assisted-by:trailer names the tool and the model versionWhen a route is added or changed
N/A.
When behaviour a client can observe changes
conformancepages gain the fifth outcome, in both languagesWhen
.github/workflows/is touchedN/A.
When a machine runtime is involved
N/A.
Related issues
Closes #923.
🤖 Generated with Claude Code