mutation_tester 1.2.0 → 1.4.0

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: 20fff769b6b8b7efeb874208f5e3c25206ca92a2fc9f220bdd4fcb520eab944c
4
- data.tar.gz: 3b630985b285958780d49d356cbe04f629ce50e1aca947d2f40d8bdddd274d42
3
+ metadata.gz: be945d45b6c4219df627808a62336abe51ce94b088d79db47e25c794cdf227bd
4
+ data.tar.gz: 80aaefd644319415bfb25cebab5eb28077f3b99c763d234d20cf6885f3c001d2
5
5
  SHA512:
6
- metadata.gz: f40cf255d91681f4ae191b444ba8f99ba897ea746b2f157190d69bce091e90c4aaec9bde9c73fe69008de17916478ff40227a7e2439de687d4a576d72d664d0a
7
- data.tar.gz: 9c8d46ca0d8a98dff41de40b61e8fdd60c68c55ed37303e02d08009968a0263bf932e0892af3e13af12810c27cb61f695f1d01a77e46f2dbc375735fe49b87a4
6
+ metadata.gz: 2ad47074139c21bb7cd6db12eb53d2dd8c350b85c24c43f2ce44ba25590da1b3a4aa3f2e9f5ea2394abbdb8779e50cb9b3bb94418a94b03a1148ef2bd186b1f0
7
+ data.tar.gz: 58078c8af01ec3155be934d6917a3b66b0779f70bc20b04a23d05f82c06e9198000156fc13a7f7eece21cff11582d6b142a5ffb4aaa11d3191c15ebe4c3d9fbb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-08-01
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
8
+ - `--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.
9
+
10
+ ## [1.3.0] - 2026-07-15
11
+
12
+ - 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.
13
+ - Runs that finish without a single scored mutant (every mutant errored or was stillborn) are now reported as an infrastructure/runner failure with the error/stillborn counts instead of a misleading "score 0.0% is below threshold" verdict. `Core` exposes `infrastructure_failure?` to distinguish such degraded runs (including the shadow-workspace abort) from a genuine threshold failure, and `mutation_test` maps them to the new exit code `3` in single-file mode (`0` pass, `1` threshold failure, `2` usage error, `130` interrupt). The batch summary names degraded files instead of blaming the threshold.
14
+
3
15
  ## [1.2.0] - 2026-07-14
4
16
 
5
17
  - Fixed the in-memory runner reporting false survivors for mutations that only take effect at class-load time (constants consumed by macros, `validates`/`has_many`/`before_save`/`scope`/`attribute`, and anything inside an `included do` block). The mutator now classifies each mutation by AST context, and the in-memory run routes load-time mutations to the file-based path while keeping method-body mutations in memory, so the default (`auto`) score matches a full `fork` run on Rails concerns and models. Measured on a real Rails concern the default score went from a misleading 1.63% to the correct 35.77%.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mutation_tester (1.2.0)
4
+ mutation_tester (1.4.0)
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,10 +81,31 @@ 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
@@ -92,8 +117,8 @@ The in-memory runner never fails silently: each case below falls back to
92
117
  file-based execution with a warning, and a mutant is marked `error` only when
93
118
  no fallback is possible.
94
119
 
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.
120
+ - The file must be classic loadable code (classes/modules) that survives being
121
+ evaluated a second time.
97
122
  - With `-p N` (N > 1) the run stays fully in memory: the environment, the
98
123
  original source and the specs are preloaded once, the preloaded process is
99
124
  forked into N pooled clones, and every parallel worker applies each mutant
data/docs/json-schema.md CHANGED
@@ -52,13 +52,13 @@ history of changes.
52
52
  | `metadata.source_file` | string | Absolute path of the mutated source file. |
53
53
  | `metadata.spec_file` | string | Absolute path of the test file that was run. |
54
54
  | `summary.total` | integer | Total number of mutations produced (all statuses). |
55
- | `summary.killed` | integer | Effective kills (a `timeout` counts as a kill). Kept for backward compatibility. |
55
+ | `summary.killed` | integer | Effective kills (a `timeout` counts as a kill; under the opt-in `timeout_policy: :separate` only real kills are counted). Kept for backward compatibility. |
56
56
  | `summary.survived` | integer | Number of surviving mutants. |
57
- | `summary.mutation_score` | number | Percentage `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals. `stillborn` and `error` are excluded from the denominator. |
57
+ | `summary.mutation_score` | number | Percentage `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals; under the opt-in `timeout_policy: :separate` it is `killed / (killed + survived) * 100` with timeouts excluded. `stillborn` and `error` are excluded from the denominator. |
58
58
  | `summary.quality_rating` | string | Human label derived from the score (Excellent/Good/Fair/Poor/Critical). |
59
59
  | `summary.categories.killed` | integer | Mutants whose covering tests failed. |
60
60
  | `summary.categories.survived` | integer | Mutants whose covering tests still passed (a test gap candidate). |
61
- | `summary.categories.timeout` | integer | Mutants that exceeded the per-mutant deadline (counted as kills in the score). |
61
+ | `summary.categories.timeout` | integer | Mutants that exceeded the per-mutant deadline (counted as kills in the score by default; excluded from the score under `timeout_policy: :separate`). |
62
62
  | `summary.categories.stillborn` | integer | Mutants whose code no longer parses. Never run; excluded from the score. |
63
63
  | `summary.categories.error` | integer | Mutants that hit a runner-side error. Excluded from the score. |
64
64
  | `mutations[].id` | integer | Stable id of the mutation within this run. |
@@ -144,7 +144,7 @@ the exit code keeps its meaning (`0` passed, `1` failed).
144
144
  | `summary.files` | integer | Files that entered the run: processed plus skipped. Files a `--since` filter left out as unchanged are not counted; they are reported on stderr. |
145
145
  | `summary.processed` | integer | Files that were actually mutation-tested. |
146
146
  | `summary.skipped` | array | One entry per skipped file: `file` (the path as given) and `reason`, one of `file not found`, `not a Ruby source file`, `a test file, not a mutable source`, `no matching spec file`. |
147
- | `summary.score` | number | Aggregate mutation score over every mutant of every processed file, with the per-file formula: `(killed + timeout) / (killed + timeout + survived) * 100`, rounded to 2 decimals; `stillborn` and `error` are excluded from the denominator. |
147
+ | `summary.score` | number | Aggregate mutation score over every mutant of every processed file, with the per-file formula: `(killed + timeout) / (killed + timeout + survived) * 100` (or `killed / (killed + survived) * 100` under `timeout_policy: :separate`), rounded to 2 decimals; `stillborn` and `error` are excluded from the denominator. |
148
148
  | `summary.passed` | boolean | `true` exactly when the process exits `0`: the run matched/processed files and every processed file met the threshold. |
149
149
  | `summary.interrupted` | boolean | `true` when `--fail-fast` stopped the batch at a surviving mutant, so remaining files were not run and the envelope is partial. |
150
150
  | `survivors[].file` | string | Absolute path of the mutated source file, the same convention as `files[].metadata.source_file`, so a survivor joins its full per-file report by an exact string match on this value. |
data/exe/mutation_test CHANGED
@@ -68,6 +68,14 @@ OptionParser.new do |opts|
68
68
  options[:fail_fast] = true
69
69
  end
70
70
 
71
+ opts.on('--timeout-factor N', Float, "Per-mutant timeout budget as N times the measured baseline test run, never below #{MutationTester::Configuration::CALIBRATED_TIMEOUT_FLOOR}s (default: #{MutationTester::Configuration::DEFAULT_TIMEOUT_FACTOR}; ignored when config.timeout is set explicitly, which keeps a fixed budget)") do |n|
72
+ options[:timeout_factor] = n
73
+ end
74
+
75
+ opts.on('--timeout-policy MODE', 'Scoring policy for timed-out mutants: killed (default) counts a timeout as a kill; separate keeps timeouts out of the score entirely (killed / (killed + survived)) and reports them only as their own category') do |mode|
76
+ options[:timeout_policy] = mode
77
+ end
78
+
71
79
  opts.on('--strict-equality', 'Enable strict equality probes (== to eql? and equal?); default off, expect noise on code that does not distinguish numeric types or object identity') do
72
80
  options[:strict_equality] = true
73
81
  end
@@ -151,6 +159,8 @@ apply_configuration = lambda do
151
159
  config.reporters = selected_reporters if selected_reporters
152
160
  config.output_dir = options[:output_dir] if options[:output_dir]
153
161
  config.test_selection = false if options[:no_test_selection]
162
+ config.timeout_factor = options[:timeout_factor] if options[:timeout_factor]
163
+ config.timeout_policy = options[:timeout_policy] if options[:timeout_policy]
154
164
  config.worker_env_var = options[:worker_env] if options.key?(:worker_env)
155
165
  config.fail_fast = options.fetch(:fail_fast, false)
156
166
  config.mutation_types[:strict_equality] = true if options[:strict_equality]
@@ -342,4 +352,5 @@ else
342
352
  success = guard_interrupt.call { core.run }
343
353
  end
344
354
 
345
- exit(success ? 0 : 1)
355
+ exit 0 if success
356
+ exit(core.infrastructure_failure? ? 3 : 1)
@@ -20,10 +20,14 @@ module MutationTester
20
20
 
21
21
  SKIP_ORDER = %i[missing not_ruby test_file no_spec].freeze
22
22
 
23
- ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, keyword_init: true) do
23
+ ProcessedEntry = Struct.new(:source_file, :spec_file, :score, :passed, :output_dir, :results, :interrupted, :degraded, keyword_init: true) do
24
24
  def passed?
25
25
  passed
26
26
  end
27
+
28
+ def degraded?
29
+ !!degraded
30
+ end
27
31
  end
28
32
 
29
33
  SkippedEntry = Struct.new(:source_file, :expected_spec, :reason, keyword_init: true)
@@ -192,9 +196,10 @@ module MutationTester
192
196
  passed: passed,
193
197
  output_dir: file_config.output_dir,
194
198
  results: core.results,
195
- interrupted: core.interrupted?
199
+ interrupted: core.interrupted?,
200
+ degraded: core.infrastructure_failure?
196
201
  )
197
- [entry, core.interrupted?]
202
+ [entry, core.stopped_on_survivor?]
198
203
  end
199
204
 
200
205
  def spec_path_for(source_file)
@@ -247,12 +252,19 @@ module MutationTester
247
252
  end
248
253
 
249
254
  puts Rainbow('=' * 80).bright
255
+ failed = result.processed.reject(&:passed?)
256
+ degraded = failed.select(&:degraded?)
250
257
  if @files && result.processed.empty?
251
258
  puts Rainbow('❌ No files were mutation-tested: every listed file was skipped').red
252
259
  elsif result.success?
253
260
  puts Rainbow('✓ All processed files met the mutation score threshold').green
261
+ elsif degraded.size == failed.size
262
+ puts Rainbow('❌ The failing files produced no scored mutants; this indicates an infrastructure or runner problem, not a test-quality gap').red
254
263
  else
255
264
  puts Rainbow('❌ One or more files did not meet the mutation score threshold').red
265
+ unless degraded.empty?
266
+ puts Rainbow("⚠️ #{degraded.size} of the failing files produced no scored mutants (infrastructure or runner problem); see the per-file output above").yellow
267
+ end
256
268
  end
257
269
 
258
270
  print_survivors(result.survivors)
@@ -3,7 +3,11 @@ require 'etc'
3
3
  module MutationTester
4
4
  class Configuration
5
5
  RUNNER_MODES = %i[auto fork spawn in_memory].freeze
6
+ TIMEOUT_POLICIES = %i[killed separate].freeze
6
7
  AUTO_PARALLEL_CAP = 8
8
+ DEFAULT_TIMEOUT = 30
9
+ DEFAULT_TIMEOUT_FACTOR = 5
10
+ CALIBRATED_TIMEOUT_FLOOR = 5
7
11
 
8
12
  def self.auto_parallel_processes
9
13
  [[Etc.nprocessors, AUTO_PARALLEL_CAP].min, 1].max
@@ -14,9 +18,9 @@ module MutationTester
14
18
  number <= 0 ? '' : (number + 1).to_s
15
19
  end
16
20
 
17
- attr_reader :parallel_processes, :runner, :worker_env_var
21
+ attr_reader :parallel_processes, :runner, :worker_env_var, :timeout, :timeout_factor, :timeout_policy
18
22
 
19
- attr_accessor :timeout,
23
+ attr_accessor :baseline_duration,
20
24
  :baseline_timeout,
21
25
  :mutation_types,
22
26
  :reporters,
@@ -33,7 +37,9 @@ module MutationTester
33
37
  self.parallel_processes = ENV['MUTATION_TESTER_PARALLEL_PROCESSES'] || self.class.auto_parallel_processes
34
38
  self.runner = ENV['MUTATION_TESTER_RUNNER'] || :auto
35
39
  self.worker_env_var = ENV['MUTATION_TESTER_WORKER_ENV']
36
- @timeout = 30
40
+ @timeout = DEFAULT_TIMEOUT
41
+ @timeout_factor = DEFAULT_TIMEOUT_FACTOR
42
+ @timeout_policy = :killed
37
43
  @baseline_timeout = 300
38
44
  @mutation_types = {
39
45
  arithmetic: true,
@@ -73,6 +79,39 @@ module MutationTester
73
79
  @reporters = source.reporters.dup
74
80
  end
75
81
 
82
+ def timeout=(value)
83
+ @timeout_explicit = true
84
+ @timeout = value
85
+ end
86
+
87
+ def timeout_explicitly_set?
88
+ !!@timeout_explicit
89
+ end
90
+
91
+ def timeout_factor=(value)
92
+ factor = coerce_numeric(value)
93
+ unless factor.positive?
94
+ warn "[MutationTester] timeout_factor must be a number greater than 0; got #{value.inspect}, falling back to #{DEFAULT_TIMEOUT_FACTOR}."
95
+ factor = DEFAULT_TIMEOUT_FACTOR
96
+ end
97
+ @timeout_factor = factor
98
+ end
99
+
100
+ def timeout_policy=(value)
101
+ policy = value.to_s.strip.downcase.to_sym
102
+ unless TIMEOUT_POLICIES.include?(policy)
103
+ warn "[MutationTester] timeout_policy must be one of #{TIMEOUT_POLICIES.join(", ")}; got #{value.inspect}, falling back to killed."
104
+ policy = :killed
105
+ end
106
+ @timeout_policy = policy
107
+ end
108
+
109
+ def effective_timeout
110
+ return @timeout if timeout_explicitly_set? || @baseline_duration.nil?
111
+
112
+ [CALIBRATED_TIMEOUT_FLOOR, @timeout_factor * @baseline_duration].max
113
+ end
114
+
76
115
  def parallel_processes=(value)
77
116
  count = value.to_i
78
117
  if count < 1
@@ -101,5 +140,13 @@ module MutationTester
101
140
 
102
141
  { @worker_env_var => self.class.worker_env_value(index) }
103
142
  end
143
+
144
+ private
145
+
146
+ def coerce_numeric(value)
147
+ Float(value)
148
+ rescue ArgumentError, TypeError
149
+ 0
150
+ end
104
151
  end
105
152
  end
@@ -11,6 +11,7 @@ module MutationTester
11
11
  @mutations = []
12
12
  @results = []
13
13
  @parse_failed = false
14
+ @shadow_aborted = false
14
15
  MutationRunner.recover_in_place_backup(@source_file)
15
16
  @original_content = File.read(@source_file)
16
17
  end
@@ -27,11 +28,15 @@ module MutationTester
27
28
  return true
28
29
  end
29
30
 
30
- return false unless shadow_environment_reliable?
31
+ unless shadow_environment_reliable?
32
+ @shadow_aborted = true
33
+ return false
34
+ end
31
35
 
32
36
  run_mutations
33
37
  generate_reports
34
38
  return report_interruption if interrupted?
39
+ return report_infrastructure_failure if infrastructure_failure?
35
40
 
36
41
  check_threshold
37
42
  ensure
@@ -39,14 +44,20 @@ module MutationTester
39
44
  ForkRunner.shutdown_all
40
45
  end
41
46
 
47
+ def stopped_on_survivor?
48
+ @config.fail_fast && @results.any? { |result| result[:status] == :survived }
49
+ end
50
+
42
51
  def interrupted?
43
- @config.fail_fast &&
44
- @results.size < @mutations.size &&
45
- @results.any? { |result| result[:status] == :survived }
52
+ stopped_on_survivor? && @results.size < @mutations.size
46
53
  end
47
54
 
48
55
  def mutation_score
49
- Reporters::BaseReporter.score(@results)
56
+ Reporters::BaseReporter.score(@results, policy: @config.timeout_policy)
57
+ end
58
+
59
+ def infrastructure_failure?
60
+ @shadow_aborted || (!@results.empty? && scored_count.zero?)
50
61
  end
51
62
 
52
63
  def threshold_met?
@@ -69,7 +80,9 @@ module MutationTester
69
80
  def run_original_tests
70
81
  puts Rainbow("\n🧪 Running original tests...").yellow
71
82
  command = test_command
83
+ baseline_started = monotonic_time
72
84
  result = command.run(timeout: @config.baseline_timeout, capture: true)
85
+ baseline_elapsed = monotonic_time - baseline_started
73
86
 
74
87
  if result.timed_out?
75
88
  puts Rainbow("❌ Original tests exceeded the baseline deadline of #{@config.baseline_timeout}s and were terminated.").red
@@ -85,9 +98,14 @@ module MutationTester
85
98
  return false
86
99
  end
87
100
  puts Rainbow('✓ Original tests passed').green
101
+ @config.baseline_duration = baseline_elapsed
88
102
  true
89
103
  end
90
104
 
105
+ def monotonic_time
106
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
107
+ end
108
+
91
109
  def replay_baseline_output(output)
92
110
  return if output.nil? || output.strip.empty?
93
111
 
@@ -178,6 +196,20 @@ module MutationTester
178
196
  false
179
197
  end
180
198
 
199
+ def scored_count
200
+ @results.count { |r| %i[killed timeout survived].include?(Reporters::BaseReporter.status_for(r)) }
201
+ end
202
+
203
+ def status_count(status)
204
+ @results.count { |r| Reporters::BaseReporter.status_for(r) == status }
205
+ end
206
+
207
+ def report_infrastructure_failure
208
+ puts Rainbow("\n❌ Run failed: none of the #{@results.size} mutants could be scored (#{status_count(:error)} error, #{status_count(:stillborn)} stillborn).").red
209
+ puts Rainbow(' This indicates an infrastructure or runner problem (test environment, workspace, or test command), not a test-quality gap.').red
210
+ false
211
+ end
212
+
181
213
  def check_threshold
182
214
  score = mutation_score
183
215
 
@@ -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)
@@ -91,10 +100,25 @@ supervise_child = lambda do |job, out, child_body|
91
100
  [timed_out, payload, reaped]
92
101
  end
93
102
 
103
+ load_test_file = lambda do |path|
104
+ $PROGRAM_NAME = path
105
+ load(path)
106
+ end
107
+
108
+ run_test_file = lambda do |job|
109
+ if framework == :minitest
110
+ MutationTester::MinitestFailFast.enabled = job['stop_on_first_failure'] ? true : false
111
+ load_test_file.call(job['spec'])
112
+ Minitest.run(Array(job['args'])) ? 0 : 1
113
+ else
114
+ args = [job['spec'], *Array(job['args'])]
115
+ args << '--fail-fast' if job['stop_on_first_failure']
116
+ RSpec::Core::Runner.run(args, STDERR, STDOUT).to_i
117
+ end
118
+ end
119
+
94
120
  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)
121
+ timed_out, payload, reaped = supervise_child.call(job, out, lambda { run_test_file.call(job) })
98
122
 
99
123
  status =
100
124
  if timed_out
@@ -111,16 +135,32 @@ end
111
135
  preload_specs = lambda do |request, out|
112
136
  begin
113
137
  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
138
+ if framework == :minitest
139
+ MutationTester::MinitestFailFast.enabled = request['stop_on_first_failure'] ? true : false
140
+ load_test_file.call(request['spec'])
141
+ preloaded = true
142
+ else
143
+ sink = File.open(File::NULL, 'w')
144
+ options = [request['spec']]
145
+ options << '--fail-fast' if request['stop_on_first_failure']
146
+ runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(options))
147
+ runner.setup(sink, sink)
148
+ preloaded = runner
149
+ end
118
150
  out.puts(JSON.generate('event' => 'preloaded', 'status' => 'ok'))
119
151
  rescue ScriptError, StandardError => e
120
152
  out.puts(JSON.generate('event' => 'preloaded', 'status' => 'error', 'message' => "#{e.class}: #{e.message}"))
121
153
  end
122
154
  end
123
155
 
156
+ run_preloaded_suite = lambda do
157
+ if framework == :minitest
158
+ Minitest.run([]) ? 0 : 1
159
+ else
160
+ preloaded.run_specs(RSpec.world.ordered_example_groups).to_i
161
+ end
162
+ end
163
+
124
164
  run_in_memory_job = lambda do |job, out|
125
165
  request = job['in_memory']
126
166
 
@@ -132,7 +172,7 @@ run_in_memory_job = lambda do |job, out|
132
172
  timed_out, payload, reaped = supervise_child.call(job, out, lambda do
133
173
  begin
134
174
  MutationTester::InMemoryLoader.apply(request['source'], request['path'])
135
- JSON.generate('code' => preloaded.run_specs(RSpec.world.ordered_example_groups).to_i)
175
+ JSON.generate('code' => run_preloaded_suite.call)
136
176
  rescue ScriptError, StandardError => e
137
177
  JSON.generate('error' => "#{e.class}: #{e.message}")
138
178
  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,16 @@ 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)
153
157
  log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
154
- job = { spec: spec_file, timeout: timeout, chdir: chdir, log: log&.path, args: args }
158
+ job = {
159
+ spec: spec_file,
160
+ timeout: timeout,
161
+ chdir: chdir,
162
+ log: log&.path,
163
+ args: args,
164
+ stop_on_first_failure: stop_on_first_failure
165
+ }
155
166
  @job_writer.puts(JSON.generate(job))
156
167
  status = await_result(timeout)['status']
157
168
  result = TestCommand::Result.new(status == 'pass', status == 'timeout')
@@ -164,8 +175,9 @@ module MutationTester
164
175
  log&.unlink
165
176
  end
166
177
 
167
- def preload(spec_file, chdir: nil)
168
- @job_writer.puts(JSON.generate(preload: { spec: spec_file, chdir: chdir }))
178
+ def preload(spec_file, chdir: nil, stop_on_first_failure: false)
179
+ request = { spec: spec_file, chdir: chdir, stop_on_first_failure: stop_on_first_failure }
180
+ @job_writer.puts(JSON.generate(preload: request))
169
181
  event = read_event(monotonic_time + BOOT_TIMEOUT)
170
182
  return [true, nil] if event.is_a?(Hash) && event['event'] == 'preloaded' && event['status'] == 'ok'
171
183
 
@@ -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
@@ -285,7 +285,7 @@ module MutationTester
285
285
  end
286
286
 
287
287
  def run_specs_in_shadow(spec_file, working_dir, example_filters: [])
288
- test_command(spec_file, example_filters: example_filters).run(timeout: @config.timeout, chdir: working_dir)
288
+ test_command(spec_file, example_filters: example_filters).run(timeout: @config.effective_timeout, chdir: working_dir)
289
289
  end
290
290
 
291
291
  def discoverable_project_root
@@ -325,7 +325,7 @@ module MutationTester
325
325
  end
326
326
 
327
327
  def run_specs_in_place(spec_file, example_filters: [])
328
- test_command(spec_file, example_filters: example_filters).run(timeout: @config.timeout)
328
+ test_command(spec_file, example_filters: example_filters).run(timeout: @config.effective_timeout)
329
329
  end
330
330
 
331
331
  def run_mutation_in_memory(mutation, result, project_root = nil)
@@ -335,7 +335,7 @@ module MutationTester
335
335
  outcome = runner.execute_in_memory(
336
336
  source: mutation[:code],
337
337
  path: @source_file,
338
- timeout: @config.timeout,
338
+ timeout: @config.effective_timeout,
339
339
  chdir: Dir.pwd
340
340
  )
341
341
 
@@ -390,19 +390,18 @@ module MutationTester
390
390
  end
391
391
 
392
392
  def prepare_in_memory_execution
393
- return 'the in-memory runner supports RSpec suites only' unless detect_test_framework(@spec_file) == :rspec
394
393
  return 'Process.fork is not supported on this platform' unless ForkRunner.available?
395
394
  if InMemoryLoader.load_time_defined_guard?(@original_content)
396
395
  return 'the source file uses defined? at load time, so redefinition would silently skip the guarded code'
397
396
  end
398
397
 
399
- runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec)
398
+ runner = ForkRunner.new(use_bundle_exec: @use_bundle_exec, framework: detect_test_framework(@spec_file))
400
399
  unless runner.ready?
401
400
  runner.shutdown
402
401
  return 'the in-memory worker failed to preload the environment'
403
402
  end
404
403
 
405
- preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd)
404
+ preloaded, message = runner.preload(@spec_file, chdir: Dir.pwd, stop_on_first_failure: true)
406
405
  unless preloaded
407
406
  runner.shutdown
408
407
  return "the spec file could not be preloaded (#{message})"
@@ -419,7 +418,7 @@ module MutationTester
419
418
  outcome = runner.execute_in_memory(
420
419
  source: @original_content,
421
420
  path: @source_file,
422
- timeout: @config.timeout,
421
+ timeout: @config.effective_timeout,
423
422
  chdir: Dir.pwd
424
423
  )
425
424
  return nil if outcome.status == 'pass'
@@ -521,6 +520,7 @@ module MutationTester
521
520
  ForkRunner.prepare_pool(
522
521
  [@config.parallel_processes, total].min,
523
522
  use_bundle_exec: @use_bundle_exec,
523
+ framework: detect_test_framework(@spec_file),
524
524
  env_for: worker_env_for
525
525
  )
526
526
  end
@@ -637,7 +637,8 @@ module MutationTester
637
637
  use_bundle_exec: @use_bundle_exec,
638
638
  runner: @config.runner,
639
639
  example_filters: example_filters,
640
- worker_env_var: @config.worker_env_var
640
+ worker_env_var: @config.worker_env_var,
641
+ stop_on_first_failure: true
641
642
  )
642
643
  end
643
644
  end
@@ -3,12 +3,14 @@ module MutationTester
3
3
  class BaseReporter
4
4
  attr_reader :results, :source_file, :spec_file, :config
5
5
 
6
- def self.score(results)
7
- effective_killed = results.count { |r| %i[killed timeout].include?(status_for(r)) }
8
- scored = results.count { |r| %i[killed timeout survived].include?(status_for(r)) }
6
+ def self.score(results, policy: :killed)
7
+ killing_statuses = policy == :separate ? %i[killed] : %i[killed timeout]
8
+ scoring_statuses = killing_statuses + %i[survived]
9
+ kills = results.count { |r| killing_statuses.include?(status_for(r)) }
10
+ scored = results.count { |r| scoring_statuses.include?(status_for(r)) }
9
11
  return 0.0 if scored.zero?
10
12
 
11
- (effective_killed.to_f / scored * 100).round(2)
13
+ (kills.to_f / scored * 100).round(2)
12
14
  end
13
15
 
14
16
  def self.status_for(result)
@@ -62,6 +64,8 @@ module MutationTester
62
64
  end
63
65
 
64
66
  def effective_killed_count
67
+ return killed_count if @config.timeout_policy == :separate
68
+
65
69
  killed_count + timeout_count
66
70
  end
67
71
 
@@ -70,7 +74,7 @@ module MutationTester
70
74
  end
71
75
 
72
76
  def mutation_score
73
- self.class.score(@results)
77
+ self.class.score(@results, policy: @config.timeout_policy)
74
78
  end
75
79
 
76
80
  def quality_rating
@@ -53,7 +53,7 @@ module MutationTester
53
53
  end
54
54
 
55
55
  def aggregate_score
56
- BaseReporter.score(@result.processed.flat_map { |entry| entry.results || [] })
56
+ BaseReporter.score(@result.processed.flat_map { |entry| entry.results || [] }, policy: @config.timeout_policy)
57
57
  end
58
58
 
59
59
  def file_reports
@@ -20,6 +20,7 @@ module MutationTester
20
20
  puts " #{Rainbow("Killed: " + killed_count.to_s).green} ✅"
21
21
  puts " #{Rainbow("Survived: " + survived_count.to_s).red} ❌"
22
22
  puts " #{Rainbow("Timeout: " + timeout_count.to_s).yellow} ⏱️"
23
+ print_timeout_deadline
23
24
  puts " #{Rainbow("Stillborn: " + stillborn_count.to_s).yellow} 🧬"
24
25
  puts " #{Rainbow("Errors: " + error_count.to_s).yellow} 💥"
25
26
  print_excluded_summary
@@ -29,6 +30,30 @@ module MutationTester
29
30
  puts "\n " + progress_bar
30
31
  end
31
32
 
33
+ def print_timeout_deadline
34
+ return unless timeout_count.positive?
35
+
36
+ puts " #{Rainbow("deadline: " + timeout_deadline_description).yellow}"
37
+ end
38
+
39
+ def timeout_deadline_description
40
+ deadline = @config.effective_timeout
41
+ return 'none (no deadline configured)' if deadline.nil?
42
+
43
+ "#{format_seconds(deadline)} #{deadline_origin}"
44
+ end
45
+
46
+ def deadline_origin
47
+ baseline = @config.baseline_duration
48
+ return '(explicitly configured)' if @config.timeout_explicitly_set? || baseline.nil?
49
+
50
+ "(#{format('%g', @config.timeout_factor)}x baseline #{format_seconds(baseline)})"
51
+ end
52
+
53
+ def format_seconds(value)
54
+ "#{format('%.2f', value)}s"
55
+ end
56
+
32
57
  def print_excluded_summary
33
58
  count = excluded_line_count
34
59
  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
 
@@ -39,8 +42,17 @@ module MutationTester
39
42
 
40
43
  def run(timeout: nil, chdir: nil, capture: false)
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
+ )
55
+ end
44
56
  end
45
57
 
46
58
  return run_captured(timeout: timeout, chdir: chdir) if capture
@@ -53,7 +65,6 @@ module MutationTester
53
65
  end
54
66
 
55
67
  def fork_execution?
56
- return false if @framework == :minitest
57
68
  return false if @runner == :spawn
58
69
 
59
70
  ForkRunner.available?
@@ -100,6 +111,18 @@ module MutationTester
100
111
  example_filters.flat_map { |filter| ['-e', filter] }
101
112
  end
102
113
 
114
+ def interpreter_args
115
+ return [] unless @stop_on_first_failure && @framework == :minitest
116
+
117
+ ['-r', MINITEST_FAIL_FAST_PATH]
118
+ end
119
+
120
+ def fail_fast_args
121
+ return [] unless @stop_on_first_failure && @framework == :rspec
122
+
123
+ ['--fail-fast']
124
+ end
125
+
103
126
  def run_captured(timeout:, chdir:)
104
127
  log = Tempfile.new(['mutation_tester_baseline', '.log'])
105
128
  spawn_options = { pgroup: true, %i[out err] => log.path }
@@ -1,3 +1,3 @@
1
1
  module MutationTester
2
- VERSION = '1.2.0'.freeze
2
+ VERSION = '1.4.0'.freeze
3
3
  end
@@ -45,7 +45,7 @@ namespace :mutation do
45
45
  end
46
46
 
47
47
  unless all_passed
48
- puts Rainbow("\n❌ One or more files did not meet the mutation score threshold").red
48
+ puts Rainbow("\n❌ One or more files failed mutation testing (see the per-file output above)").red
49
49
  abort
50
50
  end
51
51
  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,12 +200,14 @@ 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`. |
207
207
  | `--since REV` | Incremental batch mode: mutate only the files matched by `--glob` that changed since git revision `REV` (new files count as changed). Requires `--glob`. See [Incremental mode](#incremental-mode-mutate-only-what-changed). |
208
208
  | `--fail-fast` | Stop the run at the first surviving mutant and finish with a failing status. Works in single-file mode and with `--glob`. |
209
+ | `--timeout-factor N` | Per-mutant timeout budget as `N` times the measured baseline test run, never below 5 s (default: 5, must be > 0). Ignored when `config.timeout` is set explicitly, which keeps a fixed budget. See [Configuration](#configuration). |
210
+ | `--timeout-policy MODE` | Scoring policy for timed-out mutants: `killed` (default) counts a timeout as a kill; `separate` keeps timeouts out of the score entirely (`killed / (killed + survived)`) and reports them only as their own category in the console, JSON and HTML reports. |
209
211
  | `--worker-env NAME` | Set environment variable `NAME` to a distinct per-worker value before each parallel worker boots (`parallel_tests` `TEST_ENV_NUMBER` convention: worker 0 -> `""`, worker N -> `N+1`), so a `parallel_tests`-style `database.yml` selects a per-worker database. You provision the databases (e.g. `rake parallel:prepare`). Not supported by the `in_memory` runner (it falls back to `fork`). See [Making parallelism work with Rails](#making-parallelism-work-with-rails). |
210
212
  | `--strict-equality` | Enable the opt-in strict-equality probes (`==` → `eql?` and `==` → `equal?`). Default off; expect noise on code that does not distinguish numeric types or object identity. See [Strict Equality Mutations](docs/mutation-types.md#strict-equality-mutations-opt-in). |
211
213
  | `-h, --help` | Show help message. |
@@ -223,6 +225,22 @@ bundle exec mutation_test --reporters json,html --output-dir build/mutation \
223
225
  app/models/user.rb spec/models/user_spec.rb
224
226
  ```
225
227
 
228
+ ### Exit codes (single-file mode)
229
+
230
+ - `0` - the run passed (score met the threshold, or `fail_on_threshold` is disabled).
231
+ - `1` - the mutation score is below the threshold, or the input is unusable (missing
232
+ file, unknown reporter, source with a syntax error).
233
+ - `2` - a usage error (conflicting flags; see the batch sections below).
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.
236
+ This signals an infrastructure or runner problem, not a test-quality gap, so CI
237
+ hooks can distinguish it from a genuine threshold failure.
238
+ - `130` - interrupted with Ctrl+C.
239
+
240
+ Batch modes (`FILE...` lists, `--staged`, `--glob`) keep the exit codes documented in
241
+ their sections below (`0`/`1`/`2`); a degraded file is named explicitly in the batch
242
+ summary instead of being blamed on the threshold.
243
+
226
244
  ### Running with rake
227
245
 
228
246
  You can also run mutation tests through rake tasks.
@@ -403,8 +421,24 @@ MutationTester.configure do |config|
403
421
  # config.parallel_processes = 1
404
422
  config.parallel_processes = 4
405
423
 
406
- # Timeout for each test run (seconds)
407
- config.timeout = 30
424
+ # Per-mutant timeout (seconds). Calibrated automatically by default:
425
+ # max(5, timeout_factor * measured baseline duration), so a loaded machine
426
+ # cannot inflate the score by turning healthy-but-slow runs into timeout
427
+ # kills; on code paths without a measured baseline the fixed default of 30
428
+ # applies. Setting config.timeout explicitly (a number, or nil for no
429
+ # deadline at all) disables calibration and keeps that fixed budget:
430
+ # config.timeout = 30
431
+
432
+ # Multiplier for the baseline-calibrated per-mutant timeout (> 0; an
433
+ # invalid value falls back to the default with a warning):
434
+ config.timeout_factor = 5
435
+
436
+ # Scoring policy for timed-out mutants. :killed (default) counts a timeout
437
+ # as a kill: (killed + timeout) / (killed + timeout + survived). :separate
438
+ # keeps timeouts out of the score entirely, killed / (killed + survived),
439
+ # and reports them only as their own category, so timeouts under load can
440
+ # never raise the score:
441
+ config.timeout_policy = :killed
408
442
 
409
443
  # Mutant execution runner: :auto (default), :fork, :spawn or :in_memory.
410
444
  # :auto picks the fastest safe path and announces any fallback on stderr;
@@ -563,18 +597,18 @@ the fastest safe one, announcing every fallback on stderr:
563
597
 
564
598
  - **in_memory** (default where supported): re-evaluates the mutated source in the
565
599
  memory of a fresh fork of a preloaded process, with zero file writes per mutant
566
- and no shadow workspaces. RSpec only; the fastest path. Mutations that only take
567
- effect at class-load time (constants consumed by macros, `validates`/`has_many`/
600
+ and no shadow workspaces. RSpec and Minitest; the fastest path. Mutations that only
601
+ take effect at class-load time (constants consumed by macros, `validates`/`has_many`/
568
602
  `before_save`/`scope`/`attribute`, anything inside an `included do` block) cannot
569
603
  be observed by re-evaluating source in a preloaded process, so those mutants are
570
604
  routed automatically to the file-based path and the rest still run in memory (see
571
605
  below); the combined score matches a full `fork` run.
572
- - **fork**: preloads the environment once (RubyGems, Bundler, `rspec-core`) and
573
- forks a fresh child per mutant. RSpec on platforms with `Process.fork`; removes
574
- most of the fixed per-mutant boot cost.
575
- - **spawn**: starts one full process per mutant (`bundle exec rspec ...`). Slower
576
- per mutant, but works everywhere (the only runner for Minitest and for
577
- platforms without `Process.fork`).
606
+ - **fork**: preloads the environment once (RubyGems, Bundler, the test framework)
607
+ and forks a fresh child per mutant. RSpec and Minitest on platforms with
608
+ `Process.fork`; removes most of the fixed per-mutant boot cost.
609
+ - **spawn**: starts one full process per mutant (`bundle exec rspec ...` or
610
+ `bundle exec ruby test_file.rb`). Slower per mutant, but works everywhere
611
+ (the only runner on platforms without `Process.fork`).
578
612
 
579
613
  `auto` tries `in_memory`, then `fork`, then `spawn`; every step down prints one
580
614
  stderr warning with its reason, so a fallback is never silent. All runners
@@ -594,9 +628,9 @@ do not need to pick `--runner fork` for correctness on load-time code.
594
628
 
595
629
  | Mode | Picked by `auto` when | Falls back to |
596
630
  |-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
597
- | `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 |
598
- | `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 |
599
- | `spawn` | the suite is Minitest, or the platform has no `Process.fork` | nothing; it works everywhere |
631
+ | `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 |
632
+ | `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 |
633
+ | `spawn` | the platform has no `Process.fork` | nothing; it works everywhere |
600
634
 
601
635
  Force a specific runner (skipping the auto attempts) with the `--runner
602
636
  fork|spawn|in_memory` flag, the `MUTATION_TESTER_RUNNER` environment variable, or
@@ -614,6 +648,22 @@ fork and in-memory limitation lists (frozen classes, load-time `defined?` guards
614
648
  worker-death fallback, `require_relative` idempotency), see
615
649
  [docs/execution-runners.md](docs/execution-runners.md#execution-runners-fork-spawn-in-memory).
616
650
 
651
+ ### Stopping a mutant at its first failing test
652
+
653
+ A mutant only needs one failing test to be killed, so every mutant run stops at
654
+ its first failure: RSpec mutant runs get `--fail-fast`, and Minitest mutant runs
655
+ get a preloaded reporter that aborts the run the same way (both on the file-based
656
+ runners and inside the preloaded fork worker). This never changes a verdict, only
657
+ the work done to reach it: a run that stops early had already failed, and a run
658
+ with no failure is unaffected and still executes every test.
659
+
660
+ It matters most for a mutant that breaks something every test touches (a broken
661
+ class body, a constant every example reads). Such a mutant used to pay the full
662
+ test file once per mutant, which on a large test file can exceed the per-mutant
663
+ deadline and turn a decided kill into a reported timeout. Adding tests to the file
664
+ then made the score worse. The baseline run and the shadow sanity check are
665
+ unaffected: they are expected to pass, and a passing run runs every test.
666
+
617
667
  ### Test selection (fast kill with full-file confirmation)
618
668
 
619
669
  For RSpec suites on the file-based runners, MutationTester runs each mutant in
@@ -707,6 +757,18 @@ few lines of surrounding context:
707
757
  💡 Suggestion: Add tests to verify behavior for each of the 2 variants above
708
758
  ```
709
759
 
760
+ When at least one mutant timed out, the summary also names the deadline those
761
+ mutants were measured against and where it came from, so a genuine hang and a
762
+ deadline calibrated from a slow test file are distinguishable at a glance:
763
+
764
+ ```
765
+ Timeout: 3 ⏱️
766
+ deadline: 6.50s (5x baseline 1.30s)
767
+ ```
768
+
769
+ With an explicit `config.timeout` / `--timeout` the same line reads
770
+ `deadline: 30.00s (explicitly configured)`.
771
+
710
772
  ### HTML report
711
773
 
712
774
  Beautiful interactive HTML report with:
@@ -725,7 +787,9 @@ to get a single, clean JSON document on **stdout** and nothing else: the banner,
725
787
  progress spinner, colours and the "report saved" notice all go to **stderr**, so
726
788
  the stream is safe to pipe straight into `jq` or a parser. The process still
727
789
  exits `0` when the mutation score meets the configured threshold and `1` when it
728
- does not, so the exit code remains a pass/fail signal.
790
+ does not (a degraded single-file run exits `3`, see
791
+ [Exit codes](#exit-codes-single-file-mode)), so the exit code remains a
792
+ pass/fail signal.
729
793
 
730
794
  ```bash
731
795
  bundle exec mutation_test --json examples/calculator.rb examples/calculator_spec.rb | jq .
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.2.0
4
+ version: 1.4.0
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-14 00:00:00.000000000 Z
11
+ date: 2026-08-01 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