saboteur 0.8.1 → 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 +100 -0
- data/README.md +10 -0
- data/exe/saboteur +6 -2
- data/lib/saboteur/attribute_finder.rb +3 -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/preview_command.rb +26 -0
- data/lib/saboteur/cli/run_budget_options.rb +3 -0
- 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.rb +29 -307
- data/lib/saboteur/config.rb +72 -6
- data/lib/saboteur/constant_subject_finder.rb +3 -0
- data/lib/saboteur/custom_map.rb +20 -12
- data/lib/saboteur/deep_source.rb +49 -0
- data/lib/saboteur/dirty_tree_warning.rb +1 -1
- data/lib/saboteur/doctor.rb +6 -0
- data/lib/saboteur/engine/baseline_guard.rb +50 -13
- data/lib/saboteur/engine/baseline_parts.rb +51 -0
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +15 -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 +275 -22
- data/lib/saboteur/engine/diagnostics.rb +23 -6
- data/lib/saboteur/engine/distribution.rb +2 -2
- data/lib/saboteur/engine/gated_run.rb +1 -0
- data/lib/saboteur/engine/load_coverage.rb +54 -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 +7 -7
- 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 +5 -5
- data/lib/saboteur/engine/run_fingerprint.rb +2 -1
- data/lib/saboteur/engine/sandbox_policy.rb +5 -2
- data/lib/saboteur/engine/scheduler.rb +1 -1
- data/lib/saboteur/engine/selection_announcement.rb +1 -0
- 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 +33 -37
- data/lib/saboteur/engine/suite_partition.rb +22 -0
- data/lib/saboteur/engine/test_guess.rb +82 -18
- data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
- data/lib/saboteur/engine.rb +41 -9
- 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 +105 -1
- data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
- data/lib/saboteur/execution/sandbox.rb +30 -17
- data/lib/saboteur/execution/supervisor.rb +40 -7
- data/lib/saboteur/execution/supervisor_batch.rb +105 -0
- data/lib/saboteur/execution/worker_pool.rb +48 -4
- data/lib/saboteur/frameworks/rails.rb +2 -5
- 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 +46 -7
- data/lib/saboteur/line_hits.rb +81 -0
- data/lib/saboteur/method_calls.rb +91 -0
- data/lib/saboteur/mutation_source/builtin.rb +32 -22
- data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
- 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 +5 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -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 +9 -1
- data/lib/saboteur/refinement_activations.rb +22 -10
- data/lib/saboteur/reporter/agent.rb +2 -2
- data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
- 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 +29 -4
- data/lib/saboteur/result.rb +14 -4
- data/lib/saboteur/selection/coverage_map.rb +136 -10
- 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 +8 -0
- metadata +44 -2
|
@@ -134,7 +134,8 @@ module Saboteur
|
|
|
134
134
|
else
|
|
135
135
|
@diagnostics.record_discovery_failure(file, reason: "invalid_file_syntax")
|
|
136
136
|
parsed.errors.first(1).each do |message|
|
|
137
|
-
@diagnostics.warn("#{file.delete_prefix("#{@root}/")}
|
|
137
|
+
@diagnostics.warn("fix the syntax in #{Text.display_safe(file.delete_prefix("#{@root}/"))}: " \
|
|
138
|
+
"does not parse (#{Text.display_safe(message)})")
|
|
138
139
|
end
|
|
139
140
|
end
|
|
140
141
|
end
|
|
@@ -179,7 +180,7 @@ module Saboteur
|
|
|
179
180
|
macro = attribute_macro_for(pattern)
|
|
180
181
|
return "--subject #{pattern} is defined by #{macro} and has no body to mutate" if macro
|
|
181
182
|
|
|
182
|
-
"--subject #{pattern} matches no subject (patterns are names like " \
|
|
183
|
+
"fix the --subject pattern \"#{pattern}\": it matches no subject (patterns are names like " \
|
|
183
184
|
"Klass, Klass#meth or Klass.meth, not globs)"
|
|
184
185
|
end
|
|
185
186
|
|
|
@@ -266,12 +267,13 @@ module Saboteur
|
|
|
266
267
|
return subjects if reasons.empty?
|
|
267
268
|
|
|
268
269
|
constant_warned_files << canonical
|
|
269
|
-
path = file.delete_prefix("#{@root}/")
|
|
270
|
+
path = Text.display_safe(file.delete_prefix("#{@root}/"))
|
|
270
271
|
listed = reasons.first(2).join(", ")
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
"
|
|
274
|
-
"
|
|
272
|
+
listed += ", and #{reasons.size - 2} more" if reasons.size > 2
|
|
273
|
+
@diagnostics.warn("#{path}: list the constant in c.whole_file to replay it anyway, or move the " \
|
|
274
|
+
"literal into its own file\n --since: constant subject(s) in this file are not " \
|
|
275
|
+
"mutated: it runs code at load (#{listed}) that every constant mutant would run " \
|
|
276
|
+
"again, and saboteur cannot vet what re-running it does. " \
|
|
275
277
|
"#{whole_file_id_hint(rest)}")
|
|
276
278
|
opted_in
|
|
277
279
|
end
|
|
@@ -303,15 +305,15 @@ module Saboteur
|
|
|
303
305
|
@config["whole_file"].each do |pattern|
|
|
304
306
|
next if subjects.any? { |subject| File.fnmatch?(pattern.to_s, subject.id) }
|
|
305
307
|
|
|
306
|
-
@diagnostics.warn("
|
|
307
|
-
"subject in this run
|
|
308
|
+
@diagnostics.warn("fix the whole_file entry \"#{Text.display_safe(pattern.to_s)}\": it matches no " \
|
|
309
|
+
"discovered subject in this run (subject ids look like RULES, A::B::C or A::B#m)")
|
|
308
310
|
end
|
|
309
311
|
end
|
|
310
312
|
|
|
311
313
|
def warn_unsupported_constant(found)
|
|
312
|
-
path = found.file.delete_prefix("#{@root}/")
|
|
313
|
-
@diagnostics.warn("--since: changed constant #{found.id} in #{path} is not mutated
|
|
314
|
-
"
|
|
314
|
+
path = Text.display_safe(found.file.delete_prefix("#{@root}/"))
|
|
315
|
+
@diagnostics.warn("--since: changed constant #{found.id} in #{path} is not mutated\n " \
|
|
316
|
+
"#{found.reason}; only hash and array literal entries are mutated")
|
|
315
317
|
end
|
|
316
318
|
|
|
317
319
|
# The tests that can observe a constant mutant are the ones covering a
|
|
@@ -330,9 +332,9 @@ module Saboteur
|
|
|
330
332
|
subject.consumer_defs = reads.transform_values(&:defs)
|
|
331
333
|
if subject.consumer_lines.empty? && reads.any? { |_file, r| r.load.any? }
|
|
332
334
|
path = subject.file.delete_prefix("#{@root}/")
|
|
333
|
-
@diagnostics.warn("--since: changed constant #{subject.id} in #{path} is not mutated
|
|
334
|
-
"reads capture the value at load time in another file, so no test
|
|
335
|
-
"observe a change to it
|
|
335
|
+
@diagnostics.warn("--since: changed constant #{subject.id} in #{path} is not mutated\n " \
|
|
336
|
+
"its only reads capture the value at load time in another file, so no test " \
|
|
337
|
+
"can observe a change to it")
|
|
336
338
|
next
|
|
337
339
|
end
|
|
338
340
|
subject
|
|
@@ -395,11 +397,10 @@ module Saboteur
|
|
|
395
397
|
unmatched = (@changed_lines.keys.select { |file| file.end_with?(".rb") } & included) - matched
|
|
396
398
|
return if unmatched.empty?
|
|
397
399
|
|
|
398
|
-
names =
|
|
399
|
-
more = unmatched.size > 5 ? ", ..." : ""
|
|
400
|
+
names = Text.name_list(unmatched.map { |file| file.delete_prefix("#{@root}/") })
|
|
400
401
|
note = constant_note(unmatched)
|
|
401
402
|
@diagnostics.warn("--since: #{unmatched.size} changed Ruby file(s) have no subjects covering " \
|
|
402
|
-
"the changed lines, so nothing is mutated from them: #{names}
|
|
403
|
+
"the changed lines, so nothing is mutated from them: #{names}\n " \
|
|
403
404
|
"#{subjectless_hint(unmatched)}#{note}")
|
|
404
405
|
end
|
|
405
406
|
|
|
@@ -410,7 +411,7 @@ module Saboteur
|
|
|
410
411
|
constants = unmatched.count { |file| constant_warned_files.include?(file) }
|
|
411
412
|
return "" if constants.zero?
|
|
412
413
|
|
|
413
|
-
"
|
|
414
|
+
"\n #{constants} of these #{constants == 1 ? 'file has' : 'files have'} changes inside " \
|
|
414
415
|
"a constant literal that is not mutated (the constant warnings above name them)."
|
|
415
416
|
end
|
|
416
417
|
|
|
@@ -423,9 +424,8 @@ module Saboteur
|
|
|
423
424
|
embedded = unmatched.select { |file| changed_lines_inside_heredoc?(file) }
|
|
424
425
|
return "The file may not parse, or every change may sit outside a method." if embedded.empty?
|
|
425
426
|
|
|
426
|
-
names =
|
|
427
|
-
|
|
428
|
-
"In #{names}#{more} the changed lines are code embedded in a string (a heredoc, often the " \
|
|
427
|
+
names = Text.name_list(embedded.map { |file| file.delete_prefix("#{@root}/") })
|
|
428
|
+
"In #{names}, the changed lines are code embedded in a string (a heredoc, often the " \
|
|
429
429
|
"argument to class_eval/module_eval or a template constant); methods defined by evaluating " \
|
|
430
430
|
"strings are not mutation subjects, so this run cannot see that behavior."
|
|
431
431
|
end
|
|
@@ -443,12 +443,11 @@ module Saboteur
|
|
|
443
443
|
hits = excluded_territory & (@changed_lines.keys + untracked)
|
|
444
444
|
return if hits.empty?
|
|
445
445
|
|
|
446
|
-
names = hits.sort.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
"c.exclude
|
|
450
|
-
"
|
|
451
|
-
"excludes are then ignored).")
|
|
446
|
+
names = Text.name_list(hits.sort.map { |file| file.delete_prefix("#{@root}/") })
|
|
447
|
+
@diagnostics.warn("narrow or remove the c.exclude entry, or pass the file as a positional source " \
|
|
448
|
+
"operand: #{hits.size} changed Ruby file(s) are covered by c.include but removed " \
|
|
449
|
+
"by c.exclude\n --since: nothing is mutated from them: #{names} (configured " \
|
|
450
|
+
"excludes are then ignored)")
|
|
452
451
|
end
|
|
453
452
|
|
|
454
453
|
# Ruby files an include pattern and an exclude pattern both match,
|
|
@@ -473,10 +472,9 @@ module Saboteur
|
|
|
473
472
|
end
|
|
474
473
|
return subjects if dropped.empty?
|
|
475
474
|
|
|
476
|
-
names =
|
|
477
|
-
more = dropped.size > 5 ? ", ..." : ""
|
|
475
|
+
names = Text.name_list(dropped)
|
|
478
476
|
@diagnostics.inform("#{dropped.size} subject(s) skipped: intentionally untested in " \
|
|
479
|
-
"config/saboteur_custom_map.rb: #{names}
|
|
477
|
+
"config/saboteur_custom_map.rb: #{names}")
|
|
480
478
|
kept
|
|
481
479
|
end
|
|
482
480
|
|
|
@@ -501,19 +499,17 @@ module Saboteur
|
|
|
501
499
|
def report_deleted(deleted)
|
|
502
500
|
return if deleted.empty?
|
|
503
501
|
|
|
504
|
-
names =
|
|
505
|
-
more = deleted.size > 5 ? ", ..." : ""
|
|
502
|
+
names = Text.name_list(deleted)
|
|
506
503
|
verb = deleted.one? ? "subject was" : "subjects were"
|
|
507
|
-
@diagnostics.inform("#{deleted.size} changed #{verb} deleted, nothing to mutate: #{names}
|
|
504
|
+
@diagnostics.inform("#{deleted.size} changed #{verb} deleted, nothing to mutate: #{names}")
|
|
508
505
|
end
|
|
509
506
|
|
|
510
507
|
def report_unchanged_syntax(subjects)
|
|
511
508
|
return if subjects.empty?
|
|
512
509
|
|
|
513
|
-
names =
|
|
514
|
-
more = subjects.size > 5 ? ", ..." : ""
|
|
510
|
+
names = Text.name_list(subjects)
|
|
515
511
|
@diagnostics.inform("#{subjects.size} changed subject(s) differ only in comments or formatting, " \
|
|
516
|
-
"so they are skipped: #{names}
|
|
512
|
+
"so they are skipped: #{names}")
|
|
517
513
|
end
|
|
518
514
|
|
|
519
515
|
def canonical_path(file)
|
|
@@ -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
|
|
@@ -57,9 +57,10 @@ module Saboteur
|
|
|
57
57
|
# in specs; skipped_specs: spec files the diff touches that cannot run,
|
|
58
58
|
# each with the reason.
|
|
59
59
|
Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints, :changed_specs,
|
|
60
|
-
:skipped_specs, :out_of_scope, :excluded, :near, :no_suite
|
|
60
|
+
:skipped_specs, :out_of_scope, :excluded, :near, :no_suite, :without_map_entry,
|
|
61
|
+
:referenced, :crowded) do
|
|
61
62
|
def initialize(changed_specs: [], skipped_specs: {}, out_of_scope: [], excluded: [], near: {},
|
|
62
|
-
no_suite: nil, **fields)
|
|
63
|
+
no_suite: nil, without_map_entry: [], referenced: {}, crowded: {}, **fields)
|
|
63
64
|
super
|
|
64
65
|
end
|
|
65
66
|
|
|
@@ -74,10 +75,16 @@ module Saboteur
|
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
pairs = unmatched.map { |file| pair_description(file) }
|
|
77
|
-
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."
|
|
78
85
|
"no spec found for #{pairs.join(', ')}, so nothing ran.#{changed} Re-run the same command with " \
|
|
79
86
|
"one --test per existing spec path, or append --all to use the whole suite without " \
|
|
80
|
-
"changing the source scope
|
|
87
|
+
"changing the source scope.#{mapped}"
|
|
81
88
|
end
|
|
82
89
|
|
|
83
90
|
private
|
|
@@ -85,22 +92,31 @@ module Saboteur
|
|
|
85
92
|
def pair_description(file)
|
|
86
93
|
expected = hints.fetch(file)
|
|
87
94
|
gone = skipped_specs[expected] == DELETED ? ", deleted in the diff" : ""
|
|
88
|
-
found = near.fetch(file, []).any? ? "; found: #{near.fetch(file)
|
|
89
|
-
|
|
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})"
|
|
90
98
|
end
|
|
91
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
|
+
|
|
92
104
|
public
|
|
93
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
|
+
|
|
94
110
|
# The spec files the diff touched that do not run, with the reasons.
|
|
95
111
|
def skipped_message
|
|
96
|
-
named = skipped_specs.map { |path, reason| "#{path} (#{reason})" }
|
|
112
|
+
named = skipped_specs.map { |path, reason| "#{Text.display_safe(path)} (#{reason})" }
|
|
97
113
|
"inferred specs: not running spec files changed in the diff: #{named.join(', ')}"
|
|
98
114
|
end
|
|
99
115
|
|
|
100
116
|
# Why the subjectless files needed no spec.
|
|
101
117
|
def subjectless_message(since:)
|
|
102
118
|
detail = since ? "no changed mutation subjects" : "no mutation subjects"
|
|
103
|
-
"inferred specs: no spec is required for #{subjectless
|
|
119
|
+
"inferred specs: no spec is required for #{names(subjectless)}: they have #{detail}"
|
|
104
120
|
end
|
|
105
121
|
|
|
106
122
|
# The per-file warning when the run proceeds with the mapped
|
|
@@ -117,7 +133,7 @@ module Saboteur
|
|
|
117
133
|
# scope hid. Append-only recovery, like unmatched_message: extend include, or
|
|
118
134
|
# name the specs with --test.
|
|
119
135
|
def out_of_scope_message
|
|
120
|
-
named = out_of_scope.first(10)
|
|
136
|
+
named = names(out_of_scope.first(10))
|
|
121
137
|
more = out_of_scope.size - 10
|
|
122
138
|
named += " and #{more} more" if more.positive?
|
|
123
139
|
"no changed source file lies inside the include scope, so nothing ran. " \
|
|
@@ -140,7 +156,7 @@ module Saboteur
|
|
|
140
156
|
|
|
141
157
|
# Why the custom-map-excluded files needed no spec (#369).
|
|
142
158
|
def excluded_message
|
|
143
|
-
"inferred specs: no spec is required for #{excluded
|
|
159
|
+
"inferred specs: no spec is required for #{names(excluded)}: " \
|
|
144
160
|
"intentionally untested in config/saboteur_custom_map.rb"
|
|
145
161
|
end
|
|
146
162
|
|
|
@@ -248,20 +264,35 @@ module Saboteur
|
|
|
248
264
|
end
|
|
249
265
|
end
|
|
250
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
|
+
|
|
251
280
|
# Changed specs join only a run that mutates something: with no file
|
|
252
281
|
# in scope there is nothing for them to kill.
|
|
253
282
|
def result(guesses, subjectless, changed, out_of_scope: [], subject_run: false)
|
|
254
283
|
unmatched = guesses.select { |_, specs| specs.nil? }.keys
|
|
255
284
|
excluded = guesses.select { |_, specs| specs == :excluded }.keys.sort
|
|
256
285
|
inferred = guesses.values.grep(Array).flatten
|
|
257
|
-
diff =
|
|
286
|
+
diff = diff_specs(inferred, guesses)
|
|
258
287
|
unmatched -= covered_by_changed_specs(unmatched, diff[:run])
|
|
259
|
-
|
|
288
|
+
unmatched, referenced = split_referenced(unmatched)
|
|
289
|
+
Result.new(specs: (inferred + diff[:run] + referenced.values.flatten).uniq.sort,
|
|
290
|
+
referenced: referenced,
|
|
260
291
|
unmatched: unmatched,
|
|
292
|
+
without_map_entry: without_map_entry(unmatched),
|
|
261
293
|
subjectless: subjectless,
|
|
262
294
|
changed: changed,
|
|
263
|
-
|
|
264
|
-
near: unmatched.to_h { |file| [file, nearby_specs(file)] },
|
|
295
|
+
**stop_details(unmatched, diff[:skipped]),
|
|
265
296
|
changed_specs: diff[:run],
|
|
266
297
|
skipped_specs: diff[:skipped],
|
|
267
298
|
out_of_scope: out_of_scope,
|
|
@@ -269,6 +300,13 @@ module Saboteur
|
|
|
269
300
|
no_suite: NoSuiteStop.layout(@root, unmatched, subject_run))
|
|
270
301
|
end
|
|
271
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 }
|
|
308
|
+
end
|
|
309
|
+
|
|
272
310
|
# Changed Ruby files the include scope hides (#483), relative to the
|
|
273
311
|
# root: the diff touched source, but none of it lands inside include.
|
|
274
312
|
# A changed spec is a test, not hidden source; a spec-only diff keeps
|
|
@@ -314,6 +352,26 @@ module Saboteur
|
|
|
314
352
|
end
|
|
315
353
|
end
|
|
316
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
|
+
|
|
317
375
|
# The constants a file defines: owners of its def subjects
|
|
318
376
|
# ("JWT::Claims::DecodeVerifier" from "...#call") and its constant
|
|
319
377
|
# literal subjects. Shaped for ConstantReads, which matches a bare
|
|
@@ -458,7 +516,9 @@ module Saboteur
|
|
|
458
516
|
def guess_for(file)
|
|
459
517
|
mapped = @custom_map.specs_for(file)
|
|
460
518
|
return :excluded if mapped == :excluded
|
|
461
|
-
|
|
519
|
+
|
|
520
|
+
expanded = mapped && expand_mapped(file, mapped)
|
|
521
|
+
return expanded if expanded
|
|
462
522
|
|
|
463
523
|
conventional = candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
|
|
464
524
|
return [conventional] if conventional
|
|
@@ -471,8 +531,9 @@ module Saboteur
|
|
|
471
531
|
# backreference filled at match time, and neither is validated to
|
|
472
532
|
# exist when the map loads. Only specs that exist run; the literal
|
|
473
533
|
# glob string never reaches RSpec. When the winning entry's specs all
|
|
474
|
-
# vanish the
|
|
475
|
-
#
|
|
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).
|
|
476
537
|
def expand_mapped(file, specs)
|
|
477
538
|
expanded = specs.flat_map do |spec|
|
|
478
539
|
if spec.match?(/[*?\[{]/)
|
|
@@ -484,7 +545,10 @@ module Saboteur
|
|
|
484
545
|
expanded = expanded.select { |path| File.file?(File.join(@root, path)) }
|
|
485
546
|
return expanded unless expanded.empty?
|
|
486
547
|
|
|
487
|
-
|
|
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
|
|
488
552
|
end
|
|
489
553
|
|
|
490
554
|
# Candidate specs for a source file, most specific first: the nearest
|
|
@@ -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.
|
|
@@ -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,7 +274,8 @@ 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)
|
|
@@ -309,15 +323,18 @@ module Saboteur
|
|
|
309
323
|
|
|
310
324
|
def result_cache(map = nil, files = [])
|
|
311
325
|
return nil if @replay
|
|
312
|
-
return nil unless config["incremental"]
|
|
326
|
+
return nil unless config["incremental"] || config["resume"]
|
|
313
327
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
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,
|
|
317
330
|
suite_digest: SuiteFiles.new(config).digest,
|
|
318
331
|
coverage: map, fallback_files: source_files(files))
|
|
319
332
|
end
|
|
320
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
|
+
|
|
321
338
|
# A cleanup failure must never mask the run's own outcome - not even
|
|
322
339
|
# one cleanup_workers did not anticipate.
|
|
323
340
|
def safely_cleanup_workers(rails)
|
|
@@ -366,7 +383,8 @@ module Saboteur
|
|
|
366
383
|
|
|
367
384
|
candidates = Candidates.build(config, @diagnostics, hazard_guard: hazard_guard)
|
|
368
385
|
selection = CandidateSelection.new(candidates: candidates, map: map,
|
|
369
|
-
limit: config["max_mutants_per_method"], diagnostics: @diagnostics
|
|
386
|
+
limit: config["max_mutants_per_method"], diagnostics: @diagnostics,
|
|
387
|
+
root: @root)
|
|
370
388
|
lists = subjects.map { |subject| selection.for(subject, scope: scope) }
|
|
371
389
|
@shard = Engine::Shard.parse(config["shard"]) if config["shard"]
|
|
372
390
|
lists = @shard.select(subjects, lists) if @shard
|
|
@@ -428,6 +446,9 @@ module Saboteur
|
|
|
428
446
|
|
|
429
447
|
def suspend_coverage(files)
|
|
430
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
|
|
431
452
|
LoadCoverage.capture(files)
|
|
432
453
|
end
|
|
433
454
|
|
|
@@ -441,8 +462,18 @@ require_relative "engine/boot"
|
|
|
441
462
|
require_relative "engine/subject_selection"
|
|
442
463
|
require_relative "engine/unchanged_syntax"
|
|
443
464
|
require_relative "engine/suite_files"
|
|
465
|
+
require_relative "engine/reference_specs"
|
|
444
466
|
require_relative "engine/test_guess"
|
|
445
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"
|
|
446
477
|
require_relative "engine/coverage_store"
|
|
447
478
|
require_relative "engine/baseline_guard"
|
|
448
479
|
require_relative "engine/unscoped_suite_warning"
|
|
@@ -451,6 +482,7 @@ require_relative "engine/candidates"
|
|
|
451
482
|
require_relative "engine/candidate_selection"
|
|
452
483
|
require_relative "engine/diff_scope"
|
|
453
484
|
require_relative "engine/result_cache"
|
|
485
|
+
require_relative "engine/resume_manifest"
|
|
454
486
|
require_relative "engine/defined_order_prefix"
|
|
455
487
|
require_relative "engine/verdict_check"
|
|
456
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
|