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.
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, or a runtime error
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
- 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 }
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: cannot combine with
322
- # --test-command; requires --rails or --boot; minitest only; reload strategy only
323
- # (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).
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 is scored as a kill
551
- # (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
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. Regenerate the baseline " \
639
- "(run with --format json and save the output), but only after every gate that reads " \
640
- "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}."
641
804
  end
642
805
 
643
- # Runs dry-run mode. Reuses JobPlan.collect_jobs (+ filter_since) so the
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 so the summary matches the printed list.
653
- if config.since
654
- jobs = JobPlan.filter_since(jobs, source_map, config)
655
- ignored_jobs = ignored_results.map { |r| [r.subject, r.mutation, r.id] }
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