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 +4 -4
- data/CHANGELOG.md +6 -0
- data/Gemfile.lock +1 -1
- data/docs/execution-runners.md +9 -0
- data/lib/mutation_tester/core.rb +17 -5
- data/lib/mutation_tester/fork_runner/worker.rb +21 -0
- data/lib/mutation_tester/fork_runner.rb +4 -2
- data/lib/mutation_tester/mutation_runner.rb +15 -5
- data/lib/mutation_tester/reporters/console_reporter.rb +20 -0
- data/lib/mutation_tester/test_command.rb +3 -2
- data/lib/mutation_tester/version.rb +1 -1
- data/readme.md +13 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8391c9817c1f430ec2231982495e11ff1f6866c3ce11b8e67d9ab421894c44ab
|
|
4
|
+
data.tar.gz: c82152c72bf0c0faaa22807ade06134fc814dd0faddde7f3849100b29b2f54fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/docs/execution-runners.md
CHANGED
|
@@ -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/lib/mutation_tester/core.rb
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
puts Rainbow('
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
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
|
|
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`)
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: parallel
|