saboteur 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +399 -0
- data/README.md +202 -42
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/comparison.rb +1 -0
- data/lib/saboteur/baseline/evidence.rb +11 -1
- data/lib/saboteur/baseline/run_evidence.rb +1 -1
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -6
- data/lib/saboteur/cli.rb +80 -48
- data/lib/saboteur/config.rb +88 -13
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +64 -2
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/candidates.rb +4 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +5 -1
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +183 -23
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +175 -96
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/help.rb +55 -10
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +22 -13
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +12 -2
- data/lib/saboteur/reporter/agent_commands.rb +49 -16
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/agent_summary.rb +1 -1
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +4 -5
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +19 -3
- data/lib/saboteur/reporter/stryker.rb +19 -8
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +43 -10
- data/lib/saboteur/result.rb +25 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- metadata +20 -2
|
@@ -1,51 +1,211 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class Engine
|
|
6
|
-
#
|
|
8
|
+
# The specs that sit next to the mutated source files by
|
|
7
9
|
# naming convention (lib/a/b.rb -> spec/a/b_spec.rb). A guess, not a
|
|
8
10
|
# coverage fact: a spec that only reaches the file through another one
|
|
9
|
-
# is not found.
|
|
10
|
-
#
|
|
11
|
+
# is not found. Packwerk packs (a directory with a package.yml) map to the
|
|
12
|
+
# spec directory inside the same pack, and controllers also map to request
|
|
13
|
+
# specs. With --since, only files whose changed lines carry a mutation
|
|
14
|
+
# subject need a guessed spec; without --since, every included file with
|
|
15
|
+
# a mutation subject needs one. A file that needs a spec and has none
|
|
16
|
+
# stops the run before boot.
|
|
11
17
|
class TestGuess
|
|
12
|
-
|
|
18
|
+
# hints: unmatched file -> its conventional spec path (which does
|
|
19
|
+
# not exist; that is why the file is unmatched), for the stop-before-boot
|
|
20
|
+
# error. The result also builds its own messages, so a stubbed guess in
|
|
21
|
+
# the engine's specs formats exactly like a real one.
|
|
22
|
+
Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints) do
|
|
23
|
+
# The stop-before-boot usage error: names each expected spec and the
|
|
24
|
+
# ways out as an append-only instruction - never a reconstructed
|
|
25
|
+
# command, which could carry a hostile ref or a wrong source scope.
|
|
26
|
+
def unmatched_message
|
|
27
|
+
pairs = unmatched.map { |file| "#{file} (expected #{hints.fetch(file)})" }
|
|
28
|
+
"no spec found for #{pairs.join(', ')}, so nothing ran. Re-run the same command with --test " \
|
|
29
|
+
"followed by your existing spec paths, or append --all to use the whole suite without " \
|
|
30
|
+
"changing the source scope."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Why the subjectless files needed no spec.
|
|
34
|
+
def subjectless_message(since:)
|
|
35
|
+
detail = since ? "no changed mutation subjects" : "no mutation subjects"
|
|
36
|
+
"inferred specs: no spec is required for #{subjectless.join(', ')}: they have #{detail}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The warning for a guess-scoped run: the guessed specs now stand as
|
|
40
|
+
# explicit tests, with the cross-file coverage caveat that brings.
|
|
41
|
+
def scope_message(since:)
|
|
42
|
+
what = since ? "a changed file" : "a source file"
|
|
43
|
+
"inferred specs: running only #{specs.join(', ')}. A spec that reaches " \
|
|
44
|
+
"#{what} only through another file does not run, so a mutant only it would kill can " \
|
|
45
|
+
"survive, and a line no inferred spec executes is reported as NoCoverage"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
13
48
|
|
|
14
49
|
SOURCE_ROOTS = %w[lib app].freeze
|
|
15
50
|
|
|
16
|
-
def initialize(config)
|
|
51
|
+
def initialize(config, diagnostics: nil)
|
|
52
|
+
@diagnostics = diagnostics
|
|
17
53
|
@root = File.realpath(config.root)
|
|
18
54
|
@config = config
|
|
19
55
|
end
|
|
20
56
|
|
|
21
|
-
# specs: existing spec files (relative paths); unmatched: changed
|
|
22
|
-
# source files with no guessed spec; changed: how many source files
|
|
23
|
-
# changed.
|
|
24
57
|
def call
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
58
|
+
@config["since"] ? call_diff : call_all
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Subject-scoped inference (--subject runs): only the named subjects'
|
|
62
|
+
# files need a guessed spec; every one of them has a subject by
|
|
63
|
+
# construction, so there is no subjectless bucket. With --since the
|
|
64
|
+
# diff narrows the scope further: a named subject whose file is
|
|
65
|
+
# unchanged, whose changed lines sit in other methods, or whose change
|
|
66
|
+
# is comment or formatting only is out of scope and needs no spec.
|
|
67
|
+
def call_for(files, patterns = [])
|
|
68
|
+
files = effective_subject_files(files, patterns) if @config["since"]
|
|
69
|
+
guesses = files.to_h { |file| [file, guess_for(file)] }
|
|
70
|
+
result(guesses, [], files.size)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# specs: existing spec files (relative paths) guessed for the files in
|
|
74
|
+
# scope; unmatched: such files with no guessed spec; subjectless: files
|
|
75
|
+
# with no mutation subject the run checks, which neither need nor get a
|
|
76
|
+
# spec; changed: how many source files are in scope; hints: each
|
|
77
|
+
# unmatched file's conventional spec path.
|
|
78
|
+
def call_diff
|
|
79
|
+
lines_by_file = changed_lines_by_file
|
|
80
|
+
subjectless = subjectless_files(lines_by_file)
|
|
81
|
+
# Only files with a changed mutation subject get a guessed spec: a
|
|
82
|
+
# subjectless file mutates nothing, so its spec would only narrow the
|
|
83
|
+
# run for no reason.
|
|
84
|
+
guesses = lines_by_file.except(*subjectless).to_h { |file| [file, guess_for(file)] }
|
|
85
|
+
result(guesses, subjectless, lines_by_file.size)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Without --since every included file is in scope: a file needs its
|
|
89
|
+
# guessed spec unless it holds no mutation subject at all.
|
|
90
|
+
def call_all
|
|
91
|
+
finder = SubjectFinder.new
|
|
92
|
+
guesses = {}
|
|
93
|
+
subjectless = []
|
|
94
|
+
included_files.each do |file|
|
|
95
|
+
if finder.call(File.join(@root, file)).subjects.empty?
|
|
96
|
+
subjectless << file
|
|
97
|
+
else
|
|
98
|
+
guesses[file] = guess_for(file)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
result(guesses, subjectless.sort, guesses.size + subjectless.size)
|
|
29
102
|
end
|
|
30
103
|
|
|
31
104
|
private
|
|
32
105
|
|
|
33
|
-
#
|
|
34
|
-
|
|
106
|
+
# The named subjects' files inside the diff scope: the subject must
|
|
107
|
+
# sit on a changed line and survive the comment/formatting prune,
|
|
108
|
+
# mirroring the run's subject selection.
|
|
109
|
+
def effective_subject_files(files, patterns)
|
|
110
|
+
lines_by_file = changed_lines_by_file
|
|
111
|
+
comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
|
|
112
|
+
finder = SubjectFinder.new
|
|
113
|
+
files.select do |file|
|
|
114
|
+
lines = lines_by_file[file]
|
|
115
|
+
next false unless lines
|
|
116
|
+
|
|
117
|
+
finder.call(File.join(@root, file)).subjects.any? do |subject|
|
|
118
|
+
patterns.any? { |pattern| SubjectSelection.match?(subject.id, pattern) } &&
|
|
119
|
+
subject.overlaps_lines?(lines) &&
|
|
120
|
+
!comparison.unchanged?(subject)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def result(guesses, subjectless, changed)
|
|
126
|
+
unmatched = guesses.select { |_, spec| spec.nil? }.keys
|
|
127
|
+
Result.new(specs: guesses.values.compact.uniq.sort,
|
|
128
|
+
unmatched: unmatched,
|
|
129
|
+
subjectless: subjectless,
|
|
130
|
+
changed: changed,
|
|
131
|
+
hints: unmatched.to_h { |file| [file, candidates_for(file).first] })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Included files relative to the root, sorted for stable output.
|
|
135
|
+
def included_files
|
|
136
|
+
prefix = "#{@root}/"
|
|
137
|
+
@config.included_files.filter_map { |file| file.delete_prefix(prefix) if file.start_with?(prefix) }.sort
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Changed files that saboteur would mutate, relative to the root, with
|
|
141
|
+
# their changed line numbers.
|
|
142
|
+
def changed_lines_by_file
|
|
35
143
|
included = @config.included_files.to_set
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
144
|
+
Selection::GitDiff.changed_lines(@config["since"], root: @root, include: included,
|
|
145
|
+
diagnostics: @diagnostics)
|
|
146
|
+
.each_with_object({}) do |(file, lines), map|
|
|
147
|
+
canonical = Selection::CoverageMap.canonical(file)
|
|
148
|
+
map[canonical.delete_prefix("#{@root}/")] = lines if included.include?(canonical)
|
|
149
|
+
end.sort.to_h
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Changed files whose changed lines carry no mutation subject: the run
|
|
153
|
+
# mutates nothing in them, so they cannot require a spec. Mirrors the
|
|
154
|
+
# run's subject selection, including its comment/formatting-only skip:
|
|
155
|
+
# a deletion whose remaining context lines are blank or comments, or a
|
|
156
|
+
# comment-only change inside a method, leaves the file subjectless.
|
|
157
|
+
def subjectless_files(lines_by_file)
|
|
158
|
+
comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
|
|
159
|
+
finder = SubjectFinder.new
|
|
160
|
+
lines_by_file.reject do |file, lines|
|
|
161
|
+
subjects = finder.call(File.join(@root, file)).subjects
|
|
162
|
+
subjects.select { |subject| subject.overlaps_lines?(lines) }
|
|
163
|
+
.any? { |subject| !comparison.unchanged?(subject) }
|
|
164
|
+
end.keys
|
|
40
165
|
end
|
|
41
166
|
|
|
42
167
|
def guess_for(file)
|
|
43
|
-
|
|
168
|
+
candidates_for(file).find { |path| File.file?(File.join(@root, path)) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Candidate specs for a source file, most specific first: the nearest
|
|
172
|
+
# package.yml pack's own spec directory, then the app-wide one.
|
|
173
|
+
def candidates_for(file)
|
|
174
|
+
pack = pack_of(file)
|
|
175
|
+
# pack is nil for files outside packs: "#{nil}/" is "/", which
|
|
176
|
+
# never prefixes a relative path, so delete_prefix is a no-op there.
|
|
177
|
+
base = file.delete_prefix("#{pack}/")
|
|
178
|
+
spec_root = pack ? "#{pack}/spec" : "spec"
|
|
179
|
+
stem = base.delete_suffix(".rb")
|
|
44
180
|
rest = stem.split("/", 2)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
181
|
+
rooted = rest.size == 2 && SOURCE_ROOTS.include?(rest.first)
|
|
182
|
+
names = rooted ? [rest.last, "lib/#{rest.last}", stem] : [stem]
|
|
183
|
+
names = names.flat_map { |name| [name, *request_names(name)] }.uniq
|
|
184
|
+
names.map { |name| "#{spec_root}/#{name}_spec.rb" }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Rails tests controllers as request specs: spec/requests/users_spec.rb
|
|
188
|
+
# or spec/requests/users_controller_spec.rb for controllers/users_controller.rb.
|
|
189
|
+
def request_names(name)
|
|
190
|
+
return [] unless name.start_with?("controllers/")
|
|
191
|
+
|
|
192
|
+
path = name.delete_prefix("controllers/")
|
|
193
|
+
["requests/#{path}", "requests/#{path.delete_suffix('_controller')}"].uniq
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The nearest ancestor directory holding a package.yml (a packwerk
|
|
197
|
+
# pack), relative to the root. The root itself is not a pack here:
|
|
198
|
+
# Pathname#ascend stops above "." for these relative paths.
|
|
199
|
+
def pack_of(file)
|
|
200
|
+
Pathname(file).ascend do |path|
|
|
201
|
+
dir = path.to_s
|
|
202
|
+
return dir if pack_dirs.include?(dir)
|
|
203
|
+
end
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def pack_dirs
|
|
208
|
+
@pack_dirs ||= Dir.glob("**/package.yml", base: @root).to_set { |path| File.dirname(path) }
|
|
49
209
|
end
|
|
50
210
|
end
|
|
51
211
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Threads do not survive fork, so a run names the ones alive when the
|
|
7
|
+
# workers fork: by the name the owner gave the thread, else the first
|
|
8
|
+
# frame of its backtrace, so the user can tell whether they matter (a
|
|
9
|
+
# connection pool reaper, Sidekiq, an SDK's background flusher).
|
|
10
|
+
# Known-harmless threads say so inline; an unknown thread gets the
|
|
11
|
+
# generic caveat (#263).
|
|
12
|
+
module ThreadWarnings
|
|
13
|
+
# Well-known fork-time threads, with the condition each assurance
|
|
14
|
+
# hangs on. Evidence: rails-mode children reconnect the database
|
|
15
|
+
# (lib/saboteur/frameworks/rails.rb, after_fork); whether the debug
|
|
16
|
+
# gem's console also attaches to forked children depends on its
|
|
17
|
+
# fork_mode, whose default is :both, i.e. attach (RUBY_DEBUG_FORK_MODE;
|
|
18
|
+
# ruby/debug's config.rb and SESSION.activate on_fork: true in
|
|
19
|
+
# session.rb).
|
|
20
|
+
KNOWN_FORK_THREADS = {
|
|
21
|
+
"AR Pool Reaper" => "ActiveRecord's connection-pool reaper: expected harmless when children " \
|
|
22
|
+
"reconnect the database after fork (rails mode does) - it only maintains " \
|
|
23
|
+
"the parent's pool",
|
|
24
|
+
"DEBUGGER__::SESSION@server" => "the debug gem's remote-console server: what it does in " \
|
|
25
|
+
"children depends on RUBY_DEBUG_FORK_MODE - with parent no " \
|
|
26
|
+
"debugger runs in children; with child or both (the gem's " \
|
|
27
|
+
"default) the debugger activates in children and can pause or " \
|
|
28
|
+
"interfere with supervised mutation runs. For an unattended " \
|
|
29
|
+
"run, do not start the remote debugger at all: drop " \
|
|
30
|
+
"RUBY_DEBUG_OPEN or require \"debug\" without :open"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def self.warn(diagnostics)
|
|
34
|
+
others = Thread.list - [Thread.current]
|
|
35
|
+
return if others.empty?
|
|
36
|
+
|
|
37
|
+
unknown = false
|
|
38
|
+
list = others.map do |thread|
|
|
39
|
+
name = thread.name || thread.backtrace&.first || "unknown"
|
|
40
|
+
explanation = KNOWN_FORK_THREADS[name]
|
|
41
|
+
unknown = true unless explanation
|
|
42
|
+
explanation ? "#{name} (#{explanation})" : name
|
|
43
|
+
end.join(", ")
|
|
44
|
+
message = "#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}"
|
|
45
|
+
if unknown
|
|
46
|
+
message += "; a thread that does not run in a child cannot do background work a mutant's test may " \
|
|
47
|
+
"rely on (a pool reaper, a flusher), so results can differ when one is left behind"
|
|
48
|
+
end
|
|
49
|
+
diagnostics.warn(message)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# A whole-suite run (--all, c.test_selection = :all, or a --since diff
|
|
7
|
+
# with nothing to infer from) runs the loaded suite for the baseline
|
|
8
|
+
# checks; a cold coverage cache adds one more pass. Say so - with the
|
|
9
|
+
# example count and the real pass count - before those hours pass; past
|
|
10
|
+
# a size threshold, add the way to scope it (#260). The check count
|
|
11
|
+
# comes from c.baseline_passes (#55).
|
|
12
|
+
module UnscopedSuiteWarning
|
|
13
|
+
# A suite this size pays hours for an unscoped run; below it the cost
|
|
14
|
+
# summary alone is enough, without the --test lecture (#260).
|
|
15
|
+
COSTLY_SUITE_EXAMPLES = 1_000
|
|
16
|
+
REUSED_MAP_NOTE = "; the cached coverage map is reused"
|
|
17
|
+
|
|
18
|
+
def self.call(config:, diagnostics:, integration:, full_passes:)
|
|
19
|
+
return unless config["tests"].empty?
|
|
20
|
+
return unless config["since"] || config["all"] || config["test_selection"] == "all"
|
|
21
|
+
|
|
22
|
+
examples = integration.all_tests.size
|
|
23
|
+
coverage = full_passes == config["baseline_passes"] ? REUSED_MAP_NOTE : " + 1 coverage map build"
|
|
24
|
+
message = "running the loaded suite unscoped: #{examples} examples in #{integration.test_files.size} " \
|
|
25
|
+
"spec files; #{full_passes} full passes (#{checks_label(config)}#{coverage})."
|
|
26
|
+
if examples >= COSTLY_SUITE_EXAMPLES
|
|
27
|
+
message += " Pass --test to run only the specs you name: faster, but a mutant of a changed method is then " \
|
|
28
|
+
"killed only by those specs, so specs elsewhere that call this code are not run. " \
|
|
29
|
+
"The default inferred selection runs the specs named after the mutated files, " \
|
|
30
|
+
"a guess that can miss specs elsewhere."
|
|
31
|
+
end
|
|
32
|
+
diagnostics.warn(message)
|
|
33
|
+
diagnostics.inform(message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.checks_label(config)
|
|
37
|
+
"#{config['baseline_passes']} baseline check#{'s' unless config['baseline_passes'] == 1}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -22,11 +22,14 @@ module Saboteur
|
|
|
22
22
|
@baseline_timeout = baseline_timeout
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Why a result the budget never let the check run is not a verdict.
|
|
26
|
+
BUDGET_REASON = "the --max-duration budget stopped the verdict check before it could run"
|
|
27
|
+
|
|
25
28
|
def call(results)
|
|
26
29
|
fresh = results.reject(&:reused?)
|
|
27
|
-
failing_runs =
|
|
28
|
-
failing =
|
|
29
|
-
results.map { |result| checked(result, failing_runs, failing) }
|
|
30
|
+
failing_runs, unchecked_runs = check_sequences(fresh.select { |result| named_kill?(result) })
|
|
31
|
+
failing, unchecked = check_claims(fresh.select { |result| unnamed_kill?(result) })
|
|
32
|
+
results.map { |result| checked(result, failing_runs, failing, unchecked_runs, unchecked) }
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
private
|
|
@@ -47,31 +50,64 @@ module Saboteur
|
|
|
47
50
|
position ? result.covered_by.first(position + 1) : [result.killed_by.first]
|
|
48
51
|
end
|
|
49
52
|
|
|
50
|
-
# Runs each distinct kill sequence once. Returns
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
# Runs each distinct kill sequence once. Returns [failing, unchecked]:
|
|
54
|
+
# failing maps a sequence to why it failed unchanged, unchecked maps
|
|
55
|
+
# the sequences the budget never let run to why - an unverified re-run
|
|
56
|
+
# is not a rejection (#296).
|
|
57
|
+
def check_sequences(kills)
|
|
58
|
+
failing = {}
|
|
59
|
+
unchecked = {}
|
|
60
|
+
kills.map { |result| sequence(result) }.uniq.each do |tests|
|
|
54
61
|
outcome = @run_unchanged.call(tests, @baseline_timeout)
|
|
55
|
-
|
|
62
|
+
if outcome.status == :unverified
|
|
63
|
+
unchecked[tests] = outcome.message
|
|
64
|
+
elsif outcome.status != :survived
|
|
65
|
+
failing[tests] = outcome.message
|
|
66
|
+
end
|
|
56
67
|
end
|
|
68
|
+
[failing, unchecked]
|
|
57
69
|
end
|
|
58
70
|
|
|
59
|
-
# Kills with no failing test named
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
# Kills with no failing test named, checked on all their tests.
|
|
72
|
+
# Returns [failing, unchecked]: failing maps a rejected result to why
|
|
73
|
+
# it failed unchanged, unchecked maps a result the budget never let
|
|
74
|
+
# run to why.
|
|
75
|
+
def check_claims(claims)
|
|
76
|
+
failing = {}
|
|
77
|
+
unchecked = {}
|
|
78
|
+
claims.each do |result|
|
|
79
|
+
outcome = @run_unchanged.call(result.covered_by, @baseline_timeout)
|
|
80
|
+
if outcome.status == :unverified
|
|
81
|
+
unchecked[result] = outcome.message
|
|
82
|
+
elsif outcome.status != :survived
|
|
83
|
+
failing[result] = outcome.message
|
|
84
|
+
end
|
|
63
85
|
end
|
|
86
|
+
[failing, unchecked]
|
|
64
87
|
end
|
|
65
88
|
|
|
66
|
-
def checked(result, failing_runs, failing)
|
|
89
|
+
def checked(result, failing_runs, failing, unchecked_runs, unchecked)
|
|
67
90
|
return result if result.reused?
|
|
91
|
+
return checked_named(result, failing_runs, unchecked_runs) if named_kill?(result)
|
|
92
|
+
|
|
93
|
+
if failing.key?(result)
|
|
94
|
+
why = failing[result]
|
|
95
|
+
result.as_error("tests also fail with the method unchanged#{": #{why}" if why}", rejected_kill: true)
|
|
96
|
+
elsif unchecked.key?(result)
|
|
97
|
+
result.as_unverified(unchecked[result] || BUDGET_REASON)
|
|
98
|
+
else
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
end
|
|
68
102
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
103
|
+
def checked_named(result, failing_runs, unchecked_runs)
|
|
104
|
+
tests = sequence(result)
|
|
105
|
+
if failing_runs.key?(tests)
|
|
106
|
+
why = failing_runs[tests]
|
|
107
|
+
result.as_error("killing test #{tests.last} also fails with the method unchanged" \
|
|
72
108
|
"#{": #{why}" if why}", rejected_kill: true)
|
|
73
|
-
elsif
|
|
74
|
-
result.
|
|
109
|
+
elsif unchecked_runs.key?(tests)
|
|
110
|
+
result.as_unverified(unchecked_runs[tests] || BUDGET_REASON)
|
|
75
111
|
else
|
|
76
112
|
result
|
|
77
113
|
end
|