saboteur 0.7.0 → 0.8.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 +657 -0
- data/README.md +198 -1077
- 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 +6 -2
- 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 +53 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +71 -54
- data/lib/saboteur/config.rb +98 -5
- data/lib/saboteur/config_file.rb +8 -0
- 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/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +9 -0
- data/lib/saboteur/engine/baseline_guard.rb +62 -14
- data/lib/saboteur/engine/boot.rb +4 -0
- data/lib/saboteur/engine/candidate_selection.rb +19 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +32 -10
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +18 -0
- 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 +35 -0
- data/lib/saboteur/engine/load_coverage.rb +45 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +107 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +27 -0
- data/lib/saboteur/engine/sandbox_policy.rb +38 -0
- data/lib/saboteur/engine/scheduler.rb +109 -3
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +295 -20
- data/lib/saboteur/engine/test_guess.rb +315 -25
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +100 -127
- data/lib/saboteur/execution/external_runner.rb +471 -0
- data/lib/saboteur/execution/marker_sweep.rb +79 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +12 -0
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +15 -2
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +27 -10
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -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 +135 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +26 -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 +108 -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 +3 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -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 +120 -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 +11 -0
- 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 +40 -7
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +105 -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 +13 -0
- metadata +35 -2
|
@@ -41,6 +41,33 @@ module Saboteur
|
|
|
41
41
|
config["max_duration"] && (@run_started + config["max_duration"])
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Once the baselines are timed, a cold coverage build is the last
|
|
45
|
+
# unpriced phase before the mutants: it costs about one more full pass
|
|
46
|
+
# and keeps nothing when the budget cuts it off, so a resume starts it
|
|
47
|
+
# from zero again. Refuse up front when the remaining budget cannot
|
|
48
|
+
# cover that pass, naming the shortfall and the remedies (#427).
|
|
49
|
+
def refuse_unaffordable_coverage_build(store, files, baseline_elapsed = @baseline_elapsed,
|
|
50
|
+
completed_passes = @completed_baseline_passes)
|
|
51
|
+
return unless config["max_duration"]
|
|
52
|
+
return if store.reusable?(files)
|
|
53
|
+
return if completed_passes.to_i.zero?
|
|
54
|
+
|
|
55
|
+
per_pass = baseline_elapsed / completed_passes
|
|
56
|
+
remaining = config["max_duration"] - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started)
|
|
57
|
+
# A spent budget is the existing stop path's news, and an untimed
|
|
58
|
+
# (zero-second) baseline carries no estimate to project from.
|
|
59
|
+
return if remaining <= 0 || per_pass <= 0
|
|
60
|
+
return if remaining >= per_pass
|
|
61
|
+
|
|
62
|
+
needed = per_pass - remaining
|
|
63
|
+
raise BudgetPhaseTimeout,
|
|
64
|
+
"the --max-duration budget cannot cover the coverage map build: baseline passes took about " \
|
|
65
|
+
"#{per_pass.round}s each, only #{remaining.round(1)}s remain, and a cold build needs about one " \
|
|
66
|
+
"more full pass and keeps nothing when it is cut off. Re-run with --max-duration about " \
|
|
67
|
+
"#{needed.round}s higher, fewer --baseline-passes, or once without --max-duration so the " \
|
|
68
|
+
"coverage cache is built for later --incremental runs"
|
|
69
|
+
end
|
|
70
|
+
|
|
44
71
|
# Boot and baselines can spend the budget by themselves: stop before
|
|
45
72
|
# an expensive cold coverage build and mark every selected subject
|
|
46
73
|
# unrun.
|
|
@@ -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
|
|
@@ -64,6 +65,43 @@ module Saboteur
|
|
|
64
65
|
->(keep_fds) { Execution::Sandbox.restrict!(writable: writable, keep_fds: keep_fds) }
|
|
65
66
|
end
|
|
66
67
|
|
|
68
|
+
# c.sandbox = "seatbelt" (the macOS opt-in, #436): the probe decides.
|
|
69
|
+
# Confine when Seatbelt truly enforces; a clean "no Seatbelt here"
|
|
70
|
+
# degrades to the guard and canary with a one-time note; anything
|
|
71
|
+
# else - an errored probe, or an API claiming success its profile
|
|
72
|
+
# cannot prove - refuses the run, exactly like the Landlock probe
|
|
73
|
+
# (#178): opting into a sandbox must never buy silent confinement
|
|
74
|
+
# theatre. Fail-closed lives one layer down too: a child whose
|
|
75
|
+
# profile refuses to apply raises instead of running the mutant.
|
|
76
|
+
def seatbelt_hook
|
|
77
|
+
case (verdict = Execution::Seatbelt.probe)
|
|
78
|
+
when :ok
|
|
79
|
+
writable = sandbox_writable_dirs
|
|
80
|
+
->(keep_fds) { Execution::Seatbelt.restrict!(writable: writable, keep_fds: keep_fds) }
|
|
81
|
+
when :unavailable
|
|
82
|
+
seatbelt_note("this macOS lacks Seatbelt")
|
|
83
|
+
nil
|
|
84
|
+
else
|
|
85
|
+
raise Error, "sandbox is \"seatbelt\" but the Seatbelt probe #{verdict_description(verdict)}; refusing to " \
|
|
86
|
+
"run unsandboxed - set c.sandbox = true to run with the hazard guard and canary only, " \
|
|
87
|
+
"or false to opt out"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def verdict_description(verdict)
|
|
92
|
+
case verdict
|
|
93
|
+
when :unenforced then "reported a profile that did not enforce - a macOS update may have changed it (#436)"
|
|
94
|
+
else "failed (#{verdict}) - #436 tracks the deprecation"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def seatbelt_note(reason)
|
|
99
|
+
return if @sandbox_note_given
|
|
100
|
+
|
|
101
|
+
@sandbox_note_given = true
|
|
102
|
+
@diagnostics.warn(%(sandbox is "seatbelt" but #{reason}; children run with the hazard guard and canary only))
|
|
103
|
+
end
|
|
104
|
+
|
|
67
105
|
# c.sandbox_writable adds the dirs a suite genuinely needs (db/, log/,
|
|
68
106
|
# ...): without a grant, writes there fail with EACCES in the child.
|
|
69
107
|
# 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)
|
|
@@ -85,7 +92,7 @@ module Saboteur
|
|
|
85
92
|
subject = plan.subject
|
|
86
93
|
check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
|
|
87
94
|
baseline_timeout: @config["baseline_timeout"])
|
|
88
|
-
final = check.call(results)
|
|
95
|
+
final = check.call(results).map { |result| retried_in_defined_order(plan, result) }
|
|
89
96
|
# Unverified verdicts are never cached: a budget-cut check proves
|
|
90
97
|
# nothing, and a stored copy would read as confirmed next run.
|
|
91
98
|
final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
|
|
@@ -96,6 +103,46 @@ module Saboteur
|
|
|
96
103
|
final
|
|
97
104
|
end
|
|
98
105
|
|
|
106
|
+
# A kill rejected because its covering examples fail with the method
|
|
107
|
+
# unchanged may only lack the setup examples the subset run skipped
|
|
108
|
+
# (an order-dependent file, #557). Retry once with the file's examples
|
|
109
|
+
# in defined order, through the last covering example. It stands only
|
|
110
|
+
# if that whole run passes unchanged; the mutant's verdict is then the
|
|
111
|
+
# mutant's run on the same examples. Otherwise the error stays.
|
|
112
|
+
#
|
|
113
|
+
# Cost bound: at most two extra forks per rejected kill, the unchanged
|
|
114
|
+
# prefix run and one mutant run. Both spend --max-duration like every
|
|
115
|
+
# other fork: neither starts once the budget is spent, and neither gets
|
|
116
|
+
# more than the time left. A retry the budget stops is unverified, not
|
|
117
|
+
# an error or a kill. Keep this bound if the retry ever grows.
|
|
118
|
+
def retried_in_defined_order(plan, result)
|
|
119
|
+
return result unless result.rejected_kill? && !result.reused? && result.covered_by.any?
|
|
120
|
+
|
|
121
|
+
tests = DefinedOrderPrefix.new(@map.tests).for(result.covered_by)
|
|
122
|
+
return result unless tests
|
|
123
|
+
|
|
124
|
+
unchanged = unchanged_runner(plan.subject, plan).call(tests, @config["baseline_timeout"])
|
|
125
|
+
return result.as_unverified(unchanged.message || BUDGET_RETRY_REASON) if unchanged.status == :unverified
|
|
126
|
+
return result unless unchanged.status == :survived
|
|
127
|
+
|
|
128
|
+
rerun_in_prefix(plan, result, tests)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def rerun_in_prefix(plan, result, tests)
|
|
132
|
+
return result.as_unverified(BUDGET_RETRY_REASON) if budget_spent?
|
|
133
|
+
|
|
134
|
+
full = timeout_for(tests, plan.slowdown)
|
|
135
|
+
capped = budget_capped(full)
|
|
136
|
+
outcome = @runner.run(subject: plan.subject, text: result.mutated_source, test_ids: tests,
|
|
137
|
+
timeout: capped, mutation: result.mutation, eval_prefix: eval_prefix(plan))
|
|
138
|
+
return result.as_unverified(BUDGET_RETRY_REASON) if outcome.status == :timeout && capped < full
|
|
139
|
+
|
|
140
|
+
MutantResult.new(subject: plan.subject, mutation: result.mutation, status: outcome.status,
|
|
141
|
+
mutated_source: result.mutated_source, reason: outcome.message, runtime: outcome.runtime,
|
|
142
|
+
killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
|
|
143
|
+
executed: outcome.executed_ids)
|
|
144
|
+
end
|
|
145
|
+
|
|
99
146
|
# Admission is per sequence, not just at checked() entry: a check
|
|
100
147
|
# admitted with time left can hit its capped timeout mid-check, and a
|
|
101
148
|
# later sequence can arrive after the budget is spent. Neither may
|
|
@@ -204,6 +251,20 @@ module Saboteur
|
|
|
204
251
|
end
|
|
205
252
|
end
|
|
206
253
|
|
|
254
|
+
# A def-time macro's only callers ran while the app loaded: the
|
|
255
|
+
# child re-evals the def, but closure consumers captured the
|
|
256
|
+
# pristine definition, so no test can observe a mutant (#409).
|
|
257
|
+
def load_only?(subject)
|
|
258
|
+
!subject.is_a?(ConstantSubject) && @map.called_only_at_load?(subject.file, subject.start_line)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def load_time_results(candidates, subject)
|
|
262
|
+
candidates.map do |mutation, text|
|
|
263
|
+
MutantResult.new(subject: subject, mutation: mutation, status: :ran_at_load, mutated_source: text,
|
|
264
|
+
reason: Selection::CoverageMap::RAN_AT_LOAD)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
207
268
|
# One mutation whose edited lines no test ran (see unloadable).
|
|
208
269
|
def uncoverable(plan, mutation, text)
|
|
209
270
|
line_numbers = mutation.edits.flat_map { |edit| plan.subject.coverage_lines_for(edit) }.uniq
|
|
@@ -219,7 +280,50 @@ module Saboteur
|
|
|
219
280
|
timeout = timeout_for(tests, plan.slowdown)
|
|
220
281
|
outcome = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: timeout,
|
|
221
282
|
mutation: mutation, eval_prefix: eval_prefix(plan))
|
|
222
|
-
outcome
|
|
283
|
+
outcome = calibrate(plan, mutation, text, tests, timeout) if outcome.status == :timeout
|
|
284
|
+
flaky_check(plan, mutation, text, tests, outcome)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# --flaky-check (#476): one reordered confirmation run per decisive
|
|
288
|
+
# fresh verdict. A disagreement is unverified - outside the score and
|
|
289
|
+
# never cached - not "rerun until it passes". A reused verdict is
|
|
290
|
+
# already trusted evidence and never reaches here. A budget-cut
|
|
291
|
+
# outcome is already unverified and earns no second fork.
|
|
292
|
+
def flaky_check(plan, mutation, text, tests, outcome)
|
|
293
|
+
return outcome unless flaky_checkable?(outcome)
|
|
294
|
+
return outcome if budget_spent?
|
|
295
|
+
|
|
296
|
+
timeout = timeout_for(tests, plan.slowdown)
|
|
297
|
+
|
|
298
|
+
rerun = @runner.run(subject: plan.subject, text: text, test_ids: reordered(tests),
|
|
299
|
+
timeout: timeout, mutation: mutation, eval_prefix: eval_prefix(plan))
|
|
300
|
+
return outcome if rerun.status == outcome.status
|
|
301
|
+
|
|
302
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime + rerun.runtime,
|
|
303
|
+
message: flaky_message(outcome, rerun))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# A shuffled order that is guaranteed to differ (a two-test shuffle
|
|
307
|
+
# has even odds of coming out identical): order-dependence is exactly
|
|
308
|
+
# what the confirmation run hunts.
|
|
309
|
+
def reordered(tests)
|
|
310
|
+
shuffled = tests.shuffle(random: Random.new(@config["seed"]))
|
|
311
|
+
shuffled == tests && shuffled.size > 1 ? shuffled.rotate : shuffled
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def flaky_checkable?(outcome)
|
|
315
|
+
case @config["flaky_check"]
|
|
316
|
+
when "survivors" then outcome.status == :survived
|
|
317
|
+
when "decisive" then %i[survived killed].include?(outcome.status)
|
|
318
|
+
else false
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def flaky_message(outcome, rerun)
|
|
323
|
+
decisive = (outcome.failed_ids | rerun.failed_ids).first(3)
|
|
324
|
+
named = decisive.empty? ? "" : " (decisive tests: #{decisive.join(', ')})"
|
|
325
|
+
"flaky verdict: first #{outcome.status}, then #{rerun.status} on a reordered confirmation " \
|
|
326
|
+
"run#{named} - the mutant is unverified, not #{outcome.status}"
|
|
223
327
|
end
|
|
224
328
|
|
|
225
329
|
# Each mutant is checked separately: changing a body can change which
|
|
@@ -329,6 +433,8 @@ module Saboteur
|
|
|
329
433
|
# sig is one, see Execution::Wrapper) is not a shadowing - the child
|
|
330
434
|
# re-evaluates it together with the def, so the check passes.
|
|
331
435
|
def load_problem(subject)
|
|
436
|
+
return [nil, nil] unless @verify_loaded
|
|
437
|
+
|
|
332
438
|
return [nil, nil] if @reevaluator.whole_file?(subject)
|
|
333
439
|
|
|
334
440
|
owner = @reevaluator.resolve_owner(subject)
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
def selection_message(guess)
|
|
14
|
+
if guess.changed.zero?
|
|
15
|
+
target = config["since"] ? "no changed source file" : "no source file"
|
|
16
|
+
"inferred specs: #{target} to infer a spec for, so the whole suite runs"
|
|
17
|
+
elsif guess.specs.empty?
|
|
18
|
+
guess.no_specs_message(since: config["since"])
|
|
19
|
+
else
|
|
20
|
+
scope_to_guesses(guess)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def announce_selection(guess)
|
|
25
|
+
if guess.changed.zero? || guess.specs.any?
|
|
26
|
+
@diagnostics.announce(selection_message(guess))
|
|
27
|
+
else
|
|
28
|
+
@diagnostics.inform(selection_message(guess))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A spec file the diff touched that cannot run is named, also before a
|
|
33
|
+
# stop on an unmatched file: --test, the way out, never reads the diff.
|
|
34
|
+
def inform_skipped_specs(guess) = (@diagnostics.inform(guess.skipped_message) if guess.skipped_specs.any?)
|
|
35
|
+
|
|
36
|
+
# Stop before boot: name the spec-less files and the ways out. The
|
|
37
|
+
# hinted spec paths are the conventional ones - they do not exist (that
|
|
38
|
+
# is why the files are unmatched), so the message never prints them as
|
|
39
|
+
# a runnable --test command, and it never reconstructs the source
|
|
40
|
+
# scope: the user appends --test or --all to their own command. The
|
|
41
|
+
# example is still shell-safe: the ref is user input, so it is quoted
|
|
42
|
+
# and pinned to the flag with = (a ref like "-x" must not become a
|
|
43
|
+
# switch).
|
|
44
|
+
def scope_to_guesses(guess)
|
|
45
|
+
# The guesses now stand as explicit tests, like --test. guess_tests is
|
|
46
|
+
# dropped rather than set to false: an explicit false is the deprecated
|
|
47
|
+
# opt-out and would warn.
|
|
48
|
+
@config = config.with({ "tests" => guess.specs }, except: ["guess_tests"])
|
|
49
|
+
@guessed_tests = guess.specs
|
|
50
|
+
guess.scope_message(since: config["since"])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
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
|