mutation_tester 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03a2b2364ebede84e6b56cfe4fba7d0450475262d59aa754abe0ce34ee1a83ba
4
- data.tar.gz: 7439181f228ffbf2d1cf2e603ac21a91a46c764cd66f0ebfcc40dab30a2f2188
3
+ metadata.gz: 8391c9817c1f430ec2231982495e11ff1f6866c3ce11b8e67d9ab421894c44ab
4
+ data.tar.gz: c82152c72bf0c0faaa22807ade06134fc814dd0faddde7f3849100b29b2f54fa
5
5
  SHA512:
6
- metadata.gz: 6be68e2928ca7ecc1a382e01c66cb5ab7d0afa35e5cf5b6008a62d0599fe399af17a8960a3f4e62025d40e640850c4fde423cff5aa2de69d34db2f6139661342
7
- data.tar.gz: fb12cf8fd83bc37529e7e0fcdc8bb72bc902df0c846b05ad8fb856d8db8b382cf18fb2058b42f5efa2b161f573517b368d7aee92563c4115c7b3669a3d2e5ba9
6
+ metadata.gz: d663fe8bc725c6b99e50d0752c3c8452397794672ac2a79a64e1cc6da70b1076abca47bf5143050cc3afd405320ff584d17c587cca8fff47bed27a3f184ed543
7
+ data.tar.gz: f33e5cbcdf08e032015b358e2b027b7846af163d2d5bab5954abf13f6d06d48e3a584677ace35e97f9aee67e48faa96a5c5ffcc2704b9416984e4605bcecdff1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.1] - 2026-08-03
4
+
5
+ - Fixed parallel runs reporting every mutant as survived (`Killed: 0`, score `0.0%`) on projects whose test file reaches its source through `$LOAD_PATH` (a Rails app run with `-Itest` in `RUBYOPT`, for example). A preloaded worker is started once in the real project root, so Ruby absolutizes every `-I`/`RUBYLIB` entry against that directory; the forked child then changed into the shadow workspace but kept resolving `require "test_helper"` back to the original tree, which loaded the unmutated source. Each job now rewrites the `$LOAD_PATH` entries of the mirrored project root into the workspace, so a preloaded worker resolves project code exactly like the `spawn` runner that starts inside the workspace. Only Minitest suites hit this in practice: RSpec re-adds `lib` and its default path at run time, after the child has changed directory. Serial runs and the `spawn` runner were never affected, which is why the same file scored 96.55% serially and 0.0% with `-p 8`.
6
+ - The shadow-workspace sanity check now also proves that the workspace copy of the source is the code the tests execute: after the unmutated source passes there, the check replaces that copy with a `raise` and requires the run to fail. When it still passes, the run aborts as an infrastructure failure (exit code `3`) naming the likely causes, instead of reporting a complete-looking 0.0% score in which every mutant falsely survived.
7
+ - The console summary now warns when a file with at least five scored mutants killed none of them, pointing at the runner rather than at test quality and suggesting a `--runner spawn` comparison.
8
+
9
+ ## [1.4.0] - 2026-08-01
10
+
11
+ - Minitest suites now use the same execution runners as RSpec instead of being pinned to `spawn`. The fork worker preloads `minitest` (disabling the `minitest/autorun` at-exit hook and driving `Minitest.run` itself, so the file still runs exactly once per mutant) and the in-memory runner preloads the test file once and re-evaluates each mutant in a fresh fork, so a Minitest project no longer pays a full interpreter, Bundler and framework boot per mutant. Preloaded workers are now keyed by framework, so a mixed-framework `--glob` run never hands a Minitest file to an RSpec-preloaded worker.
12
+ - Every mutant run now stops at its first failing test: RSpec runs get `--fail-fast` and Minitest runs get a preloaded reporter that aborts on the first non-passing result, on all three runners. This cannot change a verdict (a run that stops early has already failed, which is what makes a mutant killed), and only mutant runs opt in: the baseline run and the shadow sanity check still run the whole file. It removes the pathology where a mutant that breaks something every test touches (a class body that no longer loads, a constant every test reads) re-raised the same error once per test, crossed the calibrated deadline, and was reported as a `timeout` instead of a `killed` - a failure mode that got worse as tests were added to the file. Measured on a 15-mutant fixture with a 0.6 s boot and 12 tests: 22.2 s -> 6.5 s by default and 22.7 s -> 13.2 s with `--runner spawn`, with an unchanged score.
13
+ - The console summary now names the deadline that timed-out mutants were measured against and where it came from (`deadline: 6.50s (5x baseline 1.30s)`, or `(explicitly configured)`), so a genuine hang and a deadline calibrated from a slow test file are no longer indistinguishable.
14
+ - `--fail-fast` now stops a batch at the first file with a surviving mutant even when that file's own run completed. `Core#stopped_on_survivor?` reports the fail-fast stop, while `Core#interrupted?` keeps its narrower meaning (mutants were left unprocessed) for the reports and the interruption banner.
15
+
3
16
  ## [1.3.0] - 2026-07-15
4
17
 
5
18
  - Calibrated the per-mutant timeout against the measured baseline run: unless `config.timeout` is set explicitly, each mutant now gets `max(5s, timeout_factor * baseline duration)` (factor configurable via `config.timeout_factor` / `--timeout-factor N`, default 5) instead of a fixed 30 s, so a loaded machine no longer inflates the mutation score by killing healthy-but-slow runs as timeouts. An explicit `config.timeout` (including `nil` for no deadline) keeps today's fixed-budget behavior and disables calibration. Added the opt-in `config.timeout_policy = :separate` / `--timeout-policy separate`, which scores `killed / (killed + survived)` with timeouts excluded from the score and reported only as their own category; the default `:killed` policy and its output are unchanged.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mutation_tester (1.3.0)
4
+ mutation_tester (1.4.1)
5
5
  parallel (~> 1.20)
6
6
  parser (~> 3.3)
7
7
  rainbow (~> 3.0)
@@ -10,14 +10,17 @@ force a runner.
10
10
  Every mutant is executed by one of three runners:
11
11
 
12
12
  - **fork**: a helper process preloads the environment
13
- once (RubyGems, Bundler and `rspec-core`, without loading the mutated file or
14
- the specs), and each mutant runs in a fresh fork of that process. The fork
15
- loads the spec only after the mutated source has been written, so every
16
- mutant is visible and no state leaks between mutants. This removes most of
13
+ once (RubyGems, Bundler and the test framework: `rspec-core` for RSpec,
14
+ `minitest` for Minitest, without loading the mutated file or the tests), and
15
+ each mutant runs in a fresh fork of that process. The fork loads the test file
16
+ only after the mutated source has been written, so every mutant is visible and
17
+ no state leaks between mutants. For Minitest the worker disables the
18
+ `minitest/autorun` at-exit hook and drives `Minitest.run` itself, so the file
19
+ runs exactly once per mutant. This removes most of
17
20
  the fixed per-mutant boot cost, which matters on large suites and in CI.
18
- - **spawn**: each mutant starts a full new process (`bundle exec rspec ...`).
19
- Slower per mutant, but works everywhere.
20
- - **in_memory** (default where supported): the helper process additionally preloads the spec
21
+ - **spawn**: each mutant starts a full new process (`bundle exec rspec ...` or
22
+ `bundle exec ruby test_file.rb`). Slower per mutant, but works everywhere.
23
+ - **in_memory** (default where supported): the helper process additionally preloads the test
21
24
  file and, through it, the original source, once per run. Each mutant then
22
25
  runs in a fresh fork that re-evaluates the mutated source in memory
23
26
  (redefining the loaded methods and class constants, with the
@@ -34,15 +37,16 @@ Selection is automatic (`auto`): the fastest safe path is tried first and every
34
37
  step down to a slower one prints a single stderr warning with its reason, so a
35
38
  fallback is never silent. The order is `in_memory` (RSpec with `Process.fork`
36
39
  available and a passing unmutated-source probe), then `fork`, then `spawn`.
40
+ Both RSpec and Minitest suites use the same three runners.
37
41
  All runners produce identical scores and per-mutant statuses, and all enforce
38
42
  the same hard per-mutant timeout (monotonic deadline plus a process-group
39
43
  kill).
40
44
 
41
45
  | Mode | Picked by `auto` when | Falls back to |
42
46
  |-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
43
- | `in_memory` | the suite is RSpec, the platform has `Process.fork`, the file has no load-time `defined?` guard, and re-applying the unmutated source in a probe child passes the suite | `fork`/`spawn` (whole run) with a stderr warning naming the reason; a single worker dying mid-run falls back only for its share of mutants; a mutant that raises while being applied falls back alone |
44
- | `fork` | the suite is RSpec, `Process.fork` is available, but in-memory is unavailable (each reason is printed) | `spawn`, with a stderr warning, when the helper process fails to preload the environment |
45
- | `spawn` | the suite is Minitest, or the platform has no `Process.fork` | nothing; it works everywhere |
47
+ | `in_memory` | the platform has `Process.fork`, the file has no load-time `defined?` guard, and re-applying the unmutated source in a probe child passes the suite | `fork`/`spawn` (whole run) with a stderr warning naming the reason; a single worker dying mid-run falls back only for its share of mutants; a mutant that raises while being applied falls back alone |
48
+ | `fork` | `Process.fork` is available, but in-memory is unavailable (each reason is printed) | `spawn`, with a stderr warning, when the helper process fails to preload the environment |
49
+ | `spawn` | the platform has no `Process.fork` | nothing; it works everywhere |
46
50
 
47
51
  Forcing a mode with `--runner fork|spawn|in_memory` skips the auto attempts and
48
52
  uses that mode directly (`in_memory` keeps its own documented safety fallbacks;
@@ -77,14 +81,44 @@ MutationTester.configure do |config|
77
81
  end
78
82
  ```
79
83
 
84
+ ### Stopping a mutant at its first failing test
85
+
86
+ Every mutant run stops as soon as one test fails, on all three runners:
87
+
88
+ - RSpec mutant runs are given `--fail-fast` (as a CLI argument on `spawn`, in the
89
+ runner arguments on `fork`, and in the preloaded configuration on `in_memory`).
90
+ - Minitest mutant runs load `lib/mutation_tester/minitest_fail_fast.rb`, which
91
+ registers a Minitest plugin whose reporter raises `Interrupt` on the first
92
+ non-passing result. On `spawn` the file is preloaded with `ruby -r`, on the
93
+ preloaded runners the worker enables the same reporter per job.
94
+
95
+ This cannot change a verdict. A run that stops early has already recorded a
96
+ failure, which is exactly what makes a mutant killed, and a run without a failure
97
+ is untouched and executes every test. Only the mutant runs opt in: the baseline
98
+ run and the shadow-workspace sanity check are expected to pass and always run the
99
+ whole file, so a failing baseline still reports every failure it finds.
100
+
101
+ The pathological case it removes is a mutant that breaks something every test
102
+ touches (a class body that no longer loads, a constant every test reads). Such a
103
+ mutant used to re-raise the same error once per test, which on a large test file
104
+ can cross the per-mutant deadline and be reported as a `timeout` instead of a
105
+ `killed`, and which gets worse as tests are added to the file.
106
+
80
107
  ### Limitations of the fork runner
81
108
 
82
- - Minitest suites always use `spawn` (fork support for Minitest is a separate
83
- decision after RSpec experience is collected).
84
109
  - Platforms without `Process.fork` (for example Windows or JRuby) always use
85
110
  `spawn`, even when `--runner fork` is requested.
86
111
  - If the helper process fails to preload the environment, the run warns once
87
112
  and falls back to `spawn`.
113
+ - The helper process is started once in the real project root, so Ruby has
114
+ already absolutized every `-I` / `RUBYLIB` entry against that directory before
115
+ any mutant runs. Changing directory into a shadow workspace cannot undo that,
116
+ so each job additionally rewrites the `$LOAD_PATH` entries that point into the
117
+ mirrored project root so they point into the workspace. Without it a Minitest
118
+ file reaching its source through `require "test_helper"` would load the
119
+ original, unmutated tree and every mutant would falsely survive. RSpec re-adds
120
+ `lib` and its default path at run time, after the child has changed directory,
121
+ so it resolves the workspace copy either way.
88
122
 
89
123
  ### Limitations of the in-memory runner
90
124
 
@@ -92,8 +126,8 @@ The in-memory runner never fails silently: each case below falls back to
92
126
  file-based execution with a warning, and a mutant is marked `error` only when
93
127
  no fallback is possible.
94
128
 
95
- - RSpec only, and the file must be classic loadable code (classes/modules).
96
- Minitest suites fall back to the file-based path with a warning.
129
+ - The file must be classic loadable code (classes/modules) that survives being
130
+ evaluated a second time.
97
131
  - With `-p N` (N > 1) the run stays fully in memory: the environment, the
98
132
  original source and the specs are preloaded once, the preloaded process is
99
133
  forked into N pooled clones, and every parallel worker applies each mutant
@@ -199,7 +199,7 @@ module MutationTester
199
199
  interrupted: core.interrupted?,
200
200
  degraded: core.infrastructure_failure?
201
201
  )
202
- [entry, core.interrupted?]
202
+ [entry, core.stopped_on_survivor?]
203
203
  end
204
204
 
205
205
  def spec_path_for(source_file)
@@ -44,10 +44,12 @@ module MutationTester
44
44
  ForkRunner.shutdown_all
45
45
  end
46
46
 
47
+ def stopped_on_survivor?
48
+ @config.fail_fast && @results.any? { |result| result[:status] == :survived }
49
+ end
50
+
47
51
  def interrupted?
48
- @config.fail_fast &&
49
- @results.size < @mutations.size &&
50
- @results.any? { |result| result[:status] == :survived }
52
+ stopped_on_survivor? && @results.size < @mutations.size
51
53
  end
52
54
 
53
55
  def mutation_score
@@ -130,13 +132,25 @@ module MutationTester
130
132
  return true if mutation_runner.in_memory_first?
131
133
 
132
134
  puts Rainbow("\n🩺 Verifying the shadow workspace with the unmutated source...").yellow
133
- if mutation_runner.shadow_baseline_passes?
134
- puts Rainbow('✓ Shadow workspace verified with the unmutated source').green
135
- return true
135
+ case mutation_runner.shadow_workspace_check
136
+ when :ok
137
+ puts Rainbow('✓ Shadow workspace verified: the unmutated source passes and the workspace copy is what the tests execute').green
138
+ true
139
+ when :canary
140
+ report_unreachable_workspace_source
141
+ else
142
+ puts Rainbow('❌ The unmutated source fails inside the shadow workspace; the shadow environment is unreliable.').red
143
+ puts Rainbow(' Every mutant would falsely die there, so the run is aborted instead of reporting a misleading score.').red
144
+ false
136
145
  end
146
+ end
137
147
 
138
- puts Rainbow('❌ The unmutated source fails inside the shadow workspace; the shadow environment is unreliable.').red
139
- puts Rainbow(' Every mutant would falsely die there, so the run is aborted instead of reporting a misleading score.').red
148
+ def report_unreachable_workspace_source
149
+ puts Rainbow(' The tests still pass with the workspace copy of the source replaced by a raise.').red
150
+ puts Rainbow(' The mutated file is therefore not the code the tests execute, so every mutant would falsely').red
151
+ puts Rainbow(' survive; the run is aborted instead of reporting a misleading 0.0% score.').red
152
+ puts Rainbow(' Usual causes: the tests resolve this source outside the workspace (an absolute entry in').red
153
+ puts Rainbow(' $LOAD_PATH, a symlinked .rb file, a preloaded copy of the class), or they never load it at all.').red
140
154
  false
141
155
  end
142
156
 
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'rspec/core'
5
4
  require_relative '../in_memory_loader'
6
5
 
7
6
  control = $stdout.dup
8
7
  control.sync = true
9
8
  STDOUT.reopen(File::NULL)
10
9
 
10
+ framework = ARGV.shift == 'minitest' ? :minitest : :rspec
11
+
12
+ if framework == :minitest
13
+ require 'minitest'
14
+ require_relative '../minitest_fail_fast'
15
+ Minitest.class_variable_set(:@@installed_at_exit, true)
16
+ else
17
+ require 'rspec/core'
18
+ end
19
+
11
20
  kill_group = lambda do |pid|
12
21
  begin
13
22
  Process.kill('KILL', -pid)
@@ -17,6 +26,26 @@ end
17
26
 
18
27
  monotonic = lambda { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
19
28
 
29
+ resolved_path = lambda do |path|
30
+ File.realpath(path)
31
+ rescue SystemCallError
32
+ path
33
+ end
34
+
35
+ mirror_load_path = lambda do |from, to|
36
+ next if from.nil? || to.nil? || from == to
37
+
38
+ roots = [from, resolved_path.call(from)].uniq.map { |root| root.chomp('/') }
39
+ $LOAD_PATH.map! do |entry|
40
+ path = File.expand_path(entry.to_s, from)
41
+ root = roots.find { |candidate| path == candidate || path.start_with?("#{candidate}/") }
42
+ next entry unless root
43
+
44
+ suffix = path.delete_prefix(root).delete_prefix('/')
45
+ suffix.empty? ? to : File.join(to, suffix)
46
+ end
47
+ end
48
+
20
49
  current_child = nil
21
50
  preloaded = nil
22
51
 
@@ -41,6 +70,7 @@ supervise_child = lambda do |job, out, child_body|
41
70
  rescue Errno::EACCES, Errno::EPERM
42
71
  end
43
72
  Dir.chdir(job['chdir']) if job['chdir']
73
+ mirror_load_path.call(job['mirror_of'], job['chdir'])
44
74
  sink = File.open(job['log'] || File::NULL, 'w')
45
75
  sink.sync = true
46
76
  STDOUT.reopen(sink)
@@ -91,10 +121,25 @@ supervise_child = lambda do |job, out, child_body|
91
121
  [timed_out, payload, reaped]
92
122
  end
93
123
 
124
+ load_test_file = lambda do |path|
125
+ $PROGRAM_NAME = path
126
+ load(path)
127
+ end
128
+
129
+ run_test_file = lambda do |job|
130
+ if framework == :minitest
131
+ MutationTester::MinitestFailFast.enabled = job['stop_on_first_failure'] ? true : false
132
+ load_test_file.call(job['spec'])
133
+ Minitest.run(Array(job['args'])) ? 0 : 1
134
+ else
135
+ args = [job['spec'], *Array(job['args'])]
136
+ args << '--fail-fast' if job['stop_on_first_failure']
137
+ RSpec::Core::Runner.run(args, STDERR, STDOUT).to_i
138
+ end
139
+ end
140
+
94
141
  run_job = lambda do |job, out|
95
- timed_out, payload, reaped = supervise_child.call(job, out, lambda do
96
- RSpec::Core::Runner.run([job['spec'], *Array(job['args'])], STDERR, STDOUT).to_i
97
- end)
142
+ timed_out, payload, reaped = supervise_child.call(job, out, lambda { run_test_file.call(job) })
98
143
 
99
144
  status =
100
145
  if timed_out
@@ -111,16 +156,32 @@ end
111
156
  preload_specs = lambda do |request, out|
112
157
  begin
113
158
  Dir.chdir(request['chdir']) if request['chdir']
114
- sink = File.open(File::NULL, 'w')
115
- runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new([request['spec']]))
116
- runner.setup(sink, sink)
117
- preloaded = runner
159
+ if framework == :minitest
160
+ MutationTester::MinitestFailFast.enabled = request['stop_on_first_failure'] ? true : false
161
+ load_test_file.call(request['spec'])
162
+ preloaded = true
163
+ else
164
+ sink = File.open(File::NULL, 'w')
165
+ options = [request['spec']]
166
+ options << '--fail-fast' if request['stop_on_first_failure']
167
+ runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(options))
168
+ runner.setup(sink, sink)
169
+ preloaded = runner
170
+ end
118
171
  out.puts(JSON.generate('event' => 'preloaded', 'status' => 'ok'))
119
172
  rescue ScriptError, StandardError => e
120
173
  out.puts(JSON.generate('event' => 'preloaded', 'status' => 'error', 'message' => "#{e.class}: #{e.message}"))
121
174
  end
122
175
  end
123
176
 
177
+ run_preloaded_suite = lambda do
178
+ if framework == :minitest
179
+ Minitest.run([]) ? 0 : 1
180
+ else
181
+ preloaded.run_specs(RSpec.world.ordered_example_groups).to_i
182
+ end
183
+ end
184
+
124
185
  run_in_memory_job = lambda do |job, out|
125
186
  request = job['in_memory']
126
187
 
@@ -132,7 +193,7 @@ run_in_memory_job = lambda do |job, out|
132
193
  timed_out, payload, reaped = supervise_child.call(job, out, lambda do
133
194
  begin
134
195
  MutationTester::InMemoryLoader.apply(request['source'], request['path'])
135
- JSON.generate('code' => preloaded.run_specs(RSpec.world.ordered_example_groups).to_i)
196
+ JSON.generate('code' => run_preloaded_suite.call)
136
197
  rescue ScriptError, StandardError => e
137
198
  JSON.generate('error' => "#{e.class}: #{e.message}")
138
199
  end
@@ -21,22 +21,22 @@ module MutationTester
21
21
  Process.respond_to?(:fork)
22
22
  end
23
23
 
24
- def acquire(use_bundle_exec:)
24
+ def acquire(use_bundle_exec:, framework: :rspec)
25
25
  return nil unless available?
26
26
 
27
- key = [Process.pid, use_bundle_exec]
27
+ key = [Process.pid, use_bundle_exec, framework]
28
28
  return registry[key] if registry.key?(key)
29
29
 
30
- registry[key] = checkout_pooled(use_bundle_exec) || boot(use_bundle_exec)
30
+ registry[key] = checkout_pooled(pool_key(use_bundle_exec, framework)) || boot(use_bundle_exec, framework)
31
31
  end
32
32
 
33
- def prepare_pool(count, use_bundle_exec:, env_for: nil)
33
+ def prepare_pool(count, use_bundle_exec:, framework: :rspec, env_for: nil)
34
34
  return unless available?
35
35
 
36
- primary = acquire(use_bundle_exec: use_bundle_exec)
36
+ primary = acquire(use_bundle_exec: use_bundle_exec, framework: framework)
37
37
  return unless primary
38
38
 
39
- refill_pool(use_bundle_exec, count, primary, env_for: env_for)
39
+ refill_pool(pool_key(use_bundle_exec, framework), count, primary, env_for: env_for)
40
40
  end
41
41
 
42
42
  def prepare_in_memory_pool(count, primary)
@@ -100,11 +100,15 @@ module MutationTester
100
100
 
101
101
  private
102
102
 
103
- def checkout_pooled(use_bundle_exec)
103
+ def pool_key(use_bundle_exec, framework)
104
+ [use_bundle_exec, framework]
105
+ end
106
+
107
+ def checkout_pooled(key)
104
108
  number = parallel_worker_number
105
109
  return nil unless number
106
110
 
107
- entry = pool[use_bundle_exec]
111
+ entry = pool[key]
108
112
  entry && entry[:runners][number]
109
113
  end
110
114
 
@@ -123,8 +127,8 @@ module MutationTester
123
127
  entry[:runners]
124
128
  end
125
129
 
126
- def boot(use_bundle_exec)
127
- runner = new(use_bundle_exec: use_bundle_exec)
130
+ def boot(use_bundle_exec, framework)
131
+ runner = new(use_bundle_exec: use_bundle_exec, framework: framework)
128
132
  return runner if runner.ready?
129
133
 
130
134
  runner.shutdown
@@ -133,8 +137,8 @@ module MutationTester
133
137
  end
134
138
  end
135
139
 
136
- def initialize(use_bundle_exec:)
137
- argv = ['ruby', WORKER_PATH]
140
+ def initialize(use_bundle_exec:, framework: :rspec)
141
+ argv = ['ruby', WORKER_PATH, framework.to_s]
138
142
  argv = ['bundle', 'exec', *argv] if use_bundle_exec
139
143
 
140
144
  job_reader, job_writer = IO.pipe
@@ -149,9 +153,18 @@ module MutationTester
149
153
  @ready
150
154
  end
151
155
 
152
- def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [])
156
+ def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [], stop_on_first_failure: false,
157
+ mirror_of: nil)
153
158
  log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
154
- job = { spec: spec_file, timeout: timeout, chdir: chdir, log: log&.path, args: args }
159
+ job = {
160
+ spec: spec_file,
161
+ timeout: timeout,
162
+ chdir: chdir,
163
+ log: log&.path,
164
+ args: args,
165
+ stop_on_first_failure: stop_on_first_failure,
166
+ mirror_of: mirror_of
167
+ }
155
168
  @job_writer.puts(JSON.generate(job))
156
169
  status = await_result(timeout)['status']
157
170
  result = TestCommand::Result.new(status == 'pass', status == 'timeout')
@@ -164,8 +177,9 @@ module MutationTester
164
177
  log&.unlink
165
178
  end
166
179
 
167
- def preload(spec_file, chdir: nil)
168
- @job_writer.puts(JSON.generate(preload: { spec: spec_file, chdir: chdir }))
180
+ def preload(spec_file, chdir: nil, stop_on_first_failure: false)
181
+ request = { spec: spec_file, chdir: chdir, stop_on_first_failure: stop_on_first_failure }
182
+ @job_writer.puts(JSON.generate(preload: request))
169
183
  event = read_event(monotonic_time + BOOT_TIMEOUT)
170
184
  return [true, nil] if event.is_a?(Hash) && event['event'] == 'preloaded' && event['status'] == 'ok'
171
185
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MutationTester
4
+ module MinitestFailFast
5
+ PLUGIN_NAME = :mutation_tester_fail_fast
6
+
7
+ class << self
8
+ attr_accessor :enabled
9
+ end
10
+
11
+ self.enabled = true
12
+ end
13
+ end
14
+
15
+ begin
16
+ require 'minitest'
17
+ rescue LoadError
18
+ Kernel.warn '[MutationTester] minitest is not loadable here; mutant runs will execute every test instead of stopping at the first failure.'
19
+ else
20
+ module MutationTester
21
+ module MinitestFailFast
22
+ class Reporter < Minitest::AbstractReporter
23
+ def record(result)
24
+ return unless MinitestFailFast.enabled
25
+ return if result.passed? || result.skipped?
26
+
27
+ raise Interrupt
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ module Minitest
34
+ def self.plugin_mutation_tester_fail_fast_init(_options)
35
+ reporter << MutationTester::MinitestFailFast::Reporter.new
36
+ end
37
+ end
38
+
39
+ unless Minitest.extensions.include?(MutationTester::MinitestFailFast::PLUGIN_NAME)
40
+ Minitest.register_plugin(MutationTester::MinitestFailFast::PLUGIN_NAME)
41
+ end
42
+ end
@@ -4,6 +4,7 @@ require 'securerandom'
4
4
 
5
5
  module MutationTester
6
6
  class MutationRunner
7
+ CANARY_SOURCE = "raise 'mutation_tester canary: the workspace copy of this source was not executed'\n".freeze
7
8
  PARALLEL_INTERRUPT_LINE = "Parallel execution interrupted, exiting ...\n"
8
9
 
9
10
  class ParallelInterruptFilter
@@ -216,7 +217,7 @@ module MutationTester
216
217
  File.write(shadow_source, mutation[:code])
217
218
 
218
219
  outcome, phase = run_two_phase(mutation) do |example_filters|
219
- run_specs_in_shadow(shadow_spec, shadow_root, example_filters: example_filters)
220
+ run_specs_in_shadow(shadow_spec, shadow_root, project_root, example_filters: example_filters)
220
221
  end
221
222
  apply_outcome(result, outcome, phase)
222
223
  ensure
@@ -240,6 +241,10 @@ module MutationTester
240
241
  end
241
242
 
242
243
  def shadow_baseline_passes?
244
+ shadow_workspace_check == :ok
245
+ end
246
+
247
+ def shadow_workspace_check
243
248
  project_root = find_project_root
244
249
 
245
250
  Dir.mktmpdir do |temp_dir|
@@ -254,12 +259,16 @@ module MutationTester
254
259
 
255
260
  File.unlink(shadow_source)
256
261
  File.write(shadow_source, @original_content)
262
+ return :baseline unless run_specs_in_shadow(shadow_spec, shadow_root, project_root).passed?
257
263
 
258
- run_specs_in_shadow(shadow_spec, shadow_root).passed?
264
+ File.write(shadow_source, CANARY_SOURCE)
265
+ return :canary if run_specs_in_shadow(shadow_spec, shadow_root, project_root).passed?
266
+
267
+ :ok
259
268
  end
260
269
  rescue => e
261
270
  warn("[MutationTester] Shadow sanity check could not prepare the shadow workspace: #{e.message}")
262
- false
271
+ :baseline
263
272
  end
264
273
 
265
274
  def shadow_copy_project(source, dest)
@@ -284,8 +293,9 @@ module MutationTester
284
293
  end
285
294
  end
286
295
 
287
- def run_specs_in_shadow(spec_file, working_dir, example_filters: [])
288
- test_command(spec_file, example_filters: example_filters).run(timeout: @config.effective_timeout, chdir: working_dir)
296
+ def run_specs_in_shadow(spec_file, working_dir, project_root, example_filters: [])
297
+ test_command(spec_file, example_filters: example_filters)
298
+ .run(timeout: @config.effective_timeout, chdir: working_dir, mirror_of: project_root)
289
299
  end
290
300
 
291
301
  def discoverable_project_root
@@ -390,19 +400,18 @@ module MutationTester
390
400
  end
391
401
 
392
402
  def prepare_in_memory_execution
393
- return 'the in-memory runner supports RSpec suites only' unless detect_test_framework(@spec_file) == :rspec
394
403
  return 'Process.fork is not supported on this platform' unless ForkRunner.available?
395
404
  if InMemoryLoader.load_time_defined_guard?(@original_content)
396
405
  return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
397
406
  end
398
407
 
399
- runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec)
408
+ runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec, framework: detect_test_framework(@spec_file))
400
409
  unless runner.ready?
401
410
  runner.shutdown
402
411
  return 'the in-memory worker failed to preload the environment'
403
412
  end
404
413
 
405
- preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd)
414
+ preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd, stop_on_first_failure: true)
406
415
  unless preloaded
407
416
  runner.shutdown
408
417
  return "the spec file could not be preloaded (#{message})"
@@ -521,6 +530,7 @@ module MutationTester
521
530
  ForkRunner.prepare_pool(
522
531
  [@config.parallel_processes, total].min,
523
532
  use_bundle_exec: @use_bundle_exec,
533
+ framework: detect_test_framework(@spec_file),
524
534
  env_for: worker_env_for
525
535
  )
526
536
  end
@@ -637,7 +647,8 @@ module MutationTester
637
647
  use_bundle_exec: @use_bundle_exec,
638
648
  runner: @config.runner,
639
649
  example_filters: example_filters,
640
- worker_env_var: @config.worker_env_var
650
+ worker_env_var: @config.worker_env_var,
651
+ stop_on_first_failure: true
641
652
  )
642
653
  end
643
654
  end
@@ -1,12 +1,15 @@
1
1
  module MutationTester
2
2
  module Reporters
3
3
  class ConsoleReporter < BaseReporter
4
+ NOTHING_KILLED_MIN_MUTANTS = 5
5
+
4
6
  def generate
5
7
  puts "\n" + Rainbow('=' * 80).bright
6
8
  puts Rainbow('🧬 MUTATION TESTING REPORT').bright.cyan
7
9
  puts Rainbow('=' * 80).bright
8
10
 
9
11
  print_summary
12
+ print_nothing_killed_warning
10
13
  print_survived_mutations if survived_count > 0
11
14
 
12
15
  puts "\n" + Rainbow('=' * 80).bright
@@ -20,6 +23,7 @@ module MutationTester
20
23
  puts " #{Rainbow("Killed: " + killed_count.to_s).green} ✅"
21
24
  puts " #{Rainbow("Survived: " + survived_count.to_s).red} ❌"
22
25
  puts " #{Rainbow("Timeout: " + timeout_count.to_s).yellow} ⏱️"
26
+ print_timeout_deadline
23
27
  puts " #{Rainbow("Stillborn: " + stillborn_count.to_s).yellow} 🧬"
24
28
  puts " #{Rainbow("Errors: " + error_count.to_s).yellow} 💥"
25
29
  print_excluded_summary
@@ -29,6 +33,47 @@ module MutationTester
29
33
  puts "\n " + progress_bar
30
34
  end
31
35
 
36
+ def print_nothing_killed_warning
37
+ return unless nothing_killed?
38
+
39
+ puts "\n#{Rainbow("⚠️ Not one of the #{scored_mutant_count} scored mutants was killed.").yellow}"
40
+ puts Rainbow(' A whole file that kills nothing is more often a runner problem (the mutated code never').yellow
41
+ puts Rainbow(' reached the tests) than a test-quality gap. Re-run with --runner spawn and compare before').yellow
42
+ puts Rainbow(' acting on this score.').yellow
43
+ end
44
+
45
+ def nothing_killed?
46
+ killed_count.zero? && timeout_count.zero? && scored_mutant_count >= NOTHING_KILLED_MIN_MUTANTS
47
+ end
48
+
49
+ def scored_mutant_count
50
+ killed_count + timeout_count + survived_count
51
+ end
52
+
53
+ def print_timeout_deadline
54
+ return unless timeout_count.positive?
55
+
56
+ puts " #{Rainbow("deadline: " + timeout_deadline_description).yellow}"
57
+ end
58
+
59
+ def timeout_deadline_description
60
+ deadline = @config.effective_timeout
61
+ return 'none (no deadline configured)' if deadline.nil?
62
+
63
+ "#{format_seconds(deadline)} #{deadline_origin}"
64
+ end
65
+
66
+ def deadline_origin
67
+ baseline = @config.baseline_duration
68
+ return '(explicitly configured)' if @config.timeout_explicitly_set? || baseline.nil?
69
+
70
+ "(#{format('%g', @config.timeout_factor)}x baseline #{format_seconds(baseline)})"
71
+ end
72
+
73
+ def format_seconds(value)
74
+ "#{format('%.2f', value)}s"
75
+ end
76
+
32
77
  def print_excluded_summary
33
78
  count = excluded_line_count
34
79
  return unless count.positive?
@@ -8,20 +8,23 @@ module MutationTester
8
8
  end
9
9
 
10
10
  POLL_INTERVAL = 0.05
11
+ MINITEST_FAIL_FAST_PATH = File.expand_path('minitest_fail_fast.rb', __dir__).freeze
11
12
 
12
13
  attr_reader :spec_file, :framework, :use_bundle_exec, :example_filters
13
14
 
14
- def initialize(spec_file, use_bundle_exec:, framework: nil, runner: :spawn, example_filters: [], worker_env_var: nil)
15
+ def initialize(spec_file, use_bundle_exec:, framework: nil, runner: :spawn, example_filters: [], worker_env_var: nil,
16
+ stop_on_first_failure: false)
15
17
  @spec_file = spec_file
16
18
  @framework = framework || self.class.detect_framework(spec_file)
17
19
  @use_bundle_exec = use_bundle_exec
18
20
  @runner = runner
19
21
  @example_filters = @framework == :rspec ? Array(example_filters) : []
20
22
  @worker_env_var = worker_env_var
23
+ @stop_on_first_failure = stop_on_first_failure
21
24
  end
22
25
 
23
26
  def argv
24
- parts = [runner, spec_file, *filter_args]
27
+ parts = [runner, *interpreter_args, spec_file, *filter_args, *fail_fast_args]
25
28
  @use_bundle_exec ? ['bundle', 'exec', *parts] : parts
26
29
  end
27
30
 
@@ -37,10 +40,20 @@ module MutationTester
37
40
  cmd
38
41
  end
39
42
 
40
- def run(timeout: nil, chdir: nil, capture: false)
43
+ def run(timeout: nil, chdir: nil, capture: false, mirror_of: nil)
41
44
  if fork_execution?
42
- fork_runner = ForkRunner.acquire(use_bundle_exec: @use_bundle_exec)
43
- return fork_runner.execute(spec_file, timeout: timeout, chdir: chdir || Dir.pwd, capture: capture, args: filter_args) if fork_runner
45
+ fork_runner = ForkRunner.acquire(use_bundle_exec: @use_bundle_exec, framework: @framework)
46
+ if fork_runner
47
+ return fork_runner.execute(
48
+ spec_file,
49
+ timeout: timeout,
50
+ chdir: chdir || Dir.pwd,
51
+ capture: capture,
52
+ args: filter_args,
53
+ stop_on_first_failure: @stop_on_first_failure,
54
+ mirror_of: mirror_of
55
+ )
56
+ end
44
57
  end
45
58
 
46
59
  return run_captured(timeout: timeout, chdir: chdir) if capture
@@ -53,7 +66,6 @@ module MutationTester
53
66
  end
54
67
 
55
68
  def fork_execution?
56
- return false if @framework == :minitest
57
69
  return false if @runner == :spawn
58
70
 
59
71
  ForkRunner.available?
@@ -100,6 +112,18 @@ module MutationTester
100
112
  example_filters.flat_map { |filter| ['-e', filter] }
101
113
  end
102
114
 
115
+ def interpreter_args
116
+ return [] unless @stop_on_first_failure && @framework == :minitest
117
+
118
+ ['-r', MINITEST_FAIL_FAST_PATH]
119
+ end
120
+
121
+ def fail_fast_args
122
+ return [] unless @stop_on_first_failure && @framework == :rspec
123
+
124
+ ['--fail-fast']
125
+ end
126
+
103
127
  def run_captured(timeout:, chdir:)
104
128
  log = Tempfile.new(['mutation_tester_baseline', '.log'])
105
129
  spawn_options = { pgroup: true, %i[out err] => log.path }
@@ -1,3 +1,3 @@
1
1
  module MutationTester
2
- VERSION = '1.3.0'.freeze
2
+ VERSION = '1.4.1'.freeze
3
3
  end
data/readme.md CHANGED
@@ -55,7 +55,7 @@ MutationTester keeps its requirements low so it drops into a wide range of proje
55
55
  |-------------------------|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
56
56
  | Ruby | `>= 3.0` | Floor is Ruby 3.0. CI runs the suite on 3.0, 3.1, 3.2 and 3.3. The gem is developed on Ruby 4.0.2, and 4.x is supported. Ruby 4.x has no prebuilt binary on the GitHub-hosted runners yet, so it is verified on the development host rather than in the CI matrix. |
57
57
  | RSpec (your project) | `3.x` | The gem shells out to your project's own `rspec`, so any RSpec 3.x works. Both ends of the range are exercised by a real mutation run: the lowest 3.0.x line in the CI framework matrix, and 3.13.x in the example jobs. |
58
- | Minitest (your project) | `5.x` and `6.x` | The gem shells out to your project's own `ruby test_file.rb`, so both the 5.x and 6.x lines work. Both are exercised by a real mutation run: 5.x in the example jobs, and 6.x in the CI framework matrix. |
58
+ | Minitest (your project) | `5.x` and `6.x` | The gem runs your project's own test file (`ruby test_file.rb`, or the same file inside a preloaded fork worker), so both the 5.x and 6.x lines work. Both are exercised by a real mutation run: 5.x in the example jobs, and 6.x in the CI framework matrix. |
59
59
 
60
60
  Notes:
61
61
 
@@ -200,7 +200,7 @@ mutation_test [OPTIONS] --glob 'lib/**/*.rb'
200
200
  | Flag | Description |
201
201
  |---|---|
202
202
  | `-p, --parallel N` | Run with N parallel processes (default: auto, derived from the CPU core count with a cap of 8; `-p 1` forces serial execution). |
203
- | `--runner MODE` | Mutant execution runner: `auto` (default) tries `in_memory` first (RSpec with `Process.fork` available and a passing unmutated-source probe), then falls back to `fork`, then `spawn`, announcing every step down on stderr with its reason; `fork` (preloaded environment, RSpec on platforms with `Process.fork`), `spawn` (one full process per mutant) and `in_memory` (mutations applied in child-process memory, zero file writes per mutant, RSpec only) force the specific mode. See [Execution runners](#execution-runners-fork-spawn-in-memory). |
203
+ | `--runner MODE` | Mutant execution runner: `auto` (default) tries `in_memory` first (`Process.fork` available and a passing unmutated-source probe), then falls back to `fork`, then `spawn`, announcing every step down on stderr with its reason; `fork` (preloaded environment, on platforms with `Process.fork`), `spawn` (one full process per mutant) and `in_memory` (mutations applied in child-process memory, zero file writes per mutant) force the specific mode. See [Execution runners](#execution-runners-fork-spawn-in-memory). |
204
204
  | `--staged` | Mutation-test the files staged in git (`git diff --cached --name-only`; files staged as deleted are ignored), mapping each to its spec like a positional `FILE` list. Cannot be combined with positional arguments or `--glob`. See [File lists and --staged](#file-lists-and---staged-test-what-you-changed). |
205
205
  | `--glob PATTERN` | Batch mode: mutation-test every source file matching `PATTERN`, mapping each to its spec by convention (see [Batch mode](#batch-mode-run-many-files-in-one-command)). |
206
206
  | `--spec-glob TEMPLATE` | Spec-mapping template with a `{name}` placeholder (default: `spec/{name}_spec.rb`). Requires a positional `FILE` list, `--staged`, or `--glob`. |
@@ -232,7 +232,9 @@ bundle exec mutation_test --reporters json,html --output-dir build/mutation \
232
232
  file, unknown reporter, source with a syntax error).
233
233
  - `2` - a usage error (conflicting flags; see the batch sections below).
234
234
  - `3` - the run aborted or degraded before reaching a verdict: the shadow workspace
235
- was unreliable, or every mutant ended as `error`/`stillborn` so nothing was scored.
235
+ was unreliable (the unmutated source failed there, or the workspace copy of the
236
+ source turned out not to be the code the tests execute), or every mutant ended as
237
+ `error`/`stillborn` so nothing was scored.
236
238
  This signals an infrastructure or runner problem, not a test-quality gap, so CI
237
239
  hooks can distinguish it from a genuine threshold failure.
238
240
  - `130` - interrupted with Ctrl+C.
@@ -523,7 +525,16 @@ invalid value (less than 1, or non-numeric) falls back to 1 with a warning on st
523
525
  In parallel mode each mutant runs in an isolated shadow workspace. Every `.rb`
524
526
  file is a physical copy (non-Ruby files stay symlinks for speed), so mutations
525
527
  apply correctly even when a spec loads the source indirectly (e.g. via
526
- `spec_helper`). The parallel mutation score therefore matches serial.
528
+ `spec_helper`), and `$LOAD_PATH` entries pointing into the project resolve
529
+ inside the workspace, so a test file that reaches its source through
530
+ `require "test_helper"` gets the mutated copy too. The parallel mutation score
531
+ therefore matches serial.
532
+
533
+ Before the first mutant, the run proves this in the workspace itself: the
534
+ unmutated source must pass there, and the same suite must fail once that copy of
535
+ the source is replaced by a `raise`. A run whose tests pass even then is aborted
536
+ as an infrastructure failure (exit code `3`) rather than reported as a 0.0%
537
+ score, because the mutated file is demonstrably not the code being executed.
527
538
 
528
539
  ### When to use serial vs parallel execution
529
540
 
@@ -597,18 +608,18 @@ the fastest safe one, announcing every fallback on stderr:
597
608
 
598
609
  - **in_memory** (default where supported): re-evaluates the mutated source in the
599
610
  memory of a fresh fork of a preloaded process, with zero file writes per mutant
600
- and no shadow workspaces. RSpec only; the fastest path. Mutations that only take
601
- effect at class-load time (constants consumed by macros, `validates`/`has_many`/
611
+ and no shadow workspaces. RSpec and Minitest; the fastest path. Mutations that only
612
+ take effect at class-load time (constants consumed by macros, `validates`/`has_many`/
602
613
  `before_save`/`scope`/`attribute`, anything inside an `included do` block) cannot
603
614
  be observed by re-evaluating source in a preloaded process, so those mutants are
604
615
  routed automatically to the file-based path and the rest still run in memory (see
605
616
  below); the combined score matches a full `fork` run.
606
- - **fork**: preloads the environment once (RubyGems, Bundler, `rspec-core`) and
607
- forks a fresh child per mutant. RSpec on platforms with `Process.fork`; removes
608
- most of the fixed per-mutant boot cost.
609
- - **spawn**: starts one full process per mutant (`bundle exec rspec ...`). Slower
610
- per mutant, but works everywhere (the only runner for Minitest and for
611
- platforms without `Process.fork`).
617
+ - **fork**: preloads the environment once (RubyGems, Bundler, the test framework)
618
+ and forks a fresh child per mutant. RSpec and Minitest on platforms with
619
+ `Process.fork`; removes most of the fixed per-mutant boot cost.
620
+ - **spawn**: starts one full process per mutant (`bundle exec rspec ...` or
621
+ `bundle exec ruby test_file.rb`). Slower per mutant, but works everywhere
622
+ (the only runner on platforms without `Process.fork`).
612
623
 
613
624
  `auto` tries `in_memory`, then `fork`, then `spawn`; every step down prints one
614
625
  stderr warning with its reason, so a fallback is never silent. All runners
@@ -628,9 +639,9 @@ do not need to pick `--runner fork` for correctness on load-time code.
628
639
 
629
640
  | Mode | Picked by `auto` when | Falls back to |
630
641
  |-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
631
- | `in_memory` | the suite is RSpec, the platform has `Process.fork`, the file has no load-time `defined?` guard, and re-applying the unmutated source in a probe child passes the suite | `fork`/`spawn` (whole run) with a stderr warning naming the reason; a single worker dying mid-run falls back only for its share of mutants; a mutant that raises while being applied falls back alone |
632
- | `fork` | the suite is RSpec, `Process.fork` is available, but in-memory is unavailable (each reason is printed) | `spawn`, with a stderr warning, when the helper process fails to preload the environment |
633
- | `spawn` | the suite is Minitest, or the platform has no `Process.fork` | nothing; it works everywhere |
642
+ | `in_memory` | the platform has `Process.fork`, the file has no load-time `defined?` guard, and re-applying the unmutated source in a probe child passes the suite | `fork`/`spawn` (whole run) with a stderr warning naming the reason; a single worker dying mid-run falls back only for its share of mutants; a mutant that raises while being applied falls back alone |
643
+ | `fork` | `Process.fork` is available, but in-memory is unavailable (each reason is printed) | `spawn`, with a stderr warning, when the helper process fails to preload the environment |
644
+ | `spawn` | the platform has no `Process.fork` | nothing; it works everywhere |
634
645
 
635
646
  Force a specific runner (skipping the auto attempts) with the `--runner
636
647
  fork|spawn|in_memory` flag, the `MUTATION_TESTER_RUNNER` environment variable, or
@@ -648,6 +659,22 @@ fork and in-memory limitation lists (frozen classes, load-time `defined?` guards
648
659
  worker-death fallback, `require_relative` idempotency), see
649
660
  [docs/execution-runners.md](docs/execution-runners.md#execution-runners-fork-spawn-in-memory).
650
661
 
662
+ ### Stopping a mutant at its first failing test
663
+
664
+ A mutant only needs one failing test to be killed, so every mutant run stops at
665
+ its first failure: RSpec mutant runs get `--fail-fast`, and Minitest mutant runs
666
+ get a preloaded reporter that aborts the run the same way (both on the file-based
667
+ runners and inside the preloaded fork worker). This never changes a verdict, only
668
+ the work done to reach it: a run that stops early had already failed, and a run
669
+ with no failure is unaffected and still executes every test.
670
+
671
+ It matters most for a mutant that breaks something every test touches (a broken
672
+ class body, a constant every example reads). Such a mutant used to pay the full
673
+ test file once per mutant, which on a large test file can exceed the per-mutant
674
+ deadline and turn a decided kill into a reported timeout. Adding tests to the file
675
+ then made the score worse. The baseline run and the shadow sanity check are
676
+ unaffected: they are expected to pass, and a passing run runs every test.
677
+
651
678
  ### Test selection (fast kill with full-file confirmation)
652
679
 
653
680
  For RSpec suites on the file-based runners, MutationTester runs each mutant in
@@ -741,6 +768,18 @@ few lines of surrounding context:
741
768
  💡 Suggestion: Add tests to verify behavior for each of the 2 variants above
742
769
  ```
743
770
 
771
+ When at least one mutant timed out, the summary also names the deadline those
772
+ mutants were measured against and where it came from, so a genuine hang and a
773
+ deadline calibrated from a slow test file are distinguishable at a glance:
774
+
775
+ ```
776
+ Timeout: 3 ⏱️
777
+ deadline: 6.50s (5x baseline 1.30s)
778
+ ```
779
+
780
+ With an explicit `config.timeout` / `--timeout` the same line reads
781
+ `deadline: 30.00s (explicitly configured)`.
782
+
744
783
  ### HTML report
745
784
 
746
785
  Beautiful interactive HTML report with:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mutation_tester
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kamil Dzierbicki
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-15 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: parallel
@@ -169,6 +169,7 @@ files:
169
169
  - lib/mutation_tester/fork_runner/worker.rb
170
170
  - lib/mutation_tester/framework_detector.rb
171
171
  - lib/mutation_tester/in_memory_loader.rb
172
+ - lib/mutation_tester/minitest_fail_fast.rb
172
173
  - lib/mutation_tester/mutation_runner.rb
173
174
  - lib/mutation_tester/mutator.rb
174
175
  - lib/mutation_tester/progress_display.rb