mutation_tester 1.4.0 → 1.4.2

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: be945d45b6c4219df627808a62336abe51ce94b088d79db47e25c794cdf227bd
4
- data.tar.gz: 80aaefd644319415bfb25cebab5eb28077f3b99c763d234d20cf6885f3c001d2
3
+ metadata.gz: c743765bd0adb455bf1162627a35cb5b3232d45cfb83dca91ab139248a3acac1
4
+ data.tar.gz: 71eb3f98c61271896c6c4cc2e3b9b69c62fee77d50b3d94c0237b0dac9eefd53
5
5
  SHA512:
6
- metadata.gz: 2ad47074139c21bb7cd6db12eb53d2dd8c350b85c24c43f2ce44ba25590da1b3a4aa3f2e9f5ea2394abbdb8779e50cb9b3bb94418a94b03a1148ef2bd186b1f0
7
- data.tar.gz: 58078c8af01ec3155be934d6917a3b66b0779f70bc20b04a23d05f82c06e9198000156fc13a7f7eece21cff11582d6b142a5ffb4aaa11d3191c15ebe4c3d9fbb
6
+ metadata.gz: 3fac687f4fd5496daced6e16cf30e3dc39a0816fc5be7fe9f37caae4a05d3e088bdf1f80ee5b23f72610741d767cd410de81c6fcec09b4456d2936549f32266c
7
+ data.tar.gz: 13842db8fe228decfe6d3e165e5f80ea3c18af549e09051a3b12342f1ff3251ea5f81f8d375d1f0bdc5e76b6320f101ce791900016b8054630a3e0c2e14f3227
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.2] - 2026-08-05
4
+
5
+ - Added `--spec-map 'PATTERN=>REPLACEMENT'`, a repeatable rule that builds the whole spec path from the whole source path with a regular expression, for the layouts the `{name}` template cannot express. `{name}` is the source path minus a leading `lib/`, so a template can only wrap it with a prefix and a suffix; every mapping that substitutes *inside* the path, after a variable-length prefix, was out of reach: `packs/identity/app/models/party.rb -> packs/identity/test/models/party_test.rb` (Packwerk / packs-rails), the same shape with Rails engines, and the plain Rails `app/ -> test/` rule. Because `--since` requires `--glob` and `--glob` maps through `--spec-glob`, such a layout previously lost the entire batch tier at once and had to be reimplemented in a wrapper script. The first matching rule wins and a source matching no rule falls back to `--spec-glob`, so one run can cover `app/` through a rule and `lib/` through the template. The flag works with `--glob`, a positional `FILE` list and `--staged`; a malformed rule is a usage error (exit code `2`) reported before any mutation runs.
6
+ - Fixed `--glob` finishing with status `0` when every matched file was skipped. The two batch paths used different predicates over the same result, and the `--glob` one gated on "something matched and every processed file passed", which is vacuously true over an empty processed list. A run that matched four files and skipped all four (a wrong `--spec-glob`, a test directory moved by a refactor) printed `All processed files met the mutation score threshold`, set `"passed": true` in the JSON envelope and passed the run, which is the worst direction for a quality gate: it vouches for a score it never measured. Both paths now share one predicate: a batch that matched files but measured none fails with status `1` and prints `No files were mutation-tested`, while a `--since` run whose matched files were all unchanged still passes, so a pull request that touches nothing keeps its documented green run.
7
+ - Added `--minimum-score N` to set the threshold a file must reach for a single run. `config.minimum_score` (default 80) had no CLI flag and no environment variable, so the CLI's own gate was fixed at 80 and the shipped `examples/hooks/pre-push` worked around it with `--json` plus `jq`. An existing codebase can now start below 80 and raise the number over time without a wrapper.
8
+
9
+ ## [1.4.1] - 2026-08-03
10
+
11
+ - 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`.
12
+ - 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.
13
+ - 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.
14
+
3
15
  ## [1.4.0] - 2026-08-01
4
16
 
5
17
  - 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.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mutation_tester (1.4.0)
4
+ mutation_tester (1.4.2)
5
5
  parallel (~> 1.20)
6
6
  parser (~> 3.3)
7
7
  rainbow (~> 3.0)
data/docs/ci.md CHANGED
@@ -29,7 +29,8 @@ The same thing, condensed to a copy-pasteable minimal workflow. It matches the
29
29
  maintained template above; reach for the template when you want the parallel and
30
30
  multi-file variants. The quality gate needs no extra configuration: the CLI exits
31
31
  non-zero when the mutation score is below the threshold, which fails the step (and
32
- the job).
32
+ the job). The threshold defaults to 80 and `--minimum-score N` sets it for a
33
+ single run, so an existing codebase can start lower and ratchet the number up.
33
34
 
34
35
  ```yaml
35
36
  name: Mutation Testing
@@ -108,6 +109,28 @@ jobs:
108
109
  A PR that touches nothing under `lib/` exits `0` with a "Nothing to mutate"
109
110
  message, so the gate never blocks unrelated changes.
110
111
 
112
+ ### What the batch exit code guarantees
113
+
114
+ A batch run exits `0` only when at least one file was actually mutation-tested
115
+ and every processed file met the threshold, or when the `--since` filter left
116
+ nothing to mutate. If the glob matched files but *every* one of them was skipped
117
+ (most often `no matching spec file`), the run exits `1`: nothing was measured, so
118
+ the gate has nothing to vouch for. That keeps a typo in `--spec-glob` /
119
+ `--spec-map`, or a refactor that moves the test directory, from silently turning
120
+ a green job into a job that measures nothing.
121
+
122
+ For an application whose tests do not sit under `spec/`, map the paths with
123
+ `--spec-map` (see
124
+ [Mapping sources to specs](../readme.md#mapping-sources-to-specs)):
125
+
126
+ ```yaml
127
+ - name: Run mutation tests on changed files only
128
+ run: |
129
+ bundle exec mutation_test --glob '{app,packs/*/app}/**/*.rb' \
130
+ --spec-map '\A((?:packs/[^/]+/)?)app/(.+)\.rb\z=>\1test/\2_test.rb' \
131
+ --since "origin/${{ github.base_ref }}" --fail-fast
132
+ ```
133
+
111
134
  ## Machine mode as a CI gate and artifact
112
135
 
113
136
  `--json` makes the CLI double as the gate (its exit code follows the threshold)
@@ -110,6 +110,15 @@ can cross the per-mutant deadline and be reported as a `timeout` instead of a
110
110
  `spawn`, even when `--runner fork` is requested.
111
111
  - If the helper process fails to preload the environment, the run warns once
112
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.
113
122
 
114
123
  ### Limitations of the in-memory runner
115
124
 
data/exe/mutation_test CHANGED
@@ -60,6 +60,14 @@ OptionParser.new do |opts|
60
60
  options[:spec_glob] = template
61
61
  end
62
62
 
63
+ opts.on('--spec-map RULE', "Regex spec-mapping rule 'PATTERN#{MutationTester::BatchRunner::SPEC_MAP_SEPARATOR}REPLACEMENT' applied to the whole source path to build the whole spec path (e.g. '\\A((?:packs/[^/]+/)?)app/(.+)\\.rb\\z#{MutationTester::BatchRunner::SPEC_MAP_SEPARATOR}\\1test/\\2_test.rb'). Repeatable; the first matching rule wins and a source matching no rule falls back to --spec-glob. Requires a FILE list, --staged, or --glob.") do |rule|
64
+ (options[:spec_map] ||= []) << rule
65
+ end
66
+
67
+ opts.on('--minimum-score N', Float, "Mutation score percentage a file must reach to pass (default: #{MutationTester::Configuration::DEFAULT_MINIMUM_SCORE})") do |score|
68
+ options[:minimum_score] = score
69
+ end
70
+
63
71
  opts.on('--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.') do |rev|
64
72
  options[:since] = rev
65
73
  end
@@ -150,6 +158,13 @@ end
150
158
  glob_mode = !options[:glob].nil?
151
159
  staged_mode = options.fetch(:staged, false)
152
160
 
161
+ spec_map = begin
162
+ (options[:spec_map] || []).map { |rule| MutationTester::BatchRunner.parse_spec_map(rule) }
163
+ rescue MutationTester::Error => e
164
+ usage_error.call(e.message,
165
+ "Quote the rule so the shell keeps it intact, e.g. --spec-map '\\Aapp/(.+)\\.rb\\z=>test/\\1_test.rb'.")
166
+ end
167
+
153
168
  apply_configuration = lambda do
154
169
  MutationTester.configure do |config|
155
170
  config.parallel_processes = options[:parallel] if options[:parallel]
@@ -158,6 +173,7 @@ apply_configuration = lambda do
158
173
  config.show_progress = !options[:no_progress]
159
174
  config.reporters = selected_reporters if selected_reporters
160
175
  config.output_dir = options[:output_dir] if options[:output_dir]
176
+ config.minimum_score = options[:minimum_score] if options[:minimum_score]
161
177
  config.test_selection = false if options[:no_test_selection]
162
178
  config.timeout_factor = options[:timeout_factor] if options[:timeout_factor]
163
179
  config.timeout_policy = options[:timeout_policy] if options[:timeout_policy]
@@ -224,18 +240,17 @@ if glob_mode
224
240
  batch = MutationTester::BatchRunner.new(
225
241
  glob: options[:glob],
226
242
  spec_template: options[:spec_glob],
243
+ spec_map: spec_map,
227
244
  config: MutationTester.configuration,
228
245
  since: options[:since],
229
246
  changed_files: changed_files
230
247
  )
231
248
 
232
- run_batch_machine.call(batch, ->(result) { result.matched_any? && result.success? }) if json_mode
249
+ run_batch_machine.call(batch, ->(result) { result.gate_passed? }) if json_mode
233
250
 
234
251
  result = guard_interrupt.call { batch.run }
235
252
 
236
- exit 1 unless result.matched_any?
237
-
238
- exit(result.success? ? 0 : 1)
253
+ exit(result.gate_passed? ? 0 : 1)
239
254
  end
240
255
 
241
256
  if !staged_mode && ARGV.empty?
@@ -255,6 +270,11 @@ if options[:spec_glob] && legacy_pair
255
270
  'Use --spec-glob with a positional FILE list, --staged, or --glob.')
256
271
  end
257
272
 
273
+ if options[:spec_map] && legacy_pair
274
+ usage_error.call('--spec-map does not apply to an explicit SOURCE_FILE TEST_FILE pair.',
275
+ 'Use --spec-map with a positional FILE list, --staged, or --glob.')
276
+ end
277
+
258
278
  if legacy_pair
259
279
  source_file, test_file = ARGV
260
280
  else
@@ -278,7 +298,7 @@ else
278
298
  single_file_json = json_mode && !staged_mode && sources.length == 1
279
299
 
280
300
  if single_file_json
281
- outcome = MutationTester::BatchRunner.new(files: sources, spec_template: options[:spec_glob]).classify(sources.first)
301
+ outcome = MutationTester::BatchRunner.new(files: sources, spec_template: options[:spec_glob], spec_map: spec_map).classify(sources.first)
282
302
  if outcome.is_a?(MutationTester::BatchRunner::SkippedEntry)
283
303
  reason = MutationTester::BatchRunner::SKIP_REASONS.fetch(outcome.reason)
284
304
  detail = outcome.expected_spec ? " (expected #{outcome.expected_spec})" : ''
@@ -293,14 +313,15 @@ else
293
313
  batch = MutationTester::BatchRunner.new(
294
314
  files: sources,
295
315
  spec_template: options[:spec_glob],
316
+ spec_map: spec_map,
296
317
  config: MutationTester.configuration
297
318
  )
298
319
 
299
- run_batch_machine.call(batch, ->(result) { result.processed.any? && result.success? }) if json_mode
320
+ run_batch_machine.call(batch, ->(result) { result.gate_passed? }) if json_mode
300
321
 
301
322
  result = guard_interrupt.call { batch.run }
302
323
 
303
- exit(result.processed.any? && result.success? ? 0 : 1)
324
+ exit(result.gate_passed? ? 0 : 1)
304
325
  end
305
326
  end
306
327
 
@@ -9,6 +9,8 @@ module MutationTester
9
9
 
10
10
  DEFAULT_SPEC_TEMPLATE = 'spec/{name}_spec.rb'.freeze
11
11
 
12
+ SPEC_MAP_SEPARATOR = '=>'.freeze
13
+
12
14
  TEST_FILE_SUFFIXES = ['_spec.rb', '.spec.rb', '_test.rb'].freeze
13
15
 
14
16
  SKIP_REASONS = {
@@ -57,6 +59,18 @@ module MutationTester
57
59
  !(processed.empty? && skipped.empty? && (unchanged || []).empty?)
58
60
  end
59
61
 
62
+ def nothing_measured?
63
+ processed.empty? && !skipped.empty?
64
+ end
65
+
66
+ def gate_passed?
67
+ return false unless matched_any?
68
+ return false if nothing_measured?
69
+ return true if processed.empty?
70
+
71
+ success?
72
+ end
73
+
60
74
  def interrupted?
61
75
  !!interrupted
62
76
  end
@@ -100,6 +114,24 @@ module MutationTester
100
114
  .map { |path| Pathname.new(File.expand_path(path, toplevel)).relative_path_from(base).to_s }
101
115
  end
102
116
 
117
+ def self.parse_spec_map(rule)
118
+ pattern, replacement = rule.to_s.split(SPEC_MAP_SEPARATOR, 2)
119
+ if replacement.nil?
120
+ raise MutationTester::Error,
121
+ "--spec-map #{rule.to_s.inspect} has no #{SPEC_MAP_SEPARATOR} separator; " \
122
+ "write it as 'PATTERN#{SPEC_MAP_SEPARATOR}REPLACEMENT'"
123
+ end
124
+ if pattern.empty?
125
+ raise MutationTester::Error, "--spec-map #{rule.to_s.inspect} has an empty pattern"
126
+ end
127
+
128
+ begin
129
+ [Regexp.new(pattern), replacement]
130
+ rescue RegexpError => e
131
+ raise MutationTester::Error, "--spec-map pattern #{pattern.inspect} is not a valid regular expression: #{e.message}"
132
+ end
133
+ end
134
+
103
135
  def self.test_file?(path)
104
136
  basename = File.basename(path)
105
137
  return true if TEST_FILE_SUFFIXES.any? { |suffix| basename.end_with?(suffix) }
@@ -118,12 +150,13 @@ module MutationTester
118
150
  end
119
151
  private_class_method :git_capture
120
152
 
121
- def initialize(glob: nil, files: nil, spec_template: nil, config: MutationTester.configuration, since: nil, changed_files: nil)
153
+ def initialize(glob: nil, files: nil, spec_template: nil, spec_map: nil, config: MutationTester.configuration, since: nil, changed_files: nil)
122
154
  raise ArgumentError, 'provide exactly one of glob: or files:' unless glob.nil? ^ files.nil?
123
155
 
124
156
  @glob = glob
125
157
  @files = files
126
158
  @spec_template = spec_template.nil? || spec_template.empty? ? DEFAULT_SPEC_TEMPLATE : spec_template
159
+ @spec_map = spec_map || []
127
160
  @config = config
128
161
  @since = since
129
162
  @changed_files = changed_files
@@ -203,6 +236,10 @@ module MutationTester
203
236
  end
204
237
 
205
238
  def spec_path_for(source_file)
239
+ normalized = source_file.sub(%r{\A\./}, '')
240
+ pattern, replacement = @spec_map.find { |rule_pattern, _| rule_pattern.match?(normalized) }
241
+ return normalized.sub(pattern, replacement) if pattern
242
+
206
243
  @spec_template.gsub(NAME_PLACEHOLDER, source_name(source_file))
207
244
  end
208
245
 
@@ -254,8 +291,9 @@ module MutationTester
254
291
  puts Rainbow('=' * 80).bright
255
292
  failed = result.processed.reject(&:passed?)
256
293
  degraded = failed.select(&:degraded?)
257
- if @files && result.processed.empty?
258
- puts Rainbow('❌ No files were mutation-tested: every listed file was skipped').red
294
+ if result.nothing_measured?
295
+ scope = @files ? 'listed' : 'matched'
296
+ puts Rainbow("❌ No files were mutation-tested: every #{scope} file was skipped").red
259
297
  elsif result.success?
260
298
  puts Rainbow('✓ All processed files met the mutation score threshold').green
261
299
  elsif degraded.size == failed.size
@@ -5,6 +5,7 @@ module MutationTester
5
5
  RUNNER_MODES = %i[auto fork spawn in_memory].freeze
6
6
  TIMEOUT_POLICIES = %i[killed separate].freeze
7
7
  AUTO_PARALLEL_CAP = 8
8
+ DEFAULT_MINIMUM_SCORE = 80.0
8
9
  DEFAULT_TIMEOUT = 30
9
10
  DEFAULT_TIMEOUT_FACTOR = 5
10
11
  CALIBRATED_TIMEOUT_FLOOR = 5
@@ -56,7 +57,7 @@ module MutationTester
56
57
  }
57
58
  @reporters = %i[console html json]
58
59
  @output_dir = 'tmp/mutation_reports'
59
- @minimum_score = 80.0
60
+ @minimum_score = DEFAULT_MINIMUM_SCORE
60
61
  @fail_on_threshold = true
61
62
  @verbose = false
62
63
  @show_file_path = true
@@ -132,13 +132,25 @@ module MutationTester
132
132
  return true if mutation_runner.in_memory_first?
133
133
 
134
134
  puts Rainbow("\n🩺 Verifying the shadow workspace with the unmutated source...").yellow
135
- if mutation_runner.shadow_baseline_passes?
136
- puts Rainbow('✓ Shadow workspace verified with the unmutated source').green
137
- 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
138
145
  end
146
+ end
139
147
 
140
- puts Rainbow('❌ The unmutated source fails inside the shadow workspace; the shadow environment is unreliable.').red
141
- 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
142
154
  false
143
155
  end
144
156
 
@@ -26,6 +26,26 @@ end
26
26
 
27
27
  monotonic = lambda { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
28
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
+
29
49
  current_child = nil
30
50
  preloaded = nil
31
51
 
@@ -50,6 +70,7 @@ supervise_child = lambda do |job, out, child_body|
50
70
  rescue Errno::EACCES, Errno::EPERM
51
71
  end
52
72
  Dir.chdir(job['chdir']) if job['chdir']
73
+ mirror_load_path.call(job['mirror_of'], job['chdir'])
53
74
  sink = File.open(job['log'] || File::NULL, 'w')
54
75
  sink.sync = true
55
76
  STDOUT.reopen(sink)
@@ -153,7 +153,8 @@ module MutationTester
153
153
  @ready
154
154
  end
155
155
 
156
- def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [], stop_on_first_failure: false)
156
+ def execute(spec_file, timeout: nil, chdir: nil, capture: false, args: [], stop_on_first_failure: false,
157
+ mirror_of: nil)
157
158
  log = capture ? Tempfile.new(['mutation_tester_fork', '.log']) : nil
158
159
  job = {
159
160
  spec: spec_file,
@@ -161,7 +162,8 @@ module MutationTester
161
162
  chdir: chdir,
162
163
  log: log&.path,
163
164
  args: args,
164
- stop_on_first_failure: stop_on_first_failure
165
+ stop_on_first_failure: stop_on_first_failure,
166
+ mirror_of: mirror_of
165
167
  }
166
168
  @job_writer.puts(JSON.generate(job))
167
169
  status = await_result(timeout)['status']
@@ -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
@@ -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
@@ -30,6 +33,23 @@ module MutationTester
30
33
  puts "\n " + progress_bar
31
34
  end
32
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
+
33
53
  def print_timeout_deadline
34
54
  return unless timeout_count.positive?
35
55
 
@@ -40,7 +40,7 @@ module MutationTester
40
40
  cmd
41
41
  end
42
42
 
43
- def run(timeout: nil, chdir: nil, capture: false)
43
+ def run(timeout: nil, chdir: nil, capture: false, mirror_of: nil)
44
44
  if fork_execution?
45
45
  fork_runner = ForkRunner.acquire(use_bundle_exec: @use_bundle_exec, framework: @framework)
46
46
  if fork_runner
@@ -50,7 +50,8 @@ module MutationTester
50
50
  chdir: chdir || Dir.pwd,
51
51
  capture: capture,
52
52
  args: filter_args,
53
- stop_on_first_failure: @stop_on_first_failure
53
+ stop_on_first_failure: @stop_on_first_failure,
54
+ mirror_of: mirror_of
54
55
  )
55
56
  end
56
57
  end
@@ -1,3 +1,3 @@
1
1
  module MutationTester
2
- VERSION = '1.4.0'.freeze
2
+ VERSION = '1.4.2'.freeze
3
3
  end
data/readme.md CHANGED
@@ -204,6 +204,8 @@ mutation_test [OPTIONS] --glob 'lib/**/*.rb'
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
+ | `--spec-map RULE` | Spec-mapping rule `'PATTERN=>REPLACEMENT'`: a regular expression applied to the whole source path to build the whole spec path, for layouts a `{name}` template cannot express (Rails `app/` -> `test/`, engines, Packwerk packs). Repeatable, first matching rule wins, a source matching no rule falls back to `--spec-glob`. Requires a positional `FILE` list, `--staged`, or `--glob`. See [Mapping sources to specs](#mapping-sources-to-specs). |
208
+ | `--minimum-score N` | Mutation score percentage a file must reach to pass (default: 80). Drives the `PASS`/`FAIL` verdict and the exit code. |
207
209
  | `--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
210
  | `--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
211
  | `--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). |
@@ -232,7 +234,9 @@ bundle exec mutation_test --reporters json,html --output-dir build/mutation \
232
234
  file, unknown reporter, source with a syntax error).
233
235
  - `2` - a usage error (conflicting flags; see the batch sections below).
234
236
  - `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.
237
+ was unreliable (the unmutated source failed there, or the workspace copy of the
238
+ source turned out not to be the code the tests execute), or every mutant ended as
239
+ `error`/`stillborn` so nothing was scored.
236
240
  This signals an infrastructure or runner problem, not a test-quality gap, so CI
237
241
  hooks can distinguish it from a genuine threshold failure.
238
242
  - `130` - interrupted with Ctrl+C.
@@ -312,8 +316,8 @@ reported as `SKIPPED` with an explicit reason and never count as a success:
312
316
  - **not a Ruby source file** - e.g. a staged `.md` or config file.
313
317
  - **a test file, not a mutable source** - a test file passed directly
314
318
  (`*_spec.rb`, `*.spec.rb`, `*_test.rb`, `test_*.rb`, or minitest content).
315
- - **no matching spec file** - the convention (or `--spec-glob`) points at a
316
- spec that does not exist; the expected path is printed.
319
+ - **no matching spec file** - the convention (or `--spec-glob` / `--spec-map`)
320
+ points at a spec that does not exist; the expected path is printed.
317
321
 
318
322
  Exit codes: `0` when at least one file was processed and every processed file
319
323
  met the threshold; `1` when any processed file was below threshold or when
@@ -334,16 +338,8 @@ run, so you no longer need to script a loop around `mutation_test` or depend on
334
338
  the Rails-only `rake mutation:test_models` task.
335
339
 
336
340
  Each matched source file is mapped to its spec by convention: `lib/X.rb` becomes
337
- `spec/X_spec.rb`. Concretely the spec path is `spec/{name}_spec.rb` where `{name}`
338
- is the source path with a leading `lib/` segment removed and the `.rb` extension
339
- stripped, subdirectories preserved (`lib/foo/bar.rb` -> `spec/foo/bar_spec.rb`).
340
-
341
- Override the convention with `--spec-glob TEMPLATE`, a template containing the
342
- `{name}` placeholder. For a Minitest project laid out under `test/`:
343
-
344
- ```bash
345
- bundle exec mutation_test --glob 'lib/**/*.rb' --spec-glob 'test/{name}_test.rb'
346
- ```
341
+ `spec/X_spec.rb`. See [Mapping sources to specs](#mapping-sources-to-specs) for
342
+ the two ways to override that convention.
347
343
 
348
344
  Behaviour:
349
345
 
@@ -357,18 +353,27 @@ Behaviour:
357
353
  file with its score and `PASS`/`FAIL` against the threshold, followed by a
358
354
  clearly separated `SKIPPED` list.
359
355
  - **A source file with no matching spec is `SKIPPED`**, reported explicitly and
360
- never counted as a success. A skipped file does not by itself fail the run.
356
+ never counted as a success. A single skipped file next to processed ones does
357
+ not by itself fail the run, but a run that skipped *every* matched file
358
+ measured nothing and fails (see the exit codes below).
361
359
 
362
360
  Exit codes:
363
361
 
364
- - `0` - every processed file met the mutation score threshold (including a
365
- `--since` run where nothing changed, see below).
362
+ - `0` - at least one file was mutation-tested and every processed file met the
363
+ mutation score threshold. A `--since` run where nothing changed also exits `0`
364
+ (see below).
366
365
  - `1` - at least one processed file was below threshold, the glob matched no
367
- source files at all, or `--fail-fast` stopped the run at a surviving mutant.
368
- - `2` - a usage error: `--spec-glob` with an explicit `SOURCE_FILE TEST_FILE`
369
- pair, `--since` given without `--glob`, `--staged` combined with positional
370
- arguments or `--glob`, or `--since`/`--staged` used outside a git repository
371
- (for `--since` also an unknown revision).
366
+ source files at all, every matched file was skipped so nothing was actually
367
+ mutation-tested, or `--fail-fast` stopped the run at a surviving mutant.
368
+ - `2` - a usage error: `--spec-glob` or `--spec-map` with an explicit
369
+ `SOURCE_FILE TEST_FILE` pair, a malformed `--spec-map` rule, `--since` given
370
+ without `--glob`, `--staged` combined with positional arguments or `--glob`,
371
+ or `--since`/`--staged` used outside a git repository (for `--since` also an
372
+ unknown revision).
373
+
374
+ The "every matched file was skipped" case is deliberate: a typo in
375
+ `--spec-glob`/`--spec-map`, or a refactor that moves the test directory, would
376
+ otherwise leave a green CI step that measured nothing.
372
377
 
373
378
  ```bash
374
379
  # Minitest project, JSON report per file, custom output directory
@@ -376,6 +381,66 @@ bundle exec mutation_test --glob 'lib/**/*.rb' --spec-glob 'test/{name}_test.rb'
376
381
  --reporters json --output-dir build/mutation
377
382
  ```
378
383
 
384
+ ### Mapping sources to specs
385
+
386
+ Every mode that takes more than an explicit `SOURCE_FILE TEST_FILE` pair (a
387
+ positional `FILE` list, `--staged`, `--glob`) derives the test path from the
388
+ source path. Two mechanisms do that, checked in this order:
389
+
390
+ 1. `--spec-map 'PATTERN=>REPLACEMENT'` - regular-expression rules.
391
+ 2. `--spec-glob TEMPLATE` - a `{name}` template (default `spec/{name}_spec.rb`).
392
+
393
+ **`--spec-glob TEMPLATE`** substitutes `{name}`, which is the source path with a
394
+ leading `lib/` segment removed and the `.rb` extension stripped, subdirectories
395
+ preserved (`lib/foo/bar.rb` -> `spec/foo/bar_spec.rb`). Because `{name}` is one
396
+ value, a template can only add a prefix and a suffix around the source path. For
397
+ a Minitest project laid out under `test/` that is enough:
398
+
399
+ ```bash
400
+ bundle exec mutation_test --glob 'lib/**/*.rb' --spec-glob 'test/{name}_test.rb'
401
+ ```
402
+
403
+ **`--spec-map 'PATTERN=>REPLACEMENT'`** covers the layouts a template cannot
404
+ express: those that substitute *inside* the path, after a variable-length
405
+ prefix. `PATTERN` is a Ruby regular expression matched against the whole source
406
+ path (a leading `./` removed); the first `=>` separates it from `REPLACEMENT`,
407
+ which may use `\1`, `\2`, ... backreferences and produces the whole spec path.
408
+ Only the first match in the path is replaced.
409
+
410
+ - The flag is repeatable and the first matching rule wins.
411
+ - A source that matches no rule falls back to `--spec-glob` (or the default
412
+ convention), so one command can cover `app/` and `lib/` at once.
413
+ - Quote the rule in single quotes so the shell leaves the backslashes alone.
414
+
415
+ Rails and Rails-shaped layouts, where the rule is "replace the `app/` segment
416
+ with `test/`, keep whatever prefix comes before it":
417
+
418
+ ```bash
419
+ # Plain Rails, Minitest: app/models/current.rb -> test/models/current_test.rb
420
+ bundle exec mutation_test --glob 'app/**/*.rb' \
421
+ --spec-map '\Aapp/(.+)\.rb\z=>test/\1_test.rb'
422
+
423
+ # Plain Rails, RSpec: app/models/user.rb -> spec/models/user_spec.rb
424
+ bundle exec mutation_test --glob 'app/**/*.rb' \
425
+ --spec-map '\Aapp/(.+)\.rb\z=>spec/\1_spec.rb'
426
+
427
+ # Packwerk / packs-rails and engines, with the app root as an optional prefix:
428
+ # app/models/current.rb -> test/models/current_test.rb
429
+ # packs/identity/app/models/party.rb -> packs/identity/test/models/party_test.rb
430
+ # engines/billing/app/jobs/send_job.rb -> engines/billing/test/jobs/send_job_test.rb
431
+ bundle exec mutation_test --glob '{app,packs/*/app,engines/*/app}/**/*.rb' \
432
+ --spec-map '\A((?:(?:packs|engines)/[^/]+/)?)app/(.+)\.rb\z=>\1test/\2_test.rb'
433
+
434
+ # app/ through the rule, lib/ through the template, in one run
435
+ bundle exec mutation_test --glob '{app,lib}/**/*.rb' \
436
+ --spec-map '\Aapp/(.+)\.rb\z=>test/\1_test.rb' \
437
+ --spec-glob 'test/{name}_test.rb'
438
+ ```
439
+
440
+ When a rule produces a path that does not exist, the file is reported as
441
+ `SKIPPED (no matching spec file)` with the expected path printed, and a run in
442
+ which *every* file was skipped that way fails with exit code `1`.
443
+
379
444
  ### Incremental mode: mutate only what changed
380
445
 
381
446
  `--since REV` narrows a `--glob` batch to the files that changed since a git
@@ -461,7 +526,7 @@ MutationTester.configure do |config|
461
526
  # Set it explicitly to write elsewhere (this example uses "mutation_reports"):
462
527
  config.output_dir = "mutation_reports"
463
528
 
464
- # Quality thresholds
529
+ # Quality thresholds. The CLI flag --minimum-score overrides this per run.
465
530
  config.minimum_score = 80.0
466
531
  config.fail_on_threshold = true
467
532
 
@@ -523,7 +588,16 @@ invalid value (less than 1, or non-numeric) falls back to 1 with a warning on st
523
588
  In parallel mode each mutant runs in an isolated shadow workspace. Every `.rb`
524
589
  file is a physical copy (non-Ruby files stay symlinks for speed), so mutations
525
590
  apply correctly even when a spec loads the source indirectly (e.g. via
526
- `spec_helper`). The parallel mutation score therefore matches serial.
591
+ `spec_helper`), and `$LOAD_PATH` entries pointing into the project resolve
592
+ inside the workspace, so a test file that reaches its source through
593
+ `require "test_helper"` gets the mutated copy too. The parallel mutation score
594
+ therefore matches serial.
595
+
596
+ Before the first mutant, the run proves this in the workspace itself: the
597
+ unmutated source must pass there, and the same suite must fail once that copy of
598
+ the source is replaced by a `raise`. A run whose tests pass even then is aborted
599
+ as an infrastructure failure (exit code `3`) rather than reported as a 0.0%
600
+ score, because the mutated file is demonstrably not the code being executed.
527
601
 
528
602
  ### When to use serial vs parallel execution
529
603
 
@@ -570,7 +644,8 @@ bundle exec mutation_test app/models/user.rb spec/models/user_spec.rb \
570
644
  -p 4 --worker-env TEST_ENV_NUMBER
571
645
 
572
646
  # Batch over a whole directory the same way
573
- bundle exec mutation_test --glob 'app/models/**/*.rb' --spec-glob 'spec/models/{name}_spec.rb' \
647
+ bundle exec mutation_test --glob 'app/models/**/*.rb' \
648
+ --spec-map '\Aapp/(.+)\.rb\z=>spec/\1_spec.rb' \
574
649
  -p 4 --worker-env TEST_ENV_NUMBER
575
650
  ```
576
651
 
@@ -842,6 +917,14 @@ can drop the `jq` comparison and let the exit code be the gate:
842
917
  bundle exec mutation_test app/models/user.rb spec/models/user_spec.rb || exit 1
843
918
  ```
844
919
 
920
+ `--minimum-score N` sets that threshold for a single run, which is how you start
921
+ below 80 in an existing codebase and ratchet the number up over time:
922
+
923
+ ```sh
924
+ bundle exec mutation_test --glob 'app/**/*.rb' \
925
+ --spec-map '\Aapp/(.+)\.rb\z=>spec/\1_spec.rb' --minimum-score 60 || exit 1
926
+ ```
927
+
845
928
  lefthook or overcommit users: call the shipped hook from your `pre-push` step
846
929
  instead of writing to `.git/hooks/`.
847
930
 
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.4.0
4
+ version: 1.4.2
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-08-01 00:00:00.000000000 Z
11
+ date: 2026-08-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: parallel