mutineer 1.7.0 → 1.8.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. metadata +5 -1
@@ -50,7 +50,7 @@ module Mutineer
50
50
  # @return [Mutineer::Baseline] baseline object.
51
51
  # @raise [Mutineer::ConfigError] when the file is missing or invalid.
52
52
  def self.load(path)
53
- doc = JSON.parse(File.read(path))
53
+ doc = JSON.parse(File.read(path, encoding: "UTF-8"))
54
54
  unless doc.is_a?(Hash) && doc["schema_version"] && doc["survivors"].is_a?(Array)
55
55
  raise ConfigError, "not a Mutineer JSON report: #{path}"
56
56
  end
@@ -73,7 +73,8 @@ module Mutineer
73
73
  # @return [String] diff text, or `""` on failure (after a stderr warning).
74
74
  def git_diff(ref, abs_file, project_root)
75
75
  out, _err, status = Open3.capture3(
76
- "git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
76
+ "git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file,
77
+ binmode: true # the hunk regex must not trip on a changed line's non-ASCII bytes
77
78
  )
78
79
  if status.success?
79
80
  return out if indexed?(abs_file, project_root)
data/lib/mutineer/cli.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "optparse"
4
5
  require "open3"
5
6
  require_relative "version"
@@ -17,6 +18,7 @@ require_relative "migrate"
17
18
  require_relative "mutator_registry"
18
19
  require_relative "triage"
19
20
  require_relative "init"
21
+ require_relative "redaction"
20
22
 
21
23
  module Mutineer
22
24
  # Command-line entry point. `start` is the single public method called by
@@ -34,7 +36,7 @@ module Mutineer
34
36
  Usage: mutineer [options] <command> [args]
35
37
 
36
38
  Commands:
37
- run [options] <source...> --test <test> [--test <test>...]
39
+ run [options] <source...> [--test <test>...]
38
40
  Mutate, run, and report
39
41
  run --dry-run [options] <source...> Print candidate mutations only
40
42
  triage REPORT.json --reason TEXT
@@ -48,7 +50,9 @@ module Mutineer
48
50
  --force replaces an existing file.
49
51
 
50
52
  Run options:
51
- --test FILE Test file covering the sources (one per flag; repeat it)
53
+ --test FILE Test file covering the sources (one per flag; repeat it).
54
+ Omit to pair each source with its test by convention
55
+ (not under --dry-run)
52
56
  --operators LIST Comma-separated operator names (default: Tier 1 set)
53
57
  --threshold FLOAT Fail (exit 1) when score < FLOAT (default: 0 = off)
54
58
  --baseline FILE Fail (exit 1) on NEW survivors / score drop vs a prior
@@ -73,7 +77,7 @@ module Mutineer
73
77
  cover these sources
74
78
  --framework NAME minitest or rspec (default: auto-detect from --test names)
75
79
  --boot FILE Require FILE once in the parent to boot the app env, then
76
- fork per mutant (Rails apps; requires --test)
80
+ fork per mutant (Rails apps; needs a test, given or found by convention)
77
81
  --rails Boot config/environment; without --daemon, defaults to
78
82
  redefine and serial execution
79
83
  --test-command CMD Run the target suite in the app's own runtime as a
@@ -97,7 +101,9 @@ module Mutineer
97
101
  matrix:)
98
102
  --allow-empty A run with no mutants is expected; do not warn (--no-allow-empty
99
103
  beats a .mutineer.yml allow_empty:)
100
- --verbose Surface the real error when a fork capture fails (alias: --debug)
104
+ --verbose Show full error details: the backtrace and causes of a load
105
+ failure, the real error of a failed fork capture, and the
106
+ --test-command output (alias: --debug)
101
107
 
102
108
  Options:
103
109
  --list-operators List available operators (default vs optional) and exit
@@ -207,7 +213,9 @@ module Mutineer
207
213
  case argv.first
208
214
  when "run"
209
215
  warn_config_root_mismatch(file_path, config.project_root) if file_path
210
- tests_as_sources = argv[1..].grep(%r{(\A|/)(test|spec)/.*_(test|spec)\.rb\z})
216
+ tests_as_sources = argv[1..].select do |arg|
217
+ Pairing.test_given_as_source?(arg, project_root: config.project_root, tests: config.tests)
218
+ end
211
219
  if config.explicit?(:tests) && tests_as_sources.any?
212
220
  warn "mutineer: #{tests_as_sources.join(', ')} looks like a test file, not a source. " \
213
221
  "--test takes one file; repeat it for each test file (--test a_test.rb --test b_test.rb)"
@@ -248,7 +256,7 @@ module Mutineer
248
256
  # @return [void]
249
257
  def self.migrate(config, file_path)
250
258
  current, old_to_new = Migrate.id_maps(config)
251
- text = File.read(file_path)
259
+ text = File.read(file_path, encoding: "UTF-8")
252
260
  outcome = Migrate.rewrite(text, current, old_to_new)
253
261
  outcome.replacements.each do |rep|
254
262
  puts "[mutineer] #{rep.old_id} -> #{rep.new_ids.join(', ')}"
@@ -336,38 +344,55 @@ module Mutineer
336
344
  config.dry_run ? dry_run(config) : execute(config)
337
345
  rescue ArgumentError => e
338
346
  # Unknown --operators value surfaces here; no backtrace reaches the user.
347
+ warn "mutineer: #{Redaction.text(e.message)}"
348
+ exit 2
349
+ rescue Mutineer::ConfigError => e
350
+ # A config value that only the booted app can check (`shared_databases`
351
+ # naming the primary database). A usage error, raised before any mutant runs.
339
352
  warn "mutineer: #{e.message}"
340
353
  exit 2
341
354
  rescue SystemCallError => e
342
355
  # A missing/unreadable path reaches here as Errno::ENOENT etc. A plain
343
356
  # message and usage exit, never a raw backtrace.
344
- warn "mutineer: #{e.message}"
357
+ warn "mutineer: #{Redaction.text(e.message)}"
345
358
  exit 2
346
359
  rescue SyntaxError => e
347
- # A syntactically invalid source file surfaces when `require`d; report it
348
- # cleanly rather than dumping a backtrace.
349
- warn "mutineer: cannot load source: #{e.message}"
360
+ # Boot, source and require files report a SyntaxError as ProjectLoadError
361
+ # below. This covers a file loaded on any other path; report it cleanly
362
+ # rather than dumping a backtrace.
363
+ warn "mutineer: cannot load source: #{Redaction.text(e.message)}"
350
364
  exit 1
351
365
  rescue Mutineer::ParseError => e
352
- warn "mutineer: error reading: #{e.message}"
366
+ warn "mutineer: error reading: #{Redaction.text(e.message)}"
353
367
  exit 1
354
368
  rescue Mutineer::SmokeCheckError => e
355
369
  # The unmutated suite is not green under --test-command: a broken
356
370
  # environment, not weak tests. Runtime error (exit 1), not usage (exit 2).
357
- warn "mutineer: #{e.message}"
371
+ warn "mutineer: #{Redaction.text(e.message)}"
358
372
  exit 1
359
373
  rescue Mutineer::ConcurrentRunError => e
360
374
  # Another process owns a source file. Runtime error (exit 1), not a
361
375
  # backtrace: the working tree is still the other run's responsibility.
362
- warn "mutineer: #{e.message}"
376
+ warn "mutineer: #{Redaction.text(e.message)}"
363
377
  exit 1
364
378
  rescue Mutineer::CoverageFromError => e
365
- warn "mutineer: #{e.message}"
379
+ warn "mutineer: #{Redaction.text(e.message)}"
366
380
  exit 2
367
381
  rescue Mutineer::DaemonBootError => e
368
382
  # The daemon is gone for good, so the run ended rather than scoring the rest
369
383
  # against it. A deliberate stop deserves a message, not a raw backtrace.
384
+ warn "mutineer: #{Redaction.text(e.message)}"
385
+ exit 1
386
+ rescue Mutineer::ProjectLoadError => e
387
+ # A boot file, source or --require file failed to load. The message is
388
+ # already redacted; --verbose adds the backtrace and the cause chain.
370
389
  warn "mutineer: #{e.message}"
390
+ if config.verbose
391
+ (e.backtrace || []).each { |frame| warn "\t#{Redaction.text(frame)}" }
392
+ e.causes.each { |cause| warn "mutineer: caused by: #{cause}" }
393
+ else
394
+ warn "mutineer: re-run with --verbose for the backtrace and causes"
395
+ end
371
396
  exit 1
372
397
  end
373
398
 
@@ -383,8 +408,9 @@ module Mutineer
383
408
  validate_test_command!(config) if config.test_command
384
409
 
385
410
  validate_since!(config) if config.since
386
- preflight_output!(config.output) if config.output
411
+ preflight_output!(config.output, config) if config.output
387
412
  preflight_baseline!(config.baseline) if config.baseline
413
+ preflight_same_file!(config) if config.output && config.baseline && !config.dry_run
388
414
 
389
415
  # When --test is omitted, infer each source's test by convention. Autopair
390
416
  # also re-detects framework from inferred tests when --framework was not set.
@@ -636,13 +662,50 @@ module Mutineer
636
662
  exit 2
637
663
  end
638
664
 
639
- # Preflights an output path.
665
+ # #281: a report written over its own baseline turns a failed gate into the
666
+ # next baseline. Same check as the Action's guard (action.yml); the message
667
+ # is that guard's text adapted for the CLI, so keep the two in step. The
668
+ # output is expanded the way Reporter#report writes it, so `x/../b.json`
669
+ # with a missing `x/` still matches `b.json`. File.identical? is false when
670
+ # either file is missing, so a new output path never matches.
671
+ # Unlike the Action's guard, a dry run skips this check: it writes no report.
672
+ #
673
+ # @api private
674
+ # @param config [Mutineer::Config] run configuration (output and baseline set).
675
+ # @return [void]
676
+ def self.preflight_same_file!(config)
677
+ output = File.expand_path(config.output)
678
+ return unless File.identical?(output, config.baseline)
679
+
680
+ warn "mutineer: --output and #{config.origin(:baseline)} are the same file ('#{File.realpath(output)}'). " \
681
+ "A failing run would replace the baseline. Write the report to a separate path. " \
682
+ "Copy that file onto the baseline only when the run exits 0."
683
+ exit 2
684
+ end
685
+
686
+ # Preflights an output path. A missing parent is a usage error (exit 2),
687
+ # except the cache directory: the documented `--output .mutineer/run.json`
688
+ # must work on a fresh checkout. It is created here, before the run, so a
689
+ # failure to create it exits 2 up front. A later failed check can leave the
690
+ # empty cache directory behind (a real run creates it anyway). A dry run
691
+ # writes no report, so it creates nothing.
640
692
  #
641
693
  # @api private
642
694
  # @param path [String] output file path.
695
+ # @param config [Mutineer::Config] run configuration (for the cache directory).
643
696
  # @return [void]
644
- def self.preflight_output!(path)
697
+ def self.preflight_output!(path, config)
645
698
  dir = File.dirname(File.expand_path(path))
699
+ if !File.exist?(dir) && dir == File.expand_path(config.cache_dir, config.project_root)
700
+ return if config.dry_run
701
+
702
+ begin
703
+ FileUtils.mkdir_p(dir)
704
+ rescue SystemCallError => e
705
+ warn "mutineer: cannot write to #{path}: #{e.message}"
706
+ exit 2
707
+ end
708
+ end
646
709
  return if File.directory?(dir) && File.writable?(dir)
647
710
 
648
711
  reason = File.directory?(dir) ? "directory is not writable" : "no such directory"
@@ -683,18 +746,24 @@ module Mutineer
683
746
  baseline: delta ? delta.legacy_matches : 0 }
684
747
  reporter.report(out: $stdout, err: $stderr, threshold: config.threshold,
685
748
  format: config.format, output: config.output, baseline: delta,
686
- scoped: !config.since.nil?, legacy_id_matches: legacy_id_matches)
749
+ scoped: !config.since.nil?, legacy_id_matches: legacy_id_matches,
750
+ declined: extras.fetch(:declined, {}))
687
751
  warn_empty_run(config, extras) if aggregate.total.zero?
688
752
 
689
753
  # Warn (stderr, so it never pollutes json/html) that an external run's score
690
754
  # is not comparable to an in-process run: no coverage narrowing (uncovered
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
693
- # only when the map is unavailable, not on every --daemon run.
755
+ # mutants count as survivors), and an infra failure that exits 1 can still
756
+ # score as a kill (#292): with the summary check on, when its output names a
757
+ # failing test; with it off (the clean run printed no Minitest or RSpec
758
+ # summary line), on any exit 1, a load crash included. Daemon coverage
759
+ # fallback warnings are emitted from the runner only when the map is
760
+ # unavailable, not on every --daemon run.
694
761
  if config.test_command
695
762
  warn "[mutineer] --test-command score is an upper bound, not comparable to an " \
696
- "in-process run: no coverage narrowing (uncovered mutants count as survivors) " \
697
- "and an infra failure is scored as a kill (except exit 126 and 127, scored error)."
763
+ "in-process run: no coverage narrowing (uncovered mutants count as survivors). " \
764
+ "Only exit 1 counts as a kill, or a SimpleCov gate exit whose summary shows a failing " \
765
+ "test (an RSpec --failure-exit-code other than 1 makes every kill an error). When the clean run printed a Minitest or RSpec summary line, " \
766
+ "Mutineer also reads it to tell a crash from a kill. See https://github.com/davidteren/mutineer/blob/main/docs/rails-setup.md#apps-on-ruby--34"
698
767
  end
699
768
 
700
769
  # Nudge toward the opt-in tier-2 operators (human report only: never
@@ -717,15 +786,21 @@ module Mutineer
717
786
  #
718
787
  # @param config [Mutineer::Config] run configuration.
719
788
  # @param extras [Hash] the run extras; `:unscoped_mutants` counts the mutants (run and
720
- # suppressed) before --since.
789
+ # suppressed) before --since, and `:declined` the candidates each operator did not mutate.
721
790
  # @return [void]
722
791
  def self.warn_empty_run(config, extras)
723
792
  # Mutants existed before --since narrowed them: the changes held none.
724
793
  if config.since && extras[:unscoped_mutants].to_i.positive?
725
794
  warn "mutineer: nothing to test in the changes since #{config.since}"
726
795
  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}. " \
796
+ declined = extras.fetch(:declined, {}).values.sum
797
+ cause = if declined.positive?
798
+ "#{declined} #{declined == 1 ? 'candidate was' : 'candidates were'} declined " \
799
+ "(--dry-run shows the count per operator)."
800
+ else
801
+ "Check that the sources contain code the operators mutate."
802
+ end
803
+ warn "mutineer: no mutants were generated. #{cause} The run exits 0, and #{BECOMES_ERROR_IN_2_0}. " \
729
804
  "Pass --allow-empty (or allow_empty: true) if an empty run is expected."
730
805
  end
731
806
  end
@@ -837,8 +912,25 @@ module Mutineer
837
912
  breakdown = per_operator.map { |op, n| "#{op}: #{n}" }.join(", ")
838
913
  summary = breakdown.empty? ? "" : "#{breakdown} — "
839
914
  puts "#{summary}#{total} mutations (dry run, not executed); " \
840
- "#{skipped} skipped (invalid); #{ignored} ignored (suppressed)"
915
+ "#{skipped} skipped (invalid); #{ignored} ignored (suppressed); " \
916
+ "#{declined_summary(extras[:declined], since: config.since)}"
841
917
  exit 0
842
918
  end
919
+
920
+ # Formats the dry run's declined count: the total, then each operator's
921
+ # count when the total is above zero. `--since` does not narrow the count,
922
+ # so under `--since` the text says so.
923
+ #
924
+ # @param declined [Hash{String => Integer}] operator name => declined count.
925
+ # @param since [String, nil] the `--since` ref, when set.
926
+ # @return [String] e.g. `2 declined (range: 2)`.
927
+ def self.declined_summary(declined, since:)
928
+ total = declined.values.sum
929
+ text = "#{total} declined"
930
+ text += " in the scanned files (not narrowed by --since)" if since
931
+ return text if total.zero?
932
+
933
+ "#{text} (#{declined.map { |op, n| "#{op}: #{n}" }.join(', ')})"
934
+ end
843
935
  end
844
936
  end
@@ -10,13 +10,19 @@ module Mutineer
10
10
  # mutant of a concern with such a block would error. {.forget} removes the
11
11
  # stored blocks that the source file defined, so the mutant's load stores
12
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?}).
13
+ # store again, such as one under an `unless defined?` guard.
14
+ #
15
+ # ActiveSupport does not run the block again in a class that already includes
16
+ # the concern, so that class keeps what the original block defined. For a def
17
+ # in the block, the mutant file also passes the mutated def to
18
+ # {.store_hook_def}, just before the `included` or `prepended` call, and
19
+ # {.place_hook_def} then loads only that def into each class that has the
20
+ # method (#297). Nothing else in the block runs again. Discovery decides which
21
+ # defs can be placed this way (see {Subject#unplaceable?}).
17
22
  #
18
23
  # Call it only in a forked child that loads the mutant. Without
19
- # ActiveSupport it does nothing.
24
+ # ActiveSupport, {.replace} and {.forget} do nothing; {.store_hook_def} and
25
+ # {.place_hook_def} work without it.
20
26
  #
21
27
  # Stdlib-only, so the app-side daemon can load it.
22
28
  module ConcernBlocks
@@ -66,6 +72,163 @@ module Mutineer
66
72
  forgotten
67
73
  end
68
74
 
75
+ # Keeps the mutated hook-block def for {.place_hook_def}. The mutant file
76
+ # calls it in the concern's body, just before the hook call, so the block has
77
+ # the same constant scope as the hook block (#297).
78
+ #
79
+ # @yield the mutated def, run later with `class_eval` in each owner.
80
+ # @return [nil]
81
+ def self.store_hook_def(&block)
82
+ @hook_def = block
83
+ nil
84
+ end
85
+
86
+ # Takes the def {.store_hook_def} kept, clears it, and runs it with
87
+ # `class_eval` in each module whose own method came from the original def
88
+ # (see {.owners}), so that module gets the mutated method and nothing else in
89
+ # the block runs again. Each owner keeps the method's visibility. A class
90
+ # that includes the concern later gets the mutant from the stored block.
91
+ #
92
+ # @param source_file [String] the file the mutant replaces.
93
+ # @param line [Integer] the original def's first line.
94
+ # @param name [Symbol] the method name.
95
+ # @param singleton [Boolean] true for a class method.
96
+ # @raise [RuntimeError] when no def was kept (the mutant file did not reach
97
+ # its hook call), or when a module keeps the original under another name
98
+ # (see {.aliases}) or in a live method handle; the child then scores the
99
+ # mutant `error`.
100
+ # @return [Integer] how many owners got the mutated def.
101
+ def self.place_hook_def(source_file:, line:, name:, singleton:)
102
+ block = @hook_def
103
+ @hook_def = nil
104
+ where = "mutineer: could not place the mutant of #{name} (#{source_file}:#{line})"
105
+ unless block
106
+ raise "#{where}: the mutant file did not reach its included or prepended call. " \
107
+ "This is a mutineer bug; please report it."
108
+ end
109
+
110
+ # Check before owner and alias lookup create temporary UnboundMethods.
111
+ if retained_hook_def?(source_file: source_file, line: line, name: name)
112
+ raise "#{where}: the original is retained in a Method or UnboundMethod object, " \
113
+ "so the mutant scores error."
114
+ end
115
+
116
+ # ponytail: one ObjectSpace walk per mutant (about 11 ms with Rails loaded);
117
+ # index modules by method name if a large app makes it slow.
118
+ modules = ObjectSpace.each_object(Module).to_a
119
+ found = owners(modules, source_file: source_file, line: line, name: name, singleton: singleton)
120
+ mod, other = aliases(modules, source_file: source_file, line: line, name: name, singleton: singleton).first
121
+ if mod
122
+ raise "#{where}: #{mod.inspect}#{singleton ? "." : "#"}#{other} is an alias of it and keeps the " \
123
+ "original method, so the mutant scores error."
124
+ end
125
+
126
+ found.each do |owner|
127
+ target = singleton ? owner.singleton_class : owner
128
+ visibility = if target.private_method_defined?(name, false) then :private
129
+ elsif target.protected_method_defined?(name, false) then :protected
130
+ end
131
+ owner.class_eval(&block) # a `def` in the block defines public
132
+ target.send(visibility, name) if visibility
133
+ end
134
+ found.size
135
+ end
136
+
137
+ # Each module whose own method `name` (its singleton class's, for a class
138
+ # method) has the original def's file and line: every place the original
139
+ # block defined it. A subclass that overrides the method, or a class that
140
+ # only inherits it, is not one.
141
+ #
142
+ # @param modules [Array<Module>] every module, including singleton classes
143
+ # into which a concern can be included.
144
+ # @param source_file [String] the file the mutant replaces.
145
+ # @param line [Integer] the original def's first line.
146
+ # @param name [Symbol] the method name.
147
+ # @param singleton [Boolean] true for a class method.
148
+ # @return [Array<Module>]
149
+ def self.owners(modules, source_file:, line:, name:, singleton:)
150
+ path = real_path(source_file)
151
+ modules.select { |mod| own_method_at?(singleton ? mod.singleton_class : mod, name, path, line) }
152
+ end
153
+
154
+ # Each other name any module has for the original def: an alias made
155
+ # outside the block. Check independently of {.owners}: a wrapper can replace
156
+ # the original name while its alias keeps the original body, so the mutant
157
+ # could falsely survive even when no current-name owner remains.
158
+ #
159
+ # @param modules [Array<Module>] every module, including singleton classes.
160
+ # @param source_file [String] the file the mutant replaces.
161
+ # @param line [Integer] the original def's first line.
162
+ # @param name [Symbol] the method name.
163
+ # @param singleton [Boolean] true for a class method.
164
+ # @return [Array<Array(Module, Symbol)>] each module and its alias name.
165
+ def self.aliases(modules, source_file:, line:, name:, singleton:)
166
+ path = real_path(source_file)
167
+ modules.flat_map do |mod|
168
+ target = singleton ? mod.singleton_class : mod
169
+ (target.instance_methods(false) + target.private_instance_methods(false)).filter_map do |other|
170
+ [mod, other] if other != name && own_method_at?(target, other, path, line, original_name: name)
171
+ end
172
+ end
173
+ end
174
+
175
+ # True when the module's own method `name` starts at this file and line.
176
+ # A prepended method can hide it from `instance_method`, so follow `super`
177
+ # until the lookup reaches the method whose owner is the module itself.
178
+ #
179
+ # @param target [Module] the module or singleton class to read.
180
+ # @param name [Symbol] the method name.
181
+ # @param path [String] the source file's real path.
182
+ # @param line [Integer] the def's first line.
183
+ # @param original_name [Symbol] the name the original definition used;
184
+ # distinguishes an alias from another definition on the same line.
185
+ # @return [Boolean]
186
+ def self.own_method_at?(target, name, path, line, original_name: name)
187
+ return false unless target.method_defined?(name, false) || target.private_method_defined?(name, false)
188
+
189
+ method = target.instance_method(name)
190
+ method = method.super_method while method && !method.owner.equal?(target)
191
+ return false unless method
192
+
193
+ file, def_line = method.source_location
194
+ method.original_name == original_name && def_line == line && !file.nil? && real_path(file) == path
195
+ end
196
+
197
+ # True when a live Method or UnboundMethod keeps the original definition.
198
+ # A wrapper can call that handle after the named method was replaced, so
199
+ # loading another def cannot make the wrapper test the mutant. A matching
200
+ # but unreachable reflection result is harmless: collect it, then check
201
+ # again. Most mutants have no matching handle and need no explicit GC.
202
+ #
203
+ # @param source_file [String] the file the mutant replaces.
204
+ # @param line [Integer] the original def's first line.
205
+ # @param name [Symbol] the original method name.
206
+ # @return [Boolean]
207
+ def self.retained_hook_def?(source_file:, line:, name:)
208
+ path = real_path(source_file)
209
+ return false unless method_handle_at?(path, line, name)
210
+
211
+ GC.start
212
+ method_handle_at?(path, line, name)
213
+ end
214
+
215
+ # True when the heap contains a handle for this original definition. Return
216
+ # only a boolean, so the first check does not itself retain a dead handle
217
+ # across the GC in {.retained_hook_def?}.
218
+ #
219
+ # @param path [String] the source file's real path.
220
+ # @param line [Integer] the original def's first line.
221
+ # @param name [Symbol] the original method name.
222
+ # @return [Boolean]
223
+ def self.method_handle_at?(path, line, name)
224
+ [Method, UnboundMethod].any? do |kind|
225
+ ObjectSpace.each_object(kind).any? do |method|
226
+ file, def_line = method.source_location
227
+ method.original_name == name && def_line == line && !file.nil? && real_path(file) == path
228
+ end
229
+ end
230
+ end
231
+
69
232
  # The file's real path, so a symlinked or relative path matches the path
70
233
  # Ruby recorded when it loaded the file.
71
234
  #
@@ -63,6 +63,7 @@ module Mutineer
63
63
  flag: "--capture-timeout"),
64
64
  ConfigOption.new(field: :cache_dir, type: :string, yaml_key: "cache_dir", flag: "--cache-dir"),
65
65
  ConfigOption.new(field: :allow_empty, type: :bool, yaml_key: "allow_empty", flag: "--allow-empty"),
66
+ ConfigOption.new(field: :shared_databases, type: :string_list, yaml_key: "shared_databases"),
66
67
  ConfigOption.new(field: :coverage_from, type: :string, flag: "--coverage-from"),
67
68
  ConfigOption.new(field: :format, type: :enum, flag: "--format", values: %w[human json html]),
68
69
  ConfigOption.new(field: :strategy, type: :enum, flag: "--strategy", values: %w[reload redefine],
@@ -84,6 +85,9 @@ module Mutineer
84
85
  # `matrix` (--matrix) runs every covering test for each mutant and reports
85
86
  # which tests kill it (see KillMatrix); it never changes a verdict.
86
87
  #
88
+ # `shared_databases` (file only) names test database configs that parallel
89
+ # `--daemon` workers may share; see DaemonBackend.worker_count_for.
90
+ #
87
91
  # Boot mode adds: boot (a file to require ONCE in the parent so the app env,
88
92
  # e.g. Rails, is booted before forking; sources are then NOT manually required)
89
93
  # and rails (sugar: defaults boot to config/environment, prefers redefine without
@@ -97,6 +101,7 @@ module Mutineer
97
101
  # :daemon_timeout stays programmatic (set by tests/Runner; no flag yet).
98
102
  :baseline, :baseline_epsilon, :fail_fast, :test_command,
99
103
  :daemon, :daemon_timeout, :timeout, :capture_timeout, :matrix, :allow_empty, :coverage_from,
104
+ :shared_databases,
100
105
  keyword_init: true
101
106
  ) do
102
107
  # Config file name.
@@ -133,6 +138,7 @@ module Mutineer
133
138
  self.daemon = false if daemon.nil?
134
139
  self.matrix = false if matrix.nil?
135
140
  self.allow_empty = false if allow_empty.nil?
141
+ self.shared_databases ||= []
136
142
  end
137
143
 
138
144
  # True when the user wrote `key`, on the command line or in the config