Skip to content

perf(mutatediff): cache clean per-package mutation results - #923

Merged
gaborage merged 2 commits into
mainfrom
feature/mutatediff-result-cache
Aug 8, 2026
Merged

perf(mutatediff): cache clean per-package mutation results#923
gaborage merged 2 commits into
mainfrom
feature/mutatediff-result-cache

Conversation

@gaborage

@gaborage gaborage commented Aug 8, 2026

Copy link
Copy Markdown
Owner

What

mutatediff had no memory: every invocation re-ran every package in the branch
diff, so amending one line on a 22-package branch re-mutated all 22. It now caches
clean per-package results, keyed on the package's own .go files (tests included),
its transitive first-party dependency closure, testdata/, the pinned engine
command, .gremlins.yaml, module files, toolchain/GOFLAGS, and a schema version.

Impact

None for consumers — dev tooling only. make mutate gains a cache under a
gitignored path; MUTATE_NO_CACHE=1 opts out. A cached skip prints its own line,
so a warm run is never mistaken for one that did the work.

Verification

Only clean results are stored: survivors, NOT COVERED, and TIMED OUT are never
cached, so a load-induced timeout cannot mint a permanent pass. A cached pass is
reused only when every line the current run would judge was judged before —
stored and compared, not hashed — so a widened range misses.

Summary by CodeRabbit

  • New Features

    • Mutation-testing runs now cache clean package results, allowing unchanged packages to be skipped on later runs.
    • Cache entries automatically expire or invalidate when relevant source, dependencies, configuration, or toolchain details change.
    • Added an option to disable result caching when needed.
  • Documentation

    • Added guidance for controlling the cache, invalidation behavior, safety rules, and dependency handling.
  • Chores

    • The cleanup command now removes cached mutation results.

The diff-scoped gate had no memory: every invocation re-ran every package
in the branch diff, so amending a one-line fix on a 22-package branch
re-mutated all 22 at a full-package gremlins run plus that package's whole
test suite each.

scripts/mutatediff/cache.go adds a gitignored result cache under
.mutatediff-cache/. A package is skipped only when a previous run judged
the same lines, over identical inputs, entirely clean.

Correctness rules, all of them fail-open:

- PASS only, and only a *fully* clean pass. A survivor, a NOT COVERED or
  TIMED OUT mutant, and a vacuous package are all refused. Timeouts are
  load-dependent, so caching one would let a machine under load mint a
  permanent pass for a mutant that never ran. Refusing every non-clean
  verdict also means a hit contributes nothing to the report, so the final
  verdict lines read exactly as they would without the cache.
- The key covers every input that can change a verdict: the package, the
  content of every .go file (test files included) and testdata/ in the
  subtree the engine mutates AND in its transitive first-party dependency
  closure (from `go list -deps -test`), the pinned engine command with its
  @Version, .gremlins.yaml, the module pins, the Go toolchain, GOOS/GOARCH,
  and the caller's GOFLAGS (which can carry -tags).
- The judged line set is compared, not hashed. A cached pass is evidence
  only about the lines it judged, so an exact match or a subset hits and
  one extra line misses.
- Every read error, parse error, schema mismatch, clock anomaly, or
  unresolvable dependency is a miss.
- Entries carry a schema version and are ignored when it differs.

Deliberately excluded from the key: worker count, CPU budget, and the
timeout ceiling. Those move timings only, and any run with a
timing-sensitive verdict is never stored in the first place.

`-no-cache` (Makefile: MUTATE_NO_CACHE=1) bypasses it; `make clean` removes
it. Every skip prints `mutatediff: <pkg> cached PASS (skipped)`, so a
cached run is never mistakable for one that did the work.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3ad4e5a7-62ec-4dfe-a6cc-56e7af3435a7

📥 Commits

Reviewing files that changed from the base of the PR and between 88c2d1d and 8c57015.

📒 Files selected for processing (2)
  • scripts/mutatediff/cache.go
  • scripts/mutatediff/cache_test.go

Walkthrough

The mutation runner caches clean package results. Cache keys include source, dependency, configuration, toolchain, platform, and flag data. The runner validates changed-line coverage and supports cache bypass through -no-cache and MUTATE_NO_CACHE.

Changes

Mutation result cache

Layer / File(s) Summary
Cache contract and fingerprints
scripts/mutatediff/cache.go, scripts/mutatediff/diff.go, scripts/mutatediff/cache_test.go
The cache defines persisted entries, validation rules, TTL handling, JSON line ranges, and deterministic fingerprints for mutation inputs.
Dependency-aware storage
scripts/mutatediff/cache.go, scripts/mutatediff/cache_test.go
The cache resolves package dependencies, hashes relevant repository content, persists entries atomically, and fails closed when cache inputs are incomplete.
Mutation-run integration and controls
scripts/mutatediff/main.go, scripts/mutatediff/main_test.go, scripts/mutatediff/cache_test.go, Makefile, wiki/testing.md
The mutation runner resolves the cache, skips cached clean packages, stores eligible results, aggregates verdicts, and exposes command-line, Makefile, and documentation controls.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant run
  participant gateRun
  participant resultCache
  participant mutationEngine
  run->>gateRun: start mutation run
  gateRun->>resultCache: lookup package result
  resultCache-->>gateRun: cached clean result or cache miss
  gateRun->>mutationEngine: mutate uncached package
  mutationEngine-->>gateRun: mutation verdicts
  gateRun->>resultCache: store clean non-vacuous result
Loading

Possibly related PRs

Poem

A rabbit stores clean verdicts with care,
Hashes and ranges guard the lair.
Changed sources cause a miss,
No-cache flags make control precise.
The mutation burrow runs bright. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: caching clean per-package mutation results in mutatediff.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/mutatediff-result-cache

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

@gaborage

gaborage commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Known limitations of the cache key

Recording these here because they are the paths by which a cache could go stale
without anyone noticing. None is live in the repo today; the first two become
live the moment the stated condition is met.

  1. //go:embed of a non-.go, non-testdata file would not invalidate. The
    key hashes *.go + testdata/** per closure dir. grep -rn "go:embed"
    currently returns nothing, so this is latent. If someone embeds a .sql or
    .html beside its source, that file changing would not miss the cache. Fix
    would be to hash every regular file in the dir.
  2. Test inputs read from outside the package dir are invisible to the key
    (e.g. a test opening a repo-root fixture). No live occurrences found.
  3. A local replace directive pointing outside the repo is not hashed. None
    in go.mod/go.work today.
  4. Non-deterministic tests break the core assumption. The cache assumes same
    inputs ⇒ same verdict; a flaky test that killed a mutant by luck freezes that
    luck for the entry's lifetime. Mitigated only by unit tests being hermetic and
    integration tests sitting behind -tags=integration.
  5. NOT COVERED blocks caching for the whole package, by design — so the
    packages with the weakest coverage get the least speedup and re-pay the full
    run every time. Storing and replaying warnings would fix it, at the cost of a
    second report path that must stay in sync with reportWarnings forever.

Reviewer note

The 32.3s → 0.60s warm-run figures were measured by the agent that wrote this,
and I have not independently reproduced them, nor re-run the local gates from
my session. CI and CodeRabbit on this PR are the first independent checks.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/mutatediff/cache.go`:
- Around line 313-318: Update the go list execution in the result-cache flow to
capture stderr alongside the command output and include it in the error returned
when the command fails. Preserve the existing wrapped context in the error so
newResultCache’s WARN log exposes the underlying go list diagnostics.
- Around line 116-126: Update repoRoot to resolve the repository’s actual
top-level directory via git rev-parse --show-toplevel before cache creation,
rather than treating the current working directory as the root; preserve the
existing error-wrapping behavior and return an error when the command cannot
identify a repository.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 994df58d-f69c-44d9-a641-e90ebef5c48e

📥 Commits

Reviewing files that changed from the base of the PR and between 378e4ba and 88c2d1d.

📒 Files selected for processing (7)
  • Makefile
  • scripts/mutatediff/cache.go
  • scripts/mutatediff/cache_test.go
  • scripts/mutatediff/diff.go
  • scripts/mutatediff/main.go
  • scripts/mutatediff/main_test.go
  • wiki/testing.md

Comment thread scripts/mutatediff/cache.go Outdated
Comment thread scripts/mutatediff/cache.go
The result cache took its root from the working directory, while the
changed-line map it is keyed against comes from `git diff`, whose paths
are repo-root relative. An invocation from a subdirectory put package
dirs and changed files in two different path spaces, and scattered one
cache directory per directory the tool ran from. repoRoot now asks git
for the top level, reusing main.go's gitOutput helper: git is already a
hard prerequisite, since run() computes the diff before it builds the
cache, so this adds no new dependency.

loadPackageGraph now captures `go list` stderr into its error. Output
stashes stderr on *exec.ExitError, but ExitError.Error prints only "exit
status 1", so the WARN that reports the cache disabling itself carried no
diagnostic at all.

writeModule git-inits its throwaway module, which is what keeps
TestResultCacheDisablesItselfOnAnUnlistableTree honest: without a
resolvable root every cache there would be nil before ever reaching the
`go list` seam that test claims to pin. It also stops a TMPDIR nested in
a real checkout from resolving to that checkout instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Aug 8, 2026

Copy link
Copy Markdown

@gaborage
gaborage merged commit 6e7717e into main Aug 8, 2026
27 checks passed
@gaborage
gaborage deleted the feature/mutatediff-result-cache branch August 8, 2026 01:05
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