You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Line coverage is held above 95%, but it only proves that code executed, not that any assertion constrains it. Coding agents extending this library cannot currently distinguish a well-tested code path from one that is merely reached. Mutation testing narrows that gap: a surviving mutant is a concrete, machine-readable location where the suite failed to detect a changed program, which makes it a candidate for a missing assertion rather than proof of one. Some survivors are equivalent or not worth killing, so the output is a triage queue, not a defect list.
Introduce Stryker.NET as a pinned, opt-in tool with a repository configuration that is correct by default. Two settings are load-bearing — the wrong test runner and the default coverage analysis each produce confidently wrong reports on this solution — so the configuration, not the invocation, must carry them.
Acceptance Criteria
dotnet tool restore installs dotnet-stryker pinned to exactly 4.16.0, and a repository-root stryker-config.json supplies the shared configuration so that a scoped run needs only a -p argument and an optional -m glob.
The committed configuration selects the Microsoft Testing Platform runner and disables coverage analysis; a run against Light.PortableResults.AspNetCore.Shared reports a non-zero mutation score, and a run against Result.cs reports zero NoCoverage mutants.
Mutation output is contained under StrykerOutput/, which is ignored by git; a completed or interrupted run adds no new entries to git status.
tests/AGENTS.md documents the file-scoped and project-scoped invocations, states that a surviving mutant is a signal to investigate together with the three permitted triage outcomes, records the measured cost per project, and records the mutation blind spots that must not be read as adequate coverage.
Mutation testing runs on demand from a developer machine only. No CI workflow is added and no existing workflow is modified; pull request validation time is unchanged.
The documented invocation is verified to run the intended tests: for a run mutating AspNetCore.Shared, Stryker's reported test count is 337 — every test project transitively referencing it — and not the 26 tests of AspNetCore.Shared.Tests alone.
A baseline is recorded in tests/AGENTS.md for the four projects that complete in minutes (AspNetCore.Shared, AspNetCore.Mvc, AspNetCore.MinimalApis, Validation.OpenApi). Each entry carries enough provenance to be reproduced and compared: commit, dotnet-stryker version, concurrency, the test count actually executed, elapsed time, and the killed, timeout, survived, compile-error, ignored, and no-coverage counts — not a percentage alone. The no-coverage count is recorded even though it is expected to be zero, because a non-zero value means coverage-analysis is no longer taking effect; it was zero in all four measured runs.
Mutation score thresholds and break-at remain unset; nothing in the repository fails a build or a test run because of a mutation score.
Technical Details
Verified constraints
The findings below come from dotnet-stryker 4.16.0 executed against this solution. They are the reason the configuration is not left at its defaults.
Microsoft Testing Platform support is still a preview feature in Stryker.NET, and it announces itself as one on every run. It is tracked upstream in stryker-mutator/stryker-net#3094, which is the issue to watch for the maturity of everything in this section.
The default vstest runner is unusable here. These test projects set UseMicrosoftTestingPlatformRunner, and VSTest cannot drive their xUnit v3 hosts (#3117). Coverage capture fails and every mutant is reported as survived — AspNetCore.Shared scores 0.00% under vstest and 100.00% under mtp. The failure is a logged error plus a plausible-looking report, not a crash, so "test-runner": "mtp" must be committed rather than passed ad hoc.
MTP coverage analysis fabricates NoCoverage. With the default perTest analysis, Result.cs reports 6 killed and 61 NoCoverage for a score of 8.96%, despite NonGenericResultTests covering those members directly. With "coverage-analysis": "off" the same file reports 67 tested, 0 uncovered, and 100.00%. A false NoCoverage is the most damaging possible output for the intended agent workflow, because it directs effort at tests that already exist. Per-test coverage for the MTP runner is in progress as #3516; revisit this setting when it ships, since it is the main lever on run time.
Everything else about the repository is compatible without special handling: .slnx analysis, central package management with lock files, the netstandard2.0;net10.0 multi-targeting (Stryker selects net10.0 unaided), the netstandard2.0 source generator, InterceptorsNamespaces, and the Verify-based snapshot tests, which leave no .received.* files behind because 4.14.2 disables DiffEngine under MTP. No strong-naming or InternalsVisibleTo seam exists to work around.
configuration is pinned to Debug in the config rather than left to the invoker. TreatWarningsAsErrors is Release-only, and Stryker's rollback should not have to contend with warnings promoted to errors. All measurements in this plan were taken under Debug.
Reports are written to StrykerOutput/<timestamp>/ beneath the working directory; the JSON and HTML reporters exist to produce files, so the goal is containment, not absence. Stryker already writes a .gitignore containing * into each timestamped directory, and a completed run leaves git status clean without any repository change. Add StrykerOutput/ to .gitignore anyway: it states the intent, covers the directory itself, and protects against a run interrupted before that inner file is written. The output location is a CLI concern — --output has no stryker-config.json equivalent — so nothing here depends on redirecting it, and no wrapper script is needed.
Cost
Measured throughput is approximately three seconds of wall clock per mutant at concurrency 8, dominated by fixed per-mutant overhead rather than test count: the 337-test and 2,398-test suites cost the same per mutant. Mutant inventory:
Project
Mutants
CompileError
Light.PortableResults
4,867
520
Light.PortableResults.Validation
2,215
83
Validation.OpenApi.SourceGeneration
1,272
204
AspNetCore.OpenApi
848
65
Validation.OpenApi
114
2
AspNetCore.Shared
44
3
AspNetCore.Mvc
33
11
AspNetCore.MinimalApis
32
11
A solution-wide run is roughly seven hours on the measured hardware, which is why this stays a local, on-demand tool rather than anything automated. Mutation testing is not added to CI in any form: no new workflow, and no change to build-and-test.yml. Whoever drives it locally chooses the scope, and the practical scopes are one file or one project — never the whole solution in the inner loop.
The two large projects are long-running even in isolation: Light.PortableResults is roughly 3.6 hours and Validation roughly 1.8 hours. When one of them is worth running end to end, split it by mutate glob along folder boundaries (Metadata/, Http/, CloudEvents/, Numbers/) so the run is interruptible and each report arrives while it is still actionable.
The JSON report is the agent-facing artifact; the HTML report is for humans. Agents filter for "status": "Survived" to obtain a work queue located at specific files and lines. A survivor is a signal to investigate, not a defect to fix on sight; see triage below.
Scoped invocation
Because the configuration names the solution, every run is a solution-context run. -p selects which source project is mutated; it does not narrow which tests execute. Stryker discovers the test projects from the solution and runs every test project that transitively references the mutated project. A -tp argument does not override this and must not be documented as if it did.
This was confirmed by test counts rather than inferred: mutating AspNetCore.Shared reports 337 tests, which is the exact sum of the six test projects referencing it (26 + 46 + 23 + 79 + 112 + 51), not the 26 in AspNetCore.Shared.Tests. Mutating Light.PortableResults reports 2,398 — the whole solution, since everything references it.
Keep this behavior rather than forcing a single test project. It matches the sociable-testing rule in tests/AGENTS.md: a mutant in AspNetCore.Shared killed by MinimalApis.Tests is legitimately killed, and isolating test projects would convert those cross-project kills into false survivors. It is also close to free, because per-mutant cost is dominated by fixed overhead — the 337-test and 2,398-test sets cost the same per mutant.
The form to document for agents is therefore source project plus optional file glob. Mutating one file takes about four minutes:
Every command runs from the repository root. Stryker resolves stryker-config.json relative to the current working directory and does not search parent directories, and a missing config file is not an error — it silently falls back to the defaults. From a subdirectory the run therefore loses both load-bearing settings at once, reverting to the vstest runner and perTest coverage analysis, which is exactly the combination that reports 0.00% or fabricates NoCoverage. The failure is a plausible report rather than a diagnostic, so tests/AGENTS.md must state the working directory as part of the invocation rather than assume it.
The four small projects need no -m at all. Measured end to end, including analysis, build, and initial test run:
Project
Tests run
Created
Tested
Elapsed
AspNetCore.Mvc
102
33
17
0:38
AspNetCore.MinimalApis
237
32
16
0:57
AspNetCore.Shared
337
44
31
1:57
Validation.OpenApi
163
114
76
1:59
Omitting -p mutates every source project in the solution and is the seven-hour path.
Do not derive these times from the three-seconds-per-mutant figure. That rate is an upper bound taken from the large projects, where the mutated assembly is referenced by the whole solution and every test project runs. Two effects make small projects cheaper: only a fraction of created mutants is ever tested — 76 of 114 for Validation.OpenApi — and per-mutant cost falls with the size of the associated test set. Use the rate to size the projects in the inventory table above, and these measurements for the small ones.
Do not use or document --since under this configuration. It was measured on this branch and never narrowed anything: with a completely clean working tree it still reported ai-plans/0063-add-stryker-mutation-testing.md as a changed test file and escalated to 16 mutants will be tested because: Non-CSharp files in test project were changed.
Two behaviors combine badly here. Stryker treats any non-C# file in the diff — including a Markdown plan — as a changed test file and responds by testing every mutant; and --since:HEAD did not pin the baseline to HEAD, so the diff kept resolving against the default branch. Because every feature branch in this repository begins by adding a plan under ai-plans/, a non-C# file is essentially always in the diff. --since therefore degrades to a full project run while presenting itself as scoped, which is worse than not using it: the cost is unchanged and the reported scope is wrong.
Explicit file and project scope is the only recommended form. Revisit --since only once MTP coverage analysis is trustworthy — a separate concern is that mapping changed test files back to mutants depends on per-mutant covering tests, which coverage-analysis: off does not produce, so a test-only edit has no reliable path to the mutants it affects.
Triaging survivors
A surviving mutant means the suite did not distinguish the mutated program from the original. That has three admissible causes, and exactly three permitted responses:
Observable behavior is genuinely unconstrained. Add or strengthen a test. This is the outcome the tool exists to produce and should be the common one.
The mutant is equivalent or invalid — semantically identical to the original, or killable only by asserting on something that is not part of the contract. Suppress it narrowly at the source with a justification, which Stryker records in the report:
// Stryker disable once Statement : equivalent - the guard is a fast path, not a behavior change
The syntax is Stryker [disable|restore][once][all|<mutator list>][: reason], and it is scope-aware. Prefer disable once over disable all, and never reach for the global ignore-mutations setting to silence a single site.
The mutant sits in a construct this configuration cannot meaningfully test. Record it and move on; see the blind spots below.
Never restructure production code solely to make a mutant killable. The root AGENTS.md ranks performance above extensibility, and this library's low-allocation in/ref/Span style is precisely the shape that produces awkward survivors. A lower mutation score is the correct outcome when the alternative is a slower or less direct implementation.
The measured runs produced exactly one survivor across all four small projects: a statement-removal mutant at BuiltInValidationErrorBuilderExtensions.cs:426, which puts Validation.OpenApi at 98.68%. It is untriaged — it is the first item to run through the three categories above, and it is the only evidence so far that the queue will contain anything at all. Every other scored run returned 100.00%, so these categories remain largely a priori rather than derived from survivors observed in this codebase.
Blind spots to document
Roughly 9.5% of mutants fail to compile, concentrated in the low-allocation out/ref style. Stryker responds with Safe Mode, which discards every mutant in the enclosing method:
CS0165: Use of unassigned local variable 'low' (Numbers/Dragon4.cs:263)
[INF] Safe Mode! Stryker will remove all mutations in GenerateDigits
Dragon4.GenerateDigits, ResultJsonReader.ReadStatusValue, ResultJsonReader.ReadIndexValue, ErrorsExtensions.WriteRichErrors, and the whole of LightResult.cs (11 of 11 mutants) receive no mutation coverage at all. This is a tool limitation, not a test defect. tests/AGENTS.md must state it explicitly so that a high mutation score in Numbers/ is not mistaken for verified behavior, and so that no one attempts to "fix" it by restructuring production code.
Mutation score is a diagnostic here, not a gate. Leave the coverage threshold machinery in build-and-test.yml alone, and do not let a mutation score fail any automated check.
Stryker.NET Mutation Testing
Rationale
Line coverage is held above 95%, but it only proves that code executed, not that any assertion constrains it. Coding agents extending this library cannot currently distinguish a well-tested code path from one that is merely reached. Mutation testing narrows that gap: a surviving mutant is a concrete, machine-readable location where the suite failed to detect a changed program, which makes it a candidate for a missing assertion rather than proof of one. Some survivors are equivalent or not worth killing, so the output is a triage queue, not a defect list.
Introduce Stryker.NET as a pinned, opt-in tool with a repository configuration that is correct by default. Two settings are load-bearing — the wrong test runner and the default coverage analysis each produce confidently wrong reports on this solution — so the configuration, not the invocation, must carry them.
Acceptance Criteria
dotnet tool restoreinstallsdotnet-strykerpinned to exactly 4.16.0, and a repository-rootstryker-config.jsonsupplies the shared configuration so that a scoped run needs only a-pargument and an optional-mglob.Light.PortableResults.AspNetCore.Sharedreports a non-zero mutation score, and a run againstResult.csreports zeroNoCoveragemutants.StrykerOutput/, which is ignored by git; a completed or interrupted run adds no new entries togit status.tests/AGENTS.mddocuments the file-scoped and project-scoped invocations, states that a surviving mutant is a signal to investigate together with the three permitted triage outcomes, records the measured cost per project, and records the mutation blind spots that must not be read as adequate coverage.AspNetCore.Shared, Stryker's reported test count is 337 — every test project transitively referencing it — and not the 26 tests ofAspNetCore.Shared.Testsalone.tests/AGENTS.mdfor the four projects that complete in minutes (AspNetCore.Shared,AspNetCore.Mvc,AspNetCore.MinimalApis,Validation.OpenApi). Each entry carries enough provenance to be reproduced and compared: commit,dotnet-strykerversion, concurrency, the test count actually executed, elapsed time, and the killed, timeout, survived, compile-error, ignored, and no-coverage counts — not a percentage alone. The no-coverage count is recorded even though it is expected to be zero, because a non-zero value meanscoverage-analysisis no longer taking effect; it was zero in all four measured runs.break-atremain unset; nothing in the repository fails a build or a test run because of a mutation score.Technical Details
Verified constraints
The findings below come from
dotnet-stryker4.16.0 executed against this solution. They are the reason the configuration is not left at its defaults.Microsoft Testing Platform support is still a preview feature in Stryker.NET, and it announces itself as one on every run. It is tracked upstream in stryker-mutator/stryker-net#3094, which is the issue to watch for the maturity of everything in this section.
The default
vstestrunner is unusable here. These test projects setUseMicrosoftTestingPlatformRunner, and VSTest cannot drive their xUnit v3 hosts (#3117). Coverage capture fails and every mutant is reported as survived —AspNetCore.Sharedscores 0.00% undervstestand 100.00% undermtp. The failure is a logged error plus a plausible-looking report, not a crash, so"test-runner": "mtp"must be committed rather than passed ad hoc.MTP coverage analysis fabricates
NoCoverage. With the defaultperTestanalysis,Result.csreports 6 killed and 61NoCoveragefor a score of 8.96%, despiteNonGenericResultTestscovering those members directly. With"coverage-analysis": "off"the same file reports 67 tested, 0 uncovered, and 100.00%. A falseNoCoverageis the most damaging possible output for the intended agent workflow, because it directs effort at tests that already exist. Per-test coverage for the MTP runner is in progress as #3516; revisit this setting when it ships, since it is the main lever on run time.The configuration is therefore:
{ "stryker-config": { "solution": "Light.PortableResults.slnx", "test-runner": "mtp", "coverage-analysis": "off", "configuration": "Debug", "reporters": ["json", "html", "progress"] } }Everything else about the repository is compatible without special handling:
.slnxanalysis, central package management with lock files, thenetstandard2.0;net10.0multi-targeting (Stryker selectsnet10.0unaided), the netstandard2.0 source generator,InterceptorsNamespaces, and the Verify-based snapshot tests, which leave no.received.*files behind because 4.14.2 disables DiffEngine under MTP. No strong-naming orInternalsVisibleToseam exists to work around.configurationis pinned toDebugin the config rather than left to the invoker.TreatWarningsAsErrorsis Release-only, and Stryker's rollback should not have to contend with warnings promoted to errors. All measurements in this plan were taken underDebug.Reports are written to
StrykerOutput/<timestamp>/beneath the working directory; the JSON and HTML reporters exist to produce files, so the goal is containment, not absence. Stryker already writes a.gitignorecontaining*into each timestamped directory, and a completed run leavesgit statusclean without any repository change. AddStrykerOutput/to.gitignoreanyway: it states the intent, covers the directory itself, and protects against a run interrupted before that inner file is written. The output location is a CLI concern —--outputhas nostryker-config.jsonequivalent — so nothing here depends on redirecting it, and no wrapper script is needed.Cost
Measured throughput is approximately three seconds of wall clock per mutant at concurrency 8, dominated by fixed per-mutant overhead rather than test count: the 337-test and 2,398-test suites cost the same per mutant. Mutant inventory:
Light.PortableResultsLight.PortableResults.ValidationValidation.OpenApi.SourceGenerationAspNetCore.OpenApiValidation.OpenApiAspNetCore.SharedAspNetCore.MvcAspNetCore.MinimalApisA solution-wide run is roughly seven hours on the measured hardware, which is why this stays a local, on-demand tool rather than anything automated. Mutation testing is not added to CI in any form: no new workflow, and no change to
build-and-test.yml. Whoever drives it locally chooses the scope, and the practical scopes are one file or one project — never the whole solution in the inner loop.The two large projects are long-running even in isolation:
Light.PortableResultsis roughly 3.6 hours andValidationroughly 1.8 hours. When one of them is worth running end to end, split it bymutateglob along folder boundaries (Metadata/,Http/,CloudEvents/,Numbers/) so the run is interruptible and each report arrives while it is still actionable.The JSON report is the agent-facing artifact; the HTML report is for humans. Agents filter for
"status": "Survived"to obtain a work queue located at specific files and lines. A survivor is a signal to investigate, not a defect to fix on sight; see triage below.Scoped invocation
Because the configuration names the solution, every run is a solution-context run.
-pselects which source project is mutated; it does not narrow which tests execute. Stryker discovers the test projects from the solution and runs every test project that transitively references the mutated project. A-tpargument does not override this and must not be documented as if it did.This was confirmed by test counts rather than inferred: mutating
AspNetCore.Sharedreports 337 tests, which is the exact sum of the six test projects referencing it (26 + 46 + 23 + 79 + 112 + 51), not the 26 inAspNetCore.Shared.Tests. MutatingLight.PortableResultsreports 2,398 — the whole solution, since everything references it.Keep this behavior rather than forcing a single test project. It matches the sociable-testing rule in
tests/AGENTS.md: a mutant inAspNetCore.Sharedkilled byMinimalApis.Testsis legitimately killed, and isolating test projects would convert those cross-project kills into false survivors. It is also close to free, because per-mutant cost is dominated by fixed overhead — the 337-test and 2,398-test sets cost the same per mutant.The form to document for agents is therefore source project plus optional file glob. Mutating one file takes about four minutes:
Every command runs from the repository root. Stryker resolves
stryker-config.jsonrelative to the current working directory and does not search parent directories, and a missing config file is not an error — it silently falls back to the defaults. From a subdirectory the run therefore loses both load-bearing settings at once, reverting to thevstestrunner andperTestcoverage analysis, which is exactly the combination that reports 0.00% or fabricatesNoCoverage. The failure is a plausible report rather than a diagnostic, sotests/AGENTS.mdmust state the working directory as part of the invocation rather than assume it.The four small projects need no
-mat all. Measured end to end, including analysis, build, and initial test run:AspNetCore.MvcAspNetCore.MinimalApisAspNetCore.SharedValidation.OpenApiOmitting
-pmutates every source project in the solution and is the seven-hour path.Do not derive these times from the three-seconds-per-mutant figure. That rate is an upper bound taken from the large projects, where the mutated assembly is referenced by the whole solution and every test project runs. Two effects make small projects cheaper: only a fraction of created mutants is ever tested — 76 of 114 for
Validation.OpenApi— and per-mutant cost falls with the size of the associated test set. Use the rate to size the projects in the inventory table above, and these measurements for the small ones.Do not use or document
--sinceunder this configuration. It was measured on this branch and never narrowed anything: with a completely clean working tree it still reportedai-plans/0063-add-stryker-mutation-testing.mdas a changed test file and escalated to16 mutants will be tested because: Non-CSharp files in test project were changed.Two behaviors combine badly here. Stryker treats any non-C# file in the diff — including a Markdown plan — as a changed test file and responds by testing every mutant; and
--since:HEADdid not pin the baseline toHEAD, so the diff kept resolving against the default branch. Because every feature branch in this repository begins by adding a plan underai-plans/, a non-C# file is essentially always in the diff.--sincetherefore degrades to a full project run while presenting itself as scoped, which is worse than not using it: the cost is unchanged and the reported scope is wrong.Explicit file and project scope is the only recommended form. Revisit
--sinceonly once MTP coverage analysis is trustworthy — a separate concern is that mapping changed test files back to mutants depends on per-mutant covering tests, whichcoverage-analysis: offdoes not produce, so a test-only edit has no reliable path to the mutants it affects.Triaging survivors
A surviving mutant means the suite did not distinguish the mutated program from the original. That has three admissible causes, and exactly three permitted responses:
Observable behavior is genuinely unconstrained. Add or strengthen a test. This is the outcome the tool exists to produce and should be the common one.
The mutant is equivalent or invalid — semantically identical to the original, or killable only by asserting on something that is not part of the contract. Suppress it narrowly at the source with a justification, which Stryker records in the report:
// Stryker disable once Statement : equivalent - the guard is a fast path, not a behavior changeThe syntax is
Stryker [disable|restore][once][all|<mutator list>][: reason], and it is scope-aware. Preferdisable onceoverdisable all, and never reach for the globalignore-mutationssetting to silence a single site.The mutant sits in a construct this configuration cannot meaningfully test. Record it and move on; see the blind spots below.
Never restructure production code solely to make a mutant killable. The root
AGENTS.mdranks performance above extensibility, and this library's low-allocationin/ref/Spanstyle is precisely the shape that produces awkward survivors. A lower mutation score is the correct outcome when the alternative is a slower or less direct implementation.The measured runs produced exactly one survivor across all four small projects: a statement-removal mutant at
BuiltInValidationErrorBuilderExtensions.cs:426, which putsValidation.OpenApiat 98.68%. It is untriaged — it is the first item to run through the three categories above, and it is the only evidence so far that the queue will contain anything at all. Every other scored run returned 100.00%, so these categories remain largely a priori rather than derived from survivors observed in this codebase.Blind spots to document
Roughly 9.5% of mutants fail to compile, concentrated in the low-allocation
out/refstyle. Stryker responds with Safe Mode, which discards every mutant in the enclosing method:Dragon4.GenerateDigits,ResultJsonReader.ReadStatusValue,ResultJsonReader.ReadIndexValue,ErrorsExtensions.WriteRichErrors, and the whole ofLightResult.cs(11 of 11 mutants) receive no mutation coverage at all. This is a tool limitation, not a test defect.tests/AGENTS.mdmust state it explicitly so that a high mutation score inNumbers/is not mistaken for verified behavior, and so that no one attempts to "fix" it by restructuring production code.Mutation score is a diagnostic here, not a gate. Leave the coverage threshold machinery in
build-and-test.ymlalone, and do not let a mutation score fail any automated check.