saboteur 0.3.0 → 0.5.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
|
@@ -14,6 +14,8 @@ module Saboteur
|
|
|
14
14
|
@runner.setup($stderr, $stdout)
|
|
15
15
|
configuration.order = "rand:#{config.fetch('seed', 1)}"
|
|
16
16
|
raise Error, "rspec quit while loading spec files" if world.wants_to_quit
|
|
17
|
+
|
|
18
|
+
refuse_unintended_focus
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
# RSpec only adds its default "spec" path when $0 is `rspec`.
|
|
@@ -48,7 +50,7 @@ module Saboteur
|
|
|
48
50
|
wanted = Set.new(ids)
|
|
49
51
|
narrow_to(wanted)
|
|
50
52
|
order_by(ids)
|
|
51
|
-
configuration.fail_fast = 1
|
|
53
|
+
configuration.fail_fast = config["matrix"] ? false : 1
|
|
52
54
|
listener = Listener.new
|
|
53
55
|
passed = execute(listener)
|
|
54
56
|
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
@@ -59,14 +61,14 @@ module Saboteur
|
|
|
59
61
|
def run_all(recorder)
|
|
60
62
|
configuration.order = "rand:#{config.fetch('seed', 1)}"
|
|
61
63
|
configuration.fail_fast = false
|
|
62
|
-
execute(Listener.new(recorder))
|
|
64
|
+
execute(Listener.new(recorder), log_phase: true)
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
def run_baseline(seed:)
|
|
66
68
|
configuration.order = "rand:#{seed}"
|
|
67
69
|
configuration.fail_fast = false
|
|
68
70
|
listener = Listener.new
|
|
69
|
-
passed = execute(listener)
|
|
71
|
+
passed = execute(listener, log_phase: true)
|
|
70
72
|
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
71
73
|
executed_ids: listener.executed, runtime: listener.runtime,
|
|
72
74
|
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
|
|
@@ -74,6 +76,24 @@ module Saboteur
|
|
|
74
76
|
|
|
75
77
|
private
|
|
76
78
|
|
|
79
|
+
def refuse_unintended_focus
|
|
80
|
+
return if config["allow_focus"]
|
|
81
|
+
|
|
82
|
+
FocusGuard.new(world:, explicit_filter_keys: explicit_inclusion_keys).check!
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Inclusion filters the user named in rspec_args or .rspec, such as
|
|
86
|
+
# `--tag slow`. Those narrow the run on purpose.
|
|
87
|
+
def explicit_inclusion_keys
|
|
88
|
+
options = configuration_options
|
|
89
|
+
filter_manager = ::RSpec::Core::FilterManager.new
|
|
90
|
+
options.configure_filter_manager(filter_manager)
|
|
91
|
+
keys = filter_manager.inclusions.rules.keys
|
|
92
|
+
# `--example TEXT` becomes a :full_description rule when the options
|
|
93
|
+
# reach the configuration, not in the filter manager.
|
|
94
|
+
options.options[:full_description] ? keys + [:full_description] : keys
|
|
95
|
+
end
|
|
96
|
+
|
|
77
97
|
def world
|
|
78
98
|
::RSpec.world
|
|
79
99
|
end
|
|
@@ -104,11 +124,19 @@ module Saboteur
|
|
|
104
124
|
world.example_groups.flat_map(&:descendants).each(&:reset_memoized)
|
|
105
125
|
end
|
|
106
126
|
|
|
107
|
-
|
|
127
|
+
# log_phase is for the baseline and coverage children, whose $stdout is
|
|
128
|
+
# redirected to the phase's log: progress and failure detail land in
|
|
129
|
+
# that log, so a failed phase can be diagnosed from it. Mutant runs
|
|
130
|
+
# stay quiet.
|
|
131
|
+
def execute(listener, log_phase: false)
|
|
108
132
|
reporter = ::RSpec::Core::Reporter.new(configuration)
|
|
109
133
|
reporter.register_listener(listener, *Listener::EVENTS)
|
|
110
134
|
groups = world.ordered_example_groups.select { |group| group.descendant_filtered_examples.any? }
|
|
111
135
|
count = world.example_count(groups)
|
|
136
|
+
if log_phase
|
|
137
|
+
logger = PhaseLogger.new($stdout, total: count)
|
|
138
|
+
reporter.register_listener(logger, *PhaseLogger::EVENTS)
|
|
139
|
+
end
|
|
112
140
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
141
|
passed = reporter.report(count) do |r|
|
|
114
142
|
configuration.with_suite_hooks { groups.map { |group| group.run(r) }.all? }
|
|
@@ -117,6 +145,42 @@ module Saboteur
|
|
|
117
145
|
passed && !world.non_example_failure
|
|
118
146
|
end
|
|
119
147
|
|
|
148
|
+
# Progress and failure detail for a phase log. Every 25 finished
|
|
149
|
+
# examples is enough to see movement on a tail; failures print RSpec's
|
|
150
|
+
# full formatted detail, and the run ends with RSpec's totals line.
|
|
151
|
+
class PhaseLogger
|
|
152
|
+
EVENTS = %i[example_finished message dump_failures dump_summary].freeze
|
|
153
|
+
|
|
154
|
+
PROGRESS_EVERY = 25
|
|
155
|
+
|
|
156
|
+
def initialize(out, total:)
|
|
157
|
+
@out = out
|
|
158
|
+
@total = total
|
|
159
|
+
@executed = 0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def example_finished(_notification)
|
|
163
|
+
@executed += 1
|
|
164
|
+
return unless @executed == @total || (@executed % PROGRESS_EVERY).zero?
|
|
165
|
+
|
|
166
|
+
@out.puts("#{@executed}/#{@total} examples finished")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A failure outside any example (a suite hook, a load error) arrives
|
|
170
|
+
# as a message, never in dump_failures.
|
|
171
|
+
def message(notification)
|
|
172
|
+
@out.puts(notification.message)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def dump_failures(notification)
|
|
176
|
+
@out.puts(notification.fully_formatted_failed_examples)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def dump_summary(notification)
|
|
180
|
+
@out.puts(notification.totals_line)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
120
184
|
class Listener
|
|
121
185
|
EVENTS = %i[example_started example_finished example_failed].freeze
|
|
122
186
|
|
|
@@ -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
|
|
@@ -5,6 +5,14 @@ require "prism"
|
|
|
5
5
|
require_relative "base"
|
|
6
6
|
require_relative "filter_replacement"
|
|
7
7
|
require_relative "literal_replacement"
|
|
8
|
+
require_relative "argument_removal"
|
|
9
|
+
require_relative "chain_removal"
|
|
10
|
+
require_relative "pair_removal"
|
|
11
|
+
require_relative "endpoint_replacement"
|
|
12
|
+
require_relative "operator_registry"
|
|
13
|
+
require_relative "range_replacement"
|
|
14
|
+
require_relative "operator_assignment"
|
|
15
|
+
require_relative "condition_negation"
|
|
8
16
|
|
|
9
17
|
module Saboteur
|
|
10
18
|
module MutationSource
|
|
@@ -13,7 +21,7 @@ module Saboteur
|
|
|
13
21
|
class Builtin < Base
|
|
14
22
|
DEFAULT_ARID = [
|
|
15
23
|
'\A(puts|pp|p|print|warn)\b',
|
|
16
|
-
'\A(
|
|
24
|
+
'\A(@|self\.|::)?(Rails\.)?logger\b',
|
|
17
25
|
'\A(::)?(StatsD|Statsd|Datadog|NewRelic)\b',
|
|
18
26
|
'\.instrument\b'
|
|
19
27
|
].freeze
|
|
@@ -45,11 +53,19 @@ module Saboteur
|
|
|
45
53
|
CATCH_ALL = %w[StandardError ::StandardError Exception ::Exception].freeze
|
|
46
54
|
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
47
55
|
|
|
48
|
-
def initialize(arid: DEFAULT_ARID)
|
|
56
|
+
def initialize(arid: DEFAULT_ARID, operators: [])
|
|
49
57
|
super()
|
|
58
|
+
@operators = OperatorRegistry.resolve(operators)
|
|
50
59
|
@arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
|
|
51
60
|
@filter_replacement = FilterReplacement.new(arid: arid)
|
|
52
61
|
@literal_replacement = LiteralReplacement.new(arid: arid)
|
|
62
|
+
@argument_removal = ArgumentRemoval.new(arid: arid)
|
|
63
|
+
@pair_removal = PairRemoval.new(arid: arid)
|
|
64
|
+
@chain_removal = ChainRemoval.new(arid: arid)
|
|
65
|
+
@endpoint_replacement = EndpointReplacement.new(arid: arid)
|
|
66
|
+
@range_replacement = RangeReplacement.new(arid: arid)
|
|
67
|
+
@operator_assignment = OperatorAssignment.new(arid: arid)
|
|
68
|
+
@condition_negation = ConditionNegation.new(arid: arid)
|
|
53
69
|
end
|
|
54
70
|
|
|
55
71
|
def name
|
|
@@ -64,6 +80,14 @@ module Saboteur
|
|
|
64
80
|
visit(body)
|
|
65
81
|
@found.concat(@filter_replacement.mutations_for(subject))
|
|
66
82
|
@found.concat(@literal_replacement.mutations_for(subject))
|
|
83
|
+
@found.concat(@argument_removal.mutations_for(subject))
|
|
84
|
+
@found.concat(@pair_removal.mutations_for(subject))
|
|
85
|
+
@found.concat(@chain_removal.mutations_for(subject))
|
|
86
|
+
@found.concat(@endpoint_replacement.mutations_for(subject))
|
|
87
|
+
@found.concat(@range_replacement.mutations_for(subject))
|
|
88
|
+
@found.concat(@operator_assignment.mutations_for(subject))
|
|
89
|
+
@found.concat(@condition_negation.mutations_for(subject))
|
|
90
|
+
@found.select { |mutation| @operators.include?(mutation.operator) }
|
|
67
91
|
ensure
|
|
68
92
|
@subject = nil
|
|
69
93
|
end
|
|
@@ -91,8 +115,8 @@ module Saboteur
|
|
|
91
115
|
end
|
|
92
116
|
|
|
93
117
|
# A raise that the method's own rescue catches is absorbed by design. It
|
|
94
|
-
#
|
|
95
|
-
#
|
|
118
|
+
# is not generated: rejecting that exception is not the rescue contract.
|
|
119
|
+
# Fine mutants still run regardless of the coarse verdicts.
|
|
96
120
|
def rescues_everything?(body)
|
|
97
121
|
clause = body.is_a?(Prism::BeginNode) ? body.rescue_clause : nil
|
|
98
122
|
while clause
|
|
@@ -128,7 +152,8 @@ module Saboteur
|
|
|
128
152
|
statements.each do |statement|
|
|
129
153
|
next if arid?(statement)
|
|
130
154
|
|
|
131
|
-
|
|
155
|
+
removable_here = statements.drop(1).any? && removable?(statement)
|
|
156
|
+
add("statement_removal", statement, "nil") if removable_here
|
|
132
157
|
visit(statement)
|
|
133
158
|
end
|
|
134
159
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
# Drops one link from the middle of a method chain: a.b.c becomes a.c.
|
|
10
|
+
# The first receiver and the last call stay, so the result is still a call
|
|
11
|
+
# on the original object.
|
|
12
|
+
class ChainRemoval < Base
|
|
13
|
+
BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
14
|
+
# Links this operator leaves alone by design. They hand back their receiver or a
|
|
15
|
+
# copy of it (tap after running a block), so dropping one changes mutability,
|
|
16
|
+
# copying or a block's side effects. Tests can observe those, but they are other
|
|
17
|
+
# contracts than "a step in the query chain went missing", and removing them
|
|
18
|
+
# mostly yields noise, so they are out of scope here.
|
|
19
|
+
IDENTITY_CALLS = %w[freeze dup clone itself tap].freeze
|
|
20
|
+
|
|
21
|
+
# Targets of a write: `a.b.c = 1`, `a.b.c += 1`, `a.b[0] ||= 1`. The whole receiver chain
|
|
22
|
+
# is the thing being assigned to, so no link of it is dropped.
|
|
23
|
+
WRITE_NODES = [Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
|
|
24
|
+
Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
27
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def name
|
|
31
|
+
"chain_removal"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mutations_for(subject)
|
|
35
|
+
found = []
|
|
36
|
+
visit(subject.node.body, subject, found)
|
|
37
|
+
found
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def visit(node, subject, found)
|
|
43
|
+
return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
44
|
+
|
|
45
|
+
if node.is_a?(Prism::StatementsNode)
|
|
46
|
+
node.body.each { |child| visit(child, subject, found) unless @arid.any? { |p| p.match?(child.slice) } }
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
remove_link(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
50
|
+
children(node).each { |child| visit(child, subject, found) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Everything under the node except the receiver chain of a write target.
|
|
54
|
+
def children(node)
|
|
55
|
+
return node.compact_child_nodes unless write_target?(node)
|
|
56
|
+
|
|
57
|
+
node.compact_child_nodes.reject { |child| child.equal?(node.receiver) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def write_target?(node)
|
|
61
|
+
WRITE_NODES.any? { |type| node.is_a?(type) } || (node.is_a?(Prism::CallNode) && node.attribute_write?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def remove_link(call, subject, found)
|
|
65
|
+
link = call.receiver
|
|
66
|
+
return unless call.call_operator_loc && !call.attribute_write? && removable?(link)
|
|
67
|
+
|
|
68
|
+
edit = Edit.new(file: subject.file, start_byte: link.receiver.end_offset,
|
|
69
|
+
end_byte: link.end_offset, replacement: "")
|
|
70
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A dotted call with a method name (a dot always has a receiver).
|
|
74
|
+
def removable?(link)
|
|
75
|
+
link.is_a?(Prism::CallNode) && link.call_operator_loc && link.message_loc &&
|
|
76
|
+
!IDENTITY_CALLS.include?(link.name.to_s)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module MutationSource
|
|
8
|
+
# Negates the predicate, retaining its evaluation and branch structure.
|
|
9
|
+
class ConditionNegation < Base
|
|
10
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
13
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def name
|
|
17
|
+
"condition_negation"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def mutations_for(subject)
|
|
21
|
+
found = []
|
|
22
|
+
visit(subject.node.body, subject, found)
|
|
23
|
+
found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def visit(node, subject, found)
|
|
29
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
30
|
+
return if @arid.any? { |pattern| pattern.match?(node.slice) }
|
|
31
|
+
|
|
32
|
+
negate(node.predicate, subject, found) if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
33
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def negate(predicate, subject, found)
|
|
37
|
+
# Existing true/false and negation-removal operators own these cases.
|
|
38
|
+
if predicate.is_a?(Prism::TrueNode) || predicate.is_a?(Prism::FalseNode) || predicate.is_a?(Prism::NilNode)
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
return if predicate.is_a?(Prism::CallNode) && predicate.name == :!
|
|
42
|
+
|
|
43
|
+
location = predicate.location
|
|
44
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
45
|
+
end_byte: location.end_offset, replacement: "!(#{predicate.slice})")
|
|
46
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
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 selected end of an explicitly received collection.
|
|
10
|
+
# Only message bytes change; arguments, receiver and block stay intact.
|
|
11
|
+
class EndpointReplacement < Base
|
|
12
|
+
REPLACEMENTS = { first: "last", last: "first" }.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
|
+
"endpoint_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_endpoint(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_endpoint(node, subject, found)
|
|
45
|
+
replacement = REPLACEMENTS[node.name]
|
|
46
|
+
return unless replacement && node.receiver
|
|
47
|
+
return if node.block
|
|
48
|
+
return unless node.arguments.nil?
|
|
49
|
+
|
|
50
|
+
location = node.message_loc
|
|
51
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
52
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
53
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def arid?(node)
|
|
57
|
+
@arid.any? { |pattern| pattern.match?(node.slice) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module MutationSource
|
|
6
|
+
# Suppresses mutants that would run a destructive call with altered
|
|
7
|
+
# inputs. A mutant is only kept when every edit is provably safe: either
|
|
8
|
+
# the subject holds no hazardous call, or the edit removes every
|
|
9
|
+
# hazardous call in the method AND its replacement introduces no new one
|
|
10
|
+
# (checked by parsing the replacement, never by substring match). Edits
|
|
11
|
+
# strictly inside a call, edits elsewhere in a method that contains a
|
|
12
|
+
# call, and edits whose replacement re-includes a hazard (for example
|
|
13
|
+
# removing a negation in front of `system(...)`, or swapping
|
|
14
|
+
# `rm_rf("build")` for `rm_rf(".")`) all leave a destructive call
|
|
15
|
+
# running with altered inputs and are suppressed (issue #102).
|
|
16
|
+
class HazardGuard
|
|
17
|
+
# "<Receiver>.<name>" for calls with a receiver, the bare name for
|
|
18
|
+
# Kernel-style calls. Bare FileUtils operations are listed too, so an
|
|
19
|
+
# `include FileUtils` does not disarm the guard. Any method on Open3,
|
|
20
|
+
# any rmtree, and backticks/%x literals are always hazardous. Replace
|
|
21
|
+
# or extend via c.hazard; run them anyway with --allow-hazardous.
|
|
22
|
+
DEFAULT_HAZARD = [
|
|
23
|
+
"FileUtils.rm", "FileUtils.rm_r", "FileUtils.rm_rf", "FileUtils.rm_f",
|
|
24
|
+
"FileUtils.remove_dir", "FileUtils.remove_entry", "FileUtils.remove_entry_secure",
|
|
25
|
+
"rm", "rm_r", "rm_rf", "rm_f", "remove_dir", "remove_entry", "remove_entry_secure",
|
|
26
|
+
"File.delete", "File.unlink",
|
|
27
|
+
"Dir.rmdir", "Dir.delete", "Dir.unlink",
|
|
28
|
+
"IO.popen",
|
|
29
|
+
"Process.spawn", "Process.exec",
|
|
30
|
+
"system", "exec", "spawn", "`"
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
OPEN3 = /\A(::)?Open3\z/
|
|
34
|
+
ALWAYS_NAMES = %w[rmtree].freeze
|
|
35
|
+
KERNEL_RECEIVERS = %w[Kernel Process].freeze
|
|
36
|
+
|
|
37
|
+
def initialize(patterns = DEFAULT_HAZARD)
|
|
38
|
+
@tags = Set.new(patterns.map { |pattern| pattern.to_s.sub(/\A::/, "") })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def name
|
|
42
|
+
"hazard_guard"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# candidates: [[mutation, mutated_text]]. Returns [kept, suppressed].
|
|
46
|
+
def partition(candidates, subject)
|
|
47
|
+
spans = hazard_spans(subject.node)
|
|
48
|
+
return [candidates, []] if spans.empty?
|
|
49
|
+
|
|
50
|
+
candidates.partition { |mutation, _| safe?(mutation, spans) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Byte spans of hazardous calls in the node tree, with the source slice
|
|
56
|
+
# of each. Inner calls are covered by the outer span, so the walk does
|
|
57
|
+
# not descend into a hazardous call.
|
|
58
|
+
def hazard_spans(root)
|
|
59
|
+
spans = []
|
|
60
|
+
stack = [root]
|
|
61
|
+
until stack.empty?
|
|
62
|
+
node = stack.pop
|
|
63
|
+
if hazardous?(node)
|
|
64
|
+
spans << [node.location.start_offset, node.location.end_offset, node.slice]
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
stack.concat(node.compact_child_nodes)
|
|
68
|
+
end
|
|
69
|
+
spans
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def hazardous?(node)
|
|
73
|
+
case node
|
|
74
|
+
when Prism::XStringNode, Prism::InterpolatedXStringNode then true
|
|
75
|
+
when Prism::CallNode then hazardous_call?(node)
|
|
76
|
+
else false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def hazardous_call?(node)
|
|
81
|
+
name = node.name.to_s
|
|
82
|
+
receiver = node.receiver&.slice&.sub(/\A::/, "")
|
|
83
|
+
return true if OPEN3.match?(receiver.to_s)
|
|
84
|
+
return true if ALWAYS_NAMES.include?(name)
|
|
85
|
+
|
|
86
|
+
tag = receiver ? "#{receiver}.#{name}" : name
|
|
87
|
+
return true if @tags.include?(tag)
|
|
88
|
+
# Kernel.system / Kernel.spawn / Process.spawn: the explicit-receiver
|
|
89
|
+
# spelling of a bare process spawn is the same hazard.
|
|
90
|
+
return true if KERNEL_RECEIVERS.include?(receiver) && @tags.include?(name)
|
|
91
|
+
|
|
92
|
+
piped_open?(node, name, receiver)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Kernel#open with a "| command" argument spawns a process.
|
|
96
|
+
def piped_open?(node, name, receiver)
|
|
97
|
+
return false unless name == "open" && (receiver.nil? || receiver == "Kernel")
|
|
98
|
+
|
|
99
|
+
first = node.arguments&.arguments&.first
|
|
100
|
+
case first
|
|
101
|
+
when Prism::StringNode then first.unescaped.start_with?("|")
|
|
102
|
+
when Prism::InterpolatedStringNode
|
|
103
|
+
part = first.parts.first
|
|
104
|
+
part.is_a?(Prism::StringNode) && part.unescaped.start_with?("|")
|
|
105
|
+
else false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A mutation is safe only while no hazardous call survives it: every
|
|
110
|
+
# edit must destroy every span (cover it wholly with a hazard-free
|
|
111
|
+
# replacement). Anything less - touching a call, or editing other
|
|
112
|
+
# code in a method that still holds a call - keeps the call running
|
|
113
|
+
# with altered inputs, so the mutant is suppressed.
|
|
114
|
+
def safe?(mutation, spans)
|
|
115
|
+
mutation.edits.all? { |edit| destroys_all?(edit, spans) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def destroys_all?(edit, spans)
|
|
119
|
+
spans.all? do |start_byte, end_byte, _slice|
|
|
120
|
+
edit.start_byte <= start_byte && edit.end_byte >= end_byte
|
|
121
|
+
end && replacement_clean?(edit.replacement)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The replacement itself, parsed as code. A replacement that parses
|
|
125
|
+
# and contains no hazardous call cannot run one; anything else -
|
|
126
|
+
# a hazard in the new text, or text that does not parse on its own -
|
|
127
|
+
# is treated as hazardous.
|
|
128
|
+
def replacement_clean?(replacement)
|
|
129
|
+
result = Prism.parse(replacement.to_s)
|
|
130
|
+
return false unless result.success?
|
|
131
|
+
|
|
132
|
+
hazard_spans(result.value).empty?
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module MutationSource
|
|
8
|
+
# Changes the arithmetic operation, without changing the assignment target.
|
|
9
|
+
class OperatorAssignment < Base
|
|
10
|
+
REPLACEMENTS = { :+ => "-=", :- => "+=", :* => "/=", :/ => "*=" }.freeze
|
|
11
|
+
WRITE_NODES = [Prism::LocalVariableOperatorWriteNode, Prism::InstanceVariableOperatorWriteNode,
|
|
12
|
+
Prism::ClassVariableOperatorWriteNode, Prism::GlobalVariableOperatorWriteNode,
|
|
13
|
+
Prism::ConstantOperatorWriteNode, Prism::ConstantPathOperatorWriteNode,
|
|
14
|
+
Prism::CallOperatorWriteNode, Prism::IndexOperatorWriteNode].freeze
|
|
15
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
18
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def name
|
|
22
|
+
"operator_assignment"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mutations_for(subject)
|
|
26
|
+
found = []
|
|
27
|
+
visit(subject.node.body, subject, found)
|
|
28
|
+
found
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def visit(node, subject, found)
|
|
34
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
35
|
+
return if @arid.any? { |pattern| pattern.match?(node.slice) }
|
|
36
|
+
|
|
37
|
+
replace(node, subject, found) if WRITE_NODES.any? { |type| node.is_a?(type) }
|
|
38
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def replace(node, subject, found)
|
|
42
|
+
replacement = REPLACEMENTS[node.binary_operator]
|
|
43
|
+
return unless replacement
|
|
44
|
+
|
|
45
|
+
location = node.binary_operator_loc
|
|
46
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
47
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
48
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|