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.
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, or a runtime error
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
- o.on("--daemon") { opts[:daemon] = true }
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: cannot combine with
321
- # --test-command; requires --rails or --boot; minitest only; reload strategy only
322
- # (redefine needs a shared VM surgical path the daemon does not ship).
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 is scored as a kill
550
- # (upper bound). Daemon coverage fallback warnings are emitted from the runner
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. Regenerate the baseline " \
638
- "(run with --format json and save the output), but only after every gate that reads " \
639
- "it runs this mutineer version or later."
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 Runner.collect_jobs (+ filter_since) so the
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 = Runner.collect_jobs(config, operator_classes)
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 so the summary matches the printed list.
652
- if config.since
653
- jobs = Runner.filter_since(jobs, source_map, config)
654
- ignored_jobs = ignored_results.map { |r| [r.subject, r.mutation, r.id] }
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