saboteur 0.8.0 → 0.9.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 +215 -0
- data/README.md +44 -6
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +116 -0
- data/lib/saboteur/baseline/run_evidence.rb +2 -1
- data/lib/saboteur/cli/agent_provenance.rb +59 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
- data/lib/saboteur/cli/doctor_command.rb +41 -0
- data/lib/saboteur/cli/error_cause.rb +40 -0
- data/lib/saboteur/cli/execution_options.rb +46 -0
- data/lib/saboteur/cli/exit_status.rb +12 -14
- data/lib/saboteur/cli/explicit_file_options.rb +21 -0
- data/lib/saboteur/cli/explicit_sources.rb +46 -0
- data/lib/saboteur/cli/failure_output.rb +35 -0
- data/lib/saboteur/cli/help_command.rb +23 -0
- data/lib/saboteur/cli/matrix_option.rb +17 -0
- data/lib/saboteur/cli/output_options.rb +27 -0
- data/lib/saboteur/cli/presets.rb +41 -1
- data/lib/saboteur/cli/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +6 -1
- data/lib/saboteur/cli/run_command.rb +33 -0
- data/lib/saboteur/cli/run_output.rb +38 -0
- data/lib/saboteur/cli/run_reporting.rb +6 -5
- data/lib/saboteur/cli/run_validation.rb +54 -0
- data/lib/saboteur/cli/source_set.rb +0 -18
- data/lib/saboteur/cli.rb +29 -305
- data/lib/saboteur/config.rb +76 -7
- data/lib/saboteur/config_file.rb +12 -8
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +24 -5
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +6 -4
- data/lib/saboteur/doctor.rb +10 -0
- data/lib/saboteur/engine/baseline_guard.rb +118 -15
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +16 -5
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
- data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
- data/lib/saboteur/engine/coverage_partial.rb +48 -0
- data/lib/saboteur/engine/coverage_parts.rb +50 -0
- data/lib/saboteur/engine/coverage_shadow.rb +92 -0
- data/lib/saboteur/engine/coverage_store.rb +296 -21
- data/lib/saboteur/engine/diagnostics.rb +24 -7
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +4 -1
- data/lib/saboteur/engine/load_coverage.rb +72 -8
- data/lib/saboteur/engine/partial_invalidation.rb +115 -0
- data/lib/saboteur/engine/partial_merge.rb +40 -0
- data/lib/saboteur/engine/pipelines.rb +11 -3
- data/lib/saboteur/engine/reference_specs.rb +71 -0
- data/lib/saboteur/engine/result_cache.rb +68 -5
- data/lib/saboteur/engine/resume_manifest.rb +110 -0
- data/lib/saboteur/engine/run_budget.rb +10 -7
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +26 -5
- data/lib/saboteur/engine/scheduler.rb +33 -25
- data/lib/saboteur/engine/selection_announcement.rb +9 -3
- data/lib/saboteur/engine/shadow_comparison.rb +121 -0
- data/lib/saboteur/engine/shadow_run.rb +38 -0
- data/lib/saboteur/engine/subject_selection.rb +144 -64
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +186 -32
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +44 -11
- data/lib/saboteur/environment_hints.rb +55 -0
- data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
- data/lib/saboteur/execution/external_runner.rb +123 -1
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -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 +39 -20
- data/lib/saboteur/execution/seatbelt.rb +10 -3
- data/lib/saboteur/execution/supervisor.rb +42 -8
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
- data/lib/saboteur/gates.rb +121 -28
- data/lib/saboteur/install.rb +6 -1
- data/lib/saboteur/integration/base.rb +19 -2
- data/lib/saboteur/integration/rspec.rb +145 -18
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +33 -21
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
- data/lib/saboteur/mutation_source/jump_value.rb +59 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
- data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
- data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
- data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
- data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
- data/lib/saboteur/mutation_source/when_removal.rb +51 -0
- data/lib/saboteur/preview.rb +13 -0
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- data/lib/saboteur/reporter/agent_summary.rb +16 -10
- data/lib/saboteur/reporter/review_comments.rb +151 -0
- data/lib/saboteur/reporter/safe_out.rb +32 -0
- data/lib/saboteur/reporter/stryker.rb +8 -6
- data/lib/saboteur/reporter/survivor_classification.rb +41 -3
- data/lib/saboteur/reporter/survivor_hints.rb +5 -0
- data/lib/saboteur/reporter/terminal.rb +45 -5
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +150 -9
- data/lib/saboteur/selection/external_test_map.rb +4 -0
- data/lib/saboteur/selection/git_diff.rb +24 -2
- data/lib/saboteur/selection/layout_only_lines.rb +45 -1
- data/lib/saboteur/selection/recorder.rb +52 -22
- data/lib/saboteur/selection/trace_recorder.rb +83 -0
- data/lib/saboteur/selection/untracked_changes.rb +1 -2
- data/lib/saboteur/subject.rb +16 -0
- data/lib/saboteur/subject_finder.rb +91 -6
- data/lib/saboteur/text.rb +31 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +7 -2
- data/lib/saboteur.rb +9 -0
- metadata +48 -16
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Splits a suite's spec files into disjoint parts for parallel coverage
|
|
7
|
+
# builds (#741). The sorted list is dealt round-robin, the rule
|
|
8
|
+
# script/rspec-shard uses without weights, so every file lands in exactly
|
|
9
|
+
# one part and the split depends only on the file list, never on load or
|
|
10
|
+
# discovery order. A part that would be empty is not returned.
|
|
11
|
+
class SuitePartition
|
|
12
|
+
def self.parts(files, total)
|
|
13
|
+
raise ArgumentError, "parts must be at least 1, got #{total}" if total < 1
|
|
14
|
+
|
|
15
|
+
sorted = files.uniq.sort
|
|
16
|
+
dealt = Array.new([total, sorted.size].min) { [] }
|
|
17
|
+
sorted.each_with_index { |file, position| dealt[position % dealt.size] << file }
|
|
18
|
+
dealt
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -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,10 @@ 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
|
|
29
|
-
|
|
60
|
+
:skipped_specs, :out_of_scope, :excluded, :near, :no_suite, :without_map_entry,
|
|
61
|
+
:referenced, :crowded) do
|
|
62
|
+
def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {},
|
|
63
|
+
no_suite: nil, without_map_entry: [], referenced: {}, crowded: {}, **fields)
|
|
30
64
|
super
|
|
31
65
|
end
|
|
32
66
|
|
|
@@ -35,12 +69,22 @@ module Saboteur
|
|
|
35
69
|
# command, which could carry a hostile ref or a wrong source scope.
|
|
36
70
|
# When the diff deleted the expected spec or added others, it says
|
|
37
71
|
# so: a renamed spec is the usual cause.
|
|
38
|
-
def unmatched_message
|
|
72
|
+
def unmatched_message(verbose: false)
|
|
73
|
+
if no_suite
|
|
74
|
+
return NoSuiteStop.message(no_suite, pairs: unmatched.map { |f| pair_description(f) }, verbose: verbose)
|
|
75
|
+
end
|
|
76
|
+
|
|
39
77
|
pairs = unmatched.map { |file| pair_description(file) }
|
|
40
|
-
changed = changed_specs.any? ? " Specs the diff adds or changes: #{changed_specs
|
|
78
|
+
changed = changed_specs.any? ? " Specs the diff adds or changes: #{names(changed_specs)}." : ""
|
|
79
|
+
# The map is the documented escape hatch for layouts the
|
|
80
|
+
# convention cannot guess - named only for files no entry covers;
|
|
81
|
+
# a file whose own entry broke heard that in the warning (#640).
|
|
82
|
+
mapped = without_map_entry.any? &&
|
|
83
|
+
" If the layout defeats the naming convention, map #{names(without_map_entry)} by hand in " \
|
|
84
|
+
"config/saboteur_custom_map.rb: no entry covers it."
|
|
41
85
|
"no spec found for #{pairs.join(', ')}, so nothing ran.#{changed} Re-run the same command with " \
|
|
42
86
|
"one --test per existing spec path, or append --all to use the whole suite without " \
|
|
43
|
-
"changing the source scope
|
|
87
|
+
"changing the source scope.#{mapped}"
|
|
44
88
|
end
|
|
45
89
|
|
|
46
90
|
private
|
|
@@ -48,22 +92,31 @@ module Saboteur
|
|
|
48
92
|
def pair_description(file)
|
|
49
93
|
expected = hints.fetch(file)
|
|
50
94
|
gone = skipped_specs[expected] == DELETED ? ", deleted in the diff" : ""
|
|
51
|
-
found = near.fetch(file, []).any? ? "; found: #{near.fetch(file)
|
|
52
|
-
|
|
95
|
+
found = near.fetch(file, []).any? ? "; found: #{names(near.fetch(file))}" : ""
|
|
96
|
+
crowd = (count = crowded[file]) ? "; #{ReferenceSpecs.crowd_note(count)}" : ""
|
|
97
|
+
"#{Text.display_safe(file)} (expected #{Text.display_safe(expected)}#{gone}#{found}#{crowd})"
|
|
53
98
|
end
|
|
54
99
|
|
|
100
|
+
# Repo-controlled names reach CI logs, so each is escaped before it
|
|
101
|
+
# joins a message (#834): a file name may hold a newline.
|
|
102
|
+
def names(list) = list.map { |name| Text.display_safe(name) }.join(", ")
|
|
103
|
+
|
|
55
104
|
public
|
|
56
105
|
|
|
106
|
+
# The files that had no spec by name and got the specs that read
|
|
107
|
+
# their constants instead (#725 replaces the guess once a map exists).
|
|
108
|
+
def referenced_message = ReferenceSpecs.note(referenced)
|
|
109
|
+
|
|
57
110
|
# The spec files the diff touched that do not run, with the reasons.
|
|
58
111
|
def skipped_message
|
|
59
|
-
named = skipped_specs.map { |path, reason| "#{path} (#{reason})" }
|
|
112
|
+
named = skipped_specs.map { |path, reason| "#{Text.display_safe(path)} (#{reason})" }
|
|
60
113
|
"inferred specs: not running spec files changed in the diff: #{named.join(', ')}"
|
|
61
114
|
end
|
|
62
115
|
|
|
63
116
|
# Why the subjectless files needed no spec.
|
|
64
117
|
def subjectless_message(since:)
|
|
65
118
|
detail = since ? "no changed mutation subjects" : "no mutation subjects"
|
|
66
|
-
"inferred specs: no spec is required for #{subjectless
|
|
119
|
+
"inferred specs: no spec is required for #{names(subjectless)}: they have #{detail}"
|
|
67
120
|
end
|
|
68
121
|
|
|
69
122
|
# The per-file warning when the run proceeds with the mapped
|
|
@@ -80,7 +133,7 @@ module Saboteur
|
|
|
80
133
|
# scope hid. Append-only recovery, like unmatched_message: extend include, or
|
|
81
134
|
# name the specs with --test.
|
|
82
135
|
def out_of_scope_message
|
|
83
|
-
named = out_of_scope.first(10)
|
|
136
|
+
named = names(out_of_scope.first(10))
|
|
84
137
|
more = out_of_scope.size - 10
|
|
85
138
|
named += " and #{more} more" if more.positive?
|
|
86
139
|
"no changed source file lies inside the include scope, so nothing ran. " \
|
|
@@ -103,7 +156,7 @@ module Saboteur
|
|
|
103
156
|
|
|
104
157
|
# Why the custom-map-excluded files needed no spec (#369).
|
|
105
158
|
def excluded_message
|
|
106
|
-
"inferred specs: no spec is required for #{excluded
|
|
159
|
+
"inferred specs: no spec is required for #{names(excluded)}: " \
|
|
107
160
|
"intentionally untested in config/saboteur_custom_map.rb"
|
|
108
161
|
end
|
|
109
162
|
|
|
@@ -148,7 +201,9 @@ module Saboteur
|
|
|
148
201
|
def call_for(files, patterns = [])
|
|
149
202
|
files = effective_subject_files(files, patterns) if @config["since"]
|
|
150
203
|
guesses = files.to_h { |file| [file, guess_for(file)] }
|
|
151
|
-
|
|
204
|
+
# A --subject run already knows its suite: the stop names the one
|
|
205
|
+
# expected spec, never the no-suite line (#606 is the full-run flood).
|
|
206
|
+
result(guesses, [], files.size, subject_run: true)
|
|
152
207
|
end
|
|
153
208
|
|
|
154
209
|
# specs: existing spec files (relative paths) guessed for the files in
|
|
@@ -209,24 +264,47 @@ module Saboteur
|
|
|
209
264
|
end
|
|
210
265
|
end
|
|
211
266
|
|
|
267
|
+
# The specs the diff itself adds or changes, when a --since run has
|
|
268
|
+
# any guesses to join them with; empty otherwise.
|
|
269
|
+
def diff_specs(inferred, guesses)
|
|
270
|
+
@config["since"] && guesses.any? ? specs_in_diff(inferred) : { run: [], skipped: {} }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Unmatched files no map entry even tries to cover: the stop points
|
|
274
|
+
# those at the map (#640); a file whose own entry broke already heard
|
|
275
|
+
# about it in the guess-time warning.
|
|
276
|
+
def without_map_entry(unmatched)
|
|
277
|
+
unmatched.select { |file| @custom_map.specs_for(file).nil? }
|
|
278
|
+
end
|
|
279
|
+
|
|
212
280
|
# Changed specs join only a run that mutates something: with no file
|
|
213
281
|
# in scope there is nothing for them to kill.
|
|
214
|
-
def result(guesses, subjectless, changed, out_of_scope: [])
|
|
282
|
+
def result(guesses, subjectless, changed, out_of_scope: [], subject_run: false)
|
|
215
283
|
unmatched = guesses.select { |_, specs| specs.nil? }.keys
|
|
216
284
|
excluded = guesses.select { |_, specs| specs == :excluded }.keys.sort
|
|
217
285
|
inferred = guesses.values.grep(Array).flatten
|
|
218
|
-
diff =
|
|
286
|
+
diff = diff_specs(inferred, guesses)
|
|
219
287
|
unmatched -= covered_by_changed_specs(unmatched, diff[:run])
|
|
220
|
-
|
|
288
|
+
unmatched, referenced = split_referenced(unmatched)
|
|
289
|
+
Result.new(specs: (inferred + diff[:run] + referenced.values.flatten).uniq.sort,
|
|
290
|
+
referenced: referenced,
|
|
221
291
|
unmatched: unmatched,
|
|
292
|
+
without_map_entry: without_map_entry(unmatched),
|
|
222
293
|
subjectless: subjectless,
|
|
223
294
|
changed: changed,
|
|
224
|
-
|
|
225
|
-
near: unmatched.to_h { |file| [file, nearby_specs(file)] },
|
|
295
|
+
**stop_details(unmatched, diff[:skipped]),
|
|
226
296
|
changed_specs: diff[:run],
|
|
227
297
|
skipped_specs: diff[:skipped],
|
|
228
298
|
out_of_scope: out_of_scope,
|
|
229
|
-
excluded: excluded
|
|
299
|
+
excluded: excluded,
|
|
300
|
+
no_suite: NoSuiteStop.layout(@root, unmatched, subject_run))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# What the stop names for each unmatched file: the spec it expected and
|
|
304
|
+
# the same-named specs elsewhere.
|
|
305
|
+
def stop_details(unmatched, skipped)
|
|
306
|
+
{ hints: unmatched.to_h { |file| [file, expected_spec(file, skipped)] },
|
|
307
|
+
near: unmatched.to_h { |file| [file, nearby_specs(file)] }, crowded: @crowded }
|
|
230
308
|
end
|
|
231
309
|
|
|
232
310
|
# Changed Ruby files the include scope hides (#483), relative to the
|
|
@@ -274,6 +352,26 @@ module Saboteur
|
|
|
274
352
|
end
|
|
275
353
|
end
|
|
276
354
|
|
|
355
|
+
# Cold start, no map: the specs that read a constant an unmatched file
|
|
356
|
+
# defines (#538 matching). Only files that would stop the run today; a
|
|
357
|
+
# same-basename spec elsewhere means the user has a spec in mind and
|
|
358
|
+
# the choice is ambiguous: that stop stays, it names the candidates (#658).
|
|
359
|
+
def split_referenced(unmatched)
|
|
360
|
+
referenced = reference_specs(unmatched)
|
|
361
|
+
[unmatched - referenced.keys, referenced]
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def reference_specs(unmatched)
|
|
365
|
+
@crowded = {}
|
|
366
|
+
unmatched = unmatched.select { |file| nearby_specs(file).empty? }
|
|
367
|
+
return {} if unmatched.empty? || suite_files.nil?
|
|
368
|
+
|
|
369
|
+
specs = suite_files.select { |path| spec?(path) }
|
|
370
|
+
found = ReferenceSpecs.new(@root, method(:defined_constants)).call(unmatched, specs)
|
|
371
|
+
@crowded = found.crowded
|
|
372
|
+
found.chosen
|
|
373
|
+
end
|
|
374
|
+
|
|
277
375
|
# The constants a file defines: owners of its def subjects
|
|
278
376
|
# ("JWT::Claims::DecodeVerifier" from "...#call") and its constant
|
|
279
377
|
# literal subjects. Shaped for ConstantReads, which matches a bare
|
|
@@ -303,13 +401,17 @@ module Saboteur
|
|
|
303
401
|
# --test without hunting (#370). Bounded; computed only for
|
|
304
402
|
# unmatched files, which are about to stop or warn.
|
|
305
403
|
def nearby_specs(file)
|
|
306
|
-
|
|
307
|
-
# glob metacharacters and must be escaped to match literally.
|
|
308
|
-
basename = File.basename(file, ".rb").gsub(/[\[\]{}?*\\]/) { |char| "\\#{char}" }
|
|
404
|
+
basename = escape_glob(File.basename(file, ".rb"))
|
|
309
405
|
Dir.glob("spec/**/#{basename}_spec.rb", base: @root)
|
|
310
406
|
.select { |path| File.file?(File.join(@root, path)) }.sort.first(3)
|
|
311
407
|
end
|
|
312
408
|
|
|
409
|
+
# A path segment is data, not a pattern: [, ], {, }, ?, * and \ are
|
|
410
|
+
# glob metacharacters and must be escaped to match literally.
|
|
411
|
+
def escape_glob(segment)
|
|
412
|
+
segment.gsub(/[\[\]{}?*\\]/) { |char| "\\#{char}" }
|
|
413
|
+
end
|
|
414
|
+
|
|
313
415
|
# lstat, not stat: a symlink can point anywhere, including a spec of
|
|
314
416
|
# another application or a fixture the suite excludes.
|
|
315
417
|
def unrunnable_reason(path)
|
|
@@ -414,18 +516,24 @@ module Saboteur
|
|
|
414
516
|
def guess_for(file)
|
|
415
517
|
mapped = @custom_map.specs_for(file)
|
|
416
518
|
return :excluded if mapped == :excluded
|
|
417
|
-
|
|
519
|
+
|
|
520
|
+
expanded = mapped && expand_mapped(file, mapped)
|
|
521
|
+
return expanded if expanded
|
|
418
522
|
|
|
419
523
|
conventional = candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
|
|
420
|
-
conventional
|
|
524
|
+
return [conventional] if conventional
|
|
525
|
+
|
|
526
|
+
unique = unique_suffix_spec(file)
|
|
527
|
+
unique && [unique]
|
|
421
528
|
end
|
|
422
529
|
|
|
423
530
|
# A mapped spec can be a glob (expanded against the root) or a regexp
|
|
424
531
|
# backreference filled at match time, and neither is validated to
|
|
425
532
|
# exist when the map loads. Only specs that exist run; the literal
|
|
426
533
|
# glob string never reaches RSpec. When the winning entry's specs all
|
|
427
|
-
# vanish the
|
|
428
|
-
#
|
|
534
|
+
# vanish the entry is stale or mistyped: warn and fall through to the
|
|
535
|
+
# naming convention (and, failing that, to the usual stop guidance),
|
|
536
|
+
# instead of dying with a ConfigError and hiding the way out (#640).
|
|
429
537
|
def expand_mapped(file, specs)
|
|
430
538
|
expanded = specs.flat_map do |spec|
|
|
431
539
|
if spec.match?(/[*?\[{]/)
|
|
@@ -437,7 +545,10 @@ module Saboteur
|
|
|
437
545
|
expanded = expanded.select { |path| File.file?(File.join(@root, path)) }
|
|
438
546
|
return expanded unless expanded.empty?
|
|
439
547
|
|
|
440
|
-
|
|
548
|
+
shown = specs.first(5).map { |spec| Text.display_safe(spec) }.join(", ")
|
|
549
|
+
shown += ", and #{specs.size - 5} more" if specs.size > 5
|
|
550
|
+
@diagnostics&.warn("fix the custom map entry for #{Text.display_safe(file)}: spec(s) #{shown} match no file")
|
|
551
|
+
nil
|
|
441
552
|
end
|
|
442
553
|
|
|
443
554
|
# Candidate specs for a source file, most specific first: the nearest
|
|
@@ -464,14 +575,31 @@ module Saboteur
|
|
|
464
575
|
|
|
465
576
|
# Layouts that mirror less of the source path, after every mirrored
|
|
466
577
|
# guess (#394): a spec/unit/ prefix (shoulda-matchers mirrors below
|
|
467
|
-
# it, #393),
|
|
468
|
-
#
|
|
469
|
-
#
|
|
470
|
-
#
|
|
471
|
-
#
|
|
578
|
+
# it, #393), directories dropped between the gem namespace and the
|
|
579
|
+
# file (rubocop-ast tests lib/rubocop/ast/node/block_node.rb in
|
|
580
|
+
# spec/rubocop/ast/block_node_spec.rb, #586), the basename at the
|
|
581
|
+
# spec root (pundit), and both together (aasm, #370). Weaker
|
|
582
|
+
# guesses come last: the first candidate that exists wins. A file
|
|
583
|
+
# outside the source roots keeps its literal guess only: dropping
|
|
584
|
+
# directories from a path the roots never anchored would guess any
|
|
585
|
+
# same-named spec.
|
|
472
586
|
def layout_variants(names)
|
|
473
587
|
flat = names.map { |name| name.split("/").last }.uniq
|
|
474
|
-
(names + names.map { |name| "unit/#{name}" } +
|
|
588
|
+
(names + names.map { |name| "unit/#{name}" } + flattened_below_namespace(names) + flat +
|
|
589
|
+
flat.map { |name| "unit/#{name}" }).uniq
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Every way to drop mid directories from a name, fewest dropped
|
|
593
|
+
# first: each guess keeps at least the leading namespace segment
|
|
594
|
+
# and the basename, so it stays anchored on the gem namespace and
|
|
595
|
+
# more specific than the bare basename flatten (#586).
|
|
596
|
+
def flattened_below_namespace(names)
|
|
597
|
+
names.flat_map do |name|
|
|
598
|
+
segments = name.split("/")
|
|
599
|
+
(segments.size - 2).downto(1).map do |keep|
|
|
600
|
+
(segments.first(keep) + [segments.last]).join("/")
|
|
601
|
+
end
|
|
602
|
+
end.uniq
|
|
475
603
|
end
|
|
476
604
|
|
|
477
605
|
# Rails tests controllers as request specs: spec/requests/users_spec.rb
|
|
@@ -483,6 +611,32 @@ module Saboteur
|
|
|
483
611
|
["requests/#{path}", "requests/#{path.delete_suffix('_controller')}"].uniq
|
|
484
612
|
end
|
|
485
613
|
|
|
614
|
+
# The last-resort guess when no named candidate exists (#600): a
|
|
615
|
+
# spec tree can strip directories the convention keeps - aasm tests
|
|
616
|
+
# lib/aasm/core/invokers/proc_invoker.rb in
|
|
617
|
+
# spec/unit/invokers/proc_invoker_spec.rb, dropping the aasm and
|
|
618
|
+
# core segments. The one anchor such a layout keeps is the file's
|
|
619
|
+
# parent directory and basename, so a spec under the spec root
|
|
620
|
+
# ending in that suffix is the guess - but only a unique one: a
|
|
621
|
+
# second match makes the layout ambiguous, and guessing would run
|
|
622
|
+
# the wrong specs, a false verdict worse than the stop, whose
|
|
623
|
+
# near-miss hint names the candidates. Only files under a source
|
|
624
|
+
# root with a real parent directory qualify: a bare basename can
|
|
625
|
+
# collide anywhere, so it stays a near-miss hint.
|
|
626
|
+
def unique_suffix_spec(file)
|
|
627
|
+
pack = pack_of(file)
|
|
628
|
+
stem = file.delete_prefix("#{pack}/").delete_suffix(".rb")
|
|
629
|
+
root_segment, _, name = stem.partition("/")
|
|
630
|
+
return nil unless SOURCE_ROOTS.include?(root_segment) && name.include?("/")
|
|
631
|
+
|
|
632
|
+
suffix = name.split("/").last(2).map { |segment| escape_glob(segment) }.join("/")
|
|
633
|
+
spec_root = pack ? "#{pack}/spec" : "spec"
|
|
634
|
+
matches = Dir.glob(File.join(@root, spec_root, "**", "#{suffix}_spec.rb"))
|
|
635
|
+
.select { |path| File.file?(path) }
|
|
636
|
+
.map { |path| path.delete_prefix("#{@root}/") }.sort
|
|
637
|
+
matches.one? ? matches.first : nil
|
|
638
|
+
end
|
|
639
|
+
|
|
486
640
|
# The nearest ancestor directory holding a package.yml (a packwerk
|
|
487
641
|
# pack), relative to the root. The root itself is not a pack here:
|
|
488
642
|
# Pathname#ascend stops above "." for these relative paths.
|
|
@@ -45,8 +45,16 @@ module Saboteur
|
|
|
45
45
|
files.flat_map do |file|
|
|
46
46
|
next [] unless file.end_with?(".rb") && File.file?(file)
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
# The diff reaches files outside the mutation scope (fixtures,
|
|
49
|
+
# scripts): an in-scope file nested too deeply already refused
|
|
50
|
+
# the run at subject finding, so a DeepSourceError here can only
|
|
51
|
+
# come from a file the run never mutates. Skip it (#719, #730).
|
|
52
|
+
begin
|
|
53
|
+
current_ids = unique_by_id(File.read(file), file).keys
|
|
54
|
+
base_subjects(file).values.reject { |subject| current_ids.include?(subject.id) }
|
|
55
|
+
rescue DeepSourceError
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
50
58
|
end
|
|
51
59
|
end
|
|
52
60
|
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "engine/thread_warnings"
|
|
|
16
16
|
|
|
17
17
|
require_relative "engine/pipelines"
|
|
18
18
|
require_relative "engine/shard"
|
|
19
|
+
require_relative "engine/suite_partition"
|
|
19
20
|
|
|
20
21
|
module Saboteur
|
|
21
22
|
# A single-use instance runs one pipeline: boot the target, select subjects, fetch the
|
|
@@ -44,7 +45,7 @@ module Saboteur
|
|
|
44
45
|
@root = File.realpath(config.root)
|
|
45
46
|
@skipped_subjects = {}
|
|
46
47
|
@replay = Replay.new(digest: digest) if digest
|
|
47
|
-
if capture_evidence && (digest || config["since"] || config["incremental"] || config["shard"])
|
|
48
|
+
if capture_evidence && (digest || config["since"] || config["incremental"] || config["resume"] || config["shard"])
|
|
48
49
|
raise ConfigError,
|
|
49
50
|
"baseline evidence requires fresh whole-scope execution: no since, replay, shard or incremental"
|
|
50
51
|
end
|
|
@@ -76,9 +77,19 @@ module Saboteur
|
|
|
76
77
|
def preflight!
|
|
77
78
|
Boot.refuse_recursion!
|
|
78
79
|
refuse_fail_fast_evidence
|
|
80
|
+
verify_resume_directory
|
|
79
81
|
end
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
# An unusable --resume directory fails before the expensive phases (#471).
|
|
84
|
+
def verify_resume_directory
|
|
85
|
+
return if @replay || !config["resume"]
|
|
86
|
+
|
|
87
|
+
ResumeManifest.new(directory: config.path("resume"), identity: Digest::SHA256.hexdigest(verdict_inputs)).verify!
|
|
88
|
+
rescue ResumeManifest::Mismatch => e
|
|
89
|
+
raise ConfigError, e.message
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def warn_custom_map = @diagnostics.add_warnings(custom_map_warnings(load_custom_map))
|
|
82
93
|
|
|
83
94
|
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
84
95
|
# map does not rot silently. doctor fails on stale keys.
|
|
@@ -136,7 +147,7 @@ module Saboteur
|
|
|
136
147
|
return if guess.changed.zero?
|
|
137
148
|
|
|
138
149
|
inform_skipped_specs(guess)
|
|
139
|
-
raise ConfigError, guess.unmatched_message if guess.unmatched.any?
|
|
150
|
+
raise ConfigError, guess.unmatched_message(verbose: @diagnostics.verbose) if guess.unmatched.any?
|
|
140
151
|
|
|
141
152
|
@diagnostics.inform(guess.excluded_message) if guess.excluded.any?
|
|
142
153
|
# Every file in scope intentionally untested: mutate nothing. An
|
|
@@ -152,13 +163,15 @@ module Saboteur
|
|
|
152
163
|
def build_report(results, subjects, scope, started)
|
|
153
164
|
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
154
165
|
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
166
|
+
live_announced: @diagnostics.live_announced,
|
|
155
167
|
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
|
|
156
168
|
diff_scope: scope, generation_events: @diagnostics.generation_events,
|
|
157
169
|
omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
|
|
158
170
|
hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
|
|
159
171
|
matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
|
|
160
172
|
operator_policy: config.operator_policy, discovery_failures: @diagnostics.discovery_failures,
|
|
161
|
-
|
|
173
|
+
test_command: config["test_command"],
|
|
174
|
+
shard: @shard, memory: @memory_report, referenced: @guessed_referenced || {})
|
|
162
175
|
end
|
|
163
176
|
|
|
164
177
|
def complete_evidence(results, subjects)
|
|
@@ -261,14 +274,16 @@ module Saboteur
|
|
|
261
274
|
def coverage_store(integration, load_lines, load_methods = {})
|
|
262
275
|
CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:, load_methods:,
|
|
263
276
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
|
|
264
|
-
timeout_cap: method(:budget_capped_timeout)
|
|
277
|
+
timeout_cap: method(:budget_capped_timeout),
|
|
278
|
+
parts: config["coverage_parts"])
|
|
265
279
|
end
|
|
266
280
|
|
|
267
281
|
def check_baseline(integration, full_passes)
|
|
268
282
|
guard = BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
269
283
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
|
|
270
284
|
full_passes: full_passes, timeout_cap: method(:budget_capped_timeout),
|
|
271
|
-
between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil
|
|
285
|
+
between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil,
|
|
286
|
+
run_started: @run_started)
|
|
272
287
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
273
288
|
guard.call
|
|
274
289
|
@baseline_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
@@ -308,15 +323,18 @@ module Saboteur
|
|
|
308
323
|
|
|
309
324
|
def result_cache(map = nil, files = [])
|
|
310
325
|
return nil if @replay
|
|
311
|
-
return nil unless config["incremental"]
|
|
326
|
+
return nil unless config["incremental"] || config["resume"]
|
|
312
327
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
ResultCache.new(path: File.join(config.path("cache_dir"), "results.json"), root: @root, inputs: inputs,
|
|
328
|
+
directory = config.path("resume") || config.path("cache_dir")
|
|
329
|
+
ResultCache.new(path: File.join(directory, "results.json"), root: @root, inputs: verdict_inputs,
|
|
316
330
|
suite_digest: SuiteFiles.new(config).digest,
|
|
317
331
|
coverage: map, fallback_files: source_files(files))
|
|
318
332
|
end
|
|
319
333
|
|
|
334
|
+
def verdict_inputs
|
|
335
|
+
JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }.merge("operator_policy" => config.operator_policy))
|
|
336
|
+
end
|
|
337
|
+
|
|
320
338
|
# A cleanup failure must never mask the run's own outcome - not even
|
|
321
339
|
# one cleanup_workers did not anticipate.
|
|
322
340
|
def safely_cleanup_workers(rails)
|
|
@@ -365,7 +383,8 @@ module Saboteur
|
|
|
365
383
|
|
|
366
384
|
candidates = Candidates.build(config, @diagnostics, hazard_guard: hazard_guard)
|
|
367
385
|
selection = CandidateSelection.new(candidates: candidates, map: map,
|
|
368
|
-
limit: config["max_mutants_per_method"], diagnostics: @diagnostics
|
|
386
|
+
limit: config["max_mutants_per_method"], diagnostics: @diagnostics,
|
|
387
|
+
root: @root)
|
|
369
388
|
lists = subjects.map { |subject| selection.for(subject, scope: scope) }
|
|
370
389
|
@shard = Engine::Shard.parse(config["shard"]) if config["shard"]
|
|
371
390
|
lists = @shard.select(subjects, lists) if @shard
|
|
@@ -427,6 +446,9 @@ module Saboteur
|
|
|
427
446
|
|
|
428
447
|
def suspend_coverage(files)
|
|
429
448
|
(@coverage_guard = CoverageGuard.new(@diagnostics)).suspend
|
|
449
|
+
# Coverage stops counting here, so the call tracer stops with it
|
|
450
|
+
# instead of crediting the calls the engine itself makes afterwards.
|
|
451
|
+
MethodCalls.booted&.pause
|
|
430
452
|
LoadCoverage.capture(files)
|
|
431
453
|
end
|
|
432
454
|
|
|
@@ -440,8 +462,18 @@ require_relative "engine/boot"
|
|
|
440
462
|
require_relative "engine/subject_selection"
|
|
441
463
|
require_relative "engine/unchanged_syntax"
|
|
442
464
|
require_relative "engine/suite_files"
|
|
465
|
+
require_relative "engine/reference_specs"
|
|
443
466
|
require_relative "engine/test_guess"
|
|
444
467
|
require_relative "engine/sandbox_policy"
|
|
468
|
+
require_relative "engine/coverage_inputs_snapshot"
|
|
469
|
+
require_relative "engine/partial_invalidation"
|
|
470
|
+
require_relative "engine/partial_merge"
|
|
471
|
+
require_relative "engine/shadow_comparison"
|
|
472
|
+
require_relative "engine/shadow_run"
|
|
473
|
+
require_relative "engine/coverage_shadow"
|
|
474
|
+
require_relative "engine/coverage_partial"
|
|
475
|
+
require_relative "engine/coverage_parts"
|
|
476
|
+
require_relative "engine/coverage_miss_note"
|
|
445
477
|
require_relative "engine/coverage_store"
|
|
446
478
|
require_relative "engine/baseline_guard"
|
|
447
479
|
require_relative "engine/unscoped_suite_warning"
|
|
@@ -450,6 +482,7 @@ require_relative "engine/candidates"
|
|
|
450
482
|
require_relative "engine/candidate_selection"
|
|
451
483
|
require_relative "engine/diff_scope"
|
|
452
484
|
require_relative "engine/result_cache"
|
|
485
|
+
require_relative "engine/resume_manifest"
|
|
453
486
|
require_relative "engine/defined_order_prefix"
|
|
454
487
|
require_relative "engine/verdict_check"
|
|
455
488
|
require_relative "engine/scheduler"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# Environment hints for runs started the wrong way (#598, #609). Detected
|
|
6
|
+
# from files, never from error text; advisory only - nothing refuses.
|
|
7
|
+
module EnvironmentHints
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# One line per detected problem, each a full sentence for the channel it
|
|
11
|
+
# lands in (boot warning, doctor line, or load-failure appendix).
|
|
12
|
+
def all(root, bundler_loaded: !defined?(Bundler).nil?, env: ENV)
|
|
13
|
+
[bundler(root, bundler_loaded: bundler_loaded), appraisal(root, env: env)].compact
|
|
14
|
+
rescue StandardError
|
|
15
|
+
# An advisory hint must never stop a run (Pod 1 security review).
|
|
16
|
+
[]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# #598: a lockfile but no Bundler means gems resolve to whatever is
|
|
20
|
+
# installed, not the lockfile's versions.
|
|
21
|
+
def bundler(root, bundler_loaded: !defined?(Bundler).nil?)
|
|
22
|
+
return nil unless File.file?(File.join(root, "Gemfile.lock"))
|
|
23
|
+
return nil if bundler_loaded
|
|
24
|
+
|
|
25
|
+
"Gemfile.lock found but Bundler is not loaded: gems resolve to whatever is installed, not the " \
|
|
26
|
+
"lockfile's versions - run `bundle exec saboteur run`"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# #609: an Appraisal project run without BUNDLE_GEMFILE pointing into
|
|
30
|
+
# gemfiles/ loads the wrong (or no) dependency set.
|
|
31
|
+
def appraisal(root, env: ENV)
|
|
32
|
+
appraisals = File.file?(File.join(root, "Appraisals"))
|
|
33
|
+
gemfiles = Dir.glob(File.join(root, "gemfiles", "*.gemfile"))
|
|
34
|
+
return nil unless appraisals || gemfiles.any?
|
|
35
|
+
|
|
36
|
+
setting = env["BUNDLE_GEMFILE"].to_s
|
|
37
|
+
under = !setting.empty? &&
|
|
38
|
+
File.expand_path(setting, root).start_with?("#{File.join(File.expand_path(root), 'gemfiles')}/")
|
|
39
|
+
return nil if under
|
|
40
|
+
|
|
41
|
+
found = (appraisals ? ["Appraisals"] : []) +
|
|
42
|
+
gemfiles.map { |f| sanitize(f.sub("#{File.expand_path(root)}/", "")) }
|
|
43
|
+
where = setting.empty? ? "is not set" : "points outside gemfiles/ (#{sanitize(setting)})"
|
|
44
|
+
"Appraisal gemfiles found (#{Text.name_list(found)}) but BUNDLE_GEMFILE #{where}: run " \
|
|
45
|
+
"`BUNDLE_GEMFILE=gemfiles/<name>.gemfile bundle exec saboteur run` or " \
|
|
46
|
+
"`bundle exec appraisal <name> saboteur run`"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A cloned repo can name gemfiles with terminal control characters (Pod 1
|
|
50
|
+
# security review): escape them before they reach the terminal.
|
|
51
|
+
def sanitize(text)
|
|
52
|
+
text.scrub("?").gsub(/[\x00-\x1f\x7f\u0080-\u009f]/) { |c| format("\\x%02x", c.ord) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|