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
data/lib/saboteur/engine.rb
CHANGED
|
@@ -6,24 +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
|
-
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators arid
|
|
22
|
+
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
|
|
23
|
+
timeout_factor timeout_min
|
|
17
24
|
max_memory_mb max_processes seed tests whole_file].freeze
|
|
18
25
|
|
|
19
26
|
attr_reader :config, :hooks
|
|
20
|
-
# The specs
|
|
27
|
+
# The specs inference narrowed the run to, or nil when it did not.
|
|
21
28
|
attr_reader :guessed_tests
|
|
22
29
|
|
|
23
|
-
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)
|
|
24
31
|
@config = config
|
|
25
32
|
@hooks = Hooks.new
|
|
26
|
-
@diagnostics = Diagnostics.new(err)
|
|
33
|
+
@diagnostics = Diagnostics.new(err, verbose: verbose)
|
|
27
34
|
@root = File.realpath(config.root)
|
|
28
35
|
@skipped_subjects = {}
|
|
29
36
|
@replay = Replay.new(digest: digest) if digest
|
|
@@ -37,11 +44,16 @@ module Saboteur
|
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
def call
|
|
40
|
-
|
|
41
|
-
refuse_fail_fast_evidence
|
|
47
|
+
preflight!
|
|
42
48
|
started = monotonic
|
|
43
|
-
|
|
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.
|
|
44
54
|
@run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
|
|
55
|
+
select_tests
|
|
56
|
+
announce_operators
|
|
45
57
|
ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
|
|
46
58
|
warn_custom_map
|
|
47
59
|
boot = Boot.new(config, hooks)
|
|
@@ -50,29 +62,31 @@ module Saboteur
|
|
|
50
62
|
@rails = boot.rails
|
|
51
63
|
subjects, scope = select_subjects(files)
|
|
52
64
|
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
|
|
53
|
-
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
65
|
+
@diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
|
|
54
66
|
build_report(results, subjects, scope, started)
|
|
55
67
|
ensure
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
64
77
|
end
|
|
65
78
|
end
|
|
66
79
|
|
|
67
|
-
def
|
|
68
|
-
|
|
80
|
+
def preflight!
|
|
81
|
+
Boot.refuse_recursion!
|
|
82
|
+
refuse_fail_fast_evidence
|
|
69
83
|
end
|
|
70
84
|
|
|
85
|
+
def warn_custom_map = @diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
|
|
86
|
+
|
|
71
87
|
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
72
88
|
# map does not rot silently. doctor fails on stale keys.
|
|
73
|
-
def load_custom_map
|
|
74
|
-
@replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
|
|
75
|
-
end
|
|
89
|
+
def load_custom_map = @replay ? CustomMap.new([]) : CustomMap.load(config.path("custom_map"), root: config.root)
|
|
76
90
|
|
|
77
91
|
def custom_map_warnings(map)
|
|
78
92
|
path = config.path("custom_map")
|
|
@@ -84,32 +98,87 @@ module Saboteur
|
|
|
84
98
|
|
|
85
99
|
private
|
|
86
100
|
|
|
87
|
-
#
|
|
88
|
-
# the
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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"
|
|
100
152
|
else
|
|
101
|
-
|
|
102
|
-
@config = Config.new(config.to_h.merge("tests" => guess.specs, "guess_tests" => false), root: config.root)
|
|
103
|
-
@guessed_tests = guess.specs
|
|
104
|
-
"--guess-tests: running only #{guess.specs.join(', ')}. A spec that reaches a changed file only " \
|
|
105
|
-
"through another file does not run, so a mutant only it would kill can survive, and a line " \
|
|
106
|
-
"no guessed spec executes is reported as NoCoverage"
|
|
153
|
+
"inferred specs: no source file has a mutation subject, so there is nothing to mutate"
|
|
107
154
|
end
|
|
108
|
-
|
|
109
|
-
|
|
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"])
|
|
110
175
|
end
|
|
111
176
|
|
|
112
177
|
# The run's immutable report, built once the last result is in.
|
|
178
|
+
def announce_operators
|
|
179
|
+
@diagnostics.inform("Operators: #{config.operator_policy['effective'].join(', ')}")
|
|
180
|
+
end
|
|
181
|
+
|
|
113
182
|
def build_report(results, subjects, scope, started)
|
|
114
183
|
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
115
184
|
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
@@ -117,7 +186,8 @@ module Saboteur
|
|
|
117
186
|
diff_scope: scope, generation_events: @diagnostics.generation_events,
|
|
118
187
|
omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
|
|
119
188
|
hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
|
|
120
|
-
matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions
|
|
189
|
+
matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
|
|
190
|
+
operator_policy: config.operator_policy, discovery_failures: @diagnostics.discovery_failures)
|
|
121
191
|
end
|
|
122
192
|
|
|
123
193
|
def complete_evidence(results, subjects)
|
|
@@ -142,10 +212,13 @@ module Saboteur
|
|
|
142
212
|
end
|
|
143
213
|
|
|
144
214
|
def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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)
|
|
149
222
|
inform_about_run(subjects, map)
|
|
150
223
|
cache = result_cache
|
|
151
224
|
scheduler = scheduler_for(map, integration, cache)
|
|
@@ -162,14 +235,17 @@ module Saboteur
|
|
|
162
235
|
results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
|
|
163
236
|
cache&.save
|
|
164
237
|
results
|
|
238
|
+
rescue BudgetPhaseTimeout => e
|
|
239
|
+
budget_phase_stop(e, subjects)
|
|
165
240
|
end
|
|
166
241
|
|
|
167
242
|
def run_candidates(subjects, lists, scheduler, cache, ignore_list)
|
|
168
243
|
@diagnostics.start_progress(lists.sum(&:size))
|
|
169
244
|
distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
|
|
170
245
|
cache: cache, on_start: method(:start_worker),
|
|
171
|
-
after_subject:
|
|
172
|
-
|
|
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) }
|
|
173
249
|
ensure
|
|
174
250
|
@diagnostics.finish_progress
|
|
175
251
|
end
|
|
@@ -182,16 +258,15 @@ module Saboteur
|
|
|
182
258
|
raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
|
|
183
259
|
end
|
|
184
260
|
|
|
185
|
-
# Subjects that were selected but never ran, with candidates, are part of
|
|
186
|
-
# the report: the verdict covers less than the selection.
|
|
187
|
-
def record_early_stop(unreached)
|
|
188
|
-
return if unreached.empty?
|
|
189
|
-
|
|
190
|
-
@early_stop = EarlyStop.new(reason: "fail_fast", unreached: unreached.map(&:id))
|
|
191
|
-
end
|
|
192
|
-
|
|
193
261
|
# --fail-fast: stop after the first subject that has a survivor the
|
|
194
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
|
+
|
|
195
270
|
def fail_fast_check(ignore_list)
|
|
196
271
|
return nil unless config["fail_fast"]
|
|
197
272
|
|
|
@@ -204,38 +279,40 @@ module Saboteur
|
|
|
204
279
|
|
|
205
280
|
def inform_about_run(subjects, map)
|
|
206
281
|
@diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
|
|
207
|
-
|
|
282
|
+
ThreadWarnings.warn(@diagnostics)
|
|
208
283
|
end
|
|
209
284
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
215
290
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
224
301
|
end
|
|
225
302
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
@
|
|
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
|
|
230
307
|
end
|
|
231
308
|
|
|
232
309
|
def scheduler_for(map, integration, cache)
|
|
233
310
|
reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
|
|
234
311
|
runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
|
|
235
312
|
supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
|
|
236
|
-
sandbox: sandbox_hook)
|
|
313
|
+
sandbox: sandbox_hook, canary: canary)
|
|
237
314
|
Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
|
|
238
|
-
cache: cache)
|
|
315
|
+
cache: cache, deadline: budget_deadline)
|
|
239
316
|
end
|
|
240
317
|
|
|
241
318
|
# The sandbox hook for each mutant child (#152); the policy and its
|
|
@@ -249,7 +326,8 @@ module Saboteur
|
|
|
249
326
|
return nil if @replay
|
|
250
327
|
return nil unless config["incremental"]
|
|
251
328
|
|
|
252
|
-
inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
|
|
329
|
+
inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
|
|
330
|
+
.merge("operator_policy" => config.operator_policy))
|
|
253
331
|
ResultCache.new(path: File.join(config.path("cache_dir"), "results.json"), root: @root, inputs: inputs,
|
|
254
332
|
suite_digest: SuiteFiles.new(config).digest)
|
|
255
333
|
end
|
|
@@ -279,8 +357,17 @@ module Saboteur
|
|
|
279
357
|
|
|
280
358
|
# Checks the tree after each subject's mutants; a vanished file means a
|
|
281
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
|
+
|
|
282
370
|
def canary_check
|
|
283
|
-
canary = Canary.new(@root, cache_dir: config["cache_dir"])
|
|
284
371
|
lambda do |subject, results|
|
|
285
372
|
last = results.reject(&:reused?).last || results.last
|
|
286
373
|
digest = last&.mutation&.digest(subject_start: subject.start_byte,
|
|
@@ -289,6 +376,9 @@ module Saboteur
|
|
|
289
376
|
end
|
|
290
377
|
end
|
|
291
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
|
+
|
|
292
382
|
def candidate_lists(subjects, scope, map)
|
|
293
383
|
return [@replay_candidate] if @replay
|
|
294
384
|
|
|
@@ -297,6 +387,7 @@ module Saboteur
|
|
|
297
387
|
limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
|
|
298
388
|
subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
|
|
299
389
|
@skipped_subjects = selection.skipped_subjects
|
|
390
|
+
@unscoped_digests = selection.unscoped_digests
|
|
300
391
|
if @run_evidence
|
|
301
392
|
@run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
|
|
302
393
|
@evidence_planned = true
|
|
@@ -325,19 +416,6 @@ module Saboteur
|
|
|
325
416
|
end
|
|
326
417
|
end
|
|
327
418
|
|
|
328
|
-
# Threads do not survive fork, so name them: with the name the owner
|
|
329
|
-
# gave the thread, else the first frame of its backtrace, so the user
|
|
330
|
-
# can tell whether they matter (a connection pool reaper, Sidekiq, an
|
|
331
|
-
# SDK's background flusher).
|
|
332
|
-
def warn_about_threads
|
|
333
|
-
others = Thread.list - [Thread.current]
|
|
334
|
-
return if others.empty?
|
|
335
|
-
|
|
336
|
-
names = others.map { |thread| thread.name || thread.backtrace&.first || "unknown" }
|
|
337
|
-
list = names.join(", ")
|
|
338
|
-
@diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
|
|
339
|
-
end
|
|
340
|
-
|
|
341
419
|
# The lines that ran while the app and spec files loaded: the executable
|
|
342
420
|
# counts from its first line, so this snapshot holds the whole boot.
|
|
343
421
|
# Read here, in the parent: a forked child does not inherit Coverage
|
|
@@ -369,22 +447,22 @@ module Saboteur
|
|
|
369
447
|
# Loads the target and stops coverage, returning what the rest of the
|
|
370
448
|
# run needs from that boot.
|
|
371
449
|
def boot_target(boot)
|
|
450
|
+
@diagnostics.inform("loading the application")
|
|
372
451
|
integration = boot.load_target
|
|
373
452
|
files = config.included_files
|
|
374
453
|
[integration, files, suspend_coverage(files)]
|
|
375
454
|
end
|
|
376
455
|
|
|
377
456
|
def suspend_coverage(files)
|
|
378
|
-
|
|
457
|
+
(@coverage_guard = CoverageGuard.new(@diagnostics)).suspend
|
|
379
458
|
capture_load_lines(files)
|
|
380
459
|
end
|
|
381
460
|
|
|
382
|
-
def monotonic
|
|
383
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
384
|
-
end
|
|
461
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
385
462
|
end
|
|
386
463
|
end
|
|
387
464
|
|
|
465
|
+
require_relative "engine/coverage_guard"
|
|
388
466
|
require_relative "engine/diagnostics"
|
|
389
467
|
require_relative "engine/boot"
|
|
390
468
|
require_relative "engine/subject_selection"
|
|
@@ -394,6 +472,7 @@ require_relative "engine/test_guess"
|
|
|
394
472
|
require_relative "engine/sandbox_policy"
|
|
395
473
|
require_relative "engine/coverage_store"
|
|
396
474
|
require_relative "engine/baseline_guard"
|
|
475
|
+
require_relative "engine/unscoped_suite_warning"
|
|
397
476
|
require_relative "engine/bytecode_equivalence"
|
|
398
477
|
require_relative "engine/candidates"
|
|
399
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
|