Skip to content

feat(execution): add execution-based mutation verification engine - #104

Merged
samtrion merged 6 commits into
mainfrom
feat/execution-based-mutation-verification
Aug 3, 2026
Merged

feat(execution): add execution-based mutation verification engine#104
samtrion merged 6 commits into
mainfrom
feat/execution-based-mutation-verification

Conversation

@samtrion

@samtrion samtrion commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

FrameShift's static analysis (FSH0001/FSH0006/FSH0007) can only ever predict whether a mutant would be noticed by a test — it never actually runs one. This PR adds the real mechanism, at three depths, plus a CLI that actually invokes it.

1. In-process, one method at a time

  • MutantAssemblyBuilder applies a candidate Mutation to a Compilation and emits it to a real assembly image, reusing the same apply-mutation logic MutantCompiler already uses to verify a mutant re-binds — taken one step further, to a runnable program.
  • IsolatedAssemblyRunner loads that image into its own collectible AssemblyLoadContext and invokes one parameterless method by reflection, treating a thrown exception (or a faulted returned Task) as the kill signal — the same signal every mainstream assertion library (xUnit, NUnit, MSTest, TUnit) already produces on a failed assertion.
  • MutationExecutionEngine.Execute/.Run orchestrate this and aggregate a batch into a MutationScore (Killed / Survived / BuildFailed).

2. Real subprocess, a whole test host at a time

  • MutantSwapWorkspace copies a test project's build output directory to a temporary location and overwrites the production assembly file with the mutant bytes, so the already-compiled test assembly keeps resolving its reference by file name straight into the mutant — no recompilation of the test assembly involved.
  • ProcessTestHostRunner runs the swapped test assembly with dotnet exec and reads nothing but the process exit code: 0 for every supported test framework (TUnit, xUnit, NUnit, MSTest, under VSTest or under Microsoft.Testing.Platform) means every test passed, anything else means at least one did not — keeping this runner test-framework agnostic without parsing a single result format.
  • MutationExecutionEngine.ExecuteViaTestHostAsync/.RunViaTestHostAsync wire both together and add a Timeout verdict, excluded from the mutation score exactly like a build failure is.

3. A CLI that actually invokes it

frameshift-execute --test-output <dir> --production-dll <file.dll> --test-dll <file.dll> \
  --source <file.cs> [--source <file.cs> ...] [--timeout-seconds <n>]

Recompiles the given production source files (referencing every assembly already in the test output directory, plus the current process's own shared framework assemblies — a framework-dependent build output never ships those itself), generates every candidate mutation with the same MutantGenerator the analyzer uses, and runs each one through ExecuteViaTestHostAsync, printing per-mutant progress and the final mutation score.

Running the CLI against a real dotnet build-produced project surfaced a real defect no hand-emitted fixture had caught: Roslyn defaults an unversioned compilation to assembly version 0.0.0.0, while the .NET SDK's own build defaults to 1.0.0.0. A recompiled mutant at the wrong version doesn't fail to compile — it fails to load, because the test host's own *.deps.json pins the production assembly's expected version and rejects a same-named file that doesn't match. That surfaced as an unhandled FileNotFoundException inside the test host and would have silently counted every single mutant as a false "Killed". Fixed by reading the original assembly's real version once and stamping the recompiled mutant with a matching [assembly: AssemblyVersion]. MutationExecutionCliRealProjectTests is the regression test — it builds a real project pair with a real dotnet build, not a hand-emitted fixture, and asserts the CLI reports the correct score against it.

Scope

NetEvolve.FrameShift.Execution targets net10.0 only, not the repo's usual wide matrix: the collectible AssemblyLoadContext it needs is CoreCLR-only (unlike the analyzer itself, which stays on netstandard2.0 to ship as a single Roslyn component), and this library has no consumer yet to keep a wider compatibility promise for.

Still not packaged as an installable dotnet tool, not wired into an MSBuild target, and the production compilation is rebuilt from explicit source files rather than a real MSBuild project evaluation (so source generators, conditional compilation symbols, and non-trivial reference graphs aren't reproduced faithfully yet). Those remain the natural next increments.

Test plan

  • dotnet build FrameShift.slnx — 0 warnings, 0 errors
  • dotnet test on NetEvolve.FrameShift.Tests.Execution (net10.0) — all 7 tests pass, including a genuine subprocess run and a full real-dotnet build regression test
  • Manually ran the CLI against a real dotnet build-produced project pair and confirmed the exact expected score (4 killed, 1 survived)
  • Existing NetEvolve.FrameShift.Tests.Unit and NetEvolve.FrameShift.Tests.Integration suites unaffected (3629 + 260 on net10.0, 0 failures)

Static analysis, however precise, can only ever predict whether a mutant
would be noticed. This adds the core mechanism to actually find out: a
new NetEvolve.FrameShift.Execution library that applies a candidate
Mutation to a real Compilation, emits it to a real assembly image, loads
that image into an isolated, collectible AssemblyLoadContext, and invokes
a test method by reflection to observe a genuine pass or fail.

- MutantAssemblyBuilder emits the mutated compilation to bytes, reusing
  the same mutation-application logic MutantCompiler already uses to
  verify a mutant re-binds, taken one step further to a runnable program.
- IsolatedAssemblyRunner loads that image into its own AssemblyLoadContext
  and invokes one parameterless method, catching a thrown exception (or a
  faulted returned Task) as the kill signal every mainstream assertion
  library already produces on a failed assertion.
- MutationExecutionEngine orchestrates both per mutant and aggregates a
  batch into a MutationScore (Killed / Survived / BuildFailed).

This library targets net6.0+ only: the collectible AssemblyLoadContext it
needs is CoreCLR-only, unlike the analyzer itself, which stays on
netstandard2.0 to ship as a single Roslyn component.

Scope: this is the core execution mechanism, proven end to end against a
dogfood fixture in NetEvolve.FrameShift.Tests.Execution, one mutation
genuinely killed and one genuinely surviving. It runs exactly one named
test method per mutant, resolved by the caller; discovering and running a
whole test suite through a real test host per framework, and wiring that
into a CLI or MSBuild gate, is intentionally left as follow-up work.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • state:ready for merge

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fe0c860d-6623-4285-becf-0c21e8fb5391

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

NetEvolve.FrameShift.Execution and its test project have no consumer and
no compatibility promise yet, so they track the newest supported runtime
instead of the widest one. Broadening the matrix back to
$(_NetTargetFrameworks) is a one-line change once there is a reason to.
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.81982% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.32%. Comparing base (4ae3c20) to head (73d7444).

Files with missing lines Patch % Lines
...volve.FrameShift.Execution/MutationExecutionCli.cs 93.54% 3 Missing and 3 partials ⚠️
....FrameShift.Execution/RuntimeAssemblyReferences.cs 77.27% 4 Missing and 1 partial ⚠️
...lve.FrameShift.Execution/IsolatedAssemblyRunner.cs 89.28% 0 Missing and 3 partials ⚠️
...ve.FrameShift.Execution/MutationExecutionEngine.cs 96.55% 1 Missing and 1 partial ⚠️
...olve.FrameShift.Execution/ProcessTestHostRunner.cs 95.74% 2 Missing ⚠️
src/NetEvolve.FrameShift.Execution/Program.cs 84.61% 2 Missing ⚠️
...olve.FrameShift.Execution/MutantAssemblyBuilder.cs 93.33% 0 Missing and 1 partial ⚠️
...olve.FrameShift.Execution/MutantExecutionResult.cs 90.90% 0 Missing and 1 partial ⚠️
...rc/NetEvolve.FrameShift.Execution/MutationScore.cs 88.88% 0 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (94.81%) is below the target coverage (95.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #104      +/-   ##
==========================================
- Coverage   95.36%   95.32%   -0.04%     
==========================================
  Files          93      107      +14     
  Lines        6233     6677     +444     
  Branches     1340     1389      +49     
==========================================
+ Hits         5944     6365     +421     
- Misses        128      140      +12     
- Partials      161      172      +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The in-process reflection runner proved the core mechanism works but
still picks one test method by hand. This adds the orchestration a real
build-time or CI gate would actually use: run a whole, already-built test
project's own test host as a real subprocess against a copy of its build
output with only the production assembly swapped for the mutant.

- MutantSwapWorkspace copies a test project's build output directory to a
  temporary location and overwrites the production assembly file with the
  mutant bytes, so the already-compiled test assembly keeps resolving its
  reference by file name straight into the mutant, no recompilation
  involved.
- ProcessTestHostRunner runs the swapped test assembly with `dotnet exec`
  and reads nothing but the process exit code: 0 for every supported test
  framework (TUnit, xUnit, NUnit, MSTest, under VSTest or under
  Microsoft.Testing.Platform) means every test passed, anything else means
  at least one did not. That is what keeps this runner test-framework
  agnostic without parsing a single result format.
- MutationExecutionEngine.ExecuteViaTestHostAsync/RunViaTestHostAsync wire
  both together and add a Timeout verdict, excluded from the mutation
  score exactly like a build failure is, for a host that had to be killed
  without ever reporting an exit code.

Proven end to end in MutationExecutionEngineTestHostTests: a real `dotnet
exec` subprocess against a real swapped-in mutant assembly, one mutation
of the exercised method genuinely exits non-zero, one mutation of an
unrelated method genuinely exits zero.

Still not wired into a CLI or an MSBuild target, and still resolved by
the caller which build output directory and which assembly names to use;
discovering that automatically from a real project is the next step.
The library API existed but nothing actually invoked it: this adds
Program.cs, a real entry point that can be started with `dotnet exec` or
`dotnet run` instead of only from a caller that links against the
library.

  frameshift-execute --test-output <dir> --production-dll <file.dll>
    --test-dll <file.dll> --source <file.cs> [--source <file.cs> ...]
    [--timeout-seconds <n>]

It recompiles the given production source files fresh (referencing every
other assembly already sitting in the test output directory, plus the
current process's own shared framework assemblies, since a
framework-dependent build output never contains those itself), generates
every candidate mutation with the same MutantGenerator the analyzer uses,
and runs each one through MutationExecutionEngine.ExecuteViaTestHostAsync,
printing progress per mutant and the final mutation score.

Running it against a real, `dotnet build`-produced project surfaced a real
defect no earlier fixture caught: Roslyn defaults an unversioned
compilation to assembly version 0.0.0.0, while the .NET SDK's own build
defaults to 1.0.0.0. A recompiled mutant at the wrong version does not
fail to compile - it fails to load, because the test host's own
*.deps.json pins the production assembly's expected version and rejects a
same-named file that does not match, which surfaced as an unhandled
FileNotFoundException inside the test host and would have silently
counted every mutant as a false "Killed". Fixed by reading the original
assembly's real version once and stamping the recompiled mutant with a
matching [assembly: AssemblyVersion] attribute.

MutationExecutionCliRealProjectTests is the regression test for exactly
that: it builds a real project pair with a real `dotnet build`, not a
hand-emitted fixture, and asserts the CLI reports the correct score
against it.
…verage gaps

Rename tests/NetEvolve.FrameShift.Tests.Execution to
NetEvolve.FrameShift.Execution.Tests.Unit per the sub-product naming
convention, and split the subprocess- and build-spawning tests into a new
NetEvolve.FrameShift.Execution.Tests.Integration project.

Add coverage for every previously untested branch of the execution CLI:
argument parsing, the console entry point, the file-swap workspace's
failure paths, the async test-invocation branch, and the test-host
subprocess runner's timeout and output-capture paths, including a
Verify-based snapshot of the CLI usage text.
…leanup

The CI Windows runner reports a killed process's still-open file
handle as UnauthorizedAccessException rather than IOException,
which the existing retry loop did not catch, making the timeout
test fail intermittently.
@samtrion
samtrion merged commit cbca626 into main Aug 3, 2026
10 of 11 checks passed
@samtrion
samtrion deleted the feat/execution-based-mutation-verification branch August 3, 2026 10:15
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