saboteur 0.6.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 +367 -0
- data/README.md +166 -40
- data/exe/saboteur +34 -7
- 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 +74 -5
- data/lib/saboteur/cli.rb +67 -46
- data/lib/saboteur/config.rb +66 -11
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +40 -1
- 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/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 +4 -0
- 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 +145 -22
- 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 +166 -95
- 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/help.rb +55 -11
- 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 +20 -11
- 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 +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +2 -1
- data/lib/saboteur/reporter/agent_commands.rb +38 -13
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +13 -4
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +34 -10
- data/lib/saboteur/result.rb +22 -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 +17 -2
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
|
|
@@ -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
|
|
@@ -28,7 +28,10 @@ module Saboteur
|
|
|
28
28
|
"chain_removal" => "x.first.to_s -> x.to_s",
|
|
29
29
|
"range" => "1..3 -> 1...3",
|
|
30
30
|
"operator_assignment" => "x += 1 -> x -= 1",
|
|
31
|
-
"condition_negation" => "x ? a : b -> !(x) ? a : b"
|
|
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 \""
|
|
32
35
|
}.freeze
|
|
33
36
|
end
|
|
34
37
|
end
|
|
@@ -32,7 +32,10 @@ module Saboteur
|
|
|
32
32
|
"argument_removal" => "Remove a call argument",
|
|
33
33
|
"pair_removal" => "Remove a hash pair",
|
|
34
34
|
"endpoint_replacement" => "Swap explicit no-argument first and last",
|
|
35
|
-
"chain_removal" => "Remove one call from a method chain"
|
|
35
|
+
"chain_removal" => "Remove one call from a method chain",
|
|
36
|
+
"method_swap" => "Swap a method with its semantic opposite",
|
|
37
|
+
"operand_replacement" => "Replace a binary operation with one operand",
|
|
38
|
+
"interpolation_removal" => "Remove part of an interpolated string"
|
|
36
39
|
}.freeze
|
|
37
40
|
POLICY_VERSION = 2
|
|
38
41
|
TIERS = { "all" => DESCRIPTIONS.keys.freeze }.freeze
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module MutationSource
|
|
8
|
+
# The tree walk shared by the statement-driven operators: visit every
|
|
9
|
+
# node except nested definitions, skip arid statements, and hand each
|
|
10
|
+
# remaining node to the operator's mutate_node hook.
|
|
11
|
+
module OperatorVisit
|
|
12
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
13
|
+
|
|
14
|
+
def mutations_for(subject)
|
|
15
|
+
found = []
|
|
16
|
+
visit(subject.node.body, subject, found)
|
|
17
|
+
found
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def visit(node, subject, found)
|
|
23
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
24
|
+
|
|
25
|
+
if node.is_a?(Prism::StatementsNode)
|
|
26
|
+
node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
mutate_node(node, subject, found)
|
|
31
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def arid?(node)
|
|
35
|
+
@arid.any? { |pattern| pattern.match?(node.slice) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/saboteur/preview.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "engine"
|
|
5
|
+
require_relative "text"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
# Source-only planning. Never boots the host or executes its test suite.
|
|
@@ -92,7 +93,7 @@ module Saboteur
|
|
|
92
93
|
# coverage map means "no estimate", never an exception.
|
|
93
94
|
def cached_map
|
|
94
95
|
path = File.join(@config.path("cache_dir"), "preview.json")
|
|
95
|
-
receipt = JSON.parse(File.read(path))
|
|
96
|
+
receipt = JSON.parse(Text.utf8_strict(File.read(path)))
|
|
96
97
|
return unless receipt["fingerprint"] == Engine::RunFingerprint.new(config: @config).snapshot.digest
|
|
97
98
|
|
|
98
99
|
Selection::CoverageMap.load(receipt["path"])
|
|
@@ -8,15 +8,26 @@ module Saboteur
|
|
|
8
8
|
module Agent
|
|
9
9
|
# The shell commands an agent is told to run next. Every word is escaped with
|
|
10
10
|
# Shellwords, so a ref, path or subject holding shell syntax stays one literal
|
|
11
|
-
# argument. A command is never cut short: when it is
|
|
12
|
-
# is left out, because half a command could run
|
|
11
|
+
# argument. A command for the agent protocol is never cut short: when it is
|
|
12
|
+
# longer than MAX_BYTES it is left out, because half a command could run
|
|
13
|
+
# something else. The text footer passes capped: false instead - there the
|
|
14
|
+
# promise is the complete command, however long (#261).
|
|
13
15
|
module Commands
|
|
14
16
|
MAX_BYTES = 1024
|
|
15
17
|
|
|
16
18
|
# Options typed on the command line that change how a run behaves.
|
|
17
19
|
SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
|
|
18
20
|
timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html",
|
|
19
|
-
operators: "--operators", exclude_operators: "--exclude-operators"
|
|
21
|
+
operators: "--operators", exclude_operators: "--exclude-operators",
|
|
22
|
+
baseline_passes: "--baseline-passes", max_duration: "--max-duration" }.freeze
|
|
23
|
+
|
|
24
|
+
# CLI-only boolean switches recorded in settings, mapped by value, so
|
|
25
|
+
# an explicit false (--no-max-duration-ok over a config true) survives
|
|
26
|
+
# into the generated commands instead of reverting to the config.
|
|
27
|
+
BOOLEAN_SETTING_FLAGS = { allow_hazardous: { true => "--allow-hazardous" },
|
|
28
|
+
allow_focus: { true => "--allow-focus" },
|
|
29
|
+
max_duration_ok: { true => "--max-duration-ok",
|
|
30
|
+
false => "--no-max-duration-ok" } }.freeze
|
|
20
31
|
|
|
21
32
|
class << self
|
|
22
33
|
# Run the same selection again: ref, subjects, sources, tests, config,
|
|
@@ -26,25 +37,37 @@ module Saboteur
|
|
|
26
37
|
finish(words, provenance)
|
|
27
38
|
end
|
|
28
39
|
|
|
29
|
-
# Replay one survivor by digest in the same selection.
|
|
30
|
-
|
|
40
|
+
# Replay one survivor by digest in the same selection. capped: false
|
|
41
|
+
# skips the protocol byte cap, for callers that print to a terminal.
|
|
42
|
+
def replay(provenance, digest, capped: true)
|
|
31
43
|
words = ["saboteur", "run", "--id", digest, *selection(provenance, replay: true), "--seed",
|
|
32
44
|
provenance.seed.to_s]
|
|
33
|
-
finish(words, provenance)
|
|
45
|
+
finish(words, provenance, capped: capped)
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
private
|
|
37
49
|
|
|
38
|
-
def finish(words, provenance)
|
|
50
|
+
def finish(words, provenance, capped: true)
|
|
39
51
|
words += ["--", *provenance.sources] unless provenance.sources.empty?
|
|
40
52
|
command = Shellwords.join(words)
|
|
41
|
-
command
|
|
53
|
+
command if !capped || command.bytesize <= MAX_BYTES
|
|
42
54
|
end
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
def mode_words(provenance)
|
|
57
|
+
words = []
|
|
58
|
+
words << "--no-cache" if provenance.cache == false
|
|
59
|
+
words << "--incremental" if provenance.incremental
|
|
60
|
+
words.concat(boolean_words(provenance))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def boolean_words(provenance)
|
|
64
|
+
BOOLEAN_SETTING_FLAGS.filter_map { |key, words| words[provenance.settings[key]] }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# An explicit scope is frozen as --test, which wins over inferred
|
|
68
|
+
# selection by precedence, whatever the config file sets.
|
|
46
69
|
def test_words(tests)
|
|
47
|
-
tests.
|
|
70
|
+
tests.flat_map { |test| ["--test", test] }
|
|
48
71
|
end
|
|
49
72
|
|
|
50
73
|
def setting_words(provenance)
|
|
@@ -56,15 +79,17 @@ module Saboteur
|
|
|
56
79
|
end
|
|
57
80
|
end
|
|
58
81
|
|
|
82
|
+
# Whole-suite scope is a bare --all, only when no tests resolved;
|
|
83
|
+
# --all never combines with --test.
|
|
59
84
|
def selection(provenance, replay: false)
|
|
60
85
|
words = []
|
|
61
86
|
words.push("--config", provenance.config_path) if provenance.config_path
|
|
62
87
|
words.push("--since", provenance.ref) if provenance.ref
|
|
63
88
|
provenance.subjects.each { |subject| words.push("--subject", subject) }
|
|
89
|
+
words << "--all" if provenance.settings[:all] && provenance.tests.empty?
|
|
64
90
|
words.push(*test_words(provenance.tests))
|
|
65
91
|
words.concat(setting_words(provenance))
|
|
66
|
-
words
|
|
67
|
-
words << "--incremental" if provenance.incremental
|
|
92
|
+
words.concat(mode_words(provenance))
|
|
68
93
|
words.push("--id", provenance.digest) if provenance.digest && !replay
|
|
69
94
|
words
|
|
70
95
|
end
|
|
@@ -8,6 +8,8 @@ module Saboteur
|
|
|
8
8
|
# stable code, a count and a plain meaning.
|
|
9
9
|
module Incomplete
|
|
10
10
|
NO_COVERAGE = "no coverage"
|
|
11
|
+
SYNTAX_FAILURE = "invalid_file_syntax"
|
|
12
|
+
UNREADABLE_FAILURE = "unreadable"
|
|
11
13
|
|
|
12
14
|
class << self
|
|
13
15
|
def reasons(report)
|
|
@@ -23,14 +25,37 @@ module Saboteur
|
|
|
23
25
|
add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
|
|
24
26
|
add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
|
|
25
27
|
add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
|
|
26
|
-
add(reasons, "
|
|
27
|
-
"
|
|
28
|
+
add(reasons, "unverified", report.counts[:unverified],
|
|
29
|
+
"mutants the --max-duration budget left unverified; their kills are not confirmed")
|
|
30
|
+
discovery_failures(reasons, report)
|
|
31
|
+
early_stop_reason(reasons, report)
|
|
28
32
|
inline_scope(reasons, report)
|
|
29
33
|
reasons
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
private
|
|
33
37
|
|
|
38
|
+
# Source the selection could not read at all: its subjects never
|
|
39
|
+
# entered the run, so a clean or complete verdict would be wrong.
|
|
40
|
+
def discovery_failures(reasons, report)
|
|
41
|
+
failures = report.discovery_failures
|
|
42
|
+
add(reasons, SYNTAX_FAILURE, failures.count { |f| f.reason == SYNTAX_FAILURE },
|
|
43
|
+
"included files did not parse; their subjects were not discovered")
|
|
44
|
+
add(reasons, UNREADABLE_FAILURE, failures.count { |f| f.reason == UNREADABLE_FAILURE },
|
|
45
|
+
"included untracked files could not be read; their subjects were not covered")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def early_stop_reason(reasons, report)
|
|
49
|
+
stop = report.early_stop
|
|
50
|
+
return unless stop
|
|
51
|
+
|
|
52
|
+
label = stop.reason == "fail_fast" ? "--fail-fast stopped" : "the --max-duration budget stopped"
|
|
53
|
+
add(reasons, stop.reason, stop.unreached.size, "selected subjects did not run because #{label} the run")
|
|
54
|
+
omitted = stop.truncated.sum { |entry| entry[:omitted] }
|
|
55
|
+
add(reasons, "#{stop.reason}_truncated", omitted,
|
|
56
|
+
"mutants omitted inside subjects that stopped mid-way because #{label} the run")
|
|
57
|
+
end
|
|
58
|
+
|
|
34
59
|
def inline_scope(reasons, report)
|
|
35
60
|
add(reasons, "inline_suppressed", report.inline_suppressions.size,
|
|
36
61
|
"candidates were suppressed by inline directives; see the full report audit")
|