saboteur 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +367 -0
- data/README.md +166 -40
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- data/lib/saboteur/cli.rb +67 -46
- data/lib/saboteur/config.rb +66 -11
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +40 -1
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +145 -22
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +166 -95
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/help.rb +55 -11
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +20 -11
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +2 -1
- data/lib/saboteur/reporter/agent_commands.rb +38 -13
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +13 -4
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +34 -10
- data/lib/saboteur/result.rb +22 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- metadata +17 -2
|
@@ -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
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -6,25 +6,31 @@ require "json"
|
|
|
6
6
|
require_relative "replay"
|
|
7
7
|
require_relative "mutation_source/hazard_guard"
|
|
8
8
|
require_relative "baseline/run_evidence"
|
|
9
|
+
require_relative "engine/early_stop_recording"
|
|
10
|
+
require_relative "engine/run_budget"
|
|
11
|
+
require_relative "engine/thread_warnings"
|
|
9
12
|
|
|
10
13
|
module Saboteur
|
|
11
14
|
# A single-use instance runs one pipeline: boot the target, select subjects, fetch the
|
|
12
15
|
# coverage map, then schedule each subject's mutants. The parts live in
|
|
13
16
|
# engine/; this class only wires them together.
|
|
14
17
|
class Engine
|
|
18
|
+
include EarlyStopRecording
|
|
19
|
+
include RunBudget
|
|
20
|
+
|
|
15
21
|
# Everything besides sources and tests that can change a verdict.
|
|
16
22
|
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
|
|
17
23
|
timeout_factor timeout_min
|
|
18
24
|
max_memory_mb max_processes seed tests whole_file].freeze
|
|
19
25
|
|
|
20
26
|
attr_reader :config, :hooks
|
|
21
|
-
# The specs
|
|
27
|
+
# The specs inference narrowed the run to, or nil when it did not.
|
|
22
28
|
attr_reader :guessed_tests
|
|
23
29
|
|
|
24
|
-
def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [])
|
|
30
|
+
def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [], verbose: false)
|
|
25
31
|
@config = config
|
|
26
32
|
@hooks = Hooks.new
|
|
27
|
-
@diagnostics = Diagnostics.new(err)
|
|
33
|
+
@diagnostics = Diagnostics.new(err, verbose: verbose)
|
|
28
34
|
@root = File.realpath(config.root)
|
|
29
35
|
@skipped_subjects = {}
|
|
30
36
|
@replay = Replay.new(digest: digest) if digest
|
|
@@ -38,12 +44,16 @@ module Saboteur
|
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def call
|
|
41
|
-
|
|
42
|
-
refuse_fail_fast_evidence
|
|
47
|
+
preflight!
|
|
43
48
|
started = monotonic
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
start_budget_clock(started)
|
|
50
|
+
# Capture evidence inputs before inference narrows the test list: the
|
|
51
|
+
# fingerprint must match what the baseline workflow recomputes from the
|
|
52
|
+
# declared config, and the inferred specs are pinned by the hashed
|
|
53
|
+
# source files anyway.
|
|
46
54
|
@run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
|
|
55
|
+
select_tests
|
|
56
|
+
announce_operators
|
|
47
57
|
ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
|
|
48
58
|
warn_custom_map
|
|
49
59
|
boot = Boot.new(config, hooks)
|
|
@@ -52,29 +62,31 @@ module Saboteur
|
|
|
52
62
|
@rails = boot.rails
|
|
53
63
|
subjects, scope = select_subjects(files)
|
|
54
64
|
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
|
|
55
|
-
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
65
|
+
@diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
|
|
56
66
|
build_report(results, subjects, scope, started)
|
|
57
67
|
ensure
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
# Cleanup runs however the call ends - a partway boot failure, no
|
|
69
|
+
# subjects, no runnable work - so a host embedding Engine never
|
|
70
|
+
# holds worker copies until process exit. Idempotent; the Rails
|
|
71
|
+
# profile's at_exit handler stays the fallback for abrupt exit.
|
|
72
|
+
begin
|
|
73
|
+
@coverage_guard&.resume # an embedded host gets its coverage back (#254)
|
|
74
|
+
ensure
|
|
75
|
+
safely_cleanup_workers(boot.rails)
|
|
76
|
+
end
|
|
66
77
|
end
|
|
67
78
|
end
|
|
68
79
|
|
|
69
|
-
def
|
|
70
|
-
|
|
80
|
+
def preflight!
|
|
81
|
+
Boot.refuse_recursion!
|
|
82
|
+
refuse_fail_fast_evidence
|
|
71
83
|
end
|
|
72
84
|
|
|
85
|
+
def warn_custom_map = @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
|
|
86
|
+
|
|
73
87
|
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
74
88
|
# map does not rot silently. doctor fails on stale keys.
|
|
75
|
-
def load_custom_map
|
|
76
|
-
@replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
|
|
77
|
-
end
|
|
89
|
+
def load_custom_map = @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
|
|
78
90
|
|
|
79
91
|
def custom_map_warnings(map)
|
|
80
92
|
path = config.path("custom_map")
|
|
@@ -86,29 +98,80 @@ module Saboteur
|
|
|
86
98
|
|
|
87
99
|
private
|
|
88
100
|
|
|
89
|
-
#
|
|
90
|
-
# the
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
# Inferred test selection, the default since 0.7.0: scope the run to the
|
|
102
|
+
# specs found next to the mutated files, as --test would. With --since
|
|
103
|
+
# only files whose changed lines carry a mutation subject need a spec;
|
|
104
|
+
# without it every included file with a subject does. A file that needs
|
|
105
|
+
# a spec and has none stops the run before boot, rather than silently
|
|
106
|
+
# narrowing the source scope or widening the test run to the whole
|
|
107
|
+
# suite. --all and c.test_selection = :all keep the whole suite.
|
|
108
|
+
# A replay keeps its recorded tests, and a --subject run infers only
|
|
109
|
+
# from the named subjects' files, so one unrelated spec-less file can
|
|
110
|
+
# never block a one-method run.
|
|
111
|
+
def select_tests
|
|
112
|
+
return unless config["tests"].empty?
|
|
113
|
+
return if @replay || config["all"] || config["test_selection"] == "all"
|
|
114
|
+
|
|
115
|
+
subjects = config["subjects"]
|
|
116
|
+
return select_subject_tests(subjects) if subjects.any?
|
|
117
|
+
|
|
118
|
+
guess = TestGuess.new(config, diagnostics: @diagnostics).call
|
|
119
|
+
@diagnostics.inform(guess.subjectless_message(since: config["since"])) if guess.subjectless.any?
|
|
120
|
+
raise ConfigError, guess.unmatched_message if guess.unmatched.any?
|
|
121
|
+
|
|
122
|
+
@diagnostics.announce(selection_message(guess))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A --subject run infers only from the files holding the named subjects:
|
|
126
|
+
# an unrelated spec-less file must not block a one-method run. A subject
|
|
127
|
+
# file without a conventional spec stops the run before boot, like any
|
|
128
|
+
# other unmatched file. With --since only named subjects inside the diff
|
|
129
|
+
# scope count: an unchanged, comment-only or otherwise pruned subject
|
|
130
|
+
# needs no spec and cannot stop the run either.
|
|
131
|
+
def select_subject_tests(patterns)
|
|
132
|
+
files = SubjectSelection.files_matching(@root, config.included_files, patterns)
|
|
133
|
+
guess = TestGuess.new(config, diagnostics: @diagnostics).call_for(files, patterns)
|
|
134
|
+
# No file in scope: the subjects are unknown (named later, in
|
|
135
|
+
# selection) or --since left every named subject outside the diff, so
|
|
136
|
+
# the run has nothing to mutate and needs no inferred spec.
|
|
137
|
+
return if guess.changed.zero?
|
|
138
|
+
|
|
139
|
+
raise ConfigError, guess.unmatched_message if guess.unmatched.any?
|
|
140
|
+
|
|
141
|
+
@diagnostics.announce(scope_to_guesses(guess))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def selection_message(guess)
|
|
145
|
+
if guess.changed.zero?
|
|
146
|
+
target = config["since"] ? "no changed source file" : "no source file"
|
|
147
|
+
"inferred specs: #{target} to infer a spec for, so the whole suite runs"
|
|
148
|
+
elsif guess.specs.empty?
|
|
149
|
+
if config["since"]
|
|
150
|
+
"inferred specs: no changed file has a mutation subject on its changed lines, " \
|
|
151
|
+
"so there is nothing to mutate"
|
|
102
152
|
else
|
|
103
|
-
|
|
104
|
-
@config = Config.new(config.to_h.merge("tests" => guess.specs, "guess_tests" => false), root: config.root)
|
|
105
|
-
@guessed_tests = guess.specs
|
|
106
|
-
"--guess-tests: running only #{guess.specs.join(', ')}. A spec that reaches a changed file only " \
|
|
107
|
-
"through another file does not run, so a mutant only it would kill can survive, and a line " \
|
|
108
|
-
"no guessed spec executes is reported as NoCoverage"
|
|
153
|
+
"inferred specs: no source file has a mutation subject, so there is nothing to mutate"
|
|
109
154
|
end
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
else
|
|
156
|
+
scope_to_guesses(guess)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Stop before boot: name the spec-less files and the ways out. The
|
|
161
|
+
# hinted spec paths are the conventional ones - they do not exist (that
|
|
162
|
+
# is why the files are unmatched), so the message never prints them as
|
|
163
|
+
# a runnable --test command, and it never reconstructs the source
|
|
164
|
+
# scope: the user appends --test or --all to their own command. The
|
|
165
|
+
# example is still shell-safe: the ref is user input, so it is quoted
|
|
166
|
+
# and pinned to the flag with = (a ref like "-x" must not become a
|
|
167
|
+
# switch).
|
|
168
|
+
def scope_to_guesses(guess)
|
|
169
|
+
# The guesses now stand as explicit tests, like --test. guess_tests is
|
|
170
|
+
# dropped rather than set to false: an explicit false is the deprecated
|
|
171
|
+
# opt-out and would warn.
|
|
172
|
+
@config = config.with({ "tests" => guess.specs }, except: ["guess_tests"])
|
|
173
|
+
@guessed_tests = guess.specs
|
|
174
|
+
guess.scope_message(since: config["since"])
|
|
112
175
|
end
|
|
113
176
|
|
|
114
177
|
# The run's immutable report, built once the last result is in.
|
|
@@ -124,7 +187,7 @@ module Saboteur
|
|
|
124
187
|
omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
|
|
125
188
|
hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
|
|
126
189
|
matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
|
|
127
|
-
operator_policy: config.operator_policy)
|
|
190
|
+
operator_policy: config.operator_policy, discovery_failures: @diagnostics.discovery_failures)
|
|
128
191
|
end
|
|
129
192
|
|
|
130
193
|
def complete_evidence(results, subjects)
|
|
@@ -149,10 +212,13 @@ module Saboteur
|
|
|
149
212
|
end
|
|
150
213
|
|
|
151
214
|
def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
215
|
+
store = coverage_store(integration, load_lines)
|
|
216
|
+
full_passes = config["baseline_passes"] + (store.reusable?(files) ? 0 : 1)
|
|
217
|
+
warn_unscoped(integration, full_passes)
|
|
218
|
+
check_baseline(integration, full_passes)
|
|
219
|
+
return [] if abandon_for_budget?(subjects)
|
|
220
|
+
|
|
221
|
+
map = store.fetch(files)
|
|
156
222
|
inform_about_run(subjects, map)
|
|
157
223
|
cache = result_cache
|
|
158
224
|
scheduler = scheduler_for(map, integration, cache)
|
|
@@ -169,14 +235,17 @@ module Saboteur
|
|
|
169
235
|
results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
|
|
170
236
|
cache&.save
|
|
171
237
|
results
|
|
238
|
+
rescue BudgetPhaseTimeout => e
|
|
239
|
+
budget_phase_stop(e, subjects)
|
|
172
240
|
end
|
|
173
241
|
|
|
174
242
|
def run_candidates(subjects, lists, scheduler, cache, ignore_list)
|
|
175
243
|
@diagnostics.start_progress(lists.sum(&:size))
|
|
176
244
|
distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
|
|
177
245
|
cache: cache, on_start: method(:start_worker),
|
|
178
|
-
after_subject:
|
|
179
|
-
|
|
246
|
+
after_subject: after_subject_hook, stop_after: stop_checks(ignore_list),
|
|
247
|
+
halt_check: config["max_duration"] ? method(:budget_exceeded?) : nil)
|
|
248
|
+
distribution.run(subjects, lists).tap { record_early_stop(distribution) }
|
|
180
249
|
ensure
|
|
181
250
|
@diagnostics.finish_progress
|
|
182
251
|
end
|
|
@@ -189,16 +258,15 @@ module Saboteur
|
|
|
189
258
|
raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
|
|
190
259
|
end
|
|
191
260
|
|
|
192
|
-
# Subjects that were selected but never ran, with candidates, are part of
|
|
193
|
-
# the report: the verdict covers less than the selection.
|
|
194
|
-
def record_early_stop(unreached)
|
|
195
|
-
return if unreached.empty?
|
|
196
|
-
|
|
197
|
-
@early_stop = EarlyStop.new(reason: "fail_fast", unreached: unreached.map(&:id))
|
|
198
|
-
end
|
|
199
|
-
|
|
200
261
|
# --fail-fast: stop after the first subject that has a survivor the
|
|
201
262
|
# ignore list does not accept. Granularity is one subject.
|
|
263
|
+
# --fail-fast and --max-duration both stop before the next subject; the
|
|
264
|
+
# distribution records whatever they leave unreached.
|
|
265
|
+
def stop_checks(ignore_list)
|
|
266
|
+
checks = [fail_fast_check(ignore_list), ->(_results) { budget_exceeded? }].compact
|
|
267
|
+
->(results) { checks.any? { |check| check.call(results) } }
|
|
268
|
+
end
|
|
269
|
+
|
|
202
270
|
def fail_fast_check(ignore_list)
|
|
203
271
|
return nil unless config["fail_fast"]
|
|
204
272
|
|
|
@@ -211,38 +279,40 @@ module Saboteur
|
|
|
211
279
|
|
|
212
280
|
def inform_about_run(subjects, map)
|
|
213
281
|
@diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
|
|
214
|
-
|
|
282
|
+
ThreadWarnings.warn(@diagnostics)
|
|
215
283
|
end
|
|
216
284
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
285
|
+
def coverage_store(integration, load_lines)
|
|
286
|
+
CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
|
|
287
|
+
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
|
|
288
|
+
timeout_cap: method(:budget_capped_timeout))
|
|
289
|
+
end
|
|
222
290
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
291
|
+
def check_baseline(integration, full_passes)
|
|
292
|
+
guard = BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
293
|
+
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics,
|
|
294
|
+
full_passes: full_passes, timeout_cap: method(:budget_capped_timeout),
|
|
295
|
+
between_passes: config["max_duration"] ? method(:budget_exceeded?) : nil)
|
|
296
|
+
guard.call
|
|
297
|
+
# Stability is the order comparison between completed checks: the cold
|
|
298
|
+
# coverage build is not a baseline pass, and a budget break shortens
|
|
299
|
+
# the loop, so certify only what actually ran.
|
|
300
|
+
@stable_suite = guard.completed_passes >= 2
|
|
231
301
|
end
|
|
232
302
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
@
|
|
303
|
+
# A --id replay targets one known mutant; the whole-suite cost summary
|
|
304
|
+
# and its --test lecture do not apply.
|
|
305
|
+
def warn_unscoped(integration, full_passes)
|
|
306
|
+
UnscopedSuiteWarning.call(config:, diagnostics: @diagnostics, integration:, full_passes:) unless @replay
|
|
237
307
|
end
|
|
238
308
|
|
|
239
309
|
def scheduler_for(map, integration, cache)
|
|
240
310
|
reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
|
|
241
311
|
runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
|
|
242
312
|
supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
|
|
243
|
-
sandbox: sandbox_hook)
|
|
313
|
+
sandbox: sandbox_hook, canary: canary)
|
|
244
314
|
Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
|
|
245
|
-
cache: cache)
|
|
315
|
+
cache: cache, deadline: budget_deadline)
|
|
246
316
|
end
|
|
247
317
|
|
|
248
318
|
# The sandbox hook for each mutant child (#152); the policy and its
|
|
@@ -287,8 +357,17 @@ module Saboteur
|
|
|
287
357
|
|
|
288
358
|
# Checks the tree after each subject's mutants; a vanished file means a
|
|
289
359
|
# mutant deleted it, so abort with the most recent mutant's digest.
|
|
360
|
+
# Per finished subject: the canary, then the --verbose marks line that
|
|
361
|
+
# makes a survivor attributable to its method mid-run (#262).
|
|
362
|
+
def after_subject_hook
|
|
363
|
+
canary = canary_check
|
|
364
|
+
lambda do |subject, results|
|
|
365
|
+
canary.call(subject, results)
|
|
366
|
+
@diagnostics.subject_progress(subject, results)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
290
370
|
def canary_check
|
|
291
|
-
canary = Canary.new(@root, cache_dir: config["cache_dir"])
|
|
292
371
|
lambda do |subject, results|
|
|
293
372
|
last = results.reject(&:reused?).last || results.last
|
|
294
373
|
digest = last&.mutation&.digest(subject_start: subject.start_byte,
|
|
@@ -297,6 +376,9 @@ module Saboteur
|
|
|
297
376
|
end
|
|
298
377
|
end
|
|
299
378
|
|
|
379
|
+
# Shared with the runner so the hazard report wins over a log loss (#283).
|
|
380
|
+
def canary = (@canary ||= Canary.new(@root, cache_dir: config["cache_dir"]))
|
|
381
|
+
|
|
300
382
|
def candidate_lists(subjects, scope, map)
|
|
301
383
|
return [@replay_candidate] if @replay
|
|
302
384
|
|
|
@@ -305,6 +387,7 @@ module Saboteur
|
|
|
305
387
|
limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
|
|
306
388
|
subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
|
|
307
389
|
@skipped_subjects = selection.skipped_subjects
|
|
390
|
+
@unscoped_digests = selection.unscoped_digests
|
|
308
391
|
if @run_evidence
|
|
309
392
|
@run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
|
|
310
393
|
@evidence_planned = true
|
|
@@ -333,19 +416,6 @@ module Saboteur
|
|
|
333
416
|
end
|
|
334
417
|
end
|
|
335
418
|
|
|
336
|
-
# Threads do not survive fork, so name them: with the name the owner
|
|
337
|
-
# gave the thread, else the first frame of its backtrace, so the user
|
|
338
|
-
# can tell whether they matter (a connection pool reaper, Sidekiq, an
|
|
339
|
-
# SDK's background flusher).
|
|
340
|
-
def warn_about_threads
|
|
341
|
-
others = Thread.list - [Thread.current]
|
|
342
|
-
return if others.empty?
|
|
343
|
-
|
|
344
|
-
names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
|
|
345
|
-
list = names.join(", ")
|
|
346
|
-
@diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
|
|
347
|
-
end
|
|
348
|
-
|
|
349
419
|
# The lines that ran while the app and spec files loaded: the executable
|
|
350
420
|
# counts from its first line, so this snapshot holds the whole boot.
|
|
351
421
|
# Read here, in the parent: a forked child does not inherit Coverage
|
|
@@ -377,22 +447,22 @@ module Saboteur
|
|
|
377
447
|
# Loads the target and stops coverage, returning what the rest of the
|
|
378
448
|
# run needs from that boot.
|
|
379
449
|
def boot_target(boot)
|
|
450
|
+
@diagnostics.inform("loading the application")
|
|
380
451
|
integration = boot.load_target
|
|
381
452
|
files = config.included_files
|
|
382
453
|
[integration, files, suspend_coverage(files)]
|
|
383
454
|
end
|
|
384
455
|
|
|
385
456
|
def suspend_coverage(files)
|
|
386
|
-
|
|
457
|
+
(@coverage_guard = CoverageGuard.new(@diagnostics)).suspend
|
|
387
458
|
capture_load_lines(files)
|
|
388
459
|
end
|
|
389
460
|
|
|
390
|
-
def monotonic
|
|
391
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
392
|
-
end
|
|
461
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
393
462
|
end
|
|
394
463
|
end
|
|
395
464
|
|
|
465
|
+
require_relative "engine/coverage_guard"
|
|
396
466
|
require_relative "engine/diagnostics"
|
|
397
467
|
require_relative "engine/boot"
|
|
398
468
|
require_relative "engine/subject_selection"
|
|
@@ -402,6 +472,7 @@ require_relative "engine/test_guess"
|
|
|
402
472
|
require_relative "engine/sandbox_policy"
|
|
403
473
|
require_relative "engine/coverage_store"
|
|
404
474
|
require_relative "engine/baseline_guard"
|
|
475
|
+
require_relative "engine/unscoped_suite_warning"
|
|
405
476
|
require_relative "engine/bytecode_equivalence"
|
|
406
477
|
require_relative "engine/candidates"
|
|
407
478
|
require_relative "engine/candidate_selection"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "../text"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
module Execution
|
|
@@ -31,7 +32,7 @@ module Saboteur
|
|
|
31
32
|
# Events in order; lines that are not this protocol's are ignored.
|
|
32
33
|
def parse(payload)
|
|
33
34
|
payload.each_line.filter_map do |line|
|
|
34
|
-
message = JSON.parse(line)
|
|
35
|
+
message = JSON.parse(Text.utf8_strict(line))
|
|
35
36
|
message if message.is_a?(Hash) && message["v"] == VERSION
|
|
36
37
|
rescue JSON::ParserError
|
|
37
38
|
nil
|