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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +432 -0
- data/README.md +157 -747
- data/lib/mutineer/baseline.rb +1 -1
- data/lib/mutineer/changed_lines.rb +2 -1
- data/lib/mutineer/cli.rb +119 -27
- data/lib/mutineer/concern_blocks.rb +168 -5
- data/lib/mutineer/config.rb +6 -0
- data/lib/mutineer/coverage_map.rb +92 -17
- data/lib/mutineer/daemon_backend.rb +75 -32
- data/lib/mutineer/daemon_client.rb +50 -17
- data/lib/mutineer/daemon_server.rb +92 -40
- data/lib/mutineer/external_backend.rb +150 -17
- data/lib/mutineer/isolation.rb +67 -25
- data/lib/mutineer/job_plan.rb +117 -33
- data/lib/mutineer/kill_channel.rb +24 -14
- data/lib/mutineer/migrate.rb +10 -8
- data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
- data/lib/mutineer/minitest_integration.rb +44 -14
- data/lib/mutineer/mutators/array_literal.rb +4 -2
- data/lib/mutineer/mutators/base.rb +22 -0
- data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
- data/lib/mutineer/mutators/chain_link.rb +6 -6
- data/lib/mutineer/mutators/condition_forcing.rb +6 -3
- data/lib/mutineer/mutators/operand_removal.rb +4 -2
- data/lib/mutineer/mutators/range_literal.rb +105 -4
- data/lib/mutineer/mutators/return_nil.rb +1 -0
- data/lib/mutineer/orphan_guard.rb +18 -6
- data/lib/mutineer/pairing.rb +62 -1
- data/lib/mutineer/project.rb +226 -12
- data/lib/mutineer/rails_worker_db.rb +200 -16
- data/lib/mutineer/redaction.rb +112 -11
- data/lib/mutineer/reporter.rb +109 -21
- data/lib/mutineer/result.rb +33 -5
- data/lib/mutineer/runner.rb +126 -18
- data/lib/mutineer/statement_lines.rb +14 -8
- data/lib/mutineer/subject.rb +13 -5
- data/lib/mutineer/test_runners/minitest.rb +2 -1
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/triage.rb +1 -1
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +2 -1
- data/skills/mutineer/SKILL.md +84 -0
- metadata +5 -1
data/lib/mutineer/baseline.rb
CHANGED
|
@@ -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...>
|
|
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;
|
|
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
|
|
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..].
|
|
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
|
-
#
|
|
348
|
-
#
|
|
349
|
-
|
|
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
|
-
#
|
|
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
|
|
692
|
-
#
|
|
693
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
728
|
-
|
|
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.
|
|
14
|
-
#
|
|
15
|
-
# ActiveSupport does not run the block
|
|
16
|
-
#
|
|
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
|
|
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
|
#
|
data/lib/mutineer/config.rb
CHANGED
|
@@ -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
|