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 +4 -4
- data/CHANGELOG.md +12 -0
- data/Gemfile.lock +1 -1
- data/docs/ci.md +24 -1
- data/docs/execution-runners.md +9 -0
- data/exe/mutation_test +28 -7
- data/lib/mutation_tester/batch_runner.rb +41 -3
- data/lib/mutation_tester/configuration.rb +2 -1
- 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 +107 -24
- 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: c743765bd0adb455bf1162627a35cb5b3232d45cfb83dca91ab139248a3acac1
|
|
4
|
+
data.tar.gz: 71eb3f98c61271896c6c4cc2e3b9b69c62fee77d50b3d94c0237b0dac9eefd53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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)
|
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/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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
258
|
-
|
|
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 =
|
|
60
|
+
@minimum_score = DEFAULT_MINIMUM_SCORE
|
|
60
61
|
@fail_on_threshold = true
|
|
61
62
|
@verbose = false
|
|
62
63
|
@show_file_path = true
|
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
|
@@ -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
|
|
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`
|
|
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`.
|
|
338
|
-
|
|
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
|
|
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` -
|
|
365
|
-
`--since` run where nothing changed
|
|
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,
|
|
368
|
-
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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`)
|
|
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'
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-08-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: parallel
|