Skip to content

Gate pull requests on the mutation score of what they changed - #316

Merged
Reefact merged 4 commits into
mainfrom
claude/automated-mutation-testing-4okfe0
Jul 27, 2026
Merged

Gate pull requests on the mutation score of what they changed#316
Reefact merged 4 commits into
mainfrom
claude/automated-mutation-testing-4okfe0

Conversation

@Reefact

@Reefact Reefact commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary

Adds automated mutation testing with Stryker.NET, as two workflows with two required checks: mutation for the FirstClassErrors libraries and tooling, justdummies-mutation for the JustDummies packages. On a pull request each one mutates only the files the diff touched and fails under that project's threshold; a weekly schedule sweeps everything, advisory.

The split into two workflows is deliberate: JustDummies is headed for a repository of its own, and this keeps that move a file move rather than a rewrite.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring
  • Analyzer / diagnostic change
  • Tests
  • Documentation
  • Build / CI / tooling

Changes

  • .github/workflows/mutation.yml — mutation gate over every FirstClassErrors project whose code ships or runs: the three libraries plus the analyzers, the documentation generator and the fce command line. Required check: Mutation gate.
  • .github/workflows/justdummies-mutation.yml — the same for JustDummies and JustDummies.Xunit. Required check: JustDummies mutation gate. Deliberate near-duplicate of the above; a diff between them shows only the cron slot, the matrix entries, the gate name and its error message.
  • build/stryker/*.json — one Stryker configuration per project: project, test projects, runner mode, thresholds.
  • .config/dotnet-tools.json — pins dotnet-stryker 4.16.0, so CI and a local run use the same mutant generator.
  • .gitignore — ignore StrykerOutput/ from local runs.
  • Bilingual reference pages for both workflows, indexed in the workflow reference; CLAUDE.md points at them.
  • ADR-0043 (Proposed) records the decision.

Out of scope, for reasons other than cost: the Usage samples and the binder benchmarks (not shipped behaviour), and GenDoc.Worker — a process entry point no test exercises in process, proven end to end by the floor job of ci; mutating it would only manufacture survivors no test could kill.

Two findings worth reading before reviewing the config

  • Stryker's default VSTest runner does not work on this test bed. An xUnit v3 test project is an executable that VSTest launches as a child process, out of reach of the in-process hooks Stryker uses to activate a mutant. The run completes, reports 1010 tests, and scores 0 % — every mutant "survived". Verified by hand: applying one of those "survivors" (errorCode == null!= in Error.CreateSafeCode) fails 7 tests. Hence "test-runner": "mtp", which Stryker flags as preview.
  • "coverage-analysis": "off" is for accuracy, not speed. Under the MTP runner, coverage-based selection still classifies killed mutants as uncovered. Measured on Error.cs: 75 % with selection on, 100 % with it off.

Thresholds

Each bar was set from that project's measured full sweep, so those gates start green and only ratchet up. Four projects have no bar: their sweeps are too long to have been run interactively, so no score was measured and none was guessed.

Project Mutants generated Measured score break
FirstClassErrors 1 083 100 % 95
FirstClassErrors.RequestBinder 459 100 % 95
JustDummies.Xunit 23 57 % 50
FirstClassErrors.Testing 80 45 % 40
JustDummies 4 906 not measured 0
FirstClassErrors.GenDoc 2 059 not measured 0
FirstClassErrors.Analyzers 1 038 not measured 0
FirstClassErrors.Cli 647 not measured 0

The four ungated legs still run, still fail on a broken build or a failing suite, and still list their survivors — they simply do not yet refuse a pull request over a score. The first weekly sweep supplies the missing figures; ADR-0043's follow-up and the workflow pages say so.

The two low scores are a real finding, not noise — FirstClassErrors.Testing and JustDummies.Xunit have behaviours nothing asserts (for instance, inverting scope is not null and TestState?.Result != Failed in ReproducibleAttribute both survive).

Both sweep caps are 350 minutes, just under GitHub's six-hour job ceiling: the weekly run is meant to take as long as it takes.

Testing

  • dotnet build FirstClassErrors.sln
  • dotnet test FirstClassErrors.sln
  • Analyzer tests pass (FirstClassErrors.Analyzers.UnitTests)

No source code changes, so the suites were not run as such. They were exercised indirectly and repeatedly: every Stryker run performs an initial test run and aborts if it fails, and full sweeps of FirstClassErrors, FirstClassErrors.Testing, FirstClassErrors.RequestBinder and JustDummies.Xunit all completed on this branch.

What was verified directly, locally:

  • the gate mechanism end to end — touching FirstClassErrors.Testing/Clock.cs selects exactly its 4 mutants, scores 75 %, and exits 2 against the 80 threshold in force at the time;
  • the no-op path — a diff touching none of a project's sources reports "unable to calculate a mutation score" and exits 0;
  • --since accepts a commit SHA and rejects HEAD, which is why the workflow resolves git merge-base first;
  • the CI warning ratchet does not reach Stryker's mutant compilation — the compile-error count is identical with GITHUB_ACTIONS=true and unset, so no escape hatch was added to Directory.Build.props;
  • that Stryker runs at all on the analyzers, the generator and the command line — including the generator's 84 Verify snapshots and its process-spawning tests — before they were added to the scope;
  • the run-summary jq step against real reports, in all three branches (nothing selected / all killed / survivors listed).

On a GitHub runner, the previous revision's five legs all passed and the --since filter behaved as designed (985 mutants Ignored. Reason: Removed by since filter, 0 total mutants will be tested, exit 0, ~1 min 25 per leg). The three tooling legs added since have not yet run there.

Documentation

  • Public API / error documentation updated
  • README / doc/ updated
  • French translation (doc/handwritten/for-users/README.fr.md) updated if user-facing behavior changed
  • No documentation change required

Both new reference pages are bilingual (EN canonical + FR), indexed in the workflow reference in both languages. No user-facing library behaviour changed, so the user documentation is untouched.

Architecture decisions

  • No architectural decision in this pull request
  • New decision recorded — ADR drafted as Proposed: ADR-0043
  • Supersedes an existing ADR — successor proposed, status not flipped: ADR-____
  • ⚠️ Conflicts with an existing ADR — flagged for the maintainer: ADR-____

Related issues

None.


One thing needing your call before merge: branch protection. The workflows cannot make themselves mandatory — Mutation gate and JustDummies mutation gate have to be marked required on main.

@Reefact
Reefact force-pushed the claude/automated-mutation-testing-4okfe0 branch from ec778c8 to bb24ecb Compare July 27, 2026 13:02
claude added 3 commits July 27, 2026 13:07
Records the decision to require a mutation-score threshold on every pull
request, measured over the mutants of the files it changed, for the five
libraries the repository ships — enforced by two independent gates, split
along the boundary JustDummies is expected to leave through.

The context section holds what measurement established before the decision:
Stryker's default VSTest runner reports every mutant as survived on this
xUnit v3 test bed, its Microsoft Testing Platform runner works but is
preview, that runner's coverage-based selection misclassifies killed mutants
as uncovered, and the cost is one test-suite run per mutant.

That cost is also why one library ships without a bar: JustDummies' sweep
runs well past an hour, no score was ever measured for it, and the follow-up
actions name the weekly sweep as what must supply one.

Drafted as Proposed; the maintainer decides whether it is accepted.
Adds two mutation workflows running Stryker.NET: `mutation` over the three
FirstClassErrors libraries, `justdummies-mutation` over the two JustDummies
packages. On a pull request each matrix leg mutates only the files the diff
touched — `--since` against the fork point resolved with `git merge-base` —
and fails when the score falls under that library's threshold. Each workflow
aggregates its matrix into one stable check name to mark as required on
`main`; a weekly schedule sweeps every mutant, advisory by construction. Both
paths render the surviving mutants into the run summary.

The two workflows are deliberate near-duplicates rather than one shared
matrix. JustDummies already holds no reference to FirstClassErrors and is
headed for a repository of its own, so the split keeps that move a file move,
and it lets the two quality bars sit at different heights.

Two settings are load-bearing rather than tuning, and both were established by
measurement. `test-runner: mtp` is required: Stryker's default VSTest runner
cannot activate a mutant in an xUnit v3 test project, so it runs to completion
and reports every mutant as survived — including mutants that break the suite
when the same edit is applied by hand. `coverage-analysis: off` is for accuracy
rather than speed: the Microsoft Testing Platform runner's coverage selection
still classifies killed mutants as uncovered, which understates the score.

Four of the five thresholds are derived from that library's measured full-sweep
score, so those gates start green and only ever ratchet up. JustDummies is the
exception: a few thousand mutants over a heavy suite put its sweep well past an
hour and no score was measured for it, so rather than invent a bar its score
gate is left off — its leg still runs, still fails on a broken build or a
failing suite, and still lists its survivors, but does not yet refuse a pull
request over a score. The weekly sweep is what supplies the missing figure.

The engine is pinned in a tool manifest: a newer Stryker invents new mutants
and would move every score on its own.

See ADR-0043 for the decision.
Adds the bilingual reference pages for `mutation` and `justdummies-mutation`,
indexes both in the workflow reference, and points CLAUDE.md's build & test
section at them.

The `mutation` page carries the shared mechanics; the JustDummies page carries
what is specific to it and defers to the other rather than duplicating prose,
plus the checklist for the day JustDummies moves to its own repository and the
reason its own score gate is still off.

Between them the pages record what measurement established, and what a reader
would otherwise re-discover the hard way: why the Microsoft Testing Platform
runner is mandatory rather than preferred, why coverage-based selection is off,
that `--since` selects per changed file and rejects `HEAD` as a target, that the
CI warning ratchet turns out not to reach Stryker's mutant compilation, where
the per-library thresholds come from, and how to answer an equivalent mutant
with a `// Stryker disable` comment rather than a lowered threshold.
@Reefact
Reefact force-pushed the claude/automated-mutation-testing-4okfe0 branch from bb24ecb to 6db70e4 Compare July 27, 2026 13:07
The scope excluded the `fce` command line, the documentation generator and the
Roslyn analyzers on a cost argument: their suites drive Roslyn compilations and
snapshot comparisons, so their mutants are the expensive ones. That argument
does not survive contact with the weekly sweep, which is the run allowed to take
as long as it takes — and the exclusion left a hole rather than a boundary, since
a pull request touching only the analyzers crossed no mutation gate at all.

Measured before making the change: Stryker runs on all three under the MTP
runner, snapshot suites and process-spawning tests included, and they carry 3744
mutants between them — about as many again as the FirstClassErrors libraries
already in scope.

They ship with their score gate disabled, like JustDummies: their sweeps are too
long to have been run interactively, so no score was measured and none was
guessed. Their legs still run, still fail on a broken build or a failing suite,
and still list survivors; the first weekly sweep supplies the four missing
figures.

What stays out now stays out for a reason other than cost: the `Usage` samples
and the binder benchmarks are not shipped behaviour, and `GenDoc.Worker` is a
process entry point no test exercises in process — the `floor` job of `ci` proves
it end to end, and mutating it would only manufacture unkillable survivors.

Both sweep caps go from 180 to 350 minutes, just under the six-hour ceiling
GitHub imposes on a job. The weekly run is meant to take as long as it takes
rather than be cut short: JustDummies alone runs well past an hour on four cores.

See ADR-0043 for the decision.
@Reefact
Reefact merged commit 606623a into main Jul 27, 2026
28 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.

2 participants