saboteur 0.7.0 → 0.8.1
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 +772 -0
- data/README.md +221 -1072
- data/lib/saboteur/attribute_finder.rb +113 -0
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +47 -3
- data/lib/saboteur/cli/run_budget_options.rb +3 -1
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +35 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +74 -55
- data/lib/saboteur/config.rb +102 -6
- data/lib/saboteur/config_file.rb +19 -7
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/custom_map.rb +12 -1
- data/lib/saboteur/dirty_tree_warning.rb +130 -0
- data/lib/saboteur/doctor.rb +13 -0
- data/lib/saboteur/engine/baseline_guard.rb +135 -21
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +20 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +58 -14
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +19 -1
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +37 -0
- data/lib/saboteur/engine/load_coverage.rb +63 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +115 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +32 -2
- data/lib/saboteur/engine/sandbox_policy.rb +59 -3
- data/lib/saboteur/engine/scheduler.rb +131 -17
- data/lib/saboteur/engine/selection_announcement.rb +59 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +380 -21
- data/lib/saboteur/engine/test_guess.rb +407 -27
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +103 -129
- data/lib/saboteur/execution/external_runner.rb +489 -0
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/marker_sweep.rb +80 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +19 -3
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +75 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +25 -6
- data/lib/saboteur/execution/seatbelt.rb +233 -0
- data/lib/saboteur/execution/supervisor.rb +29 -11
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +223 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +28 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +125 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +56 -8
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +120 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +14 -0
- metadata +39 -16
|
@@ -26,6 +26,7 @@ module Saboteur
|
|
|
26
26
|
# which refuses instead.
|
|
27
27
|
def hook
|
|
28
28
|
return nil unless @config["sandbox"]
|
|
29
|
+
return seatbelt_hook if @config["sandbox"] == "seatbelt" && Execution::Seatbelt.darwin?
|
|
29
30
|
|
|
30
31
|
abi = sandbox_abi
|
|
31
32
|
return restrict_hook if abi >= 3
|
|
@@ -38,8 +39,7 @@ module Saboteur
|
|
|
38
39
|
# same line twice (D's review, #178).
|
|
39
40
|
unless @sandbox_note_given
|
|
40
41
|
@sandbox_note_given = true
|
|
41
|
-
@diagnostics.warn(
|
|
42
|
-
"and canary only")
|
|
42
|
+
@diagnostics.warn(degraded_note(abi))
|
|
43
43
|
end
|
|
44
44
|
nil
|
|
45
45
|
end
|
|
@@ -54,16 +54,72 @@ module Saboteur
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def self.sandbox_gap(abi)
|
|
57
|
-
|
|
57
|
+
if abi.zero?
|
|
58
|
+
# Landlock is Linux-only: on macOS there is no kernel to upgrade,
|
|
59
|
+
# so name the platform instead of a Linux version (#667).
|
|
60
|
+
return "macOS has no Landlock" if Execution::Seatbelt.darwin?
|
|
61
|
+
|
|
62
|
+
return "this kernel lacks Landlock (needs Linux 6.2+)"
|
|
63
|
+
end
|
|
58
64
|
|
|
59
65
|
"this kernel's Landlock ABI is #{abi} but ABI 3 (Linux 6.2+) is needed to restrict truncate()"
|
|
60
66
|
end
|
|
61
67
|
|
|
68
|
+
# The degraded-run note: on macOS the gap is permanent, so point at
|
|
69
|
+
# the Seatbelt opt-in; on Linux the kernel wording stands alone
|
|
70
|
+
# (#667). Severity stays a warning - degrading confinement is never
|
|
71
|
+
# routine.
|
|
72
|
+
def degraded_note(abi)
|
|
73
|
+
gap = self.class.sandbox_gap(abi)
|
|
74
|
+
tail = "children run with the hazard guard and canary only"
|
|
75
|
+
return "sandbox is on but #{gap}; #{tail}" unless Execution::Seatbelt.darwin?
|
|
76
|
+
|
|
77
|
+
"sandbox is on but #{gap}; #{tail} - opt into the macOS sandbox with c.sandbox = \"seatbelt\" " \
|
|
78
|
+
"(docs/guides/macos-seatbelt-sandbox.md), or set c.sandbox = false to silence this"
|
|
79
|
+
end
|
|
80
|
+
|
|
62
81
|
def restrict_hook
|
|
63
82
|
writable = sandbox_writable_dirs
|
|
64
83
|
->(keep_fds) { Execution::Sandbox.restrict!(writable: writable, keep_fds: keep_fds) }
|
|
65
84
|
end
|
|
66
85
|
|
|
86
|
+
# c.sandbox = "seatbelt" (the macOS opt-in, #436): the probe decides.
|
|
87
|
+
# Confine when Seatbelt truly enforces; a clean "no Seatbelt here"
|
|
88
|
+
# degrades to the guard and canary with a one-time note; anything
|
|
89
|
+
# else - an errored probe, or an API claiming success its profile
|
|
90
|
+
# cannot prove - refuses the run, exactly like the Landlock probe
|
|
91
|
+
# (#178): opting into a sandbox must never buy silent confinement
|
|
92
|
+
# theatre. Fail-closed lives one layer down too: a child whose
|
|
93
|
+
# profile refuses to apply raises instead of running the mutant.
|
|
94
|
+
def seatbelt_hook
|
|
95
|
+
case (verdict = Execution::Seatbelt.probe)
|
|
96
|
+
when :ok
|
|
97
|
+
writable = sandbox_writable_dirs
|
|
98
|
+
->(keep_fds) { Execution::Seatbelt.restrict!(writable: writable, keep_fds: keep_fds) }
|
|
99
|
+
when :unavailable
|
|
100
|
+
seatbelt_note("this macOS lacks Seatbelt")
|
|
101
|
+
nil
|
|
102
|
+
else
|
|
103
|
+
raise Error, "sandbox is \"seatbelt\" but the Seatbelt probe #{verdict_description(verdict)}; refusing to " \
|
|
104
|
+
"run unsandboxed - set c.sandbox = true to run with the hazard guard and canary only, " \
|
|
105
|
+
"or false to opt out"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verdict_description(verdict)
|
|
110
|
+
case verdict
|
|
111
|
+
when :unenforced then "reported a profile that did not enforce - a macOS update may have changed it (#436)"
|
|
112
|
+
else "failed (#{verdict}) - #436 tracks the deprecation"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def seatbelt_note(reason)
|
|
117
|
+
return if @sandbox_note_given
|
|
118
|
+
|
|
119
|
+
@sandbox_note_given = true
|
|
120
|
+
@diagnostics.warn(%(sandbox is "seatbelt" but #{reason}; children run with the hazard guard and canary only))
|
|
121
|
+
end
|
|
122
|
+
|
|
67
123
|
# c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
|
|
68
124
|
# ...): without a grant, writes there fail with EACCES in the child.
|
|
69
125
|
# Only the user grants are created: the project tmp is granted when it
|
|
@@ -12,6 +12,7 @@ module Saboteur
|
|
|
12
12
|
# (VerdictCheck). Each mutant runs only the tests that executed its edit,
|
|
13
13
|
# likeliest killers first, with a timeout from their recorded durations.
|
|
14
14
|
class Scheduler
|
|
15
|
+
BUDGET_RETRY_REASON = "the --max-duration budget stopped the defined-order retry"
|
|
15
16
|
WHOLE_FILE_HINT = "; add it to c.whole_file to re-evaluate the whole file"
|
|
16
17
|
|
|
17
18
|
# A subject to run, with what its mutants need. killers counts the
|
|
@@ -27,7 +28,11 @@ module Saboteur
|
|
|
27
28
|
tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
# verify_loaded: the shadowed-definition guard resolves each subject's
|
|
32
|
+
# owner in this process. --test-command never boots the app here, so
|
|
33
|
+
# external runs pass false; their neutral check covers a red tree.
|
|
34
|
+
def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil, deadline: nil,
|
|
35
|
+
verify_loaded: true)
|
|
31
36
|
@config = config
|
|
32
37
|
@deadline = deadline
|
|
33
38
|
@runner = runner
|
|
@@ -35,6 +40,7 @@ module Saboteur
|
|
|
35
40
|
@reevaluator = reevaluator
|
|
36
41
|
@diagnostics = diagnostics
|
|
37
42
|
@cache = cache
|
|
43
|
+
@verify_loaded = verify_loaded
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def call(subject, candidates)
|
|
@@ -49,6 +55,7 @@ module Saboteur
|
|
|
49
55
|
|
|
50
56
|
tests = @map.tests_for(subject)
|
|
51
57
|
return unloadable(candidates, subject) if tests.empty?
|
|
58
|
+
return load_time_results(candidates, subject) if load_only?(subject)
|
|
52
59
|
|
|
53
60
|
plan = Plan.new(subject:, tests:, killers: Hash.new(0), slowdown: 1.0, wrapper: wrapper)
|
|
54
61
|
reused = all_reused(plan, candidates)
|
|
@@ -57,7 +64,7 @@ module Saboteur
|
|
|
57
64
|
results = run_candidates(candidates) do |mutation, text|
|
|
58
65
|
reuse(plan, mutation, text) || execute(plan, mutation, text)
|
|
59
66
|
end
|
|
60
|
-
checked(plan, results)
|
|
67
|
+
checked(plan, results, truncated: results.size < candidates.size)
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
# The per-mutant timeout: timeout_factor times what the tests took
|
|
@@ -74,18 +81,25 @@ module Saboteur
|
|
|
74
81
|
|
|
75
82
|
# Verdicts that did not hold up are warned about once per subject;
|
|
76
83
|
# only checked verdicts are stored for --incremental.
|
|
77
|
-
# A spent budget admits no check
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
# reports
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
# A spent budget admits no check for a subject that finished its
|
|
85
|
+
# mutants: forking the unchanged run with no time left would turn
|
|
86
|
+
# genuine kills into fabricated errors and cache them. Fresh kills
|
|
87
|
+
# stand unverified instead - outside the score, never cached - and
|
|
88
|
+
# the distribution marks the subject so the run reports
|
|
89
|
+
# budget-partial.
|
|
90
|
+
# A subject the budget stopped mid-list is different (#588): its
|
|
91
|
+
# completed mutants were already measured, so their checks still
|
|
92
|
+
# run, uncapped - the budget stops new mutation work, not the
|
|
93
|
+
# confirmation of work already done. The overshoot stays bounded by
|
|
94
|
+
# the subject's distinct killing sequences.
|
|
95
|
+
def checked(plan, results, truncated: false)
|
|
96
|
+
return results.map { |result| unverifiable(result) } if budget_spent? && !truncated
|
|
97
|
+
|
|
98
|
+
@confirming_truncation = truncated
|
|
85
99
|
subject = plan.subject
|
|
86
100
|
check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
|
|
87
101
|
baseline_timeout: @config["baseline_timeout"])
|
|
88
|
-
final = check.call(results)
|
|
102
|
+
final = check.call(results).map { |result| retried_in_defined_order(plan, result) }
|
|
89
103
|
# Unverified verdicts are never cached: a budget-cut check proves
|
|
90
104
|
# nothing, and a stored copy would read as confirmed next run.
|
|
91
105
|
final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
|
|
@@ -94,6 +108,50 @@ module Saboteur
|
|
|
94
108
|
@diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
|
|
95
109
|
end
|
|
96
110
|
final
|
|
111
|
+
ensure
|
|
112
|
+
@confirming_truncation = false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A kill rejected because its covering examples fail with the method
|
|
116
|
+
# unchanged may only lack the setup examples the subset run skipped
|
|
117
|
+
# (an order-dependent file, #557). Retry once with the file's examples
|
|
118
|
+
# in defined order, through the last covering example. It stands only
|
|
119
|
+
# if that whole run passes unchanged; the mutant's verdict is then the
|
|
120
|
+
# mutant's run on the same examples. Otherwise the error stays.
|
|
121
|
+
#
|
|
122
|
+
# Cost bound: at most two extra forks per rejected kill, the unchanged
|
|
123
|
+
# prefix run and one mutant run. Ordinarily both spend --max-duration
|
|
124
|
+
# like every other fork: neither starts once the budget is spent, and
|
|
125
|
+
# neither gets more than the time left. A retry the budget stops is
|
|
126
|
+
# unverified, not an error or a kill. The exception is a truncated
|
|
127
|
+
# subject (#588): its completed kills still retry, at the full
|
|
128
|
+
# timeout. Keep this bound if the retry ever grows.
|
|
129
|
+
def retried_in_defined_order(plan, result)
|
|
130
|
+
return result unless result.rejected_kill? && !result.reused? && result.covered_by.any?
|
|
131
|
+
|
|
132
|
+
tests = DefinedOrderPrefix.new(@map.tests).for(result.covered_by)
|
|
133
|
+
return result unless tests
|
|
134
|
+
|
|
135
|
+
unchanged = unchanged_runner(plan.subject, plan).call(tests, @config["baseline_timeout"])
|
|
136
|
+
return result.as_unverified(unchanged.message || BUDGET_RETRY_REASON) if unchanged.status == :unverified
|
|
137
|
+
return result unless unchanged.status == :survived
|
|
138
|
+
|
|
139
|
+
rerun_in_prefix(plan, result, tests)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def rerun_in_prefix(plan, result, tests)
|
|
143
|
+
return result.as_unverified(BUDGET_RETRY_REASON) if budget_spent? && !@confirming_truncation
|
|
144
|
+
|
|
145
|
+
full = timeout_for(tests, plan.slowdown)
|
|
146
|
+
capped = @confirming_truncation ? full : budget_capped(full)
|
|
147
|
+
outcome = @runner.run(subject: plan.subject, text: result.mutated_source, test_ids: tests,
|
|
148
|
+
timeout: capped, mutation: result.mutation, eval_prefix: eval_prefix(plan))
|
|
149
|
+
return result.as_unverified(BUDGET_RETRY_REASON) if outcome.status == :timeout && capped < full
|
|
150
|
+
|
|
151
|
+
MutantResult.new(subject: plan.subject, mutation: result.mutation, status: outcome.status,
|
|
152
|
+
mutated_source: result.mutated_source, reason: outcome.message, runtime: outcome.runtime,
|
|
153
|
+
killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
|
|
154
|
+
executed: outcome.executed_ids)
|
|
97
155
|
end
|
|
98
156
|
|
|
99
157
|
# Admission is per sequence, not just at checked() entry: a check
|
|
@@ -103,11 +161,11 @@ module Saboteur
|
|
|
103
161
|
# the check never got its answer (#296).
|
|
104
162
|
def unchanged_runner(subject, plan)
|
|
105
163
|
lambda do |tests, timeout|
|
|
106
|
-
if budget_spent?
|
|
164
|
+
if budget_spent? && !@confirming_truncation
|
|
107
165
|
Execution::Runner::Outcome.new(status: :unverified, runtime: 0.0,
|
|
108
166
|
message: "the --max-duration budget is spent")
|
|
109
167
|
else
|
|
110
|
-
capped = budget_capped(timeout)
|
|
168
|
+
capped = @confirming_truncation ? timeout : budget_capped(timeout)
|
|
111
169
|
outcome = @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: capped,
|
|
112
170
|
eval_prefix: eval_prefix(plan))
|
|
113
171
|
note_leftovers(subject, "verdict check", outcome)
|
|
@@ -191,13 +249,24 @@ module Saboteur
|
|
|
191
249
|
[@config["timeout_factor"] * runtime, @config["timeout_min"]].max
|
|
192
250
|
end
|
|
193
251
|
|
|
194
|
-
# What a subject no test ran gets.
|
|
195
|
-
#
|
|
196
|
-
# it, so it gets its own status, out of the score, with a hint.
|
|
252
|
+
# What a subject no test ran gets. Load-only code is not "no coverage":
|
|
253
|
+
# it ran, but no forked test can re-run it, so it gets its own status.
|
|
197
254
|
def unloadable(candidates, subject, line_numbers = nil)
|
|
255
|
+
return skip_all(candidates, subject, Selection::CoverageMap::PRELOADED) if @map.preloaded?(subject.file)
|
|
198
256
|
return skip_all(candidates, subject, "no coverage") unless
|
|
199
257
|
@map.ran_at_load?(subject.file, line_numbers || subject.coverage_lines)
|
|
200
258
|
|
|
259
|
+
load_time_results(candidates, subject)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A def-time macro's only callers ran while the app loaded: the
|
|
263
|
+
# child re-evals the def, but closure consumers captured the
|
|
264
|
+
# pristine definition, so no test can observe a mutant (#409).
|
|
265
|
+
def load_only?(subject)
|
|
266
|
+
!subject.is_a?(ConstantSubject) && @map.called_only_at_load?(subject.file, subject.start_line)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def load_time_results(candidates, subject)
|
|
201
270
|
candidates.map do |mutation, text|
|
|
202
271
|
MutantResult.new(subject: subject, mutation: mutation, status: :ran_at_load, mutated_source: text,
|
|
203
272
|
reason: Selection::CoverageMap::RAN_AT_LOAD)
|
|
@@ -219,7 +288,50 @@ module Saboteur
|
|
|
219
288
|
timeout = timeout_for(tests, plan.slowdown)
|
|
220
289
|
outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout,
|
|
221
290
|
mutation: mutation, eval_prefix: eval_prefix(plan))
|
|
222
|
-
outcome
|
|
291
|
+
outcome = calibrate(plan, mutation, text, tests, timeout) if outcome.status == :timeout
|
|
292
|
+
flaky_check(plan, mutation, text, tests, outcome)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# --flaky-check (#476): one reordered confirmation run per decisive
|
|
296
|
+
# fresh verdict. A disagreement is unverified - outside the score and
|
|
297
|
+
# never cached - not "rerun until it passes". A reused verdict is
|
|
298
|
+
# already trusted evidence and never reaches here. A budget-cut
|
|
299
|
+
# outcome is already unverified and earns no second fork.
|
|
300
|
+
def flaky_check(plan, mutation, text, tests, outcome)
|
|
301
|
+
return outcome unless flaky_checkable?(outcome)
|
|
302
|
+
return outcome if budget_spent?
|
|
303
|
+
|
|
304
|
+
timeout = timeout_for(tests, plan.slowdown)
|
|
305
|
+
|
|
306
|
+
rerun = @runner.run(subject: plan.subject, text: text, test_ids: reordered(tests),
|
|
307
|
+
timeout: timeout, mutation: mutation, eval_prefix: eval_prefix(plan))
|
|
308
|
+
return outcome if rerun.status == outcome.status
|
|
309
|
+
|
|
310
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime + rerun.runtime,
|
|
311
|
+
message: flaky_message(outcome, rerun))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# A shuffled order that is guaranteed to differ (a two-test shuffle
|
|
315
|
+
# has even odds of coming out identical): order-dependence is exactly
|
|
316
|
+
# what the confirmation run hunts.
|
|
317
|
+
def reordered(tests)
|
|
318
|
+
shuffled = tests.shuffle(random: Random.new(@config["seed"]))
|
|
319
|
+
shuffled == tests && shuffled.size > 1 ? shuffled.rotate : shuffled
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def flaky_checkable?(outcome)
|
|
323
|
+
case @config["flaky_check"]
|
|
324
|
+
when "survivors" then outcome.status == :survived
|
|
325
|
+
when "decisive" then %i[survived killed].include?(outcome.status)
|
|
326
|
+
else false
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def flaky_message(outcome, rerun)
|
|
331
|
+
decisive = (outcome.failed_ids | rerun.failed_ids).first(3)
|
|
332
|
+
named = decisive.empty? ? "" : " (decisive tests: #{decisive.join(', ')})"
|
|
333
|
+
"flaky verdict: first #{outcome.status}, then #{rerun.status} on a reordered confirmation " \
|
|
334
|
+
"run#{named} - the mutant is unverified, not #{outcome.status}"
|
|
223
335
|
end
|
|
224
336
|
|
|
225
337
|
# Each mutant is checked separately: changing a body can change which
|
|
@@ -329,6 +441,8 @@ module Saboteur
|
|
|
329
441
|
# sig is one, see Execution::Wrapper) is not a shadowing - the child
|
|
330
442
|
# re-evaluates it together with the def, so the check passes.
|
|
331
443
|
def load_problem(subject)
|
|
444
|
+
return [nil, nil] unless @verify_loaded
|
|
445
|
+
|
|
332
446
|
return [nil, nil] if @reevaluator.whole_file?(subject)
|
|
333
447
|
|
|
334
448
|
owner = @reevaluator.resolve_owner(subject)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Nothing to mutate is the run's outcome, already stated by the
|
|
7
|
+
# "0 subjects, 0 mutants" summary: note it once (#468). As a report
|
|
8
|
+
# warning it would print again at the end of the run, and a zero-subject
|
|
9
|
+
# run ends right away, so the repeat reads like a second failure.
|
|
10
|
+
module SelectionAnnouncement
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# A --since diff with no changed source file (a spec-only or docs-only
|
|
14
|
+
# change) yields zero subjects, so the suite never runs: say there is
|
|
15
|
+
# nothing to test rather than promise a whole-suite run that does not
|
|
16
|
+
# happen (#607).
|
|
17
|
+
def selection_message(guess)
|
|
18
|
+
if guess.changed.zero? && config["since"]
|
|
19
|
+
"inferred specs: no changed source file to infer a spec for, so there is nothing to test"
|
|
20
|
+
elsif guess.changed.zero?
|
|
21
|
+
"inferred specs: no source file to infer a spec for, so the whole suite runs"
|
|
22
|
+
elsif guess.specs.empty?
|
|
23
|
+
guess.no_specs_message(since: config["since"])
|
|
24
|
+
else
|
|
25
|
+
scope_to_guesses(guess)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def announce_selection(guess)
|
|
30
|
+
if guess.changed.zero? || guess.specs.any?
|
|
31
|
+
@diagnostics.announce(selection_message(guess))
|
|
32
|
+
else
|
|
33
|
+
@diagnostics.inform(selection_message(guess))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A spec file the diff touched that cannot run is named, also before a
|
|
38
|
+
# stop on an unmatched file: --test, the way out, never reads the diff.
|
|
39
|
+
def inform_skipped_specs(guess) = (@diagnostics.inform(guess.skipped_message) if guess.skipped_specs.any?)
|
|
40
|
+
|
|
41
|
+
# Stop before boot: name the spec-less files and the ways out. The
|
|
42
|
+
# hinted spec paths are the conventional ones - they do not exist (that
|
|
43
|
+
# is why the files are unmatched), so the message never prints them as
|
|
44
|
+
# a runnable --test command, and it never reconstructs the source
|
|
45
|
+
# scope: the user appends --test or --all to their own command. The
|
|
46
|
+
# example is still shell-safe: the ref is user input, so it is quoted
|
|
47
|
+
# and pinned to the flag with = (a ref like "-x" must not become a
|
|
48
|
+
# switch).
|
|
49
|
+
def scope_to_guesses(guess)
|
|
50
|
+
# The guesses now stand as explicit tests, like --test. guess_tests is
|
|
51
|
+
# dropped rather than set to false: an explicit false is the deprecated
|
|
52
|
+
# opt-out and would warn.
|
|
53
|
+
@config = config.with({ "tests" => guess.specs }, except: ["guess_tests"])
|
|
54
|
+
@guessed_tests = guess.specs
|
|
55
|
+
guess.scope_message(since: config["since"])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# Deterministic 1-based k/n split of the planned candidate inventory.
|
|
9
|
+
# The key names the candidate, never its position, so shards stay stable
|
|
10
|
+
# when an earlier subject gains or loses a mutant.
|
|
11
|
+
class Shard
|
|
12
|
+
FORMAT = %r{\A(\d+)/(\d+)\z}
|
|
13
|
+
INVENTORY_JOINER = "\0"
|
|
14
|
+
|
|
15
|
+
attr_reader :index, :total, :all_keys, :selected_keys, :inventory_digest
|
|
16
|
+
|
|
17
|
+
def self.parse(value)
|
|
18
|
+
match = FORMAT.match(value.to_s)
|
|
19
|
+
index, total = match.captures.map(&:to_i) if match
|
|
20
|
+
return new(index, total) if match && index.positive? && total >= 2 && index <= total
|
|
21
|
+
|
|
22
|
+
raise ConfigError, "shard must be k/n with 1 <= k <= n and n >= 2 (for example 1/4)"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(index, total)
|
|
26
|
+
@index = index
|
|
27
|
+
@total = total
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns lists in the same shape as candidate_lists: [[mutation, text]] per subject.
|
|
31
|
+
def select(subjects, lists)
|
|
32
|
+
keyed = subjects.zip(lists).map do |subject, list|
|
|
33
|
+
keys = list.map { |mutation, _| key(subject, mutation) }
|
|
34
|
+
[keys, list]
|
|
35
|
+
end
|
|
36
|
+
@all_keys = keyed.flat_map(&:first)
|
|
37
|
+
@inventory_digest = Digest::SHA256.hexdigest(@all_keys.sort.join(INVENTORY_JOINER))
|
|
38
|
+
@selected_keys = @all_keys.select { |key| shard_for(key) == index - 1 }
|
|
39
|
+
selected = @selected_keys.to_set
|
|
40
|
+
keyed.map do |keys, list|
|
|
41
|
+
list.select.with_index { |_, position| selected.include?(keys[position]) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def planned_count = all_keys.size
|
|
46
|
+
def selected_count = selected_keys.size
|
|
47
|
+
|
|
48
|
+
def line
|
|
49
|
+
"Shard #{index}/#{total}: #{selected_count} of #{planned_count} planned mutants; " \
|
|
50
|
+
"inventory sha256:#{inventory_digest}; partial run"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_h
|
|
54
|
+
{ index: index, of: total, selected: selected_count, planned: planned_count,
|
|
55
|
+
inventoryDigest: inventory_digest, partial: true }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def key(subject, mutation)
|
|
61
|
+
"#{subject.id} #{mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def shard_for(key)
|
|
65
|
+
Digest::SHA256.digest(key).unpack1("Q>") % total
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|