saboteur 0.2.0 → 0.4.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 +153 -1
- data/README.md +526 -73
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +305 -30
- data/lib/saboteur/config.rb +65 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +88 -0
- data/lib/saboteur/engine/baseline_guard.rb +116 -0
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +40 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +344 -0
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +125 -25
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +210 -0
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +69 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +14 -3
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +131 -0
- data/lib/saboteur/reporter/html.rb +213 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +21 -4
- data/lib/saboteur/reporter/terminal.rb +76 -2
- data/lib/saboteur/result.rb +61 -9
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +7 -0
- metadata +30 -2
data/lib/saboteur/ignore_list.rb
CHANGED
|
@@ -1,40 +1,94 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require "yaml"
|
|
5
|
-
|
|
6
4
|
module Saboteur
|
|
7
|
-
#
|
|
5
|
+
# Entry point used by config/saboteur_ignore.rb.
|
|
6
|
+
def self.ignore
|
|
7
|
+
collector = IgnoreList.current_collector
|
|
8
|
+
raise "Saboteur.ignore is only valid while config/saboteur_ignore.rb loads" unless collector
|
|
9
|
+
|
|
10
|
+
yield collector
|
|
11
|
+
collector
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Survivors the team has accepted. config/saboteur_ignore.rb declares them:
|
|
8
15
|
#
|
|
9
|
-
#
|
|
10
|
-
# mutation
|
|
11
|
-
#
|
|
16
|
+
# Saboteur.ignore do |i|
|
|
17
|
+
# i.mutation "1a2b3c4d5e6f7a8b",
|
|
18
|
+
# subject: "Person#adult?",
|
|
19
|
+
# reason: "equivalent: age is always an integer"
|
|
20
|
+
# end
|
|
12
21
|
#
|
|
13
22
|
# reason is mandatory; a file with a missing or blank reason does not load.
|
|
14
23
|
class IgnoreList
|
|
15
24
|
Entry = Data.define(:subject, :mutation, :reason)
|
|
16
25
|
|
|
26
|
+
# Collects the entries a saboteur_ignore.rb file declares.
|
|
27
|
+
class Collector
|
|
28
|
+
attr_reader :entries
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@entries = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mutation(digest = nil, subject: nil, reason: nil)
|
|
35
|
+
line = caller_locations(1, 1).first.lineno
|
|
36
|
+
values = { "subject" => subject, "mutation" => digest, "reason" => reason }
|
|
37
|
+
missing = values.select { |_, value| value.to_s.strip.empty? }.keys
|
|
38
|
+
raise ConfigError, "#{line}: entry is missing #{missing.join(', ')}" if missing.any?
|
|
39
|
+
|
|
40
|
+
@entries << Entry.new(
|
|
41
|
+
subject: subject.to_s.strip,
|
|
42
|
+
mutation: digest.to_s.strip,
|
|
43
|
+
reason: reason.to_s.strip
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
17
48
|
attr_reader :entries
|
|
18
49
|
|
|
50
|
+
# The collector is process-global on purpose: the ignore file calls the
|
|
51
|
+
# top-level Saboteur.ignore, which must reach the load in progress.
|
|
52
|
+
# Loads are therefore serialized - never load two ignore files at once.
|
|
19
53
|
def self.load(path)
|
|
20
54
|
return new([]) unless path && File.file?(path)
|
|
21
55
|
|
|
22
|
-
|
|
23
|
-
|
|
56
|
+
@collector = Collector.new
|
|
57
|
+
Kernel.load(path)
|
|
58
|
+
new(@collector.entries)
|
|
59
|
+
rescue ConfigError => e
|
|
60
|
+
raise ConfigError, "#{path}:#{e.message}"
|
|
61
|
+
rescue NoMethodError => e
|
|
62
|
+
raise ConfigError, unknown_directive_message(path, e) if e.receiver.is_a?(Collector)
|
|
24
63
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
raise ConfigError,
|
|
64
|
+
# A typo'd call outside the collector: same treatment as a bare
|
|
65
|
+
# constant, or the raw NoMethodError points at our internals.
|
|
66
|
+
raise ConfigError, name_error_message(path, e)
|
|
67
|
+
rescue SyntaxError, LoadError => e
|
|
68
|
+
raise ConfigError, "#{path}: #{e.message.lines.first.to_s.chomp}"
|
|
69
|
+
rescue NameError => e
|
|
70
|
+
raise ConfigError, name_error_message(path, e)
|
|
71
|
+
ensure
|
|
72
|
+
@collector = nil
|
|
28
73
|
end
|
|
29
74
|
|
|
30
|
-
def self.
|
|
31
|
-
|
|
75
|
+
def self.unknown_directive_message(path, error)
|
|
76
|
+
line = error.backtrace_locations.find { |loc| loc.path == path }&.lineno
|
|
77
|
+
"#{path}:#{line}: unknown directive #{error.name.to_s.inspect} - " \
|
|
78
|
+
'declare entries with mutation "<digest>", subject:, reason:'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.name_error_message(path, error)
|
|
82
|
+
line = error.backtrace_locations&.find { |loc| loc.path == path }&.lineno
|
|
83
|
+
"#{path}:#{line}: #{error.message.lines.first.to_s.chomp} " \
|
|
84
|
+
"- this file loads before the app boots; use plain Ruby or ENV"
|
|
85
|
+
end
|
|
32
86
|
|
|
33
|
-
|
|
34
|
-
missing = values.select { |_, value| value.empty? }.keys
|
|
35
|
-
raise ConfigError, "#{path}: entry #{index + 1} is missing #{missing.join(', ')}" if missing.any?
|
|
87
|
+
private_class_method :unknown_directive_message, :name_error_message
|
|
36
88
|
|
|
37
|
-
|
|
89
|
+
# The Collector Saboteur.ignore hands to the file being loaded.
|
|
90
|
+
def self.current_collector
|
|
91
|
+
@collector
|
|
38
92
|
end
|
|
39
93
|
|
|
40
94
|
def initialize(entries)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# Writes the Rails starter files: config/saboteur.rb, bin/saboteur and a
|
|
8
|
+
# .gitignore entry. Plain Ruby so it runs and tests without Rails; the
|
|
9
|
+
# `rails generate saboteur:install` generator is a thin wrapper over it.
|
|
10
|
+
# Existing files are never overwritten unless force is set.
|
|
11
|
+
class Install
|
|
12
|
+
CONFIG = <<~RUBY
|
|
13
|
+
# saboteur configuration for a Rails + RSpec app. CLI flags override this file.
|
|
14
|
+
Saboteur.configure do |c|
|
|
15
|
+
c.include = ["app/**/*.rb"]
|
|
16
|
+
# Empty base classes: nothing useful to mutate.
|
|
17
|
+
c.exclude = [
|
|
18
|
+
"app/models/application_record.rb",
|
|
19
|
+
"app/controllers/application_controller.rb",
|
|
20
|
+
"app/jobs/application_job.rb",
|
|
21
|
+
"app/mailers/application_mailer.rb",
|
|
22
|
+
]
|
|
23
|
+
c.require = ["./config/environment"] # boots the app before specs load
|
|
24
|
+
c.rails = true # reconnect ActiveRecord after fork, per-worker databases
|
|
25
|
+
c.jobs = 4 # parallel workers; use 1 if your specs share external state
|
|
26
|
+
end
|
|
27
|
+
RUBY
|
|
28
|
+
|
|
29
|
+
BIN = <<~RUBY
|
|
30
|
+
#!/usr/bin/env ruby
|
|
31
|
+
# frozen_string_literal: true
|
|
32
|
+
|
|
33
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
34
|
+
require "bundler/setup"
|
|
35
|
+
load Gem.bin_path("saboteur", "saboteur")
|
|
36
|
+
RUBY
|
|
37
|
+
|
|
38
|
+
IGNORE_ENTRY = ".saboteur/"
|
|
39
|
+
|
|
40
|
+
GEMFILE_HINT = <<~TEXT
|
|
41
|
+
Add saboteur to your Gemfile if it is not there yet:
|
|
42
|
+
|
|
43
|
+
group :test do
|
|
44
|
+
gem "saboteur", require: false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Then run it with SimpleCov off for the run, for example:
|
|
48
|
+
|
|
49
|
+
bin/saboteur run --since origin/main
|
|
50
|
+
TEXT
|
|
51
|
+
|
|
52
|
+
Result = Data.define(:status, :path)
|
|
53
|
+
|
|
54
|
+
def initialize(root: Dir.pwd, force: false)
|
|
55
|
+
@root = File.expand_path(root)
|
|
56
|
+
@force = force
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the list of Results (:created, :overwritten, :skipped, :appended, :unchanged).
|
|
60
|
+
def call
|
|
61
|
+
[write("config/saboteur.rb", CONFIG), write("bin/saboteur", BIN, mode: 0o755), gitignore]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Printed after install; the Gemfile is never edited.
|
|
65
|
+
def gemfile_hint
|
|
66
|
+
gemfile = File.join(@root, "Gemfile")
|
|
67
|
+
return nil if File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']saboteur["']/)
|
|
68
|
+
|
|
69
|
+
GEMFILE_HINT
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Never writes or chmods through a symlink: not the file itself, and not a
|
|
75
|
+
# symlinked directory above it (bin -> elsewhere). Without force the file is
|
|
76
|
+
# skipped; with force the link is removed and replaced by a real file or
|
|
77
|
+
# directory inside the project, and the old target is left alone.
|
|
78
|
+
def write(relative, content, mode: nil)
|
|
79
|
+
path = File.join(@root, relative)
|
|
80
|
+
link = symlink_component(relative)
|
|
81
|
+
existed = present?(path)
|
|
82
|
+
return Result.new(:skipped, relative) if (existed || link) && !@force
|
|
83
|
+
|
|
84
|
+
File.delete(link) if link
|
|
85
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
86
|
+
File.write(path, content)
|
|
87
|
+
File.chmod(mode, path) if mode
|
|
88
|
+
Result.new(existed ? :overwritten : :created, relative)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The first symlink on the way from the project root to the file, if any.
|
|
92
|
+
def symlink_component(relative)
|
|
93
|
+
current = @root
|
|
94
|
+
relative.split("/").each do |part|
|
|
95
|
+
current = File.join(current, part)
|
|
96
|
+
return current if File.symlink?(current)
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A dangling symlink does not "exist" but is not free to write either.
|
|
102
|
+
def present?(path)
|
|
103
|
+
File.exist?(path) || File.symlink?(path)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def gitignore
|
|
107
|
+
path = File.join(@root, ".gitignore")
|
|
108
|
+
link = symlink_component(".gitignore")
|
|
109
|
+
return Result.new(:skipped, ".gitignore") if link && !@force
|
|
110
|
+
|
|
111
|
+
current = File.file?(path) ? File.read(path) : ""
|
|
112
|
+
listed = listed?(current)
|
|
113
|
+
return Result.new(:unchanged, ".gitignore") if listed && !link
|
|
114
|
+
|
|
115
|
+
File.delete(link) if link
|
|
116
|
+
File.write(path, "#{current}#{missing_entry(current, listed)}")
|
|
117
|
+
Result.new(link ? :overwritten : :appended, ".gitignore")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def listed?(text)
|
|
121
|
+
text.lines.map(&:strip).include?(IGNORE_ENTRY)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What to append so .saboteur/ is on its own line; nothing if already there.
|
|
125
|
+
def missing_entry(current, listed)
|
|
126
|
+
return "" if listed
|
|
127
|
+
|
|
128
|
+
separator = current.empty? || current.end_with?("\n") ? "" : "\n"
|
|
129
|
+
"#{separator}#{IGNORE_ENTRY}\n"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -61,6 +61,14 @@ module Saboteur
|
|
|
61
61
|
raise NotImplementedError
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Checks the unchanged suite in a fresh child. Existing adapters can
|
|
65
|
+
# use their selected-test runner; adapters may override to retain full
|
|
66
|
+
# suite ordering and report every executed test.
|
|
67
|
+
def run_baseline(seed:)
|
|
68
|
+
Kernel.srand(seed)
|
|
69
|
+
run(all_tests.map(&:id))
|
|
70
|
+
end
|
|
71
|
+
|
|
64
72
|
# Files whose content decides which tests exist (for the cache key).
|
|
65
73
|
def test_files
|
|
66
74
|
[]
|
|
@@ -10,9 +10,9 @@ module Saboteur
|
|
|
10
10
|
def setup
|
|
11
11
|
require "rspec/core"
|
|
12
12
|
::RSpec::Core::Runner.disable_autorun!
|
|
13
|
-
|
|
14
|
-
@runner = ::RSpec::Core::Runner.new(options)
|
|
13
|
+
@runner = ::RSpec::Core::Runner.new(configuration_options)
|
|
15
14
|
@runner.setup($stderr, $stdout)
|
|
15
|
+
configuration.order = "rand:#{config.fetch('seed', 1)}"
|
|
16
16
|
raise Error, "rspec quit while loading spec files" if world.wants_to_quit
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -22,6 +22,16 @@ module Saboteur
|
|
|
22
22
|
args.any? { |arg| !arg.start_with?("-") } ? args : args + ["spec"]
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# RSpec's own parser reads rspec_args and .rspec, so option values such
|
|
26
|
+
# as `--require path` stay intact. Explicit tests then replace only the
|
|
27
|
+
# files and directories to run.
|
|
28
|
+
def configuration_options
|
|
29
|
+
::RSpec::Core::ConfigurationOptions.new(rspec_args).tap do |options|
|
|
30
|
+
explicit = Array(config.fetch("tests", []))
|
|
31
|
+
options.options[:files_or_directories_to_run] = explicit if explicit.any?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
25
35
|
def all_tests
|
|
26
36
|
groups = world.ordered_example_groups.flat_map(&:descendants)
|
|
27
37
|
groups.flat_map { |group| world.filtered_examples[group] }
|
|
@@ -47,8 +57,19 @@ module Saboteur
|
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
def run_all(recorder)
|
|
60
|
+
configuration.order = "rand:#{config.fetch('seed', 1)}"
|
|
50
61
|
configuration.fail_fast = false
|
|
51
|
-
execute(Listener.new(recorder))
|
|
62
|
+
execute(Listener.new(recorder), log_phase: true)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run_baseline(seed:)
|
|
66
|
+
configuration.order = "rand:#{seed}"
|
|
67
|
+
configuration.fail_fast = false
|
|
68
|
+
listener = Listener.new
|
|
69
|
+
passed = execute(listener, log_phase: true)
|
|
70
|
+
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
71
|
+
executed_ids: listener.executed, runtime: listener.runtime,
|
|
72
|
+
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
|
|
52
73
|
end
|
|
53
74
|
|
|
54
75
|
private
|
|
@@ -83,11 +104,19 @@ module Saboteur
|
|
|
83
104
|
world.example_groups.flat_map(&:descendants).each(&:reset_memoized)
|
|
84
105
|
end
|
|
85
106
|
|
|
86
|
-
|
|
107
|
+
# log_phase is for the baseline and coverage children, whose $stdout is
|
|
108
|
+
# redirected to the phase's log: progress and failure detail land in
|
|
109
|
+
# that log, so a failed phase can be diagnosed from it. Mutant runs
|
|
110
|
+
# stay quiet.
|
|
111
|
+
def execute(listener, log_phase: false)
|
|
87
112
|
reporter = ::RSpec::Core::Reporter.new(configuration)
|
|
88
113
|
reporter.register_listener(listener, *Listener::EVENTS)
|
|
89
114
|
groups = world.ordered_example_groups.select { |group| group.descendant_filtered_examples.any? }
|
|
90
115
|
count = world.example_count(groups)
|
|
116
|
+
if log_phase
|
|
117
|
+
logger = PhaseLogger.new($stdout, total: count)
|
|
118
|
+
reporter.register_listener(logger, *PhaseLogger::EVENTS)
|
|
119
|
+
end
|
|
91
120
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
121
|
passed = reporter.report(count) do |r|
|
|
93
122
|
configuration.with_suite_hooks { groups.map { |group| group.run(r) }.all? }
|
|
@@ -96,6 +125,42 @@ module Saboteur
|
|
|
96
125
|
passed && !world.non_example_failure
|
|
97
126
|
end
|
|
98
127
|
|
|
128
|
+
# Progress and failure detail for a phase log. Every 25 finished
|
|
129
|
+
# examples is enough to see movement on a tail; failures print RSpec's
|
|
130
|
+
# full formatted detail, and the run ends with RSpec's totals line.
|
|
131
|
+
class PhaseLogger
|
|
132
|
+
EVENTS = %i[example_finished message dump_failures dump_summary].freeze
|
|
133
|
+
|
|
134
|
+
PROGRESS_EVERY = 25
|
|
135
|
+
|
|
136
|
+
def initialize(out, total:)
|
|
137
|
+
@out = out
|
|
138
|
+
@total = total
|
|
139
|
+
@executed = 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def example_finished(_notification)
|
|
143
|
+
@executed += 1
|
|
144
|
+
return unless @executed == @total || (@executed % PROGRESS_EVERY).zero?
|
|
145
|
+
|
|
146
|
+
@out.puts("#{@executed}/#{@total} examples finished")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A failure outside any example (a suite hook, a load error) arrives
|
|
150
|
+
# as a message, never in dump_failures.
|
|
151
|
+
def message(notification)
|
|
152
|
+
@out.puts(notification.message)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def dump_failures(notification)
|
|
156
|
+
@out.puts(notification.fully_formatted_failed_examples)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def dump_summary(notification)
|
|
160
|
+
@out.puts(notification.totals_line)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
99
164
|
class Listener
|
|
100
165
|
EVENTS = %i[example_started example_finished example_failed].freeze
|
|
101
166
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Deletes one ordinary argument from a parenthesized method call.
|
|
10
|
+
class ArgumentRemoval < Base
|
|
11
|
+
BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
|
|
12
|
+
Prism::ParametersNode, Prism::BlockParametersNode].freeze
|
|
13
|
+
EXCLUDED = [Prism::KeywordHashNode, Prism::SplatNode, Prism::ForwardingArgumentsNode].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
16
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def name
|
|
20
|
+
"argument_removal"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def mutations_for(subject)
|
|
24
|
+
found = []
|
|
25
|
+
tokens = Prism.lex(subject.file_source).value.map(&:first)
|
|
26
|
+
visit(subject.node.body, subject, found, tokens)
|
|
27
|
+
found
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def visit(node, subject, found, tokens)
|
|
33
|
+
return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
34
|
+
|
|
35
|
+
if node.is_a?(Prism::StatementsNode)
|
|
36
|
+
node.body.each do |child|
|
|
37
|
+
visit(child, subject, found, tokens) unless @arid.any? do |p|
|
|
38
|
+
p.match?(child.slice)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
remove_arguments(node, subject, found, tokens) if node.is_a?(Prism::CallNode)
|
|
44
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found, tokens) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def remove_arguments(node, subject, found, tokens)
|
|
48
|
+
return unless node.opening_loc&.slice == "(" && node.arguments
|
|
49
|
+
|
|
50
|
+
arguments = node.arguments.arguments
|
|
51
|
+
arguments.each_with_index do |argument, index|
|
|
52
|
+
next if EXCLUDED.any? { |type| argument.is_a?(type) } || heredoc?(argument)
|
|
53
|
+
|
|
54
|
+
separator = separator_for(node, arguments, index, tokens)
|
|
55
|
+
edits = [delete(subject, argument.location)]
|
|
56
|
+
edits << delete(subject, separator) if separator
|
|
57
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def separator_for(node, arguments, index, tokens)
|
|
62
|
+
argument = arguments[index]
|
|
63
|
+
after = comma_between(argument.location.end_offset, node.closing_loc.start_offset, tokens)
|
|
64
|
+
return after if after
|
|
65
|
+
|
|
66
|
+
previous = arguments[0...index].last
|
|
67
|
+
comma_between(previous.location.end_offset, argument.location.start_offset, tokens) if previous
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def comma_between(first, last, tokens)
|
|
71
|
+
tokens.find do |token|
|
|
72
|
+
token.type == :COMMA && token.location.start_offset >= first && token.location.end_offset <= last
|
|
73
|
+
end&.location
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def heredoc?(node)
|
|
77
|
+
(node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")) ||
|
|
78
|
+
node.compact_child_nodes.any? { |child| heredoc?(child) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delete(subject, location)
|
|
82
|
+
Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset, replacement: "")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
require "prism"
|
|
5
5
|
require_relative "base"
|
|
6
|
+
require_relative "filter_replacement"
|
|
7
|
+
require_relative "literal_replacement"
|
|
8
|
+
require_relative "argument_removal"
|
|
9
|
+
require_relative "pair_removal"
|
|
6
10
|
|
|
7
11
|
module Saboteur
|
|
8
12
|
module MutationSource
|
|
@@ -46,6 +50,10 @@ module Saboteur
|
|
|
46
50
|
def initialize(arid: DEFAULT_ARID)
|
|
47
51
|
super()
|
|
48
52
|
@arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
|
|
53
|
+
@filter_replacement = FilterReplacement.new(arid: arid)
|
|
54
|
+
@literal_replacement = LiteralReplacement.new(arid: arid)
|
|
55
|
+
@argument_removal = ArgumentRemoval.new(arid: arid)
|
|
56
|
+
@pair_removal = PairRemoval.new(arid: arid)
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def name
|
|
@@ -58,7 +66,10 @@ module Saboteur
|
|
|
58
66
|
@found = []
|
|
59
67
|
coarse(body)
|
|
60
68
|
visit(body)
|
|
61
|
-
@found
|
|
69
|
+
@found.concat(@filter_replacement.mutations_for(subject))
|
|
70
|
+
@found.concat(@literal_replacement.mutations_for(subject))
|
|
71
|
+
@found.concat(@argument_removal.mutations_for(subject))
|
|
72
|
+
@found.concat(@pair_removal.mutations_for(subject))
|
|
62
73
|
ensure
|
|
63
74
|
@subject = nil
|
|
64
75
|
end
|
|
@@ -86,8 +97,8 @@ module Saboteur
|
|
|
86
97
|
end
|
|
87
98
|
|
|
88
99
|
# A raise that the method's own rescue catches is absorbed by design. It
|
|
89
|
-
#
|
|
90
|
-
#
|
|
100
|
+
# is not generated: rejecting that exception is not the rescue contract.
|
|
101
|
+
# Fine mutants still run regardless of the coarse verdicts.
|
|
91
102
|
def rescues_everything?(body)
|
|
92
103
|
clause = body.is_a?(Prism::BeginNode) ? body.rescue_clause : nil
|
|
93
104
|
while clause
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Swaps the inclusion rule of a filter call with its exclusion rule.
|
|
10
|
+
# Only message bytes change; arguments, receiver and block stay intact.
|
|
11
|
+
class FilterReplacement < Base
|
|
12
|
+
REPLACEMENTS = { select: "reject", reject: "select", select!: "reject!", reject!: "select!" }.freeze
|
|
13
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
14
|
+
|
|
15
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
16
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
17
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
"filter_replacement"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mutations_for(subject)
|
|
25
|
+
found = []
|
|
26
|
+
visit(subject.node.body, subject, found)
|
|
27
|
+
found
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def visit(node, subject, found)
|
|
33
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
34
|
+
|
|
35
|
+
if node.is_a?(Prism::StatementsNode)
|
|
36
|
+
node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
replace_filter(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
41
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def replace_filter(node, subject, found)
|
|
45
|
+
replacement = REPLACEMENTS[node.name]
|
|
46
|
+
return unless replacement && node.receiver && node.block
|
|
47
|
+
return unless node.arguments.nil?
|
|
48
|
+
|
|
49
|
+
location = node.message_loc
|
|
50
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
51
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
52
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def arid?(node)
|
|
56
|
+
@arid.any? { |pattern| pattern.match?(node.slice) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Changes literal values without rebuilding the surrounding source.
|
|
10
|
+
class LiteralReplacement < Base
|
|
11
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
12
|
+
TEXT_NODES = [Prism::InterpolatedStringNode, Prism::SymbolNode, Prism::InterpolatedSymbolNode,
|
|
13
|
+
Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode,
|
|
14
|
+
Prism::XStringNode, Prism::InterpolatedXStringNode].freeze
|
|
15
|
+
PARAMETER_NODES = [Prism::ParametersNode, Prism::BlockParametersNode].freeze
|
|
16
|
+
BOUNDARIES = (SCOPE_NODES + TEXT_NODES + PARAMETER_NODES).freeze
|
|
17
|
+
|
|
18
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
19
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
20
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
"literal_replacement"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def mutations_for(subject)
|
|
28
|
+
found = []
|
|
29
|
+
visit(subject.node.body, subject, found)
|
|
30
|
+
found
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def visit(node, subject, found)
|
|
36
|
+
return if node.nil? || boundary?(node)
|
|
37
|
+
|
|
38
|
+
if node.is_a?(Prism::StatementsNode)
|
|
39
|
+
node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
case node
|
|
44
|
+
when Prism::TrueNode then add(subject, found, node, "literal_boolean", "false")
|
|
45
|
+
when Prism::FalseNode then add(subject, found, node, "literal_boolean", "true")
|
|
46
|
+
when Prism::StringNode
|
|
47
|
+
replace_string(node, subject, found)
|
|
48
|
+
end
|
|
49
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def boundary?(node)
|
|
53
|
+
BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def replace_string(node, subject, found)
|
|
57
|
+
return if node.unescaped.empty?
|
|
58
|
+
return unless node.opening_loc && !node.opening_loc.slice.start_with?("<<")
|
|
59
|
+
|
|
60
|
+
add(subject, found, node, "literal_string", '""')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def add(subject, found, node, operator, replacement)
|
|
64
|
+
edit = Edit.new(file: subject.file, start_byte: node.location.start_offset,
|
|
65
|
+
end_byte: node.location.end_offset, replacement: replacement)
|
|
66
|
+
found << Mutation.new(subject_id: subject.id, operator: operator, edits: [edit], source: name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def arid?(node)
|
|
70
|
+
@arid.any? { |pattern| pattern.match?(node.slice) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|