saboteur 0.8.0 → 0.8.1
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 +115 -0
- data/README.md +34 -6
- data/lib/saboteur/attribute_finder.rb +113 -0
- data/lib/saboteur/cli/presets.rb +41 -1
- data/lib/saboteur/cli/run_budget_options.rb +3 -1
- data/lib/saboteur/cli/source_set.rb +0 -18
- data/lib/saboteur/cli.rb +4 -2
- data/lib/saboteur/config.rb +4 -1
- data/lib/saboteur/config_file.rb +12 -8
- data/lib/saboteur/custom_map.rb +12 -1
- data/lib/saboteur/dirty_tree_warning.rb +5 -3
- data/lib/saboteur/doctor.rb +4 -0
- data/lib/saboteur/engine/baseline_guard.rb +76 -10
- data/lib/saboteur/engine/boot.rb +7 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +1 -0
- data/lib/saboteur/engine/coverage_store.rb +25 -3
- data/lib/saboteur/engine/diagnostics.rb +1 -1
- data/lib/saboteur/engine/gated_run.rb +3 -1
- data/lib/saboteur/engine/load_coverage.rb +18 -0
- data/lib/saboteur/engine/pipelines.rb +8 -0
- data/lib/saboteur/engine/run_budget.rb +5 -2
- data/lib/saboteur/engine/sandbox_policy.rb +21 -3
- data/lib/saboteur/engine/scheduler.rb +32 -24
- data/lib/saboteur/engine/selection_announcement.rb +8 -3
- data/lib/saboteur/engine/subject_selection.rb +115 -31
- data/lib/saboteur/engine/test_guess.rb +106 -16
- data/lib/saboteur/engine.rb +3 -2
- data/lib/saboteur/execution/external_runner.rb +18 -0
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/marker_sweep.rb +2 -1
- data/lib/saboteur/execution/process_table.rb +7 -3
- data/lib/saboteur/execution/reevaluator.rb +7 -0
- data/lib/saboteur/execution/sandbox.rb +10 -4
- data/lib/saboteur/execution/seatbelt.rb +10 -3
- data/lib/saboteur/execution/supervisor.rb +2 -1
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
- data/lib/saboteur/integration/rspec.rb +99 -11
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +3 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
- data/lib/saboteur/mutation_source/operator_examples.rb +1 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +1 -0
- data/lib/saboteur/preview.rb +5 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +20 -5
- data/lib/saboteur/reporter/terminal.rb +16 -1
- data/lib/saboteur/selection/coverage_map.rb +17 -2
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +1 -0
- metadata +5 -15
|
@@ -4,12 +4,39 @@
|
|
|
4
4
|
require_relative "../text"
|
|
5
5
|
require_relative "../constant_subject"
|
|
6
6
|
require_relative "../constant_subject_finder"
|
|
7
|
+
require_relative "../attribute_finder"
|
|
7
8
|
require_relative "../constant_reads"
|
|
8
9
|
|
|
9
10
|
module Saboteur
|
|
10
11
|
class Engine
|
|
11
12
|
# The subjects to mutate: every def in the included files, narrowed by
|
|
12
13
|
# --subject patterns and by --since (methods with a changed line).
|
|
14
|
+
# A heredoc body is code the static subject walk cannot see: methods
|
|
15
|
+
# defined by class_eval'ing the string, or behavior living in a
|
|
16
|
+
# template constant, exist only when the string is evaluated (#467).
|
|
17
|
+
module HeredocSpans
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def changed_lines_inside?(file, changed_lines)
|
|
21
|
+
result = Prism.parse(File.binread(file))
|
|
22
|
+
return false if result.failure?
|
|
23
|
+
|
|
24
|
+
spans = collect(result.value)
|
|
25
|
+
changed_lines.any? { |line| spans.any? { |span| span.cover?(line) } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def collect(node, spans = [])
|
|
29
|
+
if (node.is_a?(Prism::StringNode) || node.is_a?(Prism::InterpolatedStringNode)) &&
|
|
30
|
+
node.opening_loc&.slice&.start_with?("<<")
|
|
31
|
+
# The node location of an interpolated string stops at the opening
|
|
32
|
+
# marker; opening through closing covers the whole heredoc body.
|
|
33
|
+
spans << (node.opening_loc.start_line..node.closing_loc.end_line)
|
|
34
|
+
end
|
|
35
|
+
node.compact_child_nodes.each { |child| collect(child, spans) }
|
|
36
|
+
spans
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
13
40
|
class SubjectSelection
|
|
14
41
|
# Lines changed since the --since ref, by file; nil without --since.
|
|
15
42
|
attr_reader :changed_lines
|
|
@@ -33,12 +60,36 @@ module Saboteur
|
|
|
33
60
|
subjects += constant_subjects
|
|
34
61
|
end
|
|
35
62
|
@discovered = subjects
|
|
63
|
+
warn_unmatched_whole_file(subjects) if @config["since"]
|
|
36
64
|
subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
|
|
37
65
|
subjects = by_change(subjects, @config["since"]) if @config["since"]
|
|
38
66
|
subjects = drop_intentionally_untested(subjects)
|
|
67
|
+
inform_nothing_to_test(subjects)
|
|
39
68
|
attach_consumers(subjects)
|
|
40
69
|
end
|
|
41
70
|
|
|
71
|
+
# A --since run can legitimately select nothing: the range is empty
|
|
72
|
+
# (a checkout AT the base, #643) or its changes carry no mutable Ruby
|
|
73
|
+
# (a docs-only diff, #659). Exit 0 with a notice, not a silent green:
|
|
74
|
+
# name the base, the changed-file count and why nothing was selected.
|
|
75
|
+
def inform_nothing_to_test(subjects)
|
|
76
|
+
ref = @config["since"]
|
|
77
|
+
return unless ref && subjects.empty?
|
|
78
|
+
|
|
79
|
+
changed = Selection::GitDiff.changed_paths(ref, root: @root)
|
|
80
|
+
count = changed[:present].size + changed[:deleted].size
|
|
81
|
+
reason = if count.zero?
|
|
82
|
+
"the resolved range has no changes"
|
|
83
|
+
elsif !@raw_changed_files.intersect?(@files)
|
|
84
|
+
"#{count} changed file#{'s' unless count == 1}, none in mutation scope " \
|
|
85
|
+
"(docs-only, excluded, or non-Ruby)"
|
|
86
|
+
else
|
|
87
|
+
"no subject covers the changed lines (layout-only edits, deletions, " \
|
|
88
|
+
"or intentionally untested code)"
|
|
89
|
+
end
|
|
90
|
+
@diagnostics.inform("--since #{ref}: nothing to test - #{reason}")
|
|
91
|
+
end
|
|
92
|
+
|
|
42
93
|
# "Klass#meth", "Klass.meth", or a namespace prefix ("Klass", "A::B").
|
|
43
94
|
def self.match?(id, pattern)
|
|
44
95
|
id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
|
|
@@ -96,16 +147,12 @@ module Saboteur
|
|
|
96
147
|
subjects.any? { |subject| self.class.match?(subject.id, pattern) }
|
|
97
148
|
end
|
|
98
149
|
missed.each do |pattern|
|
|
99
|
-
@diagnostics.warn(
|
|
100
|
-
"Klass, Klass#meth or Klass.meth, not globs)")
|
|
150
|
+
@diagnostics.warn(missed_pattern_message(pattern))
|
|
101
151
|
end
|
|
102
152
|
# A total miss is a typo: refuse instead of a green zero-subject
|
|
103
153
|
# summary (#379). A partial miss runs, but the matched count is
|
|
104
154
|
# on the record.
|
|
105
|
-
if missed.size == patterns.size
|
|
106
|
-
raise ConfigError, "--subject #{patterns.join(', ')} matches no subject (patterns are names like " \
|
|
107
|
-
"Klass, Klass#meth or Klass.meth, not globs)"
|
|
108
|
-
end
|
|
155
|
+
raise ConfigError, total_miss_message(missed) if missed.size == patterns.size
|
|
109
156
|
|
|
110
157
|
if missed.any?
|
|
111
158
|
@diagnostics.warn("#{patterns.size - missed.size} of #{patterns.size} --subject patterns matched")
|
|
@@ -113,6 +160,37 @@ module Saboteur
|
|
|
113
160
|
subjects.select { |subject| patterns.any? { |pattern| self.class.match?(subject.id, pattern) } }
|
|
114
161
|
end
|
|
115
162
|
|
|
163
|
+
# The refusal when every pattern missed (#379). A pattern naming an
|
|
164
|
+
# attribute-macro method gets the real reason - the method exists but
|
|
165
|
+
# has no body to mutate (#373) - instead of the pattern-format hint.
|
|
166
|
+
def total_miss_message(missed)
|
|
167
|
+
if missed.all? { |pattern| attribute_macro_for(pattern) }
|
|
168
|
+
return missed.map { |pattern| missed_pattern_message(pattern) }.join("; ")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
"--subject #{missed.join(', ')} matches no subject (patterns are names like " \
|
|
172
|
+
"Klass, Klass#meth or Klass.meth, not globs)"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Why one missed pattern matched nothing: when it names a method an
|
|
176
|
+
# attribute macro generated, say so (#373); otherwise the pattern
|
|
177
|
+
# itself is suspect.
|
|
178
|
+
def missed_pattern_message(pattern)
|
|
179
|
+
macro = attribute_macro_for(pattern)
|
|
180
|
+
return "--subject #{pattern} is defined by #{macro} and has no body to mutate" if macro
|
|
181
|
+
|
|
182
|
+
"--subject #{pattern} matches no subject (patterns are names like " \
|
|
183
|
+
"Klass, Klass#meth or Klass.meth, not globs)"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The attribute macro that generated the method a pattern names, if
|
|
187
|
+
# any. The files are parsed for macros once, only when a pattern
|
|
188
|
+
# missed.
|
|
189
|
+
def attribute_macro_for(pattern)
|
|
190
|
+
attributes = @attributes ||= @files.flat_map { |file| AttributeFinder.new.call(file) }
|
|
191
|
+
attributes.find { |found| found.id == pattern }&.macro
|
|
192
|
+
end
|
|
193
|
+
|
|
116
194
|
def by_change(subjects, ref)
|
|
117
195
|
changed = changed_subjects(subjects)
|
|
118
196
|
warn_changed_files_without_subjects
|
|
@@ -193,7 +271,8 @@ module Saboteur
|
|
|
193
271
|
@diagnostics.warn("--since: constant subject(s) in #{path} are not mutated: the file runs code at " \
|
|
194
272
|
"load (#{listed}) that every constant mutant would run again, and saboteur " \
|
|
195
273
|
"cannot vet what re-running it does. List the constant in c.whole_file to " \
|
|
196
|
-
"replay the file anyway, or move the literal into its own file."
|
|
274
|
+
"replay the file anyway, or move the literal into its own file. " \
|
|
275
|
+
"#{whole_file_id_hint(rest)}")
|
|
197
276
|
opted_in
|
|
198
277
|
end
|
|
199
278
|
|
|
@@ -203,6 +282,32 @@ module Saboteur
|
|
|
203
282
|
Array(@config["whole_file"]).any? { |pattern| File.fnmatch?(pattern.to_s, subject.id) }
|
|
204
283
|
end
|
|
205
284
|
|
|
285
|
+
# A bare constant name only ever matches a top-level constant's id -
|
|
286
|
+
# never a nested one - so name the exact ids to copy into c.whole_file.
|
|
287
|
+
# The glob example carries its blast radius: it opts in every matching
|
|
288
|
+
# subject project-wide, so the exact id(s) are the safer pick (#635).
|
|
289
|
+
def whole_file_id_hint(subjects)
|
|
290
|
+
ids = subjects.map(&:id)
|
|
291
|
+
named = ids.first(3).map { |id| "\"#{id}\"" }.join(", ")
|
|
292
|
+
named += ", +#{ids.size - 3} more" if ids.size > 3
|
|
293
|
+
"Exact id(s): #{named} (preferred); a glob like \"*#{ids.first.split('::').last}*\" also " \
|
|
294
|
+
"works but opts in every matching subject project-wide."
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# A whole_file pattern that matches nothing silently does nothing (#635):
|
|
298
|
+
# name the pattern and what subject ids look like, so a bare constant
|
|
299
|
+
# name for a nested constant (which never matches its qualified id)
|
|
300
|
+
# stands out. Scoped to this run: a pattern for a file outside the
|
|
301
|
+
# current include set also matches nothing.
|
|
302
|
+
def warn_unmatched_whole_file(subjects)
|
|
303
|
+
@config["whole_file"].each do |pattern|
|
|
304
|
+
next if subjects.any? { |subject| File.fnmatch?(pattern.to_s, subject.id) }
|
|
305
|
+
|
|
306
|
+
@diagnostics.warn("--since: whole_file pattern \"#{pattern}\" matches no discovered " \
|
|
307
|
+
"subject in this run; subject ids look like RULES, A::B::C or A::B#m")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
206
311
|
def warn_unsupported_constant(found)
|
|
207
312
|
path = found.file.delete_prefix("#{@root}/")
|
|
208
313
|
@diagnostics.warn("--since: changed constant #{found.id} in #{path} is not mutated: #{found.reason}. " \
|
|
@@ -325,30 +430,9 @@ module Saboteur
|
|
|
325
430
|
"strings are not mutation subjects, so this run cannot see that behavior."
|
|
326
431
|
end
|
|
327
432
|
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
|
|
331
|
-
# Changed lines inside one deserve the specific warning, not the
|
|
332
|
-
# generic parse/method guess. A file that does not parse keeps the
|
|
333
|
-
# generic hint: its problem is named there already.
|
|
334
|
-
def changed_lines_inside_heredoc?(file)
|
|
335
|
-
result = Prism.parse(File.binread(file))
|
|
336
|
-
return false if result.failure?
|
|
337
|
-
|
|
338
|
-
spans = heredoc_spans(result.value)
|
|
339
|
-
@changed_lines[file].any? { |line| spans.any? { |span| span.cover?(line) } }
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
def heredoc_spans(node, spans = [])
|
|
343
|
-
if (node.is_a?(Prism::StringNode) || node.is_a?(Prism::InterpolatedStringNode)) &&
|
|
344
|
-
node.opening_loc&.slice&.start_with?("<<")
|
|
345
|
-
# The node location of an interpolated string stops at the opening
|
|
346
|
-
# marker; opening through closing covers the whole heredoc body.
|
|
347
|
-
spans << (node.opening_loc.start_line..node.closing_loc.end_line)
|
|
348
|
-
end
|
|
349
|
-
node.compact_child_nodes.each { |child| heredoc_spans(child, spans) }
|
|
350
|
-
spans
|
|
351
|
-
end
|
|
433
|
+
# Changed lines inside a heredoc deserve the specific warning, not the
|
|
434
|
+
# generic parse/method guess (#467); the scan lives in HeredocSpans.
|
|
435
|
+
def changed_lines_inside_heredoc?(file) = HeredocSpans.changed_lines_inside?(file, @changed_lines[file])
|
|
352
436
|
|
|
353
437
|
# A changed Ruby file that an include pattern covers but c.exclude
|
|
354
438
|
# removes yields zero candidates with no other sign - this repo's own
|
|
@@ -16,6 +16,38 @@ module Saboteur
|
|
|
16
16
|
# subject need a guessed spec; without --since, every included file with
|
|
17
17
|
# a mutation subject needs one. A file that needs a spec and has none
|
|
18
18
|
# stops the run before boot.
|
|
19
|
+
# A project with no *_spec.rb anywhere under its root has no RSpec suite
|
|
20
|
+
# at all: a per-file stop would print one line per
|
|
21
|
+
# source file and bury the real problem (#606, sibling of the
|
|
22
|
+
# missing-rspec-core refusal #542). The stop is one line naming it, with
|
|
23
|
+
# a Minitest pointer when test/ exists; --verbose keeps the per-file list.
|
|
24
|
+
module NoSuiteStop
|
|
25
|
+
MINITEST = "no RSpec suite found - saboteur needs RSpec; " \
|
|
26
|
+
"this project appears to use Minitest (test/), see docs/minitest.md"
|
|
27
|
+
PLAIN = "no RSpec suite found - saboteur needs RSpec; no spec file exists to run, see docs/minitest.md"
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# nil when the run should not claim a missing suite: a --subject run
|
|
32
|
+
# already names its expected spec, nothing is unmatched, or a suite
|
|
33
|
+
# exists. The suite can live outside spec/ - rspec_args can point
|
|
34
|
+
# RSpec at another default path - so look under the whole root;
|
|
35
|
+
# claiming "no spec file exists" for such a project is false and
|
|
36
|
+
# drops the --test/--all remedy (#606). :minitest or :plain otherwise.
|
|
37
|
+
def layout(root, unmatched, subject_run)
|
|
38
|
+
return if subject_run || unmatched.empty?
|
|
39
|
+
|
|
40
|
+
return unless Dir.glob(File.join(root, "**", "*_spec.rb")).empty?
|
|
41
|
+
|
|
42
|
+
File.directory?(File.join(root, "test")) ? :minitest : :plain
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def message(layout, pairs:, verbose: false)
|
|
46
|
+
line = layout == :minitest ? MINITEST : PLAIN
|
|
47
|
+
verbose ? "#{line} Files without a guessed spec: #{pairs.join(', ')}." : line
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
19
51
|
class TestGuess
|
|
20
52
|
# hints: unmatched file -> its conventional spec path (which does
|
|
21
53
|
# not exist; that is why the file is unmatched), for the stop-before-boot
|
|
@@ -25,8 +57,9 @@ module Saboteur
|
|
|
25
57
|
# in specs; skipped_specs: spec files the diff touches that cannot run,
|
|
26
58
|
# each with the reason.
|
|
27
59
|
Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints, :changed_specs,
|
|
28
|
-
:skipped_specs, :out_of_scope, :excluded, :near) do
|
|
29
|
-
def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {},
|
|
60
|
+
:skipped_specs, :out_of_scope, :excluded, :near, :no_suite) do
|
|
61
|
+
def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {},
|
|
62
|
+
no_suite: nil, **fields)
|
|
30
63
|
super
|
|
31
64
|
end
|
|
32
65
|
|
|
@@ -35,7 +68,11 @@ module Saboteur
|
|
|
35
68
|
# command, which could carry a hostile ref or a wrong source scope.
|
|
36
69
|
# When the diff deleted the expected spec or added others, it says
|
|
37
70
|
# so: a renamed spec is the usual cause.
|
|
38
|
-
def unmatched_message
|
|
71
|
+
def unmatched_message(verbose: false)
|
|
72
|
+
if no_suite
|
|
73
|
+
return NoSuiteStop.message(no_suite, pairs: unmatched.map { |f| pair_description(f) }, verbose: verbose)
|
|
74
|
+
end
|
|
75
|
+
|
|
39
76
|
pairs = unmatched.map { |file| pair_description(file) }
|
|
40
77
|
changed = changed_specs.any? ? " Specs the diff adds or changes: #{changed_specs.join(', ')}." : ""
|
|
41
78
|
"no spec found for #{pairs.join(', ')}, so nothing ran.#{changed} Re-run the same command with " \
|
|
@@ -148,7 +185,9 @@ module Saboteur
|
|
|
148
185
|
def call_for(files, patterns = [])
|
|
149
186
|
files = effective_subject_files(files, patterns) if @config["since"]
|
|
150
187
|
guesses = files.to_h { |file| [file, guess_for(file)] }
|
|
151
|
-
|
|
188
|
+
# A --subject run already knows its suite: the stop names the one
|
|
189
|
+
# expected spec, never the no-suite line (#606 is the full-run flood).
|
|
190
|
+
result(guesses, [], files.size, subject_run: true)
|
|
152
191
|
end
|
|
153
192
|
|
|
154
193
|
# specs: existing spec files (relative paths) guessed for the files in
|
|
@@ -211,7 +250,7 @@ module Saboteur
|
|
|
211
250
|
|
|
212
251
|
# Changed specs join only a run that mutates something: with no file
|
|
213
252
|
# in scope there is nothing for them to kill.
|
|
214
|
-
def result(guesses, subjectless, changed, out_of_scope: [])
|
|
253
|
+
def result(guesses, subjectless, changed, out_of_scope: [], subject_run: false)
|
|
215
254
|
unmatched = guesses.select { |_, specs| specs.nil? }.keys
|
|
216
255
|
excluded = guesses.select { |_, specs| specs == :excluded }.keys.sort
|
|
217
256
|
inferred = guesses.values.grep(Array).flatten
|
|
@@ -226,7 +265,8 @@ module Saboteur
|
|
|
226
265
|
changed_specs: diff[:run],
|
|
227
266
|
skipped_specs: diff[:skipped],
|
|
228
267
|
out_of_scope: out_of_scope,
|
|
229
|
-
excluded: excluded
|
|
268
|
+
excluded: excluded,
|
|
269
|
+
no_suite: NoSuiteStop.layout(@root, unmatched, subject_run))
|
|
230
270
|
end
|
|
231
271
|
|
|
232
272
|
# Changed Ruby files the include scope hides (#483), relative to the
|
|
@@ -303,13 +343,17 @@ module Saboteur
|
|
|
303
343
|
# --test without hunting (#370). Bounded; computed only for
|
|
304
344
|
# unmatched files, which are about to stop or warn.
|
|
305
345
|
def nearby_specs(file)
|
|
306
|
-
|
|
307
|
-
# glob metacharacters and must be escaped to match literally.
|
|
308
|
-
basename = File.basename(file, ".rb").gsub(/[\[\]{}?*\\]/) { |char| "\\#{char}" }
|
|
346
|
+
basename = escape_glob(File.basename(file, ".rb"))
|
|
309
347
|
Dir.glob("spec/**/#{basename}_spec.rb", base: @root)
|
|
310
348
|
.select { |path| File.file?(File.join(@root, path)) }.sort.first(3)
|
|
311
349
|
end
|
|
312
350
|
|
|
351
|
+
# A path segment is data, not a pattern: [, ], {, }, ?, * and \ are
|
|
352
|
+
# glob metacharacters and must be escaped to match literally.
|
|
353
|
+
def escape_glob(segment)
|
|
354
|
+
segment.gsub(/[\[\]{}?*\\]/) { |char| "\\#{char}" }
|
|
355
|
+
end
|
|
356
|
+
|
|
313
357
|
# lstat, not stat: a symlink can point anywhere, including a spec of
|
|
314
358
|
# another application or a fixture the suite excludes.
|
|
315
359
|
def unrunnable_reason(path)
|
|
@@ -417,7 +461,10 @@ module Saboteur
|
|
|
417
461
|
return expand_mapped(file, mapped) if mapped
|
|
418
462
|
|
|
419
463
|
conventional = candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
|
|
420
|
-
conventional
|
|
464
|
+
return [conventional] if conventional
|
|
465
|
+
|
|
466
|
+
unique = unique_suffix_spec(file)
|
|
467
|
+
unique && [unique]
|
|
421
468
|
end
|
|
422
469
|
|
|
423
470
|
# A mapped spec can be a glob (expanded against the root) or a regexp
|
|
@@ -464,14 +511,31 @@ module Saboteur
|
|
|
464
511
|
|
|
465
512
|
# Layouts that mirror less of the source path, after every mirrored
|
|
466
513
|
# guess (#394): a spec/unit/ prefix (shoulda-matchers mirrors below
|
|
467
|
-
# it, #393),
|
|
468
|
-
#
|
|
469
|
-
#
|
|
470
|
-
#
|
|
471
|
-
#
|
|
514
|
+
# it, #393), directories dropped between the gem namespace and the
|
|
515
|
+
# file (rubocop-ast tests lib/rubocop/ast/node/block_node.rb in
|
|
516
|
+
# spec/rubocop/ast/block_node_spec.rb, #586), the basename at the
|
|
517
|
+
# spec root (pundit), and both together (aasm, #370). Weaker
|
|
518
|
+
# guesses come last: the first candidate that exists wins. A file
|
|
519
|
+
# outside the source roots keeps its literal guess only: dropping
|
|
520
|
+
# directories from a path the roots never anchored would guess any
|
|
521
|
+
# same-named spec.
|
|
472
522
|
def layout_variants(names)
|
|
473
523
|
flat = names.map { |name| name.split("/").last }.uniq
|
|
474
|
-
(names + names.map { |name| "unit/#{name}" } +
|
|
524
|
+
(names + names.map { |name| "unit/#{name}" } + flattened_below_namespace(names) + flat +
|
|
525
|
+
flat.map { |name| "unit/#{name}" }).uniq
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Every way to drop mid directories from a name, fewest dropped
|
|
529
|
+
# first: each guess keeps at least the leading namespace segment
|
|
530
|
+
# and the basename, so it stays anchored on the gem namespace and
|
|
531
|
+
# more specific than the bare basename flatten (#586).
|
|
532
|
+
def flattened_below_namespace(names)
|
|
533
|
+
names.flat_map do |name|
|
|
534
|
+
segments = name.split("/")
|
|
535
|
+
(segments.size - 2).downto(1).map do |keep|
|
|
536
|
+
(segments.first(keep) + [segments.last]).join("/")
|
|
537
|
+
end
|
|
538
|
+
end.uniq
|
|
475
539
|
end
|
|
476
540
|
|
|
477
541
|
# Rails tests controllers as request specs: spec/requests/users_spec.rb
|
|
@@ -483,6 +547,32 @@ module Saboteur
|
|
|
483
547
|
["requests/#{path}", "requests/#{path.delete_suffix('_controller')}"].uniq
|
|
484
548
|
end
|
|
485
549
|
|
|
550
|
+
# The last-resort guess when no named candidate exists (#600): a
|
|
551
|
+
# spec tree can strip directories the convention keeps - aasm tests
|
|
552
|
+
# lib/aasm/core/invokers/proc_invoker.rb in
|
|
553
|
+
# spec/unit/invokers/proc_invoker_spec.rb, dropping the aasm and
|
|
554
|
+
# core segments. The one anchor such a layout keeps is the file's
|
|
555
|
+
# parent directory and basename, so a spec under the spec root
|
|
556
|
+
# ending in that suffix is the guess - but only a unique one: a
|
|
557
|
+
# second match makes the layout ambiguous, and guessing would run
|
|
558
|
+
# the wrong specs, a false verdict worse than the stop, whose
|
|
559
|
+
# near-miss hint names the candidates. Only files under a source
|
|
560
|
+
# root with a real parent directory qualify: a bare basename can
|
|
561
|
+
# collide anywhere, so it stays a near-miss hint.
|
|
562
|
+
def unique_suffix_spec(file)
|
|
563
|
+
pack = pack_of(file)
|
|
564
|
+
stem = file.delete_prefix("#{pack}/").delete_suffix(".rb")
|
|
565
|
+
root_segment, _, name = stem.partition("/")
|
|
566
|
+
return nil unless SOURCE_ROOTS.include?(root_segment) && name.include?("/")
|
|
567
|
+
|
|
568
|
+
suffix = name.split("/").last(2).map { |segment| escape_glob(segment) }.join("/")
|
|
569
|
+
spec_root = pack ? "#{pack}/spec" : "spec"
|
|
570
|
+
matches = Dir.glob(File.join(@root, spec_root, "**", "#{suffix}_spec.rb"))
|
|
571
|
+
.select { |path| File.file?(path) }
|
|
572
|
+
.map { |path| path.delete_prefix("#{@root}/") }.sort
|
|
573
|
+
matches.one? ? matches.first : nil
|
|
574
|
+
end
|
|
575
|
+
|
|
486
576
|
# The nearest ancestor directory holding a package.yml (a packwerk
|
|
487
577
|
# pack), relative to the root. The root itself is not a pack here:
|
|
488
578
|
# Pathname#ascend stops above "." for these relative paths.
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -136,7 +136,7 @@ module Saboteur
|
|
|
136
136
|
return if guess.changed.zero?
|
|
137
137
|
|
|
138
138
|
inform_skipped_specs(guess)
|
|
139
|
-
raise ConfigError, guess.unmatched_message if guess.unmatched.any?
|
|
139
|
+
raise ConfigError, guess.unmatched_message(verbose: @diagnostics.verbose) if guess.unmatched.any?
|
|
140
140
|
|
|
141
141
|
@diagnostics.inform(guess.excluded_message) if guess.excluded.any?
|
|
142
142
|
# Every file in scope intentionally untested: mutate nothing. An
|
|
@@ -268,7 +268,8 @@ module Saboteur
|
|
|
268
268
|
guard = BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
269
269
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
|
|
270
270
|
full_passes: full_passes, timeout_cap: method(:budget_capped_timeout),
|
|
271
|
-
between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil
|
|
271
|
+
between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil,
|
|
272
|
+
run_started: @run_started)
|
|
272
273
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
273
274
|
guard.call
|
|
274
275
|
@baseline_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
@@ -335,6 +335,7 @@ module Saboteur
|
|
|
335
335
|
if defined?(Bundler)
|
|
336
336
|
original = Bundler.original_env
|
|
337
337
|
BUNDLER_PASSTHROUGH_KEYS.each { |key| env[key] = original[key] if original.key?(key) }
|
|
338
|
+
restore_dropped_rubylib_entries(env, original)
|
|
338
339
|
end
|
|
339
340
|
if (rubyopt = env["RUBYOPT"])
|
|
340
341
|
rubyopt = rubyopt.gsub(%r{\s*-rbundler/setup}, "").strip
|
|
@@ -343,6 +344,23 @@ module Saboteur
|
|
|
343
344
|
env.merge(CHILD_MARKER => "1")
|
|
344
345
|
end
|
|
345
346
|
|
|
347
|
+
# bundler <=2.5 scrubs even shell-exported entries (verified
|
|
348
|
+
# 2.5.22); bundler >=2.6.0 restores the pre-bundler value wholesale
|
|
349
|
+
# (verified 2.6.0/2.6.9), where this restore is a no-op. A
|
|
350
|
+
# relocated/toolcache Ruby needs that entry to find its stdlib
|
|
351
|
+
# (#584). Rebuild the list from the pre-bundler one - RUBYLIB is
|
|
352
|
+
# ordered, and two directories providing the same feature load in
|
|
353
|
+
# path order - then append entries only the scrubbed list has;
|
|
354
|
+
# bundler's own additions (absent from the pre-bundler value) stay
|
|
355
|
+
# dropped, and duplicates collapse to first occurrence.
|
|
356
|
+
def restore_dropped_rubylib_entries(env, original)
|
|
357
|
+
before = original["RUBYLIB"].to_s.split(File::PATH_SEPARATOR)
|
|
358
|
+
return if before.empty?
|
|
359
|
+
|
|
360
|
+
after = env["RUBYLIB"].to_s.split(File::PATH_SEPARATOR)
|
|
361
|
+
env["RUBYLIB"] = (before | after).join(File::PATH_SEPARATOR)
|
|
362
|
+
end
|
|
363
|
+
|
|
346
364
|
# Only a failing exit proves the command saw the broken file; a
|
|
347
365
|
# pass is the anchoring or no-coverage case, and a timeout or setup
|
|
348
366
|
# failure leaves visibility unknown. Both warn and name the file.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Execution
|
|
6
|
+
# The one guarded door for fiddle (#639). Ruby ships fiddle itself - a
|
|
7
|
+
# default gem on 3.x, a bundled gem from 4.0 - so a run never needs to
|
|
8
|
+
# compile it, and a box where it cannot load has a broken Ruby or a
|
|
9
|
+
# missing Gemfile line, not a reason to run mutants without the sandbox
|
|
10
|
+
# layers. Every load goes through here and a failure REFUSES with the
|
|
11
|
+
# fixes, instead of a bundler-time source build dying on missing ffi.h.
|
|
12
|
+
module FiddleLoader
|
|
13
|
+
MESSAGE = "saboteur cannot load fiddle, which Ruby normally includes " \
|
|
14
|
+
"(a default gem on Ruby 3.x, a bundled gem on Ruby 4.0+), " \
|
|
15
|
+
"and refuses to run without its sandbox layers. Fix one of: " \
|
|
16
|
+
"reinstall Ruby so its shipped fiddle returns; " \
|
|
17
|
+
"run `gem install fiddle`; " \
|
|
18
|
+
"add `gem \"fiddle\"` to the Gemfile (required on Ruby 4.0+); " \
|
|
19
|
+
"or install the libffi headers (libffi-dev on Debian/Ubuntu, " \
|
|
20
|
+
"libffi-devel on Fedora/RHEL) and reinstall fiddle."
|
|
21
|
+
|
|
22
|
+
# Loads fiddle (plus fiddle/import for the one caller using its
|
|
23
|
+
# mixin). require is idempotent, so every touchpoint asks again for
|
|
24
|
+
# free. A LoadError means the sandbox layers cannot work: REFUSE -
|
|
25
|
+
# the error propagates, it is never downgraded to a warning.
|
|
26
|
+
def self.require_fiddle!(with_import: false)
|
|
27
|
+
require "fiddle"
|
|
28
|
+
require "fiddle/import" if with_import
|
|
29
|
+
rescue LoadError
|
|
30
|
+
raise Error, MESSAGE
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
require_relative "fiddle_loader"
|
|
5
5
|
|
|
6
6
|
module Saboteur
|
|
7
7
|
module Execution
|
|
@@ -33,6 +33,7 @@ module Saboteur
|
|
|
33
33
|
# usable prctl (macOS) the environment marker still names exec'd
|
|
34
34
|
# descendants and the tree still tracks the rest.
|
|
35
35
|
def write_comm(marker)
|
|
36
|
+
FiddleLoader.require_fiddle!
|
|
36
37
|
prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
|
|
37
38
|
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP] + ([Fiddle::TYPE_LONG] * 3),
|
|
38
39
|
Fiddle::TYPE_INT)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
require_relative "fiddle_loader"
|
|
5
5
|
|
|
6
6
|
module Saboteur
|
|
7
7
|
module Execution
|
|
@@ -111,14 +111,18 @@ module Saboteur
|
|
|
111
111
|
return @functions if defined?(@functions)
|
|
112
112
|
|
|
113
113
|
@functions = begin
|
|
114
|
-
|
|
114
|
+
FiddleLoader.require_fiddle!
|
|
115
115
|
library = Fiddle.dlopen("/usr/lib/libproc.dylib")
|
|
116
116
|
[Fiddle::Function.new(library["proc_listallpids"], [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
|
|
117
117
|
Fiddle::TYPE_INT),
|
|
118
118
|
Fiddle::Function.new(library["proc_pidinfo"],
|
|
119
119
|
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG,
|
|
120
120
|
Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)]
|
|
121
|
-
|
|
121
|
+
# Error first: the loader's refusal (#639) must match even when
|
|
122
|
+
# Fiddle is undefined (the DLError constant would not resolve).
|
|
123
|
+
# The process table is not a sandbox layer, so it keeps
|
|
124
|
+
# degrading to the next backend.
|
|
125
|
+
rescue Error, LoadError, Fiddle::DLError
|
|
122
126
|
nil
|
|
123
127
|
end
|
|
124
128
|
end
|
|
@@ -191,6 +191,13 @@ module Saboteur
|
|
|
191
191
|
source = subject.file_source.dup.force_encoding(Encoding::UTF_8)
|
|
192
192
|
spliced = source.byteslice(0, subject.start_byte) + text + source.byteslice(subject.end_byte..)
|
|
193
193
|
silencing_warnings { TOPLEVEL_BINDING.eval(spliced, subject.file, 1) }
|
|
194
|
+
# The eval just loaded the file for this run: a later lazy require
|
|
195
|
+
# of it (dry-rb load_extensions-style) must be a no-op, or its
|
|
196
|
+
# re-assignment of the file's constants fires "already initialized
|
|
197
|
+
# constant" - fatal in --warnings suites with a raising Warning
|
|
198
|
+
# gate, which wrongly reports every mutant as an error (#632).
|
|
199
|
+
loaded = File.expand_path(subject.file)
|
|
200
|
+
$LOADED_FEATURES << loaded unless $LOADED_FEATURES.include?(loaded)
|
|
194
201
|
end
|
|
195
202
|
|
|
196
203
|
def eval_in_owner(owner, subject, text, extra_lines)
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
require "fiddle/import"
|
|
4
|
+
require_relative "fiddle_loader"
|
|
6
5
|
|
|
7
6
|
module Saboteur
|
|
8
7
|
module Execution
|
|
@@ -57,6 +56,13 @@ module Saboteur
|
|
|
57
56
|
return @abi_version if defined?(@abi_version)
|
|
58
57
|
return @abi_version = 0 unless linux?
|
|
59
58
|
|
|
59
|
+
# Refuses when fiddle cannot load (#639): the sandbox is mandatory,
|
|
60
|
+
# so this error must reach the run, not the DLError degrade below.
|
|
61
|
+
FiddleLoader.require_fiddle!(with_import: true)
|
|
62
|
+
@abi_version = probed_abi_version
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def probed_abi_version
|
|
60
66
|
# Build the errno reader BEFORE the probe: its lazy dlopen/dlsym run
|
|
61
67
|
# libc calls that can set errno, so building it between the probe and
|
|
62
68
|
# the read can replace the real answer with a stale 0.
|
|
@@ -66,11 +72,11 @@ module Saboteur
|
|
|
66
72
|
# the kernel's real answer - the read was clobbered between the call
|
|
67
73
|
# and here. Probe once more, now with every Fiddle function warm.
|
|
68
74
|
version, errno = probe_once if !version.positive? && errno.zero?
|
|
69
|
-
|
|
75
|
+
probe_abi(version, errno)
|
|
70
76
|
rescue Fiddle::DLError
|
|
71
77
|
# No usable libc (an unusual musl/ldso layout): the sandbox cannot
|
|
72
78
|
# be built here, so degrade to the unsandboxed note, not a crash.
|
|
73
|
-
|
|
79
|
+
0
|
|
74
80
|
end
|
|
75
81
|
|
|
76
82
|
# A positive answer is the ABI. ENOSYS/EOPNOTSUPP mean the kernel has
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
require_relative "fiddle_loader"
|
|
5
5
|
|
|
6
6
|
module Saboteur
|
|
7
7
|
module Execution
|
|
@@ -50,9 +50,16 @@ module Saboteur
|
|
|
50
50
|
return @probe if defined?(@probe)
|
|
51
51
|
return @probe = :unsupported_platform unless darwin?
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
# Refuses when fiddle cannot load (#639), before the DLError
|
|
54
|
+
# degrade below can turn it into a silent :unavailable.
|
|
55
|
+
FiddleLoader.require_fiddle!
|
|
56
|
+
@probe = probed
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def probed
|
|
60
|
+
run_probe
|
|
54
61
|
rescue Fiddle::DLError
|
|
55
|
-
|
|
62
|
+
:unavailable
|
|
56
63
|
end
|
|
57
64
|
|
|
58
65
|
def available?
|
|
@@ -5,6 +5,7 @@ require "securerandom"
|
|
|
5
5
|
|
|
6
6
|
require_relative "process_tree"
|
|
7
7
|
require_relative "protocol"
|
|
8
|
+
require_relative "fiddle_loader"
|
|
8
9
|
|
|
9
10
|
module Saboteur
|
|
10
11
|
module Execution
|
|
@@ -457,7 +458,7 @@ module Saboteur
|
|
|
457
458
|
def become_subreaper
|
|
458
459
|
return unless RUBY_PLATFORM.include?("linux")
|
|
459
460
|
|
|
460
|
-
|
|
461
|
+
FiddleLoader.require_fiddle!
|
|
461
462
|
prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
|
|
462
463
|
[Fiddle::TYPE_INT, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
|
|
463
464
|
prctl.call(PR_SET_CHILD_SUBREAPER, 1)
|