Skip to content

fix(mutation): honor filters and isolation#14848

Merged
grandizzy merged 19 commits into
mutation-testing-fastfrom
pr-13091
Jun 1, 2026
Merged

fix(mutation): honor filters and isolation#14848
grandizzy merged 19 commits into
mutation-testing-fastfrom
pr-13091

Conversation

@zerosnacks

@zerosnacks zerosnacks commented May 20, 2026

Copy link
Copy Markdown
Contributor

Stacked on #14971 towards OSS-1

  • Honor Forge test filters and isolation settings during mutation runs.
  • Reject mutation mode with incompatible test flags instead of silently producing misleading results.
  • Preserve fix(mutation): key cached results by execution inputs #14971's execution-aware result/survived cache keys while layering the remaining runtime correctness fixes on top.
  • Improve adaptive/resume behavior by loading survived spans before mutant generation.
  • Avoid invalid/duplicate mutations for compound assignments and tighten mutator tests.
  • Improve reporting/runner plumbing for skipped, timed out, and resumed mutants.

mablr and others added 4 commits May 29, 2026 15:45
…compound assignment mutation, reject incompatible flags

- runner: thread FilterArgs and --isolate through MutationRunConfig into
  compile_and_test_inner; rebuild ProjectPathsAwareFilter against the
  per-mutant temp config so --match-test/--match-contract/--match-path
  work and mutants exercise the same test set/execution model as baseline
- mod: add runtime_context_digest folded into the per-file cache key so
  cached results aren't reused when filters, isolation, fork URL/block,
  sender, or initial_balance change
- orchestrator: compute runtime_context_digest once per run and bind it
  to every MutationHandler
- binary_op_mutator: stop mutating compound assignments (a += b would
  silently be rewritten to a - b dropping the assignment); add regression
  tests
- cmd/test: bail when --mutate is combined with --list/--debug/
  --flamegraph/--flamechart/--junit instead of silently mixing modes
- fs_permissions guard now narrowed to permissions whose path can reach
  symlinked dependency trees (lib/node_modules/dependencies)

Amp-Thread-ID: https://ampcode.com/threads/T-019e4567-e7ca-717e-bcc0-bb67a3667c4d
Co-authored-by: Amp <amp@ampcode.com>
Blockers
- filter parity: pass materialized baseline filter (filter.args().clone())
  into MutationRunConfig so positional 'forge test <path>' and --rerun
  reach mutant runs too; raw self.filter dropped them silently
- bail when baseline matched zero tests; without it every compileable
  mutant was reported as 'Alive'
- negative literal mutation: add OwnedLiteral::NegatedNumber(U256)
  formatted as '-{val}'; the old Number(-*val) wrapped via two's
  complement and produced wrong-source mutants
- mutator test harness: wrap snippets in valid Solidity, panic on parse
  failure, and check emitted replacement text. Fixed 6 stale test
  expectations and 1 unmatched delegate-pattern case the old vacuous
  harness was hiding

Mediums
- survived-span resume: load retrieve_survived_spans BEFORE mutant
  generation/cached load and filter cached mutants through
  should_skip_span so cross-run adaptive skipping actually works
- runtime context digest: hash the full serialized EvmOpts (networks,
  env, gas-limit toggles, fork, sender, balance, ...) instead of a
  hand-picked 4-field subset that missed several mutation-affecting
  knobs
- deterministic output: BTreeMap for JSON survived_mutants with sorted
  entries; reporter sorts survived list by (path, line, col, span,
  mutation); persisted results_vec sorted by span
- --mutate-contract: intersect with explicit --mutate <paths> instead of
  silently overriding them; explicit paths are now respected
- workspace: copy 'script/' when present and distinct from src/test so
  projects that import script-side helpers don't see false Invalid
  mutants

Amp-Thread-ID: https://ampcode.com/threads/T-019e4567-e7ca-717e-bcc0-bb67a3667c4d
Co-authored-by: Amp <amp@ampcode.com>
@mablr mablr changed the base branch from mutation-testing-fast to mablr/mutation-cache-correctness May 29, 2026 16:49
@mablr mablr changed the title fix: mutation testing fix(mutation): honor filters and isolation May 29, 2026
@mablr mablr marked this pull request as ready for review May 29, 2026 17:15
mablr

This comment was marked as outdated.

Base automatically changed from mablr/mutation-cache-correctness to mutation-testing-fast May 29, 2026 18:27

@stevencartavia stevencartavia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

three things worth a look. All reproduced end-to-end against this branch (pr-14848 @ 79abd60e7).

Comment thread crates/forge/src/cmd/test/mod.rs Outdated
Comment thread crates/forge/src/mutation/mutators/binary_op_mutator.rs
Comment thread crates/forge/src/mutation/mod.rs Outdated
@mablr mablr dismissed their stale review May 30, 2026 22:30

stale

@mablr mablr self-requested a review May 30, 2026 22:34
@mablr mablr requested a review from stevencartavia May 30, 2026 22:34
@mablr mablr self-assigned this Jun 1, 2026
mablr added 4 commits June 1, 2026 10:36
Reject incompatible coverage/showmap mutation runs, make dependency fs_permissions checks path-aware through symlinked ancestors, validate result caches against the current mutant set, and restore live same-span adaptive skipping while keeping resume semantics strict.

@grandizzy grandizzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — per-mutant compilation still ignores the baseline-selected file set.

In crates/forge/src/mutation/runner.rs, compile_and_test_inner (around L612):

let compiler =
    ProjectCompiler::new().dynamic_test_linking(config.dynamic_test_linking).quiet(true);

let compile_output = compiler.compile(&config.project()?)?;
...
let filter = filter_args.clone().merge_with_config(config);
...
runner.test_collect(&filter)

Execution is now filter-aware, but compilation is not: each mutant compiles every test file in the temp workspace. A filtered-out test that fails to compile against the mutated source (or is otherwise uncompilable) will mark the mutant Invalid and diverge from the baseline filtered forge test — which already excludes such files via TestArgs::get_sources_to_compile.

Suggested shape

Mirror what baseline compile_and_run does. Compute the selected set once, carry it through MutationRunConfig, and re-anchor it onto each temp workspace.

1. In TestArgs::run_tests, before constructing MutationRunConfig:

let selected_sources_relative = self
    .get_sources_to_compile(&config_for_mutation, &filter)?
    .into_iter()
    .filter_map(|p| p.strip_prefix(&config_for_mutation.root).ok().map(PathBuf::from))
    .collect::<Vec<_>>();

Add selected_sources_relative: Vec<PathBuf> to MutationRunConfig.

2. Thread through run_mutations_parallel_with_progresstest_single_mutant_isolatedcompile_and_test next to filter_args (same Arc<…> pattern that's already there).

3. In compile_and_test_inner, re-anchor onto the temp config.root and pass to .files(...):

let files: Vec<PathBuf> = selected_sources_relative
    .iter()
    .map(|rel| config.root.join(rel))
    .filter(|p| p.exists())
    .collect();

let compile_output = ProjectCompiler::new()
    .dynamic_test_linking(config.dynamic_test_linking)
    .quiet(true)
    .files(files)
    .compile(&config.project()?)?;

Why this is the right shape

  • Single source of truth. get_sources_to_compile is already the canonical filter-aware compile-set helper used by forge test. Reusing it guarantees baseline and mutants stay in lockstep — no second implementation to drift.
  • Computed once. The (expensive) ABI compile inside get_sources_to_compile runs once on the original project, not per mutant.
  • Path-stable. Stripping to project-relative and re-joining onto the temp root matches the existing src_rel/test_rel/script_rel pattern in test_single_mutant_isolated.
  • Tolerant of missing files. .filter(|p| p.exists()) mirrors how the PR already handles fs_permissions paths.
  • Empty-filter fast path preserved. get_sources_to_compile returns the full src + test set when the filter is empty, so unfiltered mutation runs are unchanged.

Regression test

Add to crates/forge/tests/cli/test_cmd/mutation.rs. Without the fix every mutant is Invalid; with the fix the broken file is excluded from per-mutant compilation just like the baseline.

forgetest_init!(mutation_honors_match_path_at_compile_time, |prj, cmd| {
    // Source with a mutable arithmetic operator → guarantees ≥1 mutant
    // (e.g. `+` → `-`, `+` → `*`) that the selected test will kill.
    prj.add_source(
        "Foo.sol",
        r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract Foo {
    function add(uint256 a, uint256 b) public pure returns (uint256) {
        return a + b;
    }
}
"#,
    );

    // Selected test: compiles and passes against the original, kills the
    // arithmetic mutants.
    prj.add_test(
        "FooSelected.t.sol",
        r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "../src/Foo.sol";

contract FooSelectedTest {
    Foo internal foo;

    function setUp() public {
        foo = new Foo();
    }

    function test_Add() public view {
        assert(foo.add(2, 3) == 5);
    }
}
"#,
    );

    // Unselected test: deliberately fails to compile (undefined symbol).
    // Baseline `forge test --match-path FooSelected.t.sol` succeeds because
    // `get_sources_to_compile` excludes this file from the compile set.
    // Mutation runs must behave the same — otherwise every mutant gets
    // misclassified as `Invalid` due to a test the user explicitly excluded.
    prj.add_test(
        "FooBroken.t.sol",
        r#"
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "../src/Foo.sol";

contract FooBrokenTest {
    function test_Broken() public pure {
        // `NonExistent` is undefined → this file cannot compile.
        NonExistent x = NonExistent(0);
        x.doSomething();
    }
}
"#,
    );

    // Sanity check: baseline filtered `forge test` succeeds despite
    // FooBroken.t.sol being uncompilable. If this fails, the test scaffold
    // itself is wrong (not the mutation code under test).
    cmd.forge_fuse()
        .args(["test", "--match-path", "test/FooSelected.t.sol"])
        .assert_success();

    // Mutation run with the same path filter must also succeed and must
    // NOT mark mutants as `Invalid` due to FooBroken.t.sol failing to
    // compile against the mutated source.
    cmd.forge_fuse().args([
        "test",
        "--mutate", "src/Foo.sol",
        "--match-path", "test/FooSelected.t.sol",
        "--mutation-jobs", "1",
        "--json",
    ]);

    let out = cmd.assert_success().get_output().stdout_lossy();
    let summary = mutation_summary(&out);

    let invalid = summary["invalid"].as_u64().unwrap_or(u64::MAX);
    let killed  = summary["killed"].as_u64().unwrap_or(0);
    let alive   = summary["survived"].as_u64().unwrap_or(0);

    assert_eq!(
        invalid, 0,
        "filtered-out FooBroken.t.sol must not cause Invalid mutants; summary={summary}"
    );
    assert!(
        killed + alive >= 1,
        "expected at least one Killed/Alive mutant from arithmetic ops; summary={summary}"
    );
});

Worth considering as follow-up variants in the same file: mutation_honors_match_test_at_compile_time (use --match-test test_Add) and mutation_honors_no_match_path (invert via --no-match-path 'test/FooBroken.t.sol').

@mablr mablr requested a review from grandizzy June 1, 2026 12:19

@grandizzy grandizzy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

@grandizzy grandizzy merged commit bda7953 into mutation-testing-fast Jun 1, 2026
19 checks passed
@grandizzy grandizzy deleted the pr-13091 branch June 1, 2026 12:29
@github-project-automation github-project-automation Bot moved this to Done in Foundry Jun 1, 2026
mablr added a commit that referenced this pull request Jun 3, 2026
* feat: init

* chore: lexing/parsing

* chore: visiting contracts

* chore: wip

* chore: wip

* chore: wip

* chore: visitor not visiting anything

* chore: visitor visiting

* chore: quick refactor before its too late

* chore: quick refactor

* chore: mutation gen part1

* feat: mutation collection

* feat: visitor refactor

* feat: visitor refactor

* feat: temp folder mgmt

* feat: temp file creation logic

* feat: compiling mutants

* chore: wip future multithread

* feat: mutation set building

* feat: multithread compile

* chore: fmt

* chore: fmt

* chore: fmt

* chore: fmt

* feat: refactor for test runner

* chore: test runner wip

* feat: working poc

* chore: doc

* chore: fmt

* chore: fmt

* chore: fmt

* feat: assign mut gen

* feat: unary mut

* feat: binary mut

* feat: members unary mut

* feat: members unary mut

* feat: rm delegatecall mut

* chore: refactor modular mut wip

* chore: refactor modular mut wip

* chore: refactor modular mut wip

* feat: mutator assign trait

* chore: fmt

* chore: fmt

* feat: bin mutator

* feat: other mutator mod

* feat: visitor refactor registry

* chore: merge fix

* chore: comment

* fix: solar visitor use

* chore: clippy and min refactors

* feat: assign mutator tests

* feat: test gen mut ident

* feat: test binop and delete expr

* feat: test delegate unaryop mut

* feat: test unaryop mut

* feat: wip generic test

* refactor: add mutate-path and mutate-contract optional args, keep mutate args working like before

* feat: enable contract match filter in mutation tests

* feat: optional mutators

* feat: generic mutator test(length)

* feat: generic mutator test(content)

* fix: unary mut for bool

* test: unary mut

* test: all mutators

* test: add neg case

* chore: refactor visitor

* chore: clippy

* chore: typos

* feat: integ test (hacky)

* feat: basic reporting

* fix: copy only src

* fix: copy only src

* fix: rel path

* fix: add walker in visitor

* fix: assign mutator

* chore: dbg cleanup

* fix: dyn test linking

* fix: missing mutant fmt

* fix: path in mut ctxt

* feat: save and resume mutation tests

* chore: typo and doc

* chore: remove cache file

* merge master

* clippy

* update

* use serde instead of manual dto for json caching

checkpoint

* fix cache filenames

* initial pass at adaptive testing - skip surviving sibling or child spans

* clippy && fmt

* feat(forge): parallel mutation testing with isolated workspaces

Implements parallel mutation testing for Foundry, building on PR #11996.

Key features:
- Parallel execution via rayon thread pool with configurable --mutation-jobs
- Isolated TempDir workspaces per mutant for safe concurrent execution
- Adaptive span skipping shared across workers (skip mutations in spans
  where a mutation already survived)
- Fail-fast per mutant (stops on first test failure)
- Symlinks for lib directories to avoid expensive copies
- Preserves project layout (custom src/test/libs paths supported)

Safety improvements:
- Path traversal protection (rejects .. components)
- catch_unwind prevents single panic from aborting entire run
- Symlinked directories skipped in copy to prevent traversal attacks
- 16MB stack size for rayon threads to avoid overflow

Performance: 2.5x speedup observed with 4 workers on 150 mutants

Closes #478

* fix: clippy, rustfmt, and config test for mutation_dir

* feat(mutation): add progress display with Ctrl+C support

- Add progress bar showing runs/total and job count
- Show relative file paths instead of absolute paths
- Add spinner for active mutants with line number and mutation diff
- Capture original expression context in Mutant struct
- Center code truncation around mutated operator
- Graceful Ctrl+C: cancel pending, show report for completed, exit 130
- Enhanced reporter with Solidity diffs and security suggestions
- Add mutation_testing_with_show_progress CLI test

Amp-Thread-ID: https://ampcode.com/threads/T-019be5ee-0615-7048-97dc-7ba7b354d384
Co-authored-by: Amp <amp@ampcode.com>

* chore: rename 'runs' to 'mutation runs' in progress bar

* fix: mark doc code block as text to fix doctest

* refactor: address grandizzy review comments

- Move mutation logic from test/mod.rs to mutation/orchestrator.rs
- Implement test helper for mutator tests
- Add snapbox assertions to CLI mutation tests with full output
- Update Solar LitKind comment (still valid - no int/uint distinction)
- Use 4 workers in show-progress test for consistency

Amp-Thread-ID: https://ampcode.com/threads/T-019be9ee-1c7c-734d-8e72-7e9b837b3945
Co-authored-by: Amp <amp@ampcode.com>

* Add time and report legend

* feat(mutation): add RequireMutator and source hash cache validation

- Add RequireMutator for security-critical require/assert mutations
  - require(x) -> require(true) - always passes
  - require(x) -> require(false) - always fails
  - require(x) -> require(!x) - inverted condition

- Add source content hash to SurvivedSpans cache
  - Prevents stale span reuse when source changes
  - Invalidates cache on hash mismatch
  - Handles legacy cache format migration

- Add comprehensive integration test (mutation_testing_require_mutator)
  - Demonstrates two-phase testing workflow
  - First run: basic tests (84% score)
  - Second run: comprehensive tests (98.1% score)
  - Shows only equivalent mutant survives

Amp-Thread-ID: https://ampcode.com/threads/T-019beb12-9720-740b-b4b6-de4f92eb10d6
Co-authored-by: Amp <amp@ampcode.com>

* Add unary_op_mutator_test module to tests

* fmt imports

* Update crates/forge/src/mutation/mutators/mod.rs

* nits

* Update crates/forge/src/mutation/mutators/assignment_mutator.rs

* nits

* refactor: replace ctrlc with tokio signal, update rstest to 0.26

Addresses review comments from @zerosnacks:
- Remove ctrlc dependency, use tokio's built-in signal handling instead
- Update rstest from 0.25.0 to 0.26
- Add tokio signal feature for Ctrl+C handling

The ctrlc crate is replaced with a background thread running a tokio
runtime that waits on tokio::signal::ctrl_c(). This avoids adding the
ctrlc dependency which is on the deny list.

Amp-Thread-ID: https://ampcode.com/threads/T-019bec4d-844c-7624-9054-1b7e6b056c2c
Co-authored-by: Amp <amp@ampcode.com>

* feat(mutation): add --json support for mutation testing

When --json flag is used with --mutate, outputs JSON with:
- Summary: total, killed, survived, invalid, skipped, mutation_score, duration_secs
- Survived mutants grouped by file with line, column, original expression, and mutant

All progress messages are suppressed in JSON mode for clean output.

Amp-Thread-ID: https://ampcode.com/threads/T-019bf8c4-8b62-741e-9226-e5cf23a050db
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): symlink node_modules and soldeer dependencies in temp workspace (#13221)

Projects using npm (node_modules/) or Soldeer (dependencies/) for external
dependencies fail mutation testing because these directories are not copied
to the temporary mutation workspace.

This fix adds symlinking for these common external dependency directories,
following the same pattern already used for lib directories.

Fixes compilation errors like:
  error: 'node_modules/@uniswap/v3-core/...' not found

Tested with:
- Uniswap/universal-router (npm dependencies)
- morpho-org/morpho-blue (lib dependencies)

Amp-Thread-ID: https://ampcode.com/threads/T-019bf98e-1307-72f9-ac5f-a00d33518d40

Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): recursively symlink nested lib directories in temp workspace (#13224)

* fix(mutation): recursively symlink nested lib directories in temp workspace

Projects with nested git submodules (e.g., lib/euler-earn/lib/openzeppelin-contracts)
fail mutation testing because the temp workspace only symlinks top-level lib directories.

When libraries have their own dependencies in nested lib/ folders, and remappings
reference them with context-specific prefixes like:
  lib/euler-earn:@OpenZeppelin=lib/euler-earn/lib/openzeppelin-contracts/

These paths need to exist in the mutation workspace for compilation to succeed.

This fix adds symlink_nested_libs() which recursively walks through each library
and symlinks any nested lib/ directories, ensuring deeply nested submodules are
accessible in the temp workspace.

Tested against euler-xyz/evk-periphery which has multiple levels of nested submodules.

Amp-Thread-ID: https://ampcode.com/threads/T-019bfa2f-d0b0-703a-9822-945c87b1e2ab
Co-authored-by: Amp <amp@ampcode.com>

* test(mutation): add unit tests for symlink and copy functions

Tests cover:
- symlink_dir: basic symlink creation
- symlink_nested_libs: single level, deeply nested (3 levels), no lib dir, skip existing
- copy_dir_recursive: basic copy, skip symlinked dirs, nonexistent source
- relative_to_root: basic, same path, outside root

Amp-Thread-ID: https://ampcode.com/threads/T-019bfa2f-d0b0-703a-9822-945c87b1e2ab
Co-authored-by: Amp <amp@ampcode.com>

* style: format with nightly rustfmt

Amp-Thread-ID: https://ampcode.com/threads/T-019bfa2f-d0b0-703a-9822-945c87b1e2ab
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): support custom lib directories in nested submodules

Read each nested library's foundry.toml config to get its actual lib paths
instead of hardcoding 'lib'. Falls back to default 'lib' if no config exists.

Amp-Thread-ID: https://ampcode.com/threads/T-019bfab1-a4b5-7008-8fb4-735aa3a560ac
Co-authored-by: Amp <amp@ampcode.com>

* clippy fix

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: zerosnacks <zerosnacks@protonmail.com>

* feat(mutation): add Yul opcode mutator (#13341)

* feat: make mutation test operators configurable (#13410)

make mutators configurable


Amp-Thread-ID: https://ampcode.com/threads/T-019c477b-60f0-71af-853e-f2b5902f0906

Co-authored-by: Amp <amp@ampcode.com>

* refactor: rename UnaryOperatorMutator to UnaryOpMutator (#13411)

* make mutators configurable

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c477b-60f0-71af-853e-f2b5902f0906

* refactor: rename UnaryOperatorMutator to UnaryOpMutator

Amp-Thread-ID: https://ampcode.com/threads/T-019c477b-60f0-71af-853e-f2b5902f0906
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>

* fix: address mutation testing issues (#13412)

* fix: address critical mutation testing issues

- Rename orchestrator MutationConfig to MutationRunConfig to avoid
  naming collision with foundry_config::MutationConfig
- Make generate_mutated_solidity and restore_original_source return
  Result instead of panicking, use byte-based splicing to avoid
  UTF-8 boundary panics
- Validate relative paths in copy_project_for_mutation with
  is_safe_relative_path to prevent path escape when config paths
  are not under the project root
- Fix cache key collisions by including a hash of the full contract
  path in cache filenames (prevents src/A.sol vs contracts/A.sol
  from sharing cache entries)
- Make generate_ast return Result and propagate parse errors instead
  of silently swallowing them

Amp-Thread-ID: https://ampcode.com/threads/T-019c477b-60f0-71af-853e-f2b5902f0906
Co-authored-by: Amp <amp@ampcode.com>

* fix: harden mutation testing with path validation and diagnostics

- Validate user-provided --mutate paths: normalize relative paths
  against config.root, check existence/is_file/.sol/non-test
- Fix --mutate-contract to match any contract in a multi-contract
  file (use .any() instead of .find().is_some_and())
- Add span bounds check in generate_mutated_solidity to prevent
  panics on invalid span positions
- Replace unwrap() with expect() in MutationContextBuilder calls
  with descriptive messages for each AST node type
- Fix cross-platform relative_path: use Path::components() with
  forward-slash joining for stable output across OSes

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c47bc-5807-7721-9051-7028de802b89

* refactor: address review feedback for mutation testing

Extract cache_file_path helper to consolidate repeated cache dir
and filename prefix logic across persist/retrieve methods.

Extract ensure_safe_relative_path helper to replace repeated
validate-and-bail pattern for path safety checks.

Remove dead code: generate_mutated_solidity and
restore_original_source are unused (runner.rs uses its own
apply_mutation with checked slicing and temp directories).

Amp-Thread-ID: https://ampcode.com/threads/T-019c47ec-22ab-75db-9c35-35e0e08ac396
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>

* chore: remove dead code from mutation module (#13413)

* fix: address critical mutation testing issues

- Rename orchestrator MutationConfig to MutationRunConfig to avoid
  naming collision with foundry_config::MutationConfig
- Make generate_mutated_solidity and restore_original_source return
  Result instead of panicking, use byte-based splicing to avoid
  UTF-8 boundary panics
- Validate relative paths in copy_project_for_mutation with
  is_safe_relative_path to prevent path escape when config paths
  are not under the project root
- Fix cache key collisions by including a hash of the full contract
  path in cache filenames (prevents src/A.sol vs contracts/A.sol
  from sharing cache entries)
- Make generate_ast return Result and propagate parse errors instead
  of silently swallowing them

Amp-Thread-ID: https://ampcode.com/threads/T-019c477b-60f0-71af-853e-f2b5902f0906
Co-authored-by: Amp <amp@ampcode.com>

* fix: harden mutation testing with path validation and diagnostics

- Validate user-provided --mutate paths: normalize relative paths
  against config.root, check existence/is_file/.sol/non-test
- Fix --mutate-contract to match any contract in a multi-contract
  file (use .any() instead of .find().is_some_and())
- Add span bounds check in generate_mutated_solidity to prevent
  panics on invalid span positions
- Replace unwrap() with expect() in MutationContextBuilder calls
  with descriptive messages for each AST node type
- Fix cross-platform relative_path: use Path::components() with
  forward-slash joining for stable output across OSes

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c47bc-5807-7721-9051-7028de802b89

* refactor: address review feedback for mutation testing

Extract cache_file_path helper to consolidate repeated cache dir
and filename prefix logic across persist/retrieve methods.

Extract ensure_safe_relative_path helper to replace repeated
validate-and-bail pattern for path safety checks.

Remove dead code: generate_mutated_solidity and
restore_original_source are unused (runner.rs uses its own
apply_mutation with checked slicing and temp directories).

Amp-Thread-ID: https://ampcode.com/threads/T-019c47ec-22ab-75db-9c35-35e0e08ac396
Co-authored-by: Amp <amp@ampcode.com>

* chore: remove dead code from mutation module

Remove unused symbols superseded by the orchestrator and parallel
runner architecture:

- MutationsSummary: update_valid_mutant, dead/survived/invalid/skipped
  string formatters, get_skipped, get_report_mut
- Mutant: line_number(source) method (shadowed by field),
  format_diff, description_with_context
- ParallelMutationRunner struct and run_mutations_parallel wrapper
  (replaced by orchestrator + run_mutations_parallel_with_progress)
- Stale re-exports and unused imports

Amp-Thread-ID: https://ampcode.com/threads/T-019c47ec-22ab-75db-9c35-35e0e08ac396
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>

* refactor(mutation): extract workspace utilities into shared module (#13423)

Move project-copying logic from mutation/runner.rs into a new
crates/forge/src/workspace.rs module for reuse by other features
that need isolated temp workspaces (e.g., brutalization).

Extracted functions: copy_project, copy_dir_recursive, symlink_dir,
symlink_nested_libs, relative_to_root, ensure_safe_relative_path.

Tests moved from mutation/runner.rs to workspace.rs.

Amp-Thread-ID: https://ampcode.com/threads/T-019c4d7b-acac-75ef-9f69-f3619f6941b6

Co-authored-by: Amp <amp@ampcode.com>

* fix: resolve clippy warnings in mutation module

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* fix: use then_some instead of then with closure

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* fix: make default_excluded const fn

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* fix: add const to eligible mutation module functions

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* fix: revert with_path to non-const (PathBuf has destructor)

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* fix: make builder() const fn

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* fix: address review feedback

- Fix typo: 'Pathe' -> 'Path' in mutation_dir doc comment
- Add depth limit (max 10) to symlink_nested_libs to prevent infinite recursion
- Support all network types (Eth/OP/Tempo) in mutation testing, not just EthEvmNetwork

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* fix: rustfmt and add depth arg to test calls

Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d873c-bf66-7018-96d5-0d0e7d34e540

* test(mutation): replace rstest with mutator_tests! macro

Each parameterized case becomes a standalone #[test] (parallel execution,
individual failure reporting, IDE run buttons) without pulling in rstest.

Also fixes a pre-existing clippy::question-mark warning in
assignment_mutator.rs surfaced by make lint.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0314-c110-735c-8858-123078e60e37
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): address PR review feedback

- mutant.rs: add missing Pow/Rem variants to BinOpKind deserializer
- elim_delegate_mutator: narrow mutation span to the 'delegatecall'
  identifier so replacement does not clobber the surrounding call
- unary_op_mutator: fix Member operand swap (e.g. 'a.b++' was producing
  '++ba')
- require_mutator: use AST argument spans instead of string-splitting
  on the first comma, so 'require(foo(a, b))' is handled correctly
- mutation/mod.rs: include hash of enabled operators in the cache file
  name so changes to include/exclude_operators invalidate the cache
- orchestrator: only load survived-spans cache after mutants are
  successfully obtained
- visitor: add per-contract name filter; visit_item_contract toggles
  an 'in_allowed_contract' flag that gates expr/var/yul mutation
  collection, so --mutate-contract filters per contract rather than
  per file
- cmd/test: add clap conflicts_with between --mutate-path and
  --mutate-contract; add runtime error when --mutate has explicit
  paths combined with --mutate-path

Amp-Thread-ID: https://ampcode.com/threads/T-019e4020-a7ef-70a8-84e0-a44a82f56780
Co-authored-by: Amp <amp@ampcode.com>

* feat(mutation): add --mutation-timeout + richer progress UX (WIP)

Mirrors invariant.timeout for mutation campaigns:
- MutationConfig.timeout / --mutation-timeout SECS CLI flag
- MutationResult::TimedOut variant (separate from Invalid/Alive)
- Per-mutant enforcement via std::thread + mpsc::recv_timeout in
  runner::run_compile_and_test_with_timeout; worker slot freed
  immediately on timeout (background work unwinds via gas limit)
- Adaptive survived-span cache only marked for genuine Alive results
- Cache key folds in timeout so changed budget invalidates results
- Reporter row + legend + JSON 'timed_out' field

Progress UX (needs further iteration on formatting):
- Live tally on overall bar (k:/s:/i:/t:/sk:) + elapsed_precise
- Keyed active-mutant tracking so parallel completions remove the
  correct row instead of FIFO
- Per-result completion lines printed above bars via multi.suspend

Tested end-to-end: 70-mutant project with --mutation-timeout 2 sees
4 mutants TimedOut at exactly 2.0s, score correctly excludes them.

Unit tests + CLI mutation snapshot tests pass.

Amp-Thread-ID: https://ampcode.com/threads/T-019e4020-a7ef-70a8-84e0-a44a82f56780
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): enforce per-mutant timeout in fuzz/invariant harness; clean up progress UX

- runner: propagate config.mutation.timeout into temp_config.fuzz.timeout and
  temp_config.invariant.timeout so the inner FuzzTestTimer bails out at the
  deadline. Previously the outer recv_timeout returned TimedOut but the leaked
  worker thread kept running expensive fuzz/invariant runs and starved the
  pool — a 200k-runs fuzz test would not respect --mutation-timeout at all.
  Never raises a user-configured fuzz/invariant timeout, only lowers it.

- progress: drop the fake static '[0.0s]' prefix from in-flight spinner
  messages. Replace the emoji-prefixed completion lines (✗/⚠/⏱) with a plain
  color-coded label (KILLED green, SURVIVED red, TIMED OUT yellow) padded to
  9 chars so columns align after ANSI escapes.

- reporter: strip prescriptive prose. Drops the 'These mutations were NOT
  caught…' preamble, the entire Security Implications section, and the
  Suggestions to improve test coverage block. Shortens the legend to factual
  definitions. Removes the emoji (⚠/✓/ℹ/⏱) and parenthetical opinions from
  the survived/killed/invalid/timed-out section headers.

- Disambiguate MutationProgress::clear via UFCS to silence yansi Paint::clear
  trait shadow warning.

- Snapshot tests regenerated for the new reporter shape.

Amp-Thread-ID: https://ampcode.com/threads/T-019e4061-d738-70bd-8f43-3fc319258ed2
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): rustfmt, no-default-features, config snapshot

- runner: gate OpEvmNetwork import + dispatch behind #[cfg(feature = "optimism")]
  so cargo check --no-default-features builds. Mirrors the pattern already used
  in cmd/test/mod.rs.

- tests/cli/config.rs: update test_default_config snapshot for the new
  mutation.timeout field.

- Re-run cargo +nightly fmt across the mutation module touched by previous
  commits.

Amp-Thread-ID: https://ampcode.com/threads/T-019e4061-d738-70bd-8f43-3fc319258ed2
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): address oracle review blockers and highs

Blockers:
- runner: move TempDir ownership into worker thread so timed-out
  workers can no longer race against a deleted workspace; park
  JoinHandles in SharedMutationState.pending_workers and join them
  at the end of the parallel run to actually reclaim cleanup
- workspace: add ensure_within_root containment check (canonicalize
  + starts_with) for src/test/lib/node_modules/dependencies so a
  symlinked root cannot escape the project; validate nested lib
  dirs from untrusted dependency foundry.toml (reject .., absolute
  paths, symlinked nested roots); use entry.file_type() to skip
  symlinked entries instead of following them

Highs:
- orchestrator: do not persist cached results on a cancelled or
  short run; would otherwise be reloaded as the authoritative
  answer for the file
- mutation/mod: include --mutate-contract regex in cache key so
  cached results from a different filter aren't silently reused
- cmd/test: bail when mutation is requested with inline per-test
  network overrides (single-pass runner can't honor them) or with
  ffi/write fs_permissions (shared symlinked dep trees aren't safe
  to mutate from tests)

Adds 4 workspace tests for the new symlink-escape protections.

Amp-Thread-ID: https://ampcode.com/threads/T-019e4567-e7ca-717e-bcc0-bb67a3667c4d
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): key cached results by execution inputs (#14971)

* fix(mutation): key cached results by execution inputs

* fix(mutation): include execution inputs in cache key

* fix: clean-up

* fix(mutation): honor filters and isolation (#14848)

* fix(mutation): key cached results by execution inputs

* fix(mutation): include execution inputs in cache key

* fix(mutation): honor test filters/isolation, tighten cache key, drop compound assignment mutation, reject incompatible flags

- runner: thread FilterArgs and --isolate through MutationRunConfig into
  compile_and_test_inner; rebuild ProjectPathsAwareFilter against the
  per-mutant temp config so --match-test/--match-contract/--match-path
  work and mutants exercise the same test set/execution model as baseline
- mod: add runtime_context_digest folded into the per-file cache key so
  cached results aren't reused when filters, isolation, fork URL/block,
  sender, or initial_balance change
- orchestrator: compute runtime_context_digest once per run and bind it
  to every MutationHandler
- binary_op_mutator: stop mutating compound assignments (a += b would
  silently be rewritten to a - b dropping the assignment); add regression
  tests
- cmd/test: bail when --mutate is combined with --list/--debug/
  --flamegraph/--flamechart/--junit instead of silently mixing modes
- fs_permissions guard now narrowed to permissions whose path can reach
  symlinked dependency trees (lib/node_modules/dependencies)

Amp-Thread-ID: https://ampcode.com/threads/T-019e4567-e7ca-717e-bcc0-bb67a3667c4d
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): address fresh oracle review blockers and mediums

Blockers
- filter parity: pass materialized baseline filter (filter.args().clone())
  into MutationRunConfig so positional 'forge test <path>' and --rerun
  reach mutant runs too; raw self.filter dropped them silently
- bail when baseline matched zero tests; without it every compileable
  mutant was reported as 'Alive'
- negative literal mutation: add OwnedLiteral::NegatedNumber(U256)
  formatted as '-{val}'; the old Number(-*val) wrapped via two's
  complement and produced wrong-source mutants
- mutator test harness: wrap snippets in valid Solidity, panic on parse
  failure, and check emitted replacement text. Fixed 6 stale test
  expectations and 1 unmatched delegate-pattern case the old vacuous
  harness was hiding

Mediums
- survived-span resume: load retrieve_survived_spans BEFORE mutant
  generation/cached load and filter cached mutants through
  should_skip_span so cross-run adaptive skipping actually works
- runtime context digest: hash the full serialized EvmOpts (networks,
  env, gas-limit toggles, fork, sender, balance, ...) instead of a
  hand-picked 4-field subset that missed several mutation-affecting
  knobs
- deterministic output: BTreeMap for JSON survived_mutants with sorted
  entries; reporter sorts survived list by (path, line, col, span,
  mutation); persisted results_vec sorted by span
- --mutate-contract: intersect with explicit --mutate <paths> instead of
  silently overriding them; explicit paths are now respected
- workspace: copy 'script/' when present and distinct from src/test so
  projects that import script-side helpers don't see false Invalid
  mutants

Amp-Thread-ID: https://ampcode.com/threads/T-019e4567-e7ca-717e-bcc0-bb67a3667c4d
Co-authored-by: Amp <amp@ampcode.com>

* fix: `BTreeMap` import

* fix mutation compound assignment handling

* test(mutation): compare full mutator output sets

* fix: clean-up

* fix mutation testing review feedback

* test: update require mutator snapshot

* fix: mutation cache filtering and resumed skips

* fix(mutation): preserve survivors when resuming

* fix: test output

* fix(forge): harden mutation testing cache and isolation

Reject incompatible coverage/showmap mutation runs, make dependency fs_permissions checks path-aware through symlinked ancestors, validate result caches against the current mutant set, and restore live same-span adaptive skipping while keeping resume semantics strict.

* fix: mutation compile filters

---------

Co-authored-by: Mablr <59505383+mablr@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>

* fix(mutation): reject mutation under machine mode (#15009)

fix(forge): reject mutation under machine mode

* fix(mutation): reject empty mutation selections (#15010)

fix(forge): reject empty mutation selections

* fix(forge): preserve mutation workspace config (#15011)

* fix(forge): preserve mutation workspace config

* fix: make clippy happy

* fix(mutation): use source spans for unary targets (#15014)

fix(forge): use source spans for unary targets

* fix(mutation): surface mutator generation errors (#15015)

* fix(forge): surface mutator generation errors

* fix: surface mutator generation errors with best-effort model

* fix(mutation): bound timed-out mutation cleanup (#15016)

* fix(forge): bound timed-out mutation cleanup

* fix: `Default` impl for `SharedMutationState`

* fix(mutation): filter no-op mutation outputs (#15013)

* fix(forge): filter no-op mutation outputs

* fix: bless

* fix require condition inversion

* fix(mutation): copy mutation compiler paths (#15012)

* fix(forge): copy mutation compiler paths

* fix: copy mutation compiler paths

* fix(mutation): handle cancellation without progress UI (#15030)

* fix(mutation): handle cancellation without progress UI

* fix: according review

* refactor mutation state constructor

---------

Co-authored-by: drgorillamd <83670532+drgorillamd@users.noreply.github.com>
Co-authored-by: Simon Something /DrGoNoGo <83670532+simon-something@users.noreply.github.com>
Co-authored-by: 0xchin <alanracciatti1220@gmail.com>
Co-authored-by: emo.eth <emodoteth@gmail.com>
Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com>
Co-authored-by: grandizzy <grandizzy.the.egg@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com>
Co-authored-by: zerosnacks <zerosnacks@protonmail.com>
Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com>
Co-authored-by: Mablr <59505383+mablr@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants