mutation_tester 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be945d45b6c4219df627808a62336abe51ce94b088d79db47e25c794cdf227bd
4
- data.tar.gz: 80aaefd644319415bfb25cebab5eb28077f3b99c763d234d20cf6885f3c001d2
3
+ metadata.gz: 8391c9817c1f430ec2231982495e11ff1f6866c3ce11b8e67d9ab421894c44ab
4
+ data.tar.gz: c82152c72bf0c0faaa22807ade06134fc814dd0faddde7f3849100b29b2f54fa
5
5
  SHA512:
6
- metadata.gz: 2ad47074139c21bb7cd6db12eb53d2dd8c350b85c24c43f2ce44ba25590da1b3a4aa3f2e9f5ea2394abbdb8779e50cb9b3bb94418a94b03a1148ef2bd186b1f0
7
- data.tar.gz: 58078c8af01ec3155be934d6917a3b66b0779f70bc20b04a23d05f82c06e9198000156fc13a7f7eece21cff11582d6b142a5ffb4aaa11d3191c15ebe4c3d9fbb
6
+ metadata.gz: d663fe8bc725c6b99e50d0752c3c8452397794672ac2a79a64e1cc6da70b1076abca47bf5143050cc3afd405320ff584d17c587cca8fff47bed27a3f184ed543
7
+ data.tar.gz: f33e5cbcdf08e032015b358e2b027b7846af163d2d5bab5954abf13f6d06d48e3a584677ace35e97f9aee67e48faa96a5c5ffcc2704b9416984e4605bcecdff1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.1] - 2026-08-03
4
+
5
+ - Fixed parallel runs reporting every mutant as survived (`Killed: 0`, score `0.0%`) on projects whose test file reaches its source through `$LOAD_PATH` (a Rails app run with `-Itest` in `RUBYOPT`, for example). A preloaded worker is started once in the real project root, so Ruby absolutizes every `-I`/`RUBYLIB` entry against that directory; the forked child then changed into the shadow workspace but kept resolving `require "test_helper"` back to the original tree, which loaded the unmutated source. Each job now rewrites the `$LOAD_PATH` entries of the mirrored project root into the workspace, so a preloaded worker resolves project code exactly like the `spawn` runner that starts inside the workspace. Only Minitest suites hit this in practice: RSpec re-adds `lib` and its default path at run time, after the child has changed directory. Serial runs and the `spawn` runner were never affected, which is why the same file scored 96.55% serially and 0.0% with `-p 8`.
6
+ - The shadow-workspace sanity check now also proves that the workspace copy of the source is the code the tests execute: after the unmutated source passes there, the check replaces that copy with a `raise` and requires the run to fail. When it still passes, the run aborts as an infrastructure failure (exit code `3`) naming the likely causes, instead of reporting a complete-looking 0.0% score in which every mutant falsely survived.
7
+ - The console summary now warns when a file with at least five scored mutants killed none of them, pointing at the runner rather than at test quality and suggesting a `--runner spawn` comparison.
8
+
3
9
  ## [1.4.0] - 2026-08-01
4
10
 
5
11
  - Minitest suites now use the same execution runners as RSpec instead of being pinned to `spawn`. The fork worker preloads `minitest` (disabling the `minitest/autorun` at-exit hook and driving `Minitest.run` itself, so the file still runs exactly once per mutant) and the in-memory runner preloads the test file once and re-evaluates each mutant in a fresh fork, so a Minitest project no longer pays a full interpreter, Bundler and framework boot per mutant. Preloaded workers are now keyed by framework, so a mixed-framework `--glob` run never hands a Minitest file to an RSpec-preloaded worker.
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.1)
5
5
  parallel (~> 1.20)
6
6
  parser (~> 3.3)
7
7
  rainbow (~> 3.0)
@@ -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
 
@@ -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.1'.freeze
3
3
  end
data/readme.md CHANGED
@@ -232,7 +232,9 @@ bundle exec mutation_test --reporters json,html --output-dir build/mutation \
232
232
  file, unknown reporter, source with a syntax error).
233
233
  - `2` - a usage error (conflicting flags; see the batch sections below).
234
234
  - `3` - the run aborted or degraded before reaching a verdict: the shadow workspace
235
- was unreliable, or every mutant ended as `error`/`stillborn` so nothing was scored.
235
+ was unreliable (the unmutated source failed there, or the workspace copy of the
236
+ source turned out not to be the code the tests execute), or every mutant ended as
237
+ `error`/`stillborn` so nothing was scored.
236
238
  This signals an infrastructure or runner problem, not a test-quality gap, so CI
237
239
  hooks can distinguish it from a genuine threshold failure.
238
240
  - `130` - interrupted with Ctrl+C.
@@ -523,7 +525,16 @@ invalid value (less than 1, or non-numeric) falls back to 1 with a warning on st
523
525
  In parallel mode each mutant runs in an isolated shadow workspace. Every `.rb`
524
526
  file is a physical copy (non-Ruby files stay symlinks for speed), so mutations
525
527
  apply correctly even when a spec loads the source indirectly (e.g. via
526
- `spec_helper`). The parallel mutation score therefore matches serial.
528
+ `spec_helper`), and `$LOAD_PATH` entries pointing into the project resolve
529
+ inside the workspace, so a test file that reaches its source through
530
+ `require "test_helper"` gets the mutated copy too. The parallel mutation score
531
+ therefore matches serial.
532
+
533
+ Before the first mutant, the run proves this in the workspace itself: the
534
+ unmutated source must pass there, and the same suite must fail once that copy of
535
+ the source is replaced by a `raise`. A run whose tests pass even then is aborted
536
+ as an infrastructure failure (exit code `3`) rather than reported as a 0.0%
537
+ score, because the mutated file is demonstrably not the code being executed.
527
538
 
528
539
  ### When to use serial vs parallel execution
529
540
 
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kamil Dzierbicki
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-01 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: parallel