mutineer 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +473 -0
- data/README.md +352 -26
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +184 -24
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +541 -92
- data/lib/mutineer/daemon_backend.rb +227 -86
- data/lib/mutineer/daemon_client.rb +72 -9
- data/lib/mutineer/daemon_server.rb +229 -51
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +15 -2
- data/lib/mutineer/job_plan.rb +458 -0
- data/lib/mutineer/migrate.rb +705 -0
- data/lib/mutineer/minitest_integration.rb +48 -1
- data/lib/mutineer/mutator_registry.rb +6 -2
- data/lib/mutineer/mutators/base.rb +14 -7
- data/lib/mutineer/mutators/call_to_receiver.rb +135 -0
- data/lib/mutineer/mutators/chain_link.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +100 -0
- data/lib/mutineer/project.rb +411 -21
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1477 -48
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +83 -12
- data/lib/mutineer/result.rb +49 -8
- data/lib/mutineer/runner.rb +66 -290
- data/lib/mutineer/statement_lines.rb +54 -7
- data/lib/mutineer/subject.rb +24 -2
- data/lib/mutineer/test_runners/rspec.rb +54 -3
- data/lib/mutineer/test_runners.rb +3 -2
- data/lib/mutineer/triage.rb +111 -0
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +5 -0
- metadata +11 -1
data/lib/mutineer/cli.rb
CHANGED
|
@@ -9,10 +9,14 @@ require_relative "project"
|
|
|
9
9
|
require_relative "pairing"
|
|
10
10
|
require_relative "changed_lines"
|
|
11
11
|
require_relative "runner"
|
|
12
|
+
require_relative "job_plan"
|
|
12
13
|
require_relative "reporter"
|
|
13
14
|
require_relative "kill_matrix"
|
|
14
15
|
require_relative "baseline"
|
|
16
|
+
require_relative "migrate"
|
|
15
17
|
require_relative "mutator_registry"
|
|
18
|
+
require_relative "triage"
|
|
19
|
+
require_relative "init"
|
|
16
20
|
|
|
17
21
|
module Mutineer
|
|
18
22
|
# Command-line entry point. `start` is the single public method called by
|
|
@@ -20,7 +24,8 @@ module Mutineer
|
|
|
20
24
|
#
|
|
21
25
|
# Exit codes:
|
|
22
26
|
# 0 success / requested output (--version, --help, score >= threshold)
|
|
23
|
-
# 1 survivors below threshold,
|
|
27
|
+
# 1 survivors below threshold, a runtime error, or an ignore id
|
|
28
|
+
# `migrate` could not map
|
|
24
29
|
# 2 usage / flag error (unknown subcommand, invalid flag, unknown operator,
|
|
25
30
|
# out-of-range threshold)
|
|
26
31
|
class CLI
|
|
@@ -32,6 +37,15 @@ module Mutineer
|
|
|
32
37
|
run [options] <source...> --test <test> [--test <test>...]
|
|
33
38
|
Mutate, run, and report
|
|
34
39
|
run --dry-run [options] <source...> Print candidate mutations only
|
|
40
|
+
triage REPORT.json --reason TEXT
|
|
41
|
+
(--id ID ... | --all) Print ignore: entries for
|
|
42
|
+
survivors. Does not edit files.
|
|
43
|
+
migrate [options] <source...> Rewrite old ignore ids in
|
|
44
|
+
.mutineer.yml. --dry-run prints
|
|
45
|
+
each change and writes nothing
|
|
46
|
+
init [--rails] [--force] Write .mutineer.yml and print
|
|
47
|
+
the first run command.
|
|
48
|
+
--force replaces an existing file.
|
|
35
49
|
|
|
36
50
|
Run options:
|
|
37
51
|
--test FILE Test file covering the sources (one per flag; repeat it)
|
|
@@ -54,6 +68,9 @@ module Mutineer
|
|
|
54
68
|
and the clean check (default: 120; not with --test-command)
|
|
55
69
|
--cache-dir DIR Directory for the coverage cache (default: .mutineer;
|
|
56
70
|
not with --test-command)
|
|
71
|
+
--coverage-from FILE Reuse the coverage.json of a --rails/--boot capture over
|
|
72
|
+
these sources or more; runs alone only the tests that
|
|
73
|
+
cover these sources
|
|
57
74
|
--framework NAME minitest or rspec (default: auto-detect from --test names)
|
|
58
75
|
--boot FILE Require FILE once in the parent to boot the app env, then
|
|
59
76
|
fork per mutant (Rails apps; requires --test)
|
|
@@ -65,7 +82,9 @@ module Mutineer
|
|
|
65
82
|
on the mutineer command (not as KEY=val inside CMD)
|
|
66
83
|
--daemon Boot the app ONCE in a persistent daemon and fork per
|
|
67
84
|
mutant, with per-worker DB isolation so --jobs N is safe
|
|
68
|
-
under Rails (needs --rails/--boot; not with --test-command)
|
|
85
|
+
under Rails (needs --rails/--boot; not with --test-command).
|
|
86
|
+
--no-daemon beats a .mutineer.yml daemon: (2.0 will default
|
|
87
|
+
--rails to --daemon; --no-daemon keeps it serial)
|
|
69
88
|
--format human|json|html Report format (default: human)
|
|
70
89
|
--output FILE Write the report to FILE instead of stdout
|
|
71
90
|
--dry-run List mutations without executing
|
|
@@ -76,6 +95,8 @@ module Mutineer
|
|
|
76
95
|
killers (in-process only; not with --daemon, --test-command,
|
|
77
96
|
--fail-fast or --dry-run; --no-matrix beats a .mutineer.yml
|
|
78
97
|
matrix:)
|
|
98
|
+
--allow-empty A run with no mutants is expected; do not warn (--no-allow-empty
|
|
99
|
+
beats a .mutineer.yml allow_empty:)
|
|
79
100
|
--verbose Surface the real error when a fork capture fails (alias: --debug)
|
|
80
101
|
|
|
81
102
|
Options:
|
|
@@ -89,6 +110,14 @@ module Mutineer
|
|
|
89
110
|
# @param argv [Array<String>] raw command-line arguments.
|
|
90
111
|
# @return [void]
|
|
91
112
|
def self.start(argv)
|
|
113
|
+
if argv.first == "triage"
|
|
114
|
+
exit Triage.run(argv[1..])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if argv.first == "init"
|
|
118
|
+
exit Init.run(argv[1..])
|
|
119
|
+
end
|
|
120
|
+
|
|
92
121
|
# The CLI layer holds only the fields the user typed, so Config.resolve
|
|
93
122
|
# can tell a typed `false`/`nil` from an absent flag by whether the key exists.
|
|
94
123
|
opts = {}
|
|
@@ -110,6 +139,7 @@ module Mutineer
|
|
|
110
139
|
# conflicts with another flag can be turned off for one run.
|
|
111
140
|
o.on("--[no-]fail-fast") { |on| opts[:fail_fast] = on }
|
|
112
141
|
o.on("--[no-]matrix") { |on| opts[:matrix] = on }
|
|
142
|
+
o.on("--[no-]allow-empty") { |on| opts[:allow_empty] = on }
|
|
113
143
|
o.on("--only NAME") { |v| opts[:only] = v }
|
|
114
144
|
o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
|
|
115
145
|
# A typed "no" must beat a .mutineer.yml `since:` key: the key is present
|
|
@@ -124,6 +154,7 @@ module Mutineer
|
|
|
124
154
|
o.on("--timeout SECONDS") { |v| opts[:timeout] = Config.parse(:timeout, v) }
|
|
125
155
|
o.on("--capture-timeout SECONDS") { |v| opts[:capture_timeout] = Config.parse(:capture_timeout, v) }
|
|
126
156
|
o.on("--cache-dir DIR") { |v| opts[:cache_dir] = Config.parse(:cache_dir, v) }
|
|
157
|
+
o.on("--coverage-from FILE") { |v| opts[:coverage_from] = Config.parse(:coverage_from, v) }
|
|
127
158
|
o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
|
|
128
159
|
o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
|
|
129
160
|
o.on("--boot FILE") { |v| opts[:boot] = v }
|
|
@@ -140,7 +171,9 @@ module Mutineer
|
|
|
140
171
|
o.on("--test-command CMD") { |v| opts[:test_command] = v }
|
|
141
172
|
# Boot the app ONCE in a persistent daemon and fork per mutant, with
|
|
142
173
|
# per-worker DB isolation so --jobs N is safe under Rails.
|
|
143
|
-
|
|
174
|
+
# A typed --no-daemon beats a .mutineer.yml `daemon: true`, and pins the
|
|
175
|
+
# serial --rails path before 2.0 makes the daemon the default.
|
|
176
|
+
o.on("--[no-]daemon") { |on| opts[:daemon] = on }
|
|
144
177
|
end
|
|
145
178
|
|
|
146
179
|
begin
|
|
@@ -184,12 +217,99 @@ module Mutineer
|
|
|
184
217
|
# project becomes its root-relative path (#104). Test inference (when --test is omitted) happens in validate!.
|
|
185
218
|
config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
|
|
186
219
|
run(config)
|
|
220
|
+
when "migrate"
|
|
221
|
+
warn_config_root_mismatch(file_path, config.project_root) if file_path
|
|
222
|
+
unless file_path
|
|
223
|
+
warn "mutineer: migrate needs a .mutineer.yml (none found from #{Dir.pwd})"
|
|
224
|
+
exit 2
|
|
225
|
+
end
|
|
226
|
+
if argv[1..].empty?
|
|
227
|
+
warn "mutineer: migrate requires at least one source file"
|
|
228
|
+
exit 2
|
|
229
|
+
end
|
|
230
|
+
config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
|
|
231
|
+
migrate(config, file_path)
|
|
187
232
|
else
|
|
188
233
|
warn "mutineer: unknown command '#{argv.first}'"
|
|
189
234
|
exit 2
|
|
190
235
|
end
|
|
191
236
|
end
|
|
192
237
|
|
|
238
|
+
# Rewrites old-format ids in the loaded `.mutineer.yml` `ignore:` list.
|
|
239
|
+
# Prints each change. With `--dry-run`, writes nothing. Exits 1 when any
|
|
240
|
+
# id matched no mutant in these sources and operators, or when an id in a
|
|
241
|
+
# `{ id:, reason: }` entry now matches several mutants (split it by hand).
|
|
242
|
+
# Those ids stay in the file. Exits 0 when every id is current or was
|
|
243
|
+
# rewritten.
|
|
244
|
+
# An unsupported `ignore:` shape exits 2 and leaves the file unchanged.
|
|
245
|
+
#
|
|
246
|
+
# @param config [Mutineer::Config] run configuration, sources already set.
|
|
247
|
+
# @param file_path [String] the `.mutineer.yml` to edit.
|
|
248
|
+
# @return [void]
|
|
249
|
+
def self.migrate(config, file_path)
|
|
250
|
+
current, old_to_new = Migrate.id_maps(config)
|
|
251
|
+
text = File.read(file_path)
|
|
252
|
+
outcome = Migrate.rewrite(text, current, old_to_new)
|
|
253
|
+
outcome.replacements.each do |rep|
|
|
254
|
+
puts "[mutineer] #{rep.old_id} -> #{rep.new_ids.join(', ')}"
|
|
255
|
+
end
|
|
256
|
+
outcome.unmapped.each do |id|
|
|
257
|
+
warn "[mutineer] ignore entry #{id} matched no mutant in these sources and operators. " \
|
|
258
|
+
"It was left in place."
|
|
259
|
+
end
|
|
260
|
+
outcome.split.each do |rep|
|
|
261
|
+
warn "[mutineer] ignore entry #{rep.old_id} has a reason and now matches several mutants " \
|
|
262
|
+
"(#{rep.new_ids.join(', ')}). Split it into one entry per id by hand. It was left in place."
|
|
263
|
+
end
|
|
264
|
+
warn "[mutineer] dry run: .mutineer.yml was not changed" if config.dry_run
|
|
265
|
+
replace_file(file_path, outcome.text) if !config.dry_run && outcome.text != text
|
|
266
|
+
exit(outcome.unmapped.empty? && outcome.split.empty? ? 0 : 1)
|
|
267
|
+
rescue ArgumentError => e
|
|
268
|
+
warn "mutineer: #{e.message}"
|
|
269
|
+
exit 2
|
|
270
|
+
rescue Mutineer::ParseError => e
|
|
271
|
+
warn "mutineer: error reading: #{e.message}"
|
|
272
|
+
exit 1
|
|
273
|
+
rescue SystemCallError => e
|
|
274
|
+
warn "mutineer: #{e.message}"
|
|
275
|
+
exit 2
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Replaces `path` with `contents` without truncating the original first.
|
|
279
|
+
# The new bytes go to a temporary file in the same directory. That file
|
|
280
|
+
# is closed before it is renamed over the target, and it keeps the
|
|
281
|
+
# target's mode. A symlink is followed, so the target file changes and
|
|
282
|
+
# the link stays. If the write fails, the temporary file is removed and
|
|
283
|
+
# the original file is left as it was.
|
|
284
|
+
#
|
|
285
|
+
# @api private
|
|
286
|
+
# @param path [String] the configuration file to replace.
|
|
287
|
+
# @param contents [String] the full new text.
|
|
288
|
+
# @return [void]
|
|
289
|
+
def self.replace_file(path, contents)
|
|
290
|
+
target = File.symlink?(path) ? File.realpath(path) : path
|
|
291
|
+
directory = File.dirname(target)
|
|
292
|
+
base = File.basename(target)
|
|
293
|
+
temp = nil
|
|
294
|
+
100.times do
|
|
295
|
+
candidate = File.join(directory, ".#{base}.migrate-#{Process.pid}-#{rand(1_000_000_000)}.tmp")
|
|
296
|
+
next if File.exist?(candidate)
|
|
297
|
+
|
|
298
|
+
temp = candidate
|
|
299
|
+
break
|
|
300
|
+
end
|
|
301
|
+
raise Errno::EEXIST, target unless temp
|
|
302
|
+
|
|
303
|
+
mode = File.stat(target).mode & 0o7777
|
|
304
|
+
# `perm` is still masked by the umask, so chmod restores the exact mode.
|
|
305
|
+
File.write(temp, contents, perm: mode)
|
|
306
|
+
File.chmod(mode, temp)
|
|
307
|
+
File.rename(temp, target)
|
|
308
|
+
rescue StandardError
|
|
309
|
+
File.unlink(temp) if temp && File.exist?(temp)
|
|
310
|
+
raise
|
|
311
|
+
end
|
|
312
|
+
|
|
193
313
|
# Lists available operators.
|
|
194
314
|
#
|
|
195
315
|
# @return [void]
|
|
@@ -211,6 +331,7 @@ module Mutineer
|
|
|
211
331
|
exit 2
|
|
212
332
|
end
|
|
213
333
|
validate!(config)
|
|
334
|
+
Config.warn_rails_default_change(config)
|
|
214
335
|
|
|
215
336
|
config.dry_run ? dry_run(config) : execute(config)
|
|
216
337
|
rescue ArgumentError => e
|
|
@@ -240,6 +361,9 @@ module Mutineer
|
|
|
240
361
|
# backtrace: the working tree is still the other run's responsibility.
|
|
241
362
|
warn "mutineer: #{e.message}"
|
|
242
363
|
exit 1
|
|
364
|
+
rescue Mutineer::CoverageFromError => e
|
|
365
|
+
warn "mutineer: #{e.message}"
|
|
366
|
+
exit 2
|
|
243
367
|
rescue Mutineer::DaemonBootError => e
|
|
244
368
|
# The daemon is gone for good, so the run ended rather than scoring the rest
|
|
245
369
|
# against it. A deliberate stop deserves a message, not a raw backtrace.
|
|
@@ -270,6 +394,7 @@ module Mutineer
|
|
|
270
394
|
# cannot bypass the RSpec rejection (framework would still be minitest if we
|
|
271
395
|
# validated before discovery).
|
|
272
396
|
validate_daemon!(config) if config.daemon
|
|
397
|
+
warn_unused_coverage_from(config)
|
|
273
398
|
|
|
274
399
|
# Boot mode needs at least one --test file (nothing to select from otherwise).
|
|
275
400
|
if config.boot && config.tests.empty?
|
|
@@ -317,9 +442,10 @@ module Mutineer
|
|
|
317
442
|
end
|
|
318
443
|
|
|
319
444
|
# --daemon selects the persistent-daemon backend (boot once, fork per mutant,
|
|
320
|
-
# per-worker DB isolation on SQLite). Usage errors exit 2:
|
|
321
|
-
# --test-command; requires --rails or --boot; minitest only;
|
|
322
|
-
# (redefine needs a shared VM surgical path the daemon
|
|
445
|
+
# per-worker DB isolation on SQLite, Postgres, and MySQL). Usage errors exit 2:
|
|
446
|
+
# cannot combine with --test-command; requires --rails or --boot; minitest only;
|
|
447
|
+
# reload strategy only (redefine needs a shared VM surgical path the daemon
|
|
448
|
+
# does not ship).
|
|
323
449
|
#
|
|
324
450
|
# @api private
|
|
325
451
|
# @param config [Mutineer::Config] run configuration.
|
|
@@ -351,9 +477,24 @@ module Mutineer
|
|
|
351
477
|
UNUSED_SETTING_SCOPE = {
|
|
352
478
|
timeout: "in-process runs only",
|
|
353
479
|
capture_timeout: "in-process and --daemon coverage capture",
|
|
354
|
-
cache_dir: "the in-process and --daemon coverage cache"
|
|
480
|
+
cache_dir: "the in-process and --daemon coverage cache",
|
|
481
|
+
coverage_from: "in-process --rails/--boot runs"
|
|
355
482
|
}.freeze
|
|
356
483
|
|
|
484
|
+
# Warns when `--coverage-from` is set on a run that captures no coverage.
|
|
485
|
+
#
|
|
486
|
+
# @api private
|
|
487
|
+
# @param config [Mutineer::Config] run configuration.
|
|
488
|
+
# @return [void]
|
|
489
|
+
def self.warn_unused_coverage_from(config)
|
|
490
|
+
mode = if config.test_command then "--test-command"
|
|
491
|
+
elsif config.daemon then "--daemon"
|
|
492
|
+
elsif config.dry_run then "--dry-run"
|
|
493
|
+
elsif !config.boot then "a run without --rails/--boot"
|
|
494
|
+
end
|
|
495
|
+
warn_unused_settings(config, mode, %i[coverage_from]) if mode
|
|
496
|
+
end
|
|
497
|
+
|
|
357
498
|
# A backend that never reads a setting the user set would silently do
|
|
358
499
|
# nothing with it, so say so. `--test-command` builds no coverage map and
|
|
359
500
|
# has its own runtime, so it reads none of {UNUSED_SETTING_SCOPE}; the
|
|
@@ -543,16 +684,17 @@ module Mutineer
|
|
|
543
684
|
reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
|
|
544
685
|
format: config.format, output: config.output, baseline: delta,
|
|
545
686
|
scoped: !config.since.nil?, legacy_id_matches: legacy_id_matches)
|
|
687
|
+
warn_empty_run(config, extras) if aggregate.total.zero?
|
|
546
688
|
|
|
547
689
|
# Warn (stderr, so it never pollutes json/html) that an external run's score
|
|
548
690
|
# is not comparable to an in-process run: no coverage narrowing (uncovered
|
|
549
|
-
# mutants count as survivors), and an infra failure
|
|
550
|
-
# (
|
|
691
|
+
# mutants count as survivors), and an infra failure that still starts the
|
|
692
|
+
# suite is scored as a kill (exit 126/127 are scored error). Daemon coverage fallback warnings are emitted from the runner
|
|
551
693
|
# only when the map is unavailable, not on every --daemon run.
|
|
552
694
|
if config.test_command
|
|
553
695
|
warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
|
|
554
696
|
"in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
|
|
555
|
-
"and an infra failure is scored as a kill."
|
|
697
|
+
"and an infra failure is scored as a kill (except exit 126 and 127, scored error)."
|
|
556
698
|
end
|
|
557
699
|
|
|
558
700
|
# Nudge toward the opt-in tier-2 operators (human report only: never
|
|
@@ -567,6 +709,27 @@ module Mutineer
|
|
|
567
709
|
exit [reporter.exit_code(threshold: config.threshold), baseline_exit].max
|
|
568
710
|
end
|
|
569
711
|
|
|
712
|
+
# Says why a run has no mutants, on stderr for every format. A --since run
|
|
713
|
+
# whose sources had mutants before scoping is a stated success: the changes
|
|
714
|
+
# held no mutable Ruby code. Any other empty run is a likely mistake (wrong
|
|
715
|
+
# path, operators that never match), which 2.0 fails unless --allow-empty
|
|
716
|
+
# says it is expected.
|
|
717
|
+
#
|
|
718
|
+
# @param config [Mutineer::Config] run configuration.
|
|
719
|
+
# @param extras [Hash] the run extras; `:unscoped_mutants` counts the mutants (run and
|
|
720
|
+
# suppressed) before --since.
|
|
721
|
+
# @return [void]
|
|
722
|
+
def self.warn_empty_run(config, extras)
|
|
723
|
+
# Mutants existed before --since narrowed them: the changes held none.
|
|
724
|
+
if config.since && extras[:unscoped_mutants].to_i.positive?
|
|
725
|
+
warn "mutineer: nothing to test in the changes since #{config.since}"
|
|
726
|
+
elsif !config.allow_empty
|
|
727
|
+
warn "mutineer: no mutants were generated. Check that the sources contain code the " \
|
|
728
|
+
"operators mutate. The run exits 0, and #{BECOMES_ERROR_IN_2_0}. " \
|
|
729
|
+
"Pass --allow-empty (or allow_empty: true) if an empty run is expected."
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
570
733
|
# The tier-2 operators not in the active set, as a one-line hint (or nil when
|
|
571
734
|
# they are all already enabled). `active` nil means the default (Tier-1) set.
|
|
572
735
|
#
|
|
@@ -623,7 +786,8 @@ module Mutineer
|
|
|
623
786
|
warn "[mutineer] ignore entry #{old} uses the old id format, which did not include the " \
|
|
624
787
|
"file path. It matched these new ids: #{listed}. This list covers only mutants in " \
|
|
625
788
|
"this run's sources and operators; a run over every source gives the complete " \
|
|
626
|
-
"replacement. #{advice}"
|
|
789
|
+
"replacement. #{advice} To update the file, run `mutineer migrate`. " \
|
|
790
|
+
"#{BECOMES_ERROR_IN_2_0}."
|
|
627
791
|
end
|
|
628
792
|
end
|
|
629
793
|
|
|
@@ -634,28 +798,24 @@ module Mutineer
|
|
|
634
798
|
# @return [void]
|
|
635
799
|
def self.warn_legacy_baseline
|
|
636
800
|
warn "[mutineer] the baseline uses the old id format, which did not include the file " \
|
|
637
|
-
"path, so survivors were matched on their old ids and files.
|
|
638
|
-
"
|
|
639
|
-
"
|
|
801
|
+
"path, so survivors were matched on their old ids and files. Please generate a new " \
|
|
802
|
+
"baseline with `--format json`, but only after every gate that reads it runs this " \
|
|
803
|
+
"mutineer version or later. #{BECOMES_ERROR_IN_2_0}."
|
|
640
804
|
end
|
|
641
805
|
|
|
642
|
-
# Runs dry-run mode. Reuses
|
|
806
|
+
# Runs dry-run mode. Reuses JobPlan.collect_jobs (+ scope_since) so the
|
|
643
807
|
# candidate list cannot drift from a real run's job selection.
|
|
644
808
|
#
|
|
645
809
|
# @param config [Mutineer::Config] run configuration.
|
|
646
810
|
# @return [void]
|
|
647
811
|
def self.dry_run(config)
|
|
648
812
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
649
|
-
jobs, ignored_results, source_map, extras =
|
|
813
|
+
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
650
814
|
warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
|
|
651
|
-
# Narrow jobs and ignored the same way
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
ignored = Runner.filter_since(ignored_jobs, source_map, config).size
|
|
656
|
-
else
|
|
657
|
-
ignored = ignored_results.size
|
|
658
|
-
end
|
|
815
|
+
# Narrow jobs and ignored the same way a real run does, so the summary
|
|
816
|
+
# matches the printed list.
|
|
817
|
+
jobs, ignored_results, = JobPlan.scope_since(jobs, ignored_results, source_map, config)
|
|
818
|
+
ignored = ignored_results.size
|
|
659
819
|
|
|
660
820
|
per_operator = Hash.new(0)
|
|
661
821
|
skipped = 0
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Lets the reload strategy load a concern file a second time (#256).
|
|
5
|
+
#
|
|
6
|
+
# ActiveSupport::Concern stores an `included` or `prepended` block once. A
|
|
7
|
+
# second block with another `source_location` raises
|
|
8
|
+
# `MultipleIncludedBlocks` (or `MultiplePrependBlocks`). Rails reloads keep
|
|
9
|
+
# the file's path, but a mutant loads from a temp file beside it, so every
|
|
10
|
+
# mutant of a concern with such a block would error. {.forget} removes the
|
|
11
|
+
# stored blocks that the source file defined, so the mutant's load stores
|
|
12
|
+
# its own. {.replace} wraps the load and puts back any block the load did not
|
|
13
|
+
# store again, such as one under an `unless defined?` guard. A class that
|
|
14
|
+
# already includes the concern keeps what the original block defined in it:
|
|
15
|
+
# ActiveSupport does not run the block there again.
|
|
16
|
+
# Discovery marks the defs in these blocks (see {Subject#unplaceable?}).
|
|
17
|
+
#
|
|
18
|
+
# Call it only in a forked child that loads the mutant. Without
|
|
19
|
+
# ActiveSupport it does nothing.
|
|
20
|
+
#
|
|
21
|
+
# Stdlib-only, so the app-side daemon can load it.
|
|
22
|
+
module ConcernBlocks
|
|
23
|
+
# Where ActiveSupport::Concern stores each block.
|
|
24
|
+
STORED_BLOCKS = %i[@_included_block @_prepended_block].freeze
|
|
25
|
+
|
|
26
|
+
# Forgets the source file's stored blocks, runs the block that loads the
|
|
27
|
+
# mutant, then puts back each forgotten block the load did not store again.
|
|
28
|
+
# A concern the mutant's load skips (an `unless defined?` guard) keeps its
|
|
29
|
+
# original block, so a class that includes it during the tests still gets it.
|
|
30
|
+
#
|
|
31
|
+
# @param source_file [String] the file the mutant replaces.
|
|
32
|
+
# @yield the mutant's load.
|
|
33
|
+
# @return [Object] what the block returns.
|
|
34
|
+
def self.replace(source_file)
|
|
35
|
+
forgotten = forget(source_file)
|
|
36
|
+
yield
|
|
37
|
+
ensure
|
|
38
|
+
forgotten&.each do |concern, name, block|
|
|
39
|
+
concern.instance_variable_set(name, block) unless concern.instance_variable_defined?(name)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Removes every stored `included` and `prepended` block that the source
|
|
44
|
+
# file defined, from every concern. Blocks from other files stay.
|
|
45
|
+
#
|
|
46
|
+
# @param source_file [String] the file the mutant replaces.
|
|
47
|
+
# @return [Array<Array(Module, Symbol, Proc)>] each concern, where it stored
|
|
48
|
+
# the block, and the block removed.
|
|
49
|
+
def self.forget(source_file)
|
|
50
|
+
return [] unless defined?(::ActiveSupport::Concern)
|
|
51
|
+
|
|
52
|
+
path = real_path(source_file)
|
|
53
|
+
forgotten = []
|
|
54
|
+
ObjectSpace.each_object(::ActiveSupport::Concern) do |concern|
|
|
55
|
+
STORED_BLOCKS.each do |name|
|
|
56
|
+
next unless concern.instance_variable_defined?(name)
|
|
57
|
+
|
|
58
|
+
block = concern.instance_variable_get(name)
|
|
59
|
+
file = block.source_location&.first
|
|
60
|
+
next unless file && real_path(file) == path
|
|
61
|
+
|
|
62
|
+
concern.remove_instance_variable(name)
|
|
63
|
+
forgotten << [concern, name, block]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
forgotten
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The file's real path, so a symlinked or relative path matches the path
|
|
70
|
+
# Ruby recorded when it loaded the file.
|
|
71
|
+
#
|
|
72
|
+
# @param file [String] a file path.
|
|
73
|
+
# @return [String]
|
|
74
|
+
def self.real_path(file)
|
|
75
|
+
File.realpath(file)
|
|
76
|
+
rescue SystemCallError
|
|
77
|
+
File.expand_path(file)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|