mutineer 1.6.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 +248 -0
- data/README.md +324 -23
- data/lib/mutineer/child_coverage.rb +53 -0
- data/lib/mutineer/cli.rb +182 -23
- data/lib/mutineer/concern_blocks.rb +80 -0
- data/lib/mutineer/config.rb +148 -8
- data/lib/mutineer/coverage_map.rb +175 -12
- data/lib/mutineer/daemon_backend.rb +182 -56
- data/lib/mutineer/daemon_client.rb +30 -0
- data/lib/mutineer/daemon_server.rb +157 -25
- data/lib/mutineer/external_backend.rb +12 -1
- data/lib/mutineer/init.rb +124 -0
- data/lib/mutineer/isolation.rb +14 -2
- data/lib/mutineer/job_plan.rb +129 -23
- data/lib/mutineer/migrate.rb +705 -0
- 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 +72 -0
- data/lib/mutineer/project.rb +80 -6
- data/lib/mutineer/rails_run_env.rb +73 -0
- data/lib/mutineer/rails_worker_db.rb +1408 -53
- data/lib/mutineer/redaction.rb +98 -0
- data/lib/mutineer/reporter.rb +66 -12
- data/lib/mutineer/result.rb +22 -13
- data/lib/mutineer/runner.rb +24 -12
- data/lib/mutineer/statement_lines.rb +15 -8
- data/lib/mutineer/subject.rb +22 -4
- data/lib/mutineer/test_runners/rspec.rb +15 -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 +4 -0
- metadata +9 -1
data/lib/mutineer/cli.rb
CHANGED
|
@@ -13,7 +13,10 @@ require_relative "job_plan"
|
|
|
13
13
|
require_relative "reporter"
|
|
14
14
|
require_relative "kill_matrix"
|
|
15
15
|
require_relative "baseline"
|
|
16
|
+
require_relative "migrate"
|
|
16
17
|
require_relative "mutator_registry"
|
|
18
|
+
require_relative "triage"
|
|
19
|
+
require_relative "init"
|
|
17
20
|
|
|
18
21
|
module Mutineer
|
|
19
22
|
# Command-line entry point. `start` is the single public method called by
|
|
@@ -21,7 +24,8 @@ module Mutineer
|
|
|
21
24
|
#
|
|
22
25
|
# Exit codes:
|
|
23
26
|
# 0 success / requested output (--version, --help, score >= threshold)
|
|
24
|
-
# 1 survivors below threshold,
|
|
27
|
+
# 1 survivors below threshold, a runtime error, or an ignore id
|
|
28
|
+
# `migrate` could not map
|
|
25
29
|
# 2 usage / flag error (unknown subcommand, invalid flag, unknown operator,
|
|
26
30
|
# out-of-range threshold)
|
|
27
31
|
class CLI
|
|
@@ -33,6 +37,15 @@ module Mutineer
|
|
|
33
37
|
run [options] <source...> --test <test> [--test <test>...]
|
|
34
38
|
Mutate, run, and report
|
|
35
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.
|
|
36
49
|
|
|
37
50
|
Run options:
|
|
38
51
|
--test FILE Test file covering the sources (one per flag; repeat it)
|
|
@@ -55,6 +68,9 @@ module Mutineer
|
|
|
55
68
|
and the clean check (default: 120; not with --test-command)
|
|
56
69
|
--cache-dir DIR Directory for the coverage cache (default: .mutineer;
|
|
57
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
|
|
58
74
|
--framework NAME minitest or rspec (default: auto-detect from --test names)
|
|
59
75
|
--boot FILE Require FILE once in the parent to boot the app env, then
|
|
60
76
|
fork per mutant (Rails apps; requires --test)
|
|
@@ -66,7 +82,9 @@ module Mutineer
|
|
|
66
82
|
on the mutineer command (not as KEY=val inside CMD)
|
|
67
83
|
--daemon Boot the app ONCE in a persistent daemon and fork per
|
|
68
84
|
mutant, with per-worker DB isolation so --jobs N is safe
|
|
69
|
-
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)
|
|
70
88
|
--format human|json|html Report format (default: human)
|
|
71
89
|
--output FILE Write the report to FILE instead of stdout
|
|
72
90
|
--dry-run List mutations without executing
|
|
@@ -77,6 +95,8 @@ module Mutineer
|
|
|
77
95
|
killers (in-process only; not with --daemon, --test-command,
|
|
78
96
|
--fail-fast or --dry-run; --no-matrix beats a .mutineer.yml
|
|
79
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:)
|
|
80
100
|
--verbose Surface the real error when a fork capture fails (alias: --debug)
|
|
81
101
|
|
|
82
102
|
Options:
|
|
@@ -90,6 +110,14 @@ module Mutineer
|
|
|
90
110
|
# @param argv [Array<String>] raw command-line arguments.
|
|
91
111
|
# @return [void]
|
|
92
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
|
+
|
|
93
121
|
# The CLI layer holds only the fields the user typed, so Config.resolve
|
|
94
122
|
# can tell a typed `false`/`nil` from an absent flag by whether the key exists.
|
|
95
123
|
opts = {}
|
|
@@ -111,6 +139,7 @@ module Mutineer
|
|
|
111
139
|
# conflicts with another flag can be turned off for one run.
|
|
112
140
|
o.on("--[no-]fail-fast") { |on| opts[:fail_fast] = on }
|
|
113
141
|
o.on("--[no-]matrix") { |on| opts[:matrix] = on }
|
|
142
|
+
o.on("--[no-]allow-empty") { |on| opts[:allow_empty] = on }
|
|
114
143
|
o.on("--only NAME") { |v| opts[:only] = v }
|
|
115
144
|
o.on("--since REF") { |v| opts[:since] = Config.parse(:since, v) }
|
|
116
145
|
# A typed "no" must beat a .mutineer.yml `since:` key: the key is present
|
|
@@ -125,6 +154,7 @@ module Mutineer
|
|
|
125
154
|
o.on("--timeout SECONDS") { |v| opts[:timeout] = Config.parse(:timeout, v) }
|
|
126
155
|
o.on("--capture-timeout SECONDS") { |v| opts[:capture_timeout] = Config.parse(:capture_timeout, v) }
|
|
127
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) }
|
|
128
158
|
o.on("--strategy STRAT") { |v| opts[:strategy] = Config.parse(:strategy, v) }
|
|
129
159
|
o.on("--framework NAME") { |v| opts[:framework] = Config.parse(:framework, v) }
|
|
130
160
|
o.on("--boot FILE") { |v| opts[:boot] = v }
|
|
@@ -141,7 +171,9 @@ module Mutineer
|
|
|
141
171
|
o.on("--test-command CMD") { |v| opts[:test_command] = v }
|
|
142
172
|
# Boot the app ONCE in a persistent daemon and fork per mutant, with
|
|
143
173
|
# per-worker DB isolation so --jobs N is safe under Rails.
|
|
144
|
-
|
|
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 }
|
|
145
177
|
end
|
|
146
178
|
|
|
147
179
|
begin
|
|
@@ -185,12 +217,99 @@ module Mutineer
|
|
|
185
217
|
# project becomes its root-relative path (#104). Test inference (when --test is omitted) happens in validate!.
|
|
186
218
|
config.sources = Pairing.expand_sources(argv[1..], project_root: config.project_root)
|
|
187
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)
|
|
188
232
|
else
|
|
189
233
|
warn "mutineer: unknown command '#{argv.first}'"
|
|
190
234
|
exit 2
|
|
191
235
|
end
|
|
192
236
|
end
|
|
193
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
|
+
|
|
194
313
|
# Lists available operators.
|
|
195
314
|
#
|
|
196
315
|
# @return [void]
|
|
@@ -212,6 +331,7 @@ module Mutineer
|
|
|
212
331
|
exit 2
|
|
213
332
|
end
|
|
214
333
|
validate!(config)
|
|
334
|
+
Config.warn_rails_default_change(config)
|
|
215
335
|
|
|
216
336
|
config.dry_run ? dry_run(config) : execute(config)
|
|
217
337
|
rescue ArgumentError => e
|
|
@@ -241,6 +361,9 @@ module Mutineer
|
|
|
241
361
|
# backtrace: the working tree is still the other run's responsibility.
|
|
242
362
|
warn "mutineer: #{e.message}"
|
|
243
363
|
exit 1
|
|
364
|
+
rescue Mutineer::CoverageFromError => e
|
|
365
|
+
warn "mutineer: #{e.message}"
|
|
366
|
+
exit 2
|
|
244
367
|
rescue Mutineer::DaemonBootError => e
|
|
245
368
|
# The daemon is gone for good, so the run ended rather than scoring the rest
|
|
246
369
|
# against it. A deliberate stop deserves a message, not a raw backtrace.
|
|
@@ -271,6 +394,7 @@ module Mutineer
|
|
|
271
394
|
# cannot bypass the RSpec rejection (framework would still be minitest if we
|
|
272
395
|
# validated before discovery).
|
|
273
396
|
validate_daemon!(config) if config.daemon
|
|
397
|
+
warn_unused_coverage_from(config)
|
|
274
398
|
|
|
275
399
|
# Boot mode needs at least one --test file (nothing to select from otherwise).
|
|
276
400
|
if config.boot && config.tests.empty?
|
|
@@ -318,9 +442,10 @@ module Mutineer
|
|
|
318
442
|
end
|
|
319
443
|
|
|
320
444
|
# --daemon selects the persistent-daemon backend (boot once, fork per mutant,
|
|
321
|
-
# per-worker DB isolation on SQLite). Usage errors exit 2:
|
|
322
|
-
# --test-command; requires --rails or --boot; minitest only;
|
|
323
|
-
# (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).
|
|
324
449
|
#
|
|
325
450
|
# @api private
|
|
326
451
|
# @param config [Mutineer::Config] run configuration.
|
|
@@ -352,9 +477,24 @@ module Mutineer
|
|
|
352
477
|
UNUSED_SETTING_SCOPE = {
|
|
353
478
|
timeout: "in-process runs only",
|
|
354
479
|
capture_timeout: "in-process and --daemon coverage capture",
|
|
355
|
-
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"
|
|
356
482
|
}.freeze
|
|
357
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
|
+
|
|
358
498
|
# A backend that never reads a setting the user set would silently do
|
|
359
499
|
# nothing with it, so say so. `--test-command` builds no coverage map and
|
|
360
500
|
# has its own runtime, so it reads none of {UNUSED_SETTING_SCOPE}; the
|
|
@@ -544,16 +684,17 @@ module Mutineer
|
|
|
544
684
|
reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
|
|
545
685
|
format: config.format, output: config.output, baseline: delta,
|
|
546
686
|
scoped: !config.since.nil?, legacy_id_matches: legacy_id_matches)
|
|
687
|
+
warn_empty_run(config, extras) if aggregate.total.zero?
|
|
547
688
|
|
|
548
689
|
# Warn (stderr, so it never pollutes json/html) that an external run's score
|
|
549
690
|
# is not comparable to an in-process run: no coverage narrowing (uncovered
|
|
550
|
-
# mutants count as survivors), and an infra failure
|
|
551
|
-
# (
|
|
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
|
|
552
693
|
# only when the map is unavailable, not on every --daemon run.
|
|
553
694
|
if config.test_command
|
|
554
695
|
warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
|
|
555
696
|
"in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
|
|
556
|
-
"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)."
|
|
557
698
|
end
|
|
558
699
|
|
|
559
700
|
# Nudge toward the opt-in tier-2 operators (human report only: never
|
|
@@ -568,6 +709,27 @@ module Mutineer
|
|
|
568
709
|
exit [reporter.exit_code(threshold: config.threshold), baseline_exit].max
|
|
569
710
|
end
|
|
570
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
|
+
|
|
571
733
|
# The tier-2 operators not in the active set, as a one-line hint (or nil when
|
|
572
734
|
# they are all already enabled). `active` nil means the default (Tier-1) set.
|
|
573
735
|
#
|
|
@@ -624,7 +786,8 @@ module Mutineer
|
|
|
624
786
|
warn "[mutineer] ignore entry #{old} uses the old id format, which did not include the " \
|
|
625
787
|
"file path. It matched these new ids: #{listed}. This list covers only mutants in " \
|
|
626
788
|
"this run's sources and operators; a run over every source gives the complete " \
|
|
627
|
-
"replacement. #{advice}"
|
|
789
|
+
"replacement. #{advice} To update the file, run `mutineer migrate`. " \
|
|
790
|
+
"#{BECOMES_ERROR_IN_2_0}."
|
|
628
791
|
end
|
|
629
792
|
end
|
|
630
793
|
|
|
@@ -635,12 +798,12 @@ module Mutineer
|
|
|
635
798
|
# @return [void]
|
|
636
799
|
def self.warn_legacy_baseline
|
|
637
800
|
warn "[mutineer] the baseline uses the old id format, which did not include the file " \
|
|
638
|
-
"path, so survivors were matched on their old ids and files.
|
|
639
|
-
"
|
|
640
|
-
"
|
|
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}."
|
|
641
804
|
end
|
|
642
805
|
|
|
643
|
-
# Runs dry-run mode. Reuses JobPlan.collect_jobs (+
|
|
806
|
+
# Runs dry-run mode. Reuses JobPlan.collect_jobs (+ scope_since) so the
|
|
644
807
|
# candidate list cannot drift from a real run's job selection.
|
|
645
808
|
#
|
|
646
809
|
# @param config [Mutineer::Config] run configuration.
|
|
@@ -649,14 +812,10 @@ module Mutineer
|
|
|
649
812
|
operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
|
|
650
813
|
jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
|
|
651
814
|
warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
|
|
652
|
-
# Narrow jobs and ignored the same way
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
ignored = JobPlan.filter_since(ignored_jobs, source_map, config).size
|
|
657
|
-
else
|
|
658
|
-
ignored = ignored_results.size
|
|
659
|
-
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
|
|
660
819
|
|
|
661
820
|
per_operator = Hash.new(0)
|
|
662
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
|