saboteur 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +399 -0
- data/README.md +202 -42
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/comparison.rb +1 -0
- data/lib/saboteur/baseline/evidence.rb +11 -1
- data/lib/saboteur/baseline/run_evidence.rb +1 -1
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -6
- data/lib/saboteur/cli.rb +80 -48
- data/lib/saboteur/config.rb +88 -13
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +64 -2
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +5 -1
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +183 -23
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +175 -96
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/help.rb +55 -10
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +22 -13
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +12 -2
- data/lib/saboteur/reporter/agent_commands.rb +49 -16
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/agent_summary.rb +1 -1
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +4 -5
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +19 -3
- data/lib/saboteur/reporter/stryker.rb +19 -8
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +43 -10
- data/lib/saboteur/result.rb +25 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- metadata +20 -2
data/lib/saboteur/help.rb
CHANGED
|
@@ -25,7 +25,10 @@ module Saboteur
|
|
|
25
25
|
2 => "Usage or configuration error: fix the command or config/saboteur.rb. A replay --id that matches no " \
|
|
26
26
|
"mutant is only found after the target has booted. A named source or test file that does not exist.",
|
|
27
27
|
3 => "Tool error: saboteur could not finish, or more than half of the executed mutants errored (such " \
|
|
28
|
-
"a run tested almost nothing). With --threshold, also any errored or resource-limited mutant."
|
|
28
|
+
"a run tested almost nothing). With --threshold, also any errored or resource-limited mutant.",
|
|
29
|
+
4 => "Time budget spent: --max-duration stopped the run before the next subject, so the score is " \
|
|
30
|
+
"partial. --max-duration-ok leaves the exit to the plain verdict: 0, 1, or 3 when the budget " \
|
|
31
|
+
"stopped the run before any mutant was tested."
|
|
29
32
|
}.freeze
|
|
30
33
|
|
|
31
34
|
DOCTOR_EXIT_CODE_MEANINGS = {
|
|
@@ -40,15 +43,20 @@ module Saboteur
|
|
|
40
43
|
summary: "Mutate your code and report the mutants your tests do not catch",
|
|
41
44
|
description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
|
|
42
45
|
"tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
|
|
43
|
-
"directories after run replace include and exclude
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
+
"directories after run replace include and exclude. Without --test, the run infers the " \
|
|
47
|
+
"specs named after the mutated files and runs only those; a mutated file with no " \
|
|
48
|
+
"conventional spec stops the run with exit 2 before anything boots, and --all keeps " \
|
|
49
|
+
"the whole configured suite. --threshold PERCENT replaces the survivor check: exit 0 " \
|
|
50
|
+
"when the score is at or above PERCENT, exit 1 when it is below. All operators run " \
|
|
51
|
+
"unless you select a smaller set; exclusions always remove operators from that set. " \
|
|
52
|
+
"Inline comment syntax: " \
|
|
46
53
|
'# saboteur:disable-line [operator ...] reason="...". A reason is required.',
|
|
47
54
|
takes_options: true,
|
|
48
55
|
exit_codes: EXIT_CODE_MEANINGS,
|
|
49
56
|
examples: [
|
|
50
|
-
["Pull request: only methods changed since the target branch
|
|
51
|
-
|
|
57
|
+
["Pull request: only methods changed since the target branch, with the inferred specs",
|
|
58
|
+
"saboteur run --since origin/main"],
|
|
59
|
+
["Whole project and whole suite, four workers, repeatable seed", "saboteur run --all --jobs 4 --seed 42"],
|
|
52
60
|
["One class or method", "saboteur run --subject 'Person#adult?'"],
|
|
53
61
|
["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
|
|
54
62
|
["Mutate named files against named specs, skipping the whole-suite coverage run",
|
|
@@ -57,6 +65,35 @@ module Saboteur
|
|
|
57
65
|
["AI agent: bounded JSON summary and strict exit codes", "saboteur run --agent --since origin/main"]
|
|
58
66
|
]
|
|
59
67
|
),
|
|
68
|
+
Command.new(
|
|
69
|
+
name: "pr",
|
|
70
|
+
summary: "Preset: the fast pull-request loop over the lines changed since a ref",
|
|
71
|
+
description: "Expands to saboteur run --baseline-passes 1 --max-duration 10m --max-duration-ok " \
|
|
72
|
+
"--since REF, with REF the first operand (default main): one baseline check, a " \
|
|
73
|
+
"ten-minute wall-clock budget, and mutants limited to the changed methods. The " \
|
|
74
|
+
"preset opts out of the budget-specific exit 4; the normal and incomplete " \
|
|
75
|
+
"verdicts still fail the run (survivors exit 1, a budget stop before any mutant " \
|
|
76
|
+
"ran exits 3). Every run option still works; an explicit flag after the preset " \
|
|
77
|
+
"wins (#244).",
|
|
78
|
+
takes_options: true,
|
|
79
|
+
exit_codes: EXIT_CODE_MEANINGS,
|
|
80
|
+
examples: [["Changed lines since main", "saboteur pr"],
|
|
81
|
+
["Changed lines since another target", "saboteur pr origin/develop"],
|
|
82
|
+
["The same loop with two baseline checks", "saboteur pr --baseline-passes 2"]]
|
|
83
|
+
),
|
|
84
|
+
Command.new(
|
|
85
|
+
name: "audit",
|
|
86
|
+
summary: "Preset: the whole project, all operators, both reports",
|
|
87
|
+
description: "Expands to saboteur run --all --operators all --baseline-passes 2 " \
|
|
88
|
+
"--json .saboteur/audit-report.json --html .saboteur/audit-report.html: every " \
|
|
89
|
+
"subject against the whole suite, all operators, both baseline checks, writing " \
|
|
90
|
+
"the Stryker JSON and HTML reports. Configured selection limits (since, " \
|
|
91
|
+
"subjects, exclude_operators, max_duration) still apply; an explicit flag after " \
|
|
92
|
+
"the preset wins (#244), and an explicit --test replaces the injected --all.",
|
|
93
|
+
takes_options: true,
|
|
94
|
+
exit_codes: EXIT_CODE_MEANINGS,
|
|
95
|
+
examples: [["Full project audit with JSON and HTML reports", "saboteur audit"]]
|
|
96
|
+
),
|
|
60
97
|
Command.new(
|
|
61
98
|
name: "doctor",
|
|
62
99
|
summary: "Check the environment, and with --suite run the baseline twice, without mutating anything",
|
|
@@ -121,7 +158,7 @@ module Saboteur
|
|
|
121
158
|
|
|
122
159
|
AGENT_EXIT_CODES = " With --agent the codes are stricter: 0 only when the run was complete and clean; " \
|
|
123
160
|
"1 survivors to review;\n 2 usage or config error; 3 incomplete (no coverage, caps, errors, " \
|
|
124
|
-
"nothing selected) or tool failure."
|
|
161
|
+
"a budget stop before any mutant ran, nothing selected) or tool failure."
|
|
125
162
|
|
|
126
163
|
FILES = [
|
|
127
164
|
["config/saboteur.rb", "Settings as Ruby (Saboteur.configure). Command line options override it."],
|
|
@@ -165,8 +202,16 @@ module Saboteur
|
|
|
165
202
|
@commands.select(&:takes_options).map { |command| options_section(command) }
|
|
166
203
|
end
|
|
167
204
|
|
|
205
|
+
# run, audit and pr share one exit-code table; the overview keeps every
|
|
206
|
+
# command's section but carries the agent note only on the first table
|
|
207
|
+
# that shares it, so the phrase appears exactly once.
|
|
168
208
|
def exit_code_sections
|
|
169
|
-
|
|
209
|
+
noted = []
|
|
210
|
+
@commands.select(&:exit_codes).map do |command|
|
|
211
|
+
first = noted.none? { |codes| codes.equal?(command.exit_codes) }
|
|
212
|
+
noted << command.exit_codes
|
|
213
|
+
exit_codes_section(command.exit_codes, command.name, agent_note: first)
|
|
214
|
+
end
|
|
170
215
|
end
|
|
171
216
|
|
|
172
217
|
def title
|
|
@@ -203,10 +248,10 @@ module Saboteur
|
|
|
203
248
|
"EXAMPLES\n#{examples.map { |note, line| " # #{note}\n #{line}" }.join("\n")}\n"
|
|
204
249
|
end
|
|
205
250
|
|
|
206
|
-
def exit_codes_section(codes, command_name = nil)
|
|
251
|
+
def exit_codes_section(codes, command_name = nil, agent_note: true)
|
|
207
252
|
rows = codes.map { |code, meaning| " #{code} #{meaning}" }
|
|
208
253
|
title = command_name ? "EXIT CODES (saboteur #{command_name})" : "EXIT CODES"
|
|
209
|
-
note = codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
|
|
254
|
+
note = agent_note && codes.equal?(EXIT_CODE_MEANINGS) ? "#{AGENT_EXIT_CODES}\n" : ""
|
|
210
255
|
"#{title}\n#{rows.join("\n")}\n#{note}"
|
|
211
256
|
end
|
|
212
257
|
|
data/lib/saboteur/ignore_list.rb
CHANGED
|
@@ -103,7 +103,10 @@ module Saboteur
|
|
|
103
103
|
|
|
104
104
|
# Marks matching survivors as ignored. Returns warnings for stale entries:
|
|
105
105
|
# entries whose subject was examined but whose mutation no longer exists.
|
|
106
|
-
|
|
106
|
+
# unscoped_digests (subject id => every digest generated before a diff
|
|
107
|
+
# scope cut) keeps a scoped run from calling a scoped-out entry stale
|
|
108
|
+
# (#284).
|
|
109
|
+
def apply(results, unscoped_digests: nil)
|
|
107
110
|
seen = {}
|
|
108
111
|
results.each do |result|
|
|
109
112
|
key = [result.subject.id, result.digest]
|
|
@@ -113,8 +116,15 @@ module Saboteur
|
|
|
113
116
|
seen[key] = true
|
|
114
117
|
result.ignored = entry.reason if result.survived?
|
|
115
118
|
end
|
|
119
|
+
stale_warnings(results, seen, unscoped_digests)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def stale_warnings(results, seen, unscoped_digests)
|
|
116
125
|
examined = results.to_set { |result| result.subject.id }
|
|
117
126
|
entries.select { |entry| examined.include?(entry.subject) && !seen[[entry.subject, entry.mutation]] }
|
|
127
|
+
.reject { |entry| unscoped_digests&.fetch(entry.subject, nil)&.include?(entry.mutation) }
|
|
118
128
|
.map { |entry| "stale ignore entry #{entry.subject} #{entry.mutation}: no such mutation" }
|
|
119
129
|
end
|
|
120
130
|
end
|
data/lib/saboteur/install.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require_relative "text"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
# Writes the Rails starter files: config/saboteur.rb, bin/saboteur and a
|
|
@@ -64,7 +65,7 @@ module Saboteur
|
|
|
64
65
|
# Printed after install; the Gemfile is never edited.
|
|
65
66
|
def gemfile_hint
|
|
66
67
|
gemfile = File.join(@root, "Gemfile")
|
|
67
|
-
return nil if File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']saboteur["']/)
|
|
68
|
+
return nil if File.file?(gemfile) && Text.utf8(File.read(gemfile)).match?(/^\s*gem\s+["']saboteur["']/)
|
|
68
69
|
|
|
69
70
|
GEMFILE_HINT
|
|
70
71
|
end
|
|
@@ -108,8 +109,8 @@ module Saboteur
|
|
|
108
109
|
link = symlink_component(".gitignore")
|
|
109
110
|
return Result.new(:skipped, ".gitignore") if link && !@force
|
|
110
111
|
|
|
111
|
-
current = File.file?(path) ? File.read(path) : ""
|
|
112
|
-
listed = listed?(current)
|
|
112
|
+
current = File.file?(path) ? Text.utf8_identity(File.read(path)) : ""
|
|
113
|
+
listed = listed?(Text.utf8(current))
|
|
113
114
|
return Result.new(:unchanged, ".gitignore") if listed && !link
|
|
114
115
|
|
|
115
116
|
File.delete(link) if link
|
|
@@ -10,8 +10,13 @@ module Saboteur
|
|
|
10
10
|
# so a kill by a failed assertion can be told from a kill by a crash.
|
|
11
11
|
# failure_messages: the first line of each failure, so a verdict that
|
|
12
12
|
# does not hold up can say why the test failed.
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
# failure_details: the full message and first backtrace frames of each
|
|
14
|
+
# failure, so a neutral re-run (the verdict check) can say exactly why
|
|
15
|
+
# the killer failed with the method unchanged (#296).
|
|
16
|
+
RunResult = Data.define(:passed, :failed_ids, :executed_ids, :runtime, :failure_classes, :failure_messages,
|
|
17
|
+
:failure_details) do
|
|
18
|
+
def initialize(passed:, failed_ids:, executed_ids:, runtime:, failure_classes: {}, failure_messages: {},
|
|
19
|
+
failure_details: {})
|
|
15
20
|
super
|
|
16
21
|
end
|
|
17
22
|
end
|
|
@@ -55,7 +55,8 @@ module Saboteur
|
|
|
55
55
|
passed = execute(listener)
|
|
56
56
|
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
57
57
|
executed_ids: listener.executed, runtime: listener.runtime,
|
|
58
|
-
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages
|
|
58
|
+
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages,
|
|
59
|
+
failure_details: listener.failure_details)
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def run_all(recorder)
|
|
@@ -71,7 +72,8 @@ module Saboteur
|
|
|
71
72
|
passed = execute(listener, log_phase: true)
|
|
72
73
|
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
73
74
|
executed_ids: listener.executed, runtime: listener.runtime,
|
|
74
|
-
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages
|
|
75
|
+
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages,
|
|
76
|
+
failure_details: listener.failure_details)
|
|
75
77
|
end
|
|
76
78
|
|
|
77
79
|
private
|
|
@@ -185,8 +187,10 @@ module Saboteur
|
|
|
185
187
|
EVENTS = %i[example_started example_finished example_failed].freeze
|
|
186
188
|
|
|
187
189
|
MESSAGE_LIMIT = 200
|
|
190
|
+
DETAIL_LIMIT = 2_000
|
|
191
|
+
BACKTRACE_LIMIT = 5
|
|
188
192
|
|
|
189
|
-
attr_reader :executed, :failed, :failure_classes, :failure_messages
|
|
193
|
+
attr_reader :executed, :failed, :failure_classes, :failure_messages, :failure_details
|
|
190
194
|
attr_accessor :runtime
|
|
191
195
|
|
|
192
196
|
def initialize(recorder = nil)
|
|
@@ -195,6 +199,7 @@ module Saboteur
|
|
|
195
199
|
@failed = []
|
|
196
200
|
@failure_classes = {}
|
|
197
201
|
@failure_messages = {}
|
|
202
|
+
@failure_details = {}
|
|
198
203
|
@runtime = 0.0
|
|
199
204
|
end
|
|
200
205
|
|
|
@@ -213,12 +218,43 @@ module Saboteur
|
|
|
213
218
|
exception = notification.example.execution_result.exception
|
|
214
219
|
@failure_classes[notification.example.id] = exception&.class&.name
|
|
215
220
|
@failure_messages[notification.example.id] = first_line(exception&.message)
|
|
221
|
+
@failure_details[notification.example.id] = detail_for(exception)
|
|
216
222
|
end
|
|
217
223
|
|
|
218
224
|
private
|
|
219
225
|
|
|
226
|
+
# The full failure, class and first frames: a neutral re-run that
|
|
227
|
+
# fails needs enough context to explain why (the compact first line
|
|
228
|
+
# alone reads as just "expected: 0").
|
|
229
|
+
def detail_for(exception)
|
|
230
|
+
return nil unless exception
|
|
231
|
+
|
|
232
|
+
message = Text.utf8("#{exception.class.name}: #{exception.message}")
|
|
233
|
+
([message] + relevant_frames(exception)).join("\n")[0, DETAIL_LIMIT]
|
|
234
|
+
rescue StandardError
|
|
235
|
+
exception.message.to_s.dump[0, DETAIL_LIMIT]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# An expectation failure leads with rspec's own frames (notify_failure,
|
|
239
|
+
# handle_failure); under the cap they would crowd out the spec line.
|
|
240
|
+
# Keep the project frames; fall back to the raw ones when rspec itself
|
|
241
|
+
# raised, so the detail never comes back empty.
|
|
242
|
+
def relevant_frames(exception)
|
|
243
|
+
frames = Array(exception.backtrace).map { |frame| Text.utf8(frame.to_s) }
|
|
244
|
+
project = frames.reject { |frame| frame.include?("/gems/rspec") }
|
|
245
|
+
(project.empty? ? frames : project).first(BACKTRACE_LIMIT)
|
|
246
|
+
end
|
|
247
|
+
|
|
220
248
|
def first_line(message)
|
|
221
|
-
|
|
249
|
+
# Exception messages can carry bytes invalid under the locale encoding
|
|
250
|
+
# (e.g. a path with raw bytes); scrub before splitting, or .lines/.strip
|
|
251
|
+
# raises Encoding::CompatibilityError and the run reports error (CI #272).
|
|
252
|
+
Text.utf8(message.to_s).lines.map(&:strip).find { |line| !line.empty? }.to_s[0, MESSAGE_LIMIT]
|
|
253
|
+
rescue StandardError
|
|
254
|
+
# Text.utf8 is mutable code: when the mutant under test breaks it,
|
|
255
|
+
# the raise must not flip the run to error - the recorded failure
|
|
256
|
+
# is what classifies the kill. dump is ASCII-safe for any bytes.
|
|
257
|
+
message.to_s.dump[0, MESSAGE_LIMIT]
|
|
222
258
|
end
|
|
223
259
|
end
|
|
224
260
|
end
|
|
@@ -13,6 +13,9 @@ require_relative "operator_registry"
|
|
|
13
13
|
require_relative "range_replacement"
|
|
14
14
|
require_relative "operator_assignment"
|
|
15
15
|
require_relative "condition_negation"
|
|
16
|
+
require_relative "method_swap"
|
|
17
|
+
require_relative "operand_replacement"
|
|
18
|
+
require_relative "interpolation_removal"
|
|
16
19
|
|
|
17
20
|
module Saboteur
|
|
18
21
|
module MutationSource
|
|
@@ -53,9 +56,9 @@ module Saboteur
|
|
|
53
56
|
CATCH_ALL = %w[StandardError ::StandardError Exception ::Exception].freeze
|
|
54
57
|
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
55
58
|
|
|
56
|
-
def initialize(arid: DEFAULT_ARID, operators: [])
|
|
59
|
+
def initialize(arid: DEFAULT_ARID, operators: [], exclude_operators: [])
|
|
57
60
|
super()
|
|
58
|
-
@operators = OperatorRegistry.
|
|
61
|
+
@operators = OperatorRegistry.select(operators, exclude_operators)
|
|
59
62
|
@arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
|
|
60
63
|
@filter_replacement = FilterReplacement.new(arid: arid)
|
|
61
64
|
@literal_replacement = LiteralReplacement.new(arid: arid)
|
|
@@ -66,6 +69,9 @@ module Saboteur
|
|
|
66
69
|
@range_replacement = RangeReplacement.new(arid: arid)
|
|
67
70
|
@operator_assignment = OperatorAssignment.new(arid: arid)
|
|
68
71
|
@condition_negation = ConditionNegation.new(arid: arid)
|
|
72
|
+
@method_swap = MethodSwap.new(arid: arid)
|
|
73
|
+
@operand_replacement = OperandReplacement.new(arid: arid)
|
|
74
|
+
@interpolation_removal = InterpolationRemoval.new(arid: arid)
|
|
69
75
|
end
|
|
70
76
|
|
|
71
77
|
def name
|
|
@@ -78,20 +84,20 @@ module Saboteur
|
|
|
78
84
|
@found = []
|
|
79
85
|
coarse(body)
|
|
80
86
|
visit(body)
|
|
81
|
-
@found.concat(
|
|
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))
|
|
87
|
+
@found.concat(delegated.flat_map { |source| source.mutations_for(subject) })
|
|
90
88
|
@found.select { |mutation| @operators.include?(mutation.operator) }
|
|
91
89
|
ensure
|
|
92
90
|
@subject = nil
|
|
93
91
|
end
|
|
94
92
|
|
|
93
|
+
# The single-purpose sources run after the shared walk, in registration
|
|
94
|
+
# order; each filters its own operator through the same policy below.
|
|
95
|
+
def delegated
|
|
96
|
+
[@filter_replacement, @literal_replacement, @argument_removal, @pair_removal, @chain_removal,
|
|
97
|
+
@endpoint_replacement, @range_replacement, @operator_assignment, @condition_negation,
|
|
98
|
+
@method_swap, @operand_replacement, @interpolation_removal]
|
|
99
|
+
end
|
|
100
|
+
|
|
95
101
|
private
|
|
96
102
|
|
|
97
103
|
# --- coarse ---------------------------------------------------------
|
|
@@ -185,9 +191,12 @@ module Saboteur
|
|
|
185
191
|
add("safe_navigation", node.call_operator_loc, ".") if node.safe_navigation?
|
|
186
192
|
end
|
|
187
193
|
|
|
194
|
+
# Only the infix spelling is binary: an operator call has no call
|
|
195
|
+
# operator, and its receiver, message, single argument and lack of a
|
|
196
|
+
# block all follow from that (a block binds to the last operand's own
|
|
197
|
+
# call, never to the operator).
|
|
188
198
|
def binary?(node)
|
|
189
|
-
node.
|
|
190
|
-
node.arguments&.arguments&.size == 1 && node.block.nil?
|
|
199
|
+
node.call_operator_loc.nil?
|
|
191
200
|
end
|
|
192
201
|
|
|
193
202
|
def numeric_operands?(node)
|
|
@@ -31,6 +31,7 @@ module Saboteur
|
|
|
31
31
|
].freeze
|
|
32
32
|
|
|
33
33
|
OPEN3 = /\A(::)?Open3\z/
|
|
34
|
+
SEND_NAMES = %w[send public_send __send__].freeze
|
|
34
35
|
ALWAYS_NAMES = %w[rmtree].freeze
|
|
35
36
|
KERNEL_RECEIVERS = %w[Kernel Process].freeze
|
|
36
37
|
|
|
@@ -81,6 +82,19 @@ module Saboteur
|
|
|
81
82
|
name = node.name.to_s
|
|
82
83
|
receiver = node.receiver&.slice&.sub(/\A::/, "")
|
|
83
84
|
return true if OPEN3.match?(receiver.to_s)
|
|
85
|
+
|
|
86
|
+
args = node.arguments&.arguments || []
|
|
87
|
+
# obj.send(:rmtree, x) is obj.rmtree(x) with indirection (#240): the
|
|
88
|
+
# resolved selector faces the same direct-call semantics, with the
|
|
89
|
+
# arguments shifted past it (#294 review). A dynamic selector stays
|
|
90
|
+
# out of scope, as documented.
|
|
91
|
+
return true if SEND_NAMES.include?(name) && (selector = literal_selector(args.first)) &&
|
|
92
|
+
direct_hazard?(selector, receiver, args[1])
|
|
93
|
+
|
|
94
|
+
direct_hazard?(name, receiver, args.first)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def direct_hazard?(name, receiver, first_arg)
|
|
84
98
|
return true if ALWAYS_NAMES.include?(name)
|
|
85
99
|
|
|
86
100
|
tag = receiver ? "#{receiver}.#{name}" : name
|
|
@@ -89,14 +103,18 @@ module Saboteur
|
|
|
89
103
|
# spelling of a bare process spawn is the same hazard.
|
|
90
104
|
return true if KERNEL_RECEIVERS.include?(receiver) && @tags.include?(name)
|
|
91
105
|
|
|
92
|
-
piped_open?(
|
|
106
|
+
piped_open?(first_arg, name, receiver)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A literal symbol or string selector names the real call.
|
|
110
|
+
def literal_selector(first)
|
|
111
|
+
first.unescaped if first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)
|
|
93
112
|
end
|
|
94
113
|
|
|
95
114
|
# Kernel#open with a "| command" argument spawns a process.
|
|
96
|
-
def piped_open?(
|
|
115
|
+
def piped_open?(first, name, receiver)
|
|
97
116
|
return false unless name == "open" && (receiver.nil? || receiver == "Kernel")
|
|
98
117
|
|
|
99
|
-
first = node.arguments&.arguments&.first
|
|
100
118
|
case first
|
|
101
119
|
when Prism::StringNode then first.unescaped.start_with?("|")
|
|
102
120
|
when Prism::InterpolatedStringNode
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "operator_visit"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module MutationSource
|
|
10
|
+
# Removes one piece of an interpolated string at a time: each #{expr}
|
|
11
|
+
# and each literal segment. Plain strings stay with literal_replacement
|
|
12
|
+
# and backticks stay untouched (the hazard guard owns those).
|
|
13
|
+
class InterpolationRemoval < Base
|
|
14
|
+
include OperatorVisit
|
|
15
|
+
|
|
16
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
17
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
18
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def name
|
|
22
|
+
"interpolation_removal"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def mutate_node(node, subject, found)
|
|
28
|
+
remove_parts(node, subject, found) if node.is_a?(Prism::InterpolatedStringNode)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def remove_parts(node, subject, found)
|
|
32
|
+
node.parts.each do |part|
|
|
33
|
+
location = part.location
|
|
34
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
35
|
+
end_byte: location.end_offset, replacement: "")
|
|
36
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "operator_visit"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module MutationSource
|
|
10
|
+
# Swaps a method whose name has a semantic opposite: min with max,
|
|
11
|
+
# any? with all?, map with each. Only message bytes change; receiver,
|
|
12
|
+
# arguments and block stay intact. A receiver is required, so a bare
|
|
13
|
+
# max(a, b) Kernel call is left alone.
|
|
14
|
+
class MethodSwap < Base
|
|
15
|
+
include OperatorVisit
|
|
16
|
+
|
|
17
|
+
SWAPS = { min: "max", max: "min", any?: "all?", all?: "any?", map: "each", each: "map" }.freeze
|
|
18
|
+
|
|
19
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
20
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
21
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def name
|
|
25
|
+
"method_swap"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def mutate_node(node, subject, found)
|
|
31
|
+
swap(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def swap(node, subject, found)
|
|
35
|
+
replacement = SWAPS[node.name]
|
|
36
|
+
return unless replacement && node.receiver
|
|
37
|
+
|
|
38
|
+
location = node.message_loc
|
|
39
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
40
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
41
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -15,13 +15,15 @@ module Saboteur
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# Stable id for the ignore list. Offsets are made relative to subject_start
|
|
18
|
-
# so edits elsewhere in the file do not change the id.
|
|
18
|
+
# so edits elsewhere in the file do not change the id. The digest hashes
|
|
19
|
+
# bytes, not encodings: a binary-read source and a UTF-8 replacement hold
|
|
20
|
+
# the same identity, and joining them without .b raises under LANG=C.
|
|
19
21
|
def digest(subject_start:, original_source:)
|
|
20
22
|
parts = edits.map do |edit|
|
|
21
23
|
original = original_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
|
|
22
|
-
[edit.start_byte - subject_start, original, edit.replacement].join("\0")
|
|
24
|
+
[edit.start_byte - subject_start, original.to_s.b, edit.replacement.b].join("\0".b)
|
|
23
25
|
end
|
|
24
|
-
Digest::SHA256.hexdigest([subject_id, operator, *parts].join("\x1F"))[0, 16]
|
|
26
|
+
Digest::SHA256.hexdigest([subject_id.to_s.b, operator.to_s.b, *parts].join("\x1F".b))[0, 16]
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "operator_visit"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module MutationSource
|
|
10
|
+
# Replaces a binary operation with each of its operands: a + b yields a
|
|
11
|
+
# and b. Arithmetic and comparison calls only; boolean connectors stay
|
|
12
|
+
# with boolean_logic, which already replaces a connector with each side.
|
|
13
|
+
# An operand binds at least as tightly as the operation it replaces, so
|
|
14
|
+
# it needs no parentheses (the re-parse check backs this).
|
|
15
|
+
class OperandReplacement < Base
|
|
16
|
+
include OperatorVisit
|
|
17
|
+
|
|
18
|
+
FAMILIES = %i[+ - * / % ** < <= > >= == !=].freeze
|
|
19
|
+
|
|
20
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
21
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
22
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def name
|
|
26
|
+
"operand_replacement"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def mutate_node(node, subject, found)
|
|
32
|
+
replace_operands(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def replace_operands(node, subject, found)
|
|
36
|
+
return unless FAMILIES.include?(node.name) && binary?(node)
|
|
37
|
+
|
|
38
|
+
[node.receiver, *node.arguments.arguments].each do |operand|
|
|
39
|
+
edit = Edit.new(file: subject.file, start_byte: node.start_offset,
|
|
40
|
+
end_byte: node.end_offset, replacement: operand.slice)
|
|
41
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Only the infix spelling is binary: an operator call has no call
|
|
46
|
+
# operator, and its receiver, message, single argument and lack of a
|
|
47
|
+
# block all follow from that (a block binds to the last operand's own
|
|
48
|
+
# call, never to the operator).
|
|
49
|
+
def binary?(node)
|
|
50
|
+
node.call_operator_loc.nil?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module MutationSource
|
|
6
|
+
module OperatorExamples
|
|
7
|
+
EXAMPLES = {
|
|
8
|
+
"body_nil" => "def value = 1 -> def value = nil",
|
|
9
|
+
"body_true" => "def ok? = false -> def ok? = true",
|
|
10
|
+
"body_false" => "def ok? = true -> def ok? = false",
|
|
11
|
+
"body_raise" => "def value = 1 -> def value; raise; end",
|
|
12
|
+
"comparison" => "a < b -> a <= b",
|
|
13
|
+
"arithmetic" => "a + b -> a - b",
|
|
14
|
+
"boolean_logic" => "a && b -> a",
|
|
15
|
+
"condition_true" => "if x -> if true",
|
|
16
|
+
"condition_false" => "if x -> if false",
|
|
17
|
+
"negation_removal" => "!x -> x",
|
|
18
|
+
"literal_number" => "1 -> 0",
|
|
19
|
+
"or_assign" => "x ||= 1 -> x = 1",
|
|
20
|
+
"safe_navigation" => "x&.name -> x.name",
|
|
21
|
+
"statement_removal" => "check; value -> nil; value",
|
|
22
|
+
"filter_replacement" => "xs.select { |x| x } -> xs.reject { |x| x }",
|
|
23
|
+
"literal_string" => "\"hello\" -> \"\"",
|
|
24
|
+
"literal_boolean" => "true -> false",
|
|
25
|
+
"argument_removal" => "send(a, b) -> send(a)",
|
|
26
|
+
"pair_removal" => "{a: 1, b: 2} -> {b: 2}",
|
|
27
|
+
"endpoint_replacement" => "xs.first -> xs.last",
|
|
28
|
+
"chain_removal" => "x.first.to_s -> x.to_s",
|
|
29
|
+
"range" => "1..3 -> 1...3",
|
|
30
|
+
"operator_assignment" => "x += 1 -> x -= 1",
|
|
31
|
+
"condition_negation" => "x ? a : b -> !(x) ? a : b",
|
|
32
|
+
"method_swap" => "xs.min -> xs.max",
|
|
33
|
+
"operand_replacement" => "a + b -> a",
|
|
34
|
+
"interpolation_removal" => "\"hi \#{name}\" -> \"hi \""
|
|
35
|
+
}.freeze
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|