Skip to content

feat(report): leave out, name and fail on the mutants the command cannot compile - #19

Merged
Disble merged 9 commits into
mainfrom
fix/unmeasurable-scope
Sep 20, 2026
Merged

Disble merged 9 commits into
mainfrom
fix/unmeasurable-scope

Conversation

@Disble

@Disble Disble commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Answers docs/reports/ditto-mutation-scope.md, committed here in the reporting
repository's words.

The defect. ditto staged scoped 43 mutants to a staged change spanning five
packages and judged them with one command naming one of them. 17 of the 20
survivors lived in a package that command never compiles, so nothing it ran could
have killed them: the run's ceiling was 0.605, and the number printed as a verdict
was 0.53 against a bar of 0.80. The score was measuring the tests and the command's
scope together, and nothing in the output told the two apart.

The fix. A release now reads which packages the test command executes — from
the command's own go test -json stream, which the baseline run already produces,
resolved through go list -deps -test so a mutant in a dependency of a named
package is correctly judged rather than accused. The mutants it cannot compile:

  • leave the numerator and the denominator, exactly as a mutant that never
    compiled does (S = D/(M − E)), so the printed score is a real measurement of
    what could be judged;
  • are not run, because a guaranteed survivor bought with a full suite run is
    not evidence about anybody's tests;
  • are named with the package that holds them, and the run exits 3, so a gate
    can tell "unmeasurable scope" from "below the bar" — both were 1 before, and the
    responses differ: no test answers the first.

--include-prefix arrives with it, the mirror of --exclude-prefix, because the
fix makes it necessary: narrowing the run to the package the command names is the
honest pass the report wanted, in one flag instead of one exclusion per package the
change touches.

Evidence, not claims. testdata/unmeasuredproject holds all three classes at
once — a package the command names, one its tests import (not unmeasured: its mutant
dies to the named package's tests), and one nothing compiles. Its golden pins the
output, the exit code, and a control where the same fixture under ./... reports
nothing unmeasured and exits 0. The same fixture was also run through the built
binary in a throwaway git repository, which is where the box and the exit code below
were read from.

Two limits, stated rather than hidden (docs/backlog.md entry 28): a
--test-command that emits no stream — make, gotestsum, a wrapper — gets no
check at all, and the check is per package rather than per file, so a build-tagged
file for another OS stays an ordinary survivor.

Breaking

  • A scope holding mutants the command cannot compile now fails with exit 3 and does
    not run them; the score printed is over the mutants it could judge.
  • PlanStaged, RunStaged, PlanChanged and RunChanged take a ditto.Prefixes
    instead of one []string (one-line change per call site).
  • One output line moved, deliberately and pinned: the baseline announcement prints
    before the first file's announcement, because asking the laboratory which packages
    the command compiles is what pays for the baseline. No verdict moved and every
    mutant address is identical.

Verified locally

gofmt -l . clean, golangci-lint 0 issues, the full suite green with a cold cache
(go test -count=1 ./..., 85s, including the new golden), and
mutantsPerReleaseOnThisRepository ratcheted 873 → 949 with per-file attribution in
perf/baseline.json. Not run here: the -race leg — this shell's C compiler
path is broken (cgo: C compiler "C:\Program" not found), so CI's devbox leg is the
one that runs it.

Please rebase rather than squash: the branch is eight work units, each with its
own tests and its reason, and a squash would collapse them into one entry.

internal/gobuildrunner resolved `go` to an absolute path and internal/commandscope
is about to need the same answer, and the two must agree: a scope resolved by one
toolchain and a build made by another would compare two compilers' answers and
the disagreement would look like a property of the module under test.

The resolution moves to internal/gotoolchain, unchanged, with the reasoning that
earned it -- PATH decides which compiler runs unless GOROOT is preferred
(go:S4036, CWE-426) -- and a test that pins both branches. Its mutants move with
it, so mutantsPerReleaseOnThisRepository is unchanged at 873.
Ditto scopes mutants to a change and judges them with one configured command, and
those are two different questions: a staged change spanning five packages, judged
by a command naming one of them, produces mutants nothing it runs can kill. They
survive, and the score mixes 'your tests missed this' with 'your command cannot
see this'.

internal/commandscope answers the second question without parsing the command.
`go test -json` names every package it executes -- including the ones with no
test files, which it reports as a skip -- and `go list -deps -test` names every
package those compile in. The closure is the load-bearing half: a mutant in a
dependency of a named package IS killable by that package's tests, so a set
comparison against the names alone would accuse a correct run. -test is what
makes a test-only import count.

A command that emits no stream -- make, gotestsum, a wrapper -- is not guessed at:
unknown refuses nothing, because a missing accusation costs a number somebody
chose an opaque command to get and a wrong one fails a run that was correct.

One process per scope, measured at 0.28-0.31s over this repository's 64 executed
packages, and nothing at all for a command ditto cannot read.

Reading it needs the baseline's own output, and result.Output answered only for
an Ok: a green suite is an Err, and a green suite is exactly the run whose stream
carries the scope. Widened to the command's output whichever way it ended.
The laboratory is the only place that can learn what the configured test command
executes without paying for it: the baseline run it already makes once per
release prints the `go test -json` stream that names every package the command
executes, and the sandbox it ran in is the tree the rest is resolved against.

So Executes(repository, path) answers whether the command compiles the package
that owns a file, and the first call is what pays for the baseline on a release
that never asks otherwise. There is no second cost: the same sandbox, the same
sync.Once. Held by count, not by argument — one baseline, one toolchain query,
however many files ask.

It answers true when the question cannot be answered: a command that is not `go
test -json` has no readable package scope, and neither does one whose toolchain
cannot be asked. A missing accusation leaves a number somebody configured an
opaque command to get; a wrong one fails a run that was correct.
…not compile

A mutant the test command never compiles is not badly tested, it is unmeasured:
nothing the command runs can kill it, so it survives, and a score counting it
mixes 'your tests missed this' with 'your command cannot see this'. Measured on
the report that asked for this: 43 mutants, 23 killed, 20 survived, 17 of those
survivors in one package the command never builds, printed as 0.53 against a bar
of 0.80 over a run whose ceiling was 0.605. docs/reports/ditto-mutation-scope.md.

So the report separates the class the field already separates, exactly as it does
for a mutant that never compiled (Zhu/Hall/May, S = D/(M - E)): out of the
numerator and the denominator, named with the packages that hold them, and the
run does not pass whatever the ratio over the rest says. The test command is not
started for them either -- a guaranteed survivor bought with a full suite run is
not evidence about anybody's tests.

  | Total: 3  Killed: 2  Survived: 1  Unmeasured: 1
  | 1 of the 4 mutants in this scope are never compiled by this test command, so
  | nothing it runs can kill them and they are out of the score entirely:
  |   internal/untested (1)
  exit 3

The exit code is the half a wrapper needs: a scope ditto cannot measure and a
score below the bar were both 1, and the responses differ -- the second is
answered by testing more, the first by naming every package the scope mutates in
--test-command or by narrowing the scope, and by no test at all.

Two decorators forward the new count, the test double applies the same exclusion,
reporter's summary is one pass instead of three, and --test-command's help now
names the lever that exists: it said 'name the package that owns the change',
which is true only while the scope holds one.

The golden moved by one line and it is the only line it moved: asking the
laboratory which packages the command can execute is what pays for the baseline,
so the baseline announcement now prints before the first file's announcement
rather than between it and the first mutant. No verdict moved and every address
is identical.
… cannot compile part of its scope

The fixture holds all three classes at once, which is what makes it worth its
runtime: two mutants the named package's tests kill, one that survives them, and
one in a package the command never compiles. The middle package is the
load-bearing one -- `shared` is not named either, and it is NOT unmeasured,
because the named package's tests import it and its mutant dies to them. A check
that compared names instead of reading the toolchain's closure fails this test.

The exit code is pinned beside the output, because it is the half a wrapper
reads, and the control is the same fixture under a command that names every
package: nothing unmeasured, exit 0, and the shared mutant still judged. So the
failure the first half asserts is about the command's scope and not the fixture.

Generated with DITTO_GOLDEN_UPDATE=1 and read before it was trusted.
The report asked for it in the same breath as the unmeasurable-score fix, and the
fix is what makes it necessary: a run now fails when the test command cannot
compile part of the scope, and the honest answer is usually to narrow the run to
the package the command names rather than to widen the command. One flag does
that; --exclude-prefix needs one flag per other package the change happens to
touch.

The public shape changes with it, and that is the point rather than a side
effect: PlanStaged, RunStaged, PlanChanged and RunChanged take a ditto.Prefixes
instead of one []string, so choosing what a run is about and narrowing it are one
argument instead of two that can drift apart.

An empty entry in either list is ignored rather than honoured, on both sides and
for the same reason: every path starts with the empty string, so a list carrying
one empty entry means 'no filter' when it was built and 'nothing at all' when it
was not -- and the second reading silently mutates no files.

Include narrows and exclude still removes, which the tests hold as a pair: a file
has to survive both to be planned. -h names the pairing, because the moment a
reader needs it is the moment a run just refused their scope.
… not

The readme gains the section the report asked for, with the real box: what a run
prints when its command cannot compile part of the scope, why each of the three
decisions is there -- out of both sides, not run, exit 3 -- and the two limits
where ditto says nothing rather than guessing.

docs/metrics.md gains the classification row, because that table is where this
repository decides what a verdict means, and this is the first row whose fix is
not in the tests. docs/backlog.md gains entries 28 and 29: the two ways the check
stays silent, and per-package runs as a cost-model change with the measurement
that would have to argue for it. The learning log gains four lines, three of
which are things that cost a measurement to find: a name comparison would have
failed correct runs, result.Output answered only for a kill, and asking a
question that pays for the baseline moves a line in the report.

docs/reports/ditto-mutation-scope.md is committed with the release it produced,
in the reporting repository's words rather than paraphrased.
.golangci.yml sets fix: true, so the pre-commit gate writes wsl's whitespace fix
into the working tree on every run — the file is never committed dirty, but it
stays dirty after every commit until the fix is carried. One line, and this is
the commit that carries it rather than the next one rediscovering it.
The MUTATE pass over this branch removed the nil check Executes made on its scope
and every test still passed, which is what that check was worth: verifyBaseline
always produces a scope — commandscope.New answers for an empty stream by
declining to refuse anything — so the branch was unreachable, and a guard whose
removal is invisible is dead weight wearing defense as a costume.

The fail-open it looked like it was doing lives one layer down, where a test
holds it: the scope answers true when the command named no package. Deleting this
one is what makes that the only place the rule lives.

mutantsPerReleaseOnThisRepository 949 -> 948, and the ratchet asked for the
deletion to be recorded rather than kept as an unclaimed gain.
@sonarqubecloud

Copy link
Copy Markdown

@Disble
Disble merged commit ac3c2dd into main Sep 20, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant