Implementation plan
This is the initial plan for building mutrim from the current scaffold. It orders the work so
that every phase leaves mise run ci green and produces something testable on its own.
Architecture and conventions are fixed in CLAUDE.md; this document only
decides what to build in which order and what “done” means for each step.
Scope of the first release
Section titled “Scope of the first release”- In: mutation testing for Go packages, first without Bazel (
go test -overlay), then under Bazel viamutation_test(...)with schemata + sharding. Done whenbazel test //...:mutant_*works on theexamples/fixtures. - Out (later releases): per-test coverage matrix, weighted greedy set cover, subsumption reports. The package skeletons exist from day one so the API can grow into them, but no minimizer work starts before the Bazel milestone ships.
Repository layout
Section titled “Repository layout”cmd/mutrim/ CLI: `gen`, `overlay`, `run`, later `minimize` (thin; JSON on stdout)mutator/ AST rewriting, operators (ops_*.go), type-check pre-filter, mutant IDs, mutants.json, overlay source, schemata lowering (schemata.go)mutator/testdata/ fixture packages + golden files for the mutator testsmut/ runtime package imported by schemata sources; reads GOMUTANT_IDrunner/ re-exec test binary per mutant, sharding, report.json, incrementalcriteria/ Criterion interface, SiteCoverage, Mutation, matrix compositionminimize/ greedy set cover, subsumption, protection rulesexamples/ Bazel fixtures calling mutation_test on themselves (Phase 3)defs.bzl, MODULE.bazelPackages are created when their phase starts, not as empty placeholders.
Phase 0 — Foundation (done)
Section titled “Phase 0 — Foundation (done)”Goal: real module layout, dependencies pinned, fixtures in place.
- Add
golang.org/x/tools(go/packages) togo.mod; delete root placeholders. mutator/testdata/fixtures: one package per operator family with golden expectations, plus an “excluded files” package (_test.go,.pb.go,mock_*.go, generated header) and akillablepackage with a test for the end-to-end overlay check.cmd/mutrimuses plainflagsubcommands; JSON to stdout, logs to stderr.
Phase 1 — Mutator (Bazel-independent, done)
Section titled “Phase 1 — Mutator (Bazel-independent, done)”Goal: mutrim gen ./pkg writes a mutants.json listing only type-checked-viable mutants,
and mutrim overlay lets go test -overlay run a single mutant.
- Loading.
go/packagesonce per package withNeedName|NeedFiles|NeedSyntax|NeedTypes|NeedTypesInfo|NeedDeps|NeedImports. Exclusion filter applied to file names and build constraints before any rewriting. - Operator interface.
Families: one table-driventype Operator interface {Name() string // stable, used in mutant IDsSites(ctx *Context, n ast.Node) []Site // rewrites this operator can perform on n}type Site struct {Node ast.NodeDescription string // "< -> <=" for mutants.jsonApply, Undo func() // in-place AST rewrite and its exact inverse}
BinaryOpfor the operator swaps plus a type per statement family; CLAUDE.md lists every operator mutrim ships. Only function bodies are walked. - Mutant ID.
sha256(pkgPath, enclosing func name, AST path from func root, operator name, description)truncated to 16 hex chars. Position-independent by construction; a test asserts that inserting lines above a site does not change its ID. - Type-check pre-filter. For each binary-operator site:
Apply, runtypes.CheckExpron the rewritten expression in its original scope, recordviable=falseon error,Undo. Statement mutants are well-typed by construction and have no check. Context-dependent failures (constant overflow, unused import after a return replacement) are left to the build, which the runner counts as NOT VIABLE. mutants.json. One entry per candidate:{id, pkg, file, line, col, func, operator, description, viable, ignored?, reason?}. Non-viable entries are kept in the file (so the runner can report NOT_VIABLE counts) but never executed; so are the entries agenfilter or an inline//mutrim:disabledirective suppressed, which the runner reports IGNORED and no score counts.- Overlay mode.
mutrim overlay -id <id>prints ago build -overlayJSON pointing at a temp file with that single mutant applied (go/format). This is the fast dev loop and the non-Bazel user path.
Done: golden tests cover every operator, the ID stability test passes, and a test runs
go test -overlay with a known-killable mutant on a fixture and asserts the test fails.
Phase 2 — Schemata and runner (done)
Section titled “Phase 2 — Schemata and runner (done)”Goal: one build per package; the test binary re-executed per mutant.
-
Schemata lowering (
mutator.Lower, driven by each operator’sSite.Schematacallback). Every viable mutant of a package is embedded into rewritten sources that import themutruntime package. Helpers take the operands eagerly wherever Go evaluates them eagerly, so evaluation order andrecover()semantics are untouched and the operand types are inferred by generics instead of being spelled out:Original Lowered a < bmut.Cmp(id, a, b, "<", "<=")(generic overOrdered)a == bmut.Not(id, a == b)(!=is exactly the negation)a + b,a % bmut.Arith(id, a, b, "+", "-"),mut.ArithIntfor%a && bmut.And(id, a, func() bool { return b })(short-circuit)if cif mut.Not(id, c)i++mut.Inc(id, &i); map elements useif mut.Active(id) {…}return x{ if mut.Active(id) { return <zero> }; return x }The operators added after this phase follow the same two shapes: an expression becomes a helper call, a statement is wrapped in an
if mut.Active(id). Where several mutants sit on one node, one rebuilds the node and the others wrap what it left (Site.Wraps), so the lowerings nest.Sites the lowering declines stay as they are and their mutants are reported
NOT_VIABLEunder schemata: constant expressions (a call is not a constant), boolean results or operands of a defined type (the helpers return plainbool), untyped non-constant operands,&&/||whose right operand callsrecover(),m[k]++in aforpost statement, a body whose last statement makes itsswitchorifterminating, and a generic library function, which has no function value.mutrim gen -schemataflipsviableto false for them so the runner never selects an ID the binary does not contain.The
mutruntime readsGOMUTANT_IDonce at init. With the variable unset every helper is the identity;TestSchemataIdentityruns the fixture suite against the schemata source and pins the generated file with a golden. -
Runner (
mutrim run -test-bin <path> -mutants mutants.json). For each viable mutant whoseid % TEST_TOTAL_SHARDS == TEST_SHARD_INDEX: exec the binary withGOMUTANT_ID=id,-test.v -test.failfastand a timeout (issue #31:-timeout-factor(3) × the traced durations of the tests reaching the mutant +-timeout-const(2s), at least 10s — tests that spawn the Go toolchain can miss the build cache under a mutant — and at most-timeout-factor× the baseline run;-timeoutoverrides it), classify KILLED / LIVED / TIMEOUT / RUN_ERROR (issue #27: a run that dies from outside the tests — no--- FAIL:line, afatal error:, a signal, an exit code the testing package never uses — is never a kill; a--- FAIL:orpanic:line overrides it).-testsis an allowlist for-test.run; without it the whole binary runs (Phase 4 narrows by per-test coverage and drops failfast). -
report.jsonwritten toTEST_UNDECLARED_OUTPUTS_DIR(or-out):{mutant_id, status, tests_run, killed_by, duration_ms, timeout_ms}plus totals, the baseline and the timeout cap. TIMEOUT counts as KILLED in the score; RUN_ERROR counts towards no score (totals.run_error) and is never copied forward. Next to the mutation scoretotalsalso reports the mutant coveragetotals.coverage, the fraction of viable mutants a test reaches (issue #34). -
Incremental re-runs.
-previous report.json: mutants whose ID is present are copied forward; new IDs are executed;NOT_VIABLEis always recomputed frommutants.json. IDs are content hashes, so an untouched function keeps its result. The tests are checked too (issue #30, PIT / StrykerJS incremental):tests[].hashis the SHA-256 of the test function’s source (-test-srcs), and a kill is copied only while every killer still exists, reaches the mutant and keeps its hash; any other result only while the reaching tests and their hashes are unchanged.
Done: runner/testdata/schemata.golden is the report of the schemata fixture, produced by
gen -schemata + go test -c -overlay + run, and every embedded mutant of a tested
function is KILLED there; the CLI test runs the same pipeline through mutrim.
Phase 3 — Bazel integration (first release, done)
Section titled “Phase 3 — Bazel integration (first release, done)”Goal: bazel_dep(name = "mutrim") + mutation_test(name, srcs, embed, shard_count = N).
- Loading without
go list. A sandboxed action has no module cache, somutrim gen -importpath <path> -importcfg <file> -stdlib <dir> files...type-checks the package’s files withgo/types, reading dependency types from the export data rules_go already compiled (mutator.LoadFiles,gcexportdata; the same approach as nogo). The importcfg hasgo build’s format; the standard library is found under<stdlib>/<goos_goarch>/<path>.a. Files excluded by build constraints are copied through untouched, so the schemata directory is always a complete copy of the package. mutrim_schematarule (bazel/mutation_test.bzl). Reads the library’sGoInfo/GoArchive, writes the importcfg fromGoArchive.transitive, runs oneMutrimGenaction producing the schemata sources andmutants.json, and provides aGoInfowith the same import path plus a dependency on//mut, so ago_testcan embed it in place of the original library.mutation_testmacro expands to that rule, ago_teston the schemata sources (the identity check: withGOMUTANT_IDunset the tests must still pass), and ansh_test(bazel/run.sh) that runsmutrim runon the test binary withshard_count;report.jsonlands in the undeclared outputs. The runner strips Bazel’s test-protocol variables (TEST_TOTAL_SHARDS,TESTBRIDGE_TEST_ONLY,XML_OUTPUT_FILE, …) from the child environment: the rules_go test main acts on them too.- Repository.
MODULE.bazel(rules_go 0.63, gazelle 0.54, rules_shell; buildifier as a dev dependency), gazelle-generated BUILD files,examples/calcandexamples/stats(a dependency on another workspace package and on the standard library) dogfooding the macro,mise.bazel.toml(fmt:bazel,lint:bazel,test:bazel,ci:bazel) and theci_bazel.ymlworkflow onubuntu-latest.
Not supported: cgo packages. //go:embed works (examples/greet): rules_go resolves the
library’s patterns against its embedsrcs although the schemata sources are generated into a
subdirectory, and mutation_test takes the tests’ embedsrcs. mutrim’s own tests
that shell out to go are excluded from the Bazel build and stay on go test ./....
Done: bazel test //... passes and report.json is visible as an undeclared test output.
Tag v0.1.0.
Phase 4 — Criteria and minimizer (second release, done)
Section titled “Phase 4 — Criteria and minimizer (second release, done)”Goal: a per-test kill matrix, and mutrim minimize reporting what it implies.
-
Per-test coverage without
-cover. The plan was block coverage from-test.coverprofile, butgo test -c -cover -overlayinstruments the on-disk sources and ignores the overlay (the mutants are not even selectable in such a binary), and under Bazel instrumentation only exists inbazel coverage. Instead themutruntime got a trace mode: withGOMUTANT_TRACE=<file>every site the process reaches appends its ID once. The runner lists the binary’s tests (-test.list), runs each on its own that way, and records per test its duration and reached sites (report.json→tests). This is site coverage: exact for narrowing, identical under Bazel andgo test, blind only to code with no mutant site at all. Block coverage closes that gap the same way (issue #39): the schemata sources callmut.Reach(id)at the head of every block, a trace-only site that is never a mutant, sotests[].blocksis per-test block coverage andcriteria.BlockCoverageitsCriterion(minimize -w-block,-blocks blocks.jsonfor theuncoveredlisting). -
Narrowed runner. Each mutant runs only against the tests that reach its site, without failfast, so
killed_byis the complete kill matrix (a timed-out mutant is attributed to the tests that started and never finished). A mutant no test reaches isNO_COVERAGE, never executed, and counts as surviving in the score. A run that dies from outside the tests — no--- FAIL:line, the runtime’sfatal error:, a signal, or an exit code the testing package never uses — is aRUN_ERROR(issue #27): no test failed, so the exit says nothing about the mutant. It counts towards no score,-previousnever copies it forward, andminimizeignores it.-previouscopies forward KILLED / LIVED / TIMEOUT only; NOT_VIABLE and NO_COVERAGE are recomputed, both being free. -
criteria.Criterion(Name,Rows: test → labels) withSiteCoverageandMutation;Composeunions weighted criteria into aMatrixofRequirement{Label, Weight}columns andTest{Name, DurationMS, Covers bitset}rows, sorted so the output is deterministic. The JSON form (indices per test) is the export for an exact solver. -
minimize.Greedy: protected tests first, then repeatedly the test with the best gain = Σ weight of newly satisfied requirements / max(duration ms, 1), ties broken by name, until no test gains. Every other test is redundant and comes with the selected tests that each subsume it. Protection: a name regexp (default^TestRegression_) andTagged, which parses_test.gofiles for a//mutrim:keepdoc-comment line (a directive-shaped line, so it is read from the raw comments, notCommentGroup.Text). -
CLI and Bazel.
mutrim minimize -mutants mutants.json -srcs dir [-keep re] [-tag t] [-w-site 1] [-w-kill 5] [-matrix out.json] report.json...printsselected,redundantandweak_spots(functions with LIVED or NO_COVERAGE mutants, fromrunner.WeakSpots).bazel/run.shruns it aftermutrim run, with the macro’ssrcsas data, so everymutation_testleavesreport.jsonandminimize.jsonin its undeclared outputs. -
Subtest rows (
run -subtests,mutation_test(subtests = True)). Go suites are mostly table-driven, so the unit a person deletes isTestParse/empty_input, notTestParse. The baseline’s-test.voutput enumerates the subtests (there is no-test.listfor them); each is traced with-test.run '^TestX$/^case$', and per mutant the reaching subtests of one parent run in one process, attributed by their--- FAIL:lines. Rows carryparent;-keepmatches the full name and a tag on the parent protects every row. Known hazard, as in Stryker’s per-test mode: subtests must be order-independent. -
Confirmation reruns (
run -confirm-kills,run -confirm-baseline, the matchingmutation_testattributes). A kill read from one run is unstable (Shi, Bell, Marinov, ISSTA 2019: 9% of mutant × test pairs), and a per-test matrix turns one flaky kill into an “essential” or a “redundant” test.-confirm-kills Nreruns only the killing tests until each has failed N runs; an unreproduced failure goes tosuspicious_by, and a mutant whose every killer turned suspicious isLIVED.-confirm-baseline Nrepeats each trace run; a test that fails in some runs and passes in others isflakyintests[]instead of an error, its failures are never kills, andminimizedrops it from the matrix and lists it inflaky_tests.totals.suspicioussizes the problem.
Done: the schemata fixture has a redundant test, a TestRegression_* test and a tagged test;
the runner golden shows complete killed_by lists and NO_COVERAGE for the untested
function, and the CLI test checks the minimize verdict end to end. criteria and minimize
run mutation_test on themselves under Bazel, next to examples/.
Testing approach
Section titled “Testing approach”Golden files under mutator/testdata/ list every mutant with its position, operator,
viability and the source line after the rewrite, and the test re-generates after all
apply/undo round trips to prove the AST is restored. The per-mutant expected-output idea
follows go-mutesting’s test layout; the fixtures and expectations here are written from
scratch, and nothing is copied from gremlins (Apache-2.0) or go-mutesting (MIT).
Risks and open questions
Section titled “Risks and open questions”- Generic helpers vs. untyped constants.
mut.Cmp(id, 1, x, …)infersTfrom the typed operand and converts the constant exactly as the original expression did. Only an untyped non-constant operand (a bare shift) with no typed sibling is declined. //go:generateoutput detection is heuristic (Code generated ... DO NOT EDITheader); document it rather than trying to be clever.- Timeouts need a per-package baseline run; the runner measures it once before mutating.
Immediate next steps
Section titled “Immediate next steps”- Tag
v0.2.0once the Bazel workflow is green onmain. - Merge shard reports inside the Bazel test tree (a
mutrim_minimizerule over the shards’ outputs) instead of asking the user to pass them together.