Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

active_mutator

Gem Version

Mutation testing for Ruby, built on Prism. Open source, RSpec-integrated, Rails-first. Available on RubyGems.

active_mutator mutates your code one small change at a time (> becomes >=, && becomes ||, a statement gets deleted, a condition gets forced, and so on). It runs exactly the examples that cover the mutated line, and reports every mutant your suite fails to kill. A surviving mutant is a behavior change no test notices: a precise, machine-verified test gap.

A surviving mutant, in one example

defdiscount(total)return0iftotal < 100total / 10end
it{expect(calc.discount(50)).toeq(0)}it{expect(calc.discount(200)).toeq(20)}

Both examples pass. Line coverage on discount is 100%. Run active_mutator and one mutant survives anyway:

Surviving mutants:
Calculator#discount (lib/calculator.rb:11)
replace `<` with `<=`
- total < 100
+ total <= 100

Nothing in the test suite calls discount(100), the one input where < and <= disagree. The tests pass, and coverage is green. But the boundary is still unverified. That gap is invisible to coverage and obvious to mutation testing. Add it { expect(calc.discount(100)).to eq(0) } and the mutant is killed.

What is mutation testing?

Coverage answers "did a test run this line?" Mutation testing answers "would a test notice if this line were wrong?" That is a different, and usually more useful, question.

active_mutator applies one small, syntactically valid change to your code (a "mutant") and re-runs only the examples that cover it. If a test fails, the mutant is killed: your tests correctly reject that wrong behavior. If every covering test still passes, the mutant survived: something changed and nothing noticed. A survivor is not a hypothetical. It is the exact line, the exact before and after diff, and proof that no assertion depends on the difference.

Mutation score is (killed + timeout) / (killed + timeout + survived + error). 100% is usually not the right target. Some mutants are behaviorally equivalent to the original and can never be killed by any test. That is why active_mutator has a committed acceptance ledger. It lets you close survivors out with a stated reason instead of chasing an unreachable score.

Full primer, including the origin of the technique and further reading: docs/guides/what-is-mutation-testing.md.

Install

# Gemfilegroup:development,:testdogem"active_mutator"end

Requires Ruby 3.2 or later, RSpec, and a green suite. Linux/macOS (MRI fork).

Quick start

bundle install
bundle exec active_mutator app/models/calculator.rb

The first run performs an instrumented baseline of your suite to build the coverage map. The map is cached in .active_mutator/ and refreshed incrementally after that (see docs/guides/how-it-works.md). Then each mutant runs in its own fork against only its covering examples.

Reading the output

$ bundle exec active_mutator app/models/calculator.rb
.....S..T...U..A....
killed: 14
survived: 1
timeout: 1
error: 0
uncovered: 1
accepted: 1
invalid (discarded): 2
Mutation score: 93.8%
Surviving mutants:
Calculator#discount (app/models/calculator.rb:9)
replace `<` with `<=`
- total < 100
+ total <= 100

Each character on the progress line is one mutant, printed as it finishes:

CharStatusMeaning
.killeda covering test failed. Good, the mutant is dead
Ssurvivedevery covering test passed. This is a test gap
Ttimeoutran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible
Eerrorthe worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run
Uuncoveredno test executes the mutated line at all. This is coverage debt, worse than a survivor
Aacceptedmatches a known-equivalent entry in the acceptance ledger. Excluded from the score

invalid mutants (edits that don't even re-parse as valid Ruby) are discarded before scheduling and reported as a count only. Exit code is 1 if unaccepted survivors or errors exist (or, with --fail-at, if the score is below the threshold), 0 otherwise, including when there are only uncovered or accepted results. The JSON report's exit_reason field (unaccepted_survivors, worker_errors, clean) is independent of the --fail-at gate. A --since or --subject run that plans zero mutants prints no score; it warns with the cause and exits 1 unless --allow-empty is given.

When survivors exist, the summary also prints a per-operator table showing how often each operator's mutants survive, to help spot likely-equivalent mutant patterns.

How it works, compactly

  1. Subject discovery: a Prism visitor finds every method (def) in your target files.
  2. Source-span edits: each operator emits byte-range text edits against the original file, not a rewritten AST. Every mutant is re-parsed with Prism and discarded (invalid) if the edit produced something that doesn't parse. No unparser is ever built or maintained.
  3. Coverage-mapped test selection: one instrumented baseline run maps every source line to the examples that cover it. Incremental runs refresh only what changed instead of re-running the whole suite.
  4. Fork-per-mutant kill runs: the parent preloads your app and spec helper once. Each mutant is inserted and exercised in its own fork against just its covering examples, so results can't bleed state between mutants.

Full architecture, including the coverage-cache format, the fork pipeline, the serial lane for browser specs, timeout budgets, and every status, is in docs/guides/how-it-works.md.

Usage

active_mutator # mutate app/ and lib/, full run
active_mutator app/models # scope by path (directory)
active_mutator app/models/document.rb # scope to a single file
active_mutator --changed # uncommitted work only (dev loop)
active_mutator --since origin/main # PR scope (CI)
active_mutator --subject 'Foo::Bar#baz'# one method
active_mutator --exclude 'lib/generated'# skip a subtree (repeatable)

--subject also takes broader expressions: Foo::Bar (all methods of that constant), Foo::Bar* (raw name prefix — matches Foo::Bar::Qux and also Foo::Barn), Foo::Bar#* (instance methods only), Foo::Bar.* (singleton methods only).

--exclude PAT is a glob relative to the project root, applied during subject discovery, and gitignore-like: lib/generated, lib/generated/, and lib/generated/** all exclude the whole subtree. File globs like **/legacy/* work too.

Skip a single method by putting # active_mutator:skip on the line above its def:

# active_mutator:skipdeflegacy_delegatortarget.callend

Statuses: killed (test failed, this is good), survived (test gap), timeout (counts as detected), uncovered (no covering example, this is coverage debt), accepted (known-equivalent, see ledger), error, invalid (discarded). Exit code is 1 if unaccepted survivors exist (or, with --fail-at, if the score is below the threshold). Mistyped positional paths (a file that doesn't exist, or a non-.rb file) are an error (exit 2) instead of a vacuous green run.

Score = (killed + timeout) / (killed + timeout + survived + error).

The dev loop

TDD until green, then verify the tests constrain the behavior:

bundle exec active_mutator --changed --format json

Kill survivors by writing the missing tests. For genuine equivalent mutants:

bundle exec active_mutator --changed --accept-survivors # records to ledger
git add .active_mutator_accepted.json # committed state

Acceptance takes effect on the next run. The accepting run still exits 1. Scoped accepting runs (--changed, --subject, path args) are safe: the ledger only prunes entries in files fully scanned by non-narrowed runs, so out-of-scope acceptances are never dropped. Agent workflow: see docs/skills/mutation-check.md.

Reports

--format stryker-json writes .active_mutator/mutation-report.json in the Stryker mutation-testing-report-schema v2 format. Open it in the Stryker report viewer for per-file mutant maps with inline diffs, filterable by status.

--format github prints one ::warning annotation per surviving mutant, so survivors show inline on the PR diff. Pairs with the CI recipe:

bundle exec active_mutator --since origin/main --format github

CI recipe

  • Per-PR: active_mutator --since origin/main --format github (minutes; survivors annotate the PR diff)
  • Nightly: active_mutator --force-baseline (full run; also recovers the residual blind spot — constant-reference detection handles the common newly-covering-example case since 0.2)

Flags

FlagDefaultMeaning
--jobs Nhalf the coresfork-pool width
--changednonemutate uncommitted + untracked work
--since REFnonemutate methods changed since REF
--subject EXPRnonesubject expression, e.g. Foo#bar, Foo::Bar, Foo::Bar*, Foo#*, Foo.*
--exclude PATnoneskip files matching glob during subject discovery (repeatable, gitignore-like)
--max-mutants Nnonedeterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N)
--debug-planoffprint planned mutants as JSON and exit without running
--allow-emptyoffexit 0 when --since/--subject plan no mutants (default: warn and exit 1)
--format terminal|json|stryker-json|githubterminalreport format
--accept-survivorsoffrecord survivors to the acceptance ledger
--force-baselineoffignore cached coverage map
--preload-helper FILE / --no-preload-helperauto-detectparent spec-helper preload
--serial-pattern PATspec/system/, spec/features/covering-path prefixes forced serial
--spec-path DIRspec/where spec files live, relative to the project root (repeatable; the first use replaces the default spec/), e.g. --spec-path engines/billing/spec --spec-path spec
--browser-boot-seconds S15serial-lane timeout bump
--timeout-factor F / --timeout-floor S8 / 10mutation timeout budget
--[no-]adaptive-timeoutonscale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; --timeout-factor/--timeout-floor set the starting budget)
--require FILEnonepreload files (repeatable)
--operator FILEnoneload a custom operator file before analysis (repeatable)
--[no-]class-levelonmutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects
--fail-at SCOREnone (strict)exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only)

--spec-path tells active_mutator where spec files live (coverage classification, digests, escalation); RSpec's own discovery is still the project's job — a project with specs under test/ also needs --default-path test in its .rspec. The serial-lane defaults stay spec/system/ and spec/features/ regardless of --spec-path; a custom spec root with browser specs should set --serial-pattern (e.g. --serial-pattern test/system/) itself.

--debug-plan prints the planned mutant list as one JSON document ({"planned": [...], "pre_resolved": {...}}) and exits without running anything. A coverage baseline is still built or loaded, since timeouts and covering examples come from it.

Every active_mutator process sets ENV["ACTIVE_MUTATOR"] = "1". Use it to guard SimpleCov or other tooling in your spec helper:

SimpleCov.start"rails"unlessENV["ACTIVE_MUTATOR"]

Configuration file

Put team-wide settings in .active_mutator.yml at the project root; CLI flags override file values (--require and --exclude add to the file's lists; the first --serial-pattern replaces them). Recognized keys: jobs, format, timeout_factor, timeout_floor, browser_boot_seconds, fail_at, exclude, serial_patterns, spec_paths (where spec files live, relative to the project root; replaces the default spec), requires, operators (custom operator files, loaded before analysis; see Custom operators), preload_helper (a path, or false to skip preload), adaptive_timeout (true/false), class_level (true/false, default true — mutate class-level code), class_level_closure_cap (integer, default 10 — max constants a class-body mutant may reload before it is skipped). Unknown keys and wrong types are errors, not silent no-ops.

# .active_mutator.ymljobs: 4exclude:
- lib/generatedserial_patterns:
- spec/system/spec_paths:
- engines/billing/spec
- specfail_at: 90# legacy suite: gate on score instead of zero-survivors

Class-level mutation

Class-level code — macros (validates, scope, has_many), constants, and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a … (class body) subject alongside its method subjects, and the same operator set runs over its class-level statements. Because re-running a macro accumulates rather than replaces (calling validates twice adds a second validator), a class-body mutant can't be inserted with class_eval the way a def mutant is. Instead active_mutator removes the target constant and re-evaluates the whole mutated file, reloading anything attached to it (includers, subclasses, extenders) in dependency order. See docs/guides/how-it-works.md for the full closure-reload pipeline.

Disable it with --no-class-level (or class_level: false in the config file). A class-body mutant whose closure can't be reloaded faithfully — the closure exceeds class_level_closure_cap (default 10), the constant was reopened elsewhere, or an attacher is anonymous/native — is reported skipped (progress char -): listed but not counted in the score, because a mutant we can't insert faithfully must not be called survived or killed.

Known limits

Method bodies and Zeitwerk-shaped class bodies are mutated; the remaining limits are:

  • Class-body mutation requires a Zeitwerk-shaped file — exactly one top-level class/module per file. Multi-constant files and core-class monkey-patches/reopens are not class-body-mutated (issue #32). Their method bodies still are.
  • Most code inside blocks is not mutated.ActiveSupport::Concern DSL blocks (included/prepended/class_methods do … end) ARE mutated — their bodies re-run as class-level code in the includer (issue #31). Every other block (has_many :x do … end and any do … end/{ … } body) is pruned to avoid false survivors from mutating code whose run-time context is unknown.
  • Constants captured by value go stale. A reference that holds the target by value rather than by ancestry — an alias (ALIAS = SomeClass), a registry the class was pushed into, a memoized instance, a class variable captured at load — keeps pointing at the pre-reload object after the closure reload. Such stale references can produce false survivors.
  • Whole-file re-eval re-runs class-body side effects. The reload re-evaluates the target and every attacher's class body, so non-idempotent load-time side effects (global self-registration, descendant tracking) run twice — which can double or mask a count a spec asserts on.
  • refine-based modules are not discovered or reloaded. Refinements are anonymous and don't appear in normal ancestors.
  • RSpec only. Test selection, worker setup, and the world-group filter are all RSpec-API-shaped.
  • Method-body scope details: plain heredoc bodies ARE mutated (emptied); interpolated heredocs are skipped. class << self bodies are mutated as singleton subjects (class << obj and top-level class << self are skipped). Nested defs mutate as part of the enclosing method's body — they get no subject of their own (a directly-inserted mutant would be reverted whenever the outer method re-runs the def).
  • The incremental baseline's residual blind spot: constant-reference detection handles the common case since 0.2; a few residual cases (pure indirection, partially-covering files, leaf-only or wrapper-only references, class ::Foo, Data.define/Struct.new value objects) are caught by nightly --force-baseline.

Guides

  • What is mutation testing?: the concepts. Kill/survive, score, equivalent mutants, further reading.
  • How it works: architecture. Subject discovery, source-span edits, the coverage map, the fork pipeline, and honest limits.
  • Operator reference: every mutation active_mutator can generate, with before/after examples and what a survivor of each one means.
  • Custom operators: write and load your own mutation operators with --operator / the operators: config key.
  • Mutation-check skill: the agent-facing workflow. Run, read survivors, strengthen tests, or accept with a reason.

Contributing

Issues and pull requests welcome. Run bundle exec rspec before sending a change. Also run bundle exec active_mutator --changed on your own diff before sending a change that touches lib/. This is a good idea for the same reason you'd want it run on any other codebase.

If a run dies with baseline suite failed and a LoadError mentioning bundler-2.x/lib/gems/bundler-2.x/exe/bundle, your Ruby manager (seen with mise) breaks nested bundle exec: the baseline shells out to bundle exec rspec, and bundler's exported RUBYLIB makes the inner binstub resolve the wrong path. Skip the outer bundler instead:

ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job

The :e2e specs nest bundle exec on their own inside the fixture project, so on such a machine they fail either way; rely on the CI e2e job for those.

License

MIT.

About

Mutation testing for Ruby — Prism source-span mutations, coverage-mapped test selection, fork-per-mutant kill pipeline. Rails-first.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages