saboteur 0.6.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 +1024 -0
- data/README.md +199 -952
- 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 +80 -4
- data/lib/saboteur/cli/help_options.rb +17 -0
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +40 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- 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 +136 -98
- data/lib/saboteur/config.rb +163 -15
- 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/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/dirty_tree_warning.rb +128 -0
- data/lib/saboteur/doctor.rb +49 -1
- data/lib/saboteur/engine/baseline_guard.rb +136 -14
- data/lib/saboteur/engine/boot.rb +16 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +28 -1
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +79 -15
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +41 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +134 -8
- data/lib/saboteur/engine/early_stop_recording.rb +47 -0
- 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 +67 -9
- data/lib/saboteur/engine/run_budget.rb +90 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +56 -11
- data/lib/saboteur/engine/scheduler.rb +222 -20
- data/lib/saboteur/engine/selection_announcement.rb +54 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +373 -19
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +438 -25
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +202 -158
- 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/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +68 -12
- data/lib/saboteur/execution/runner.rb +225 -24
- data/lib/saboteur/execution/sandbox.rb +21 -3
- data/lib/saboteur/execution/seatbelt.rb +226 -0
- data/lib/saboteur/execution/supervisor.rb +45 -13
- data/lib/saboteur/execution/worker_pool.rb +110 -8
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +108 -13
- data/lib/saboteur/ignore_list.rb +40 -7
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +175 -9
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/builtin.rb +44 -14
- 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 +129 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +193 -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_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +6 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +7 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- 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 +122 -13
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +47 -15
- data/lib/saboteur/reporter/agent_incomplete.rb +38 -2
- 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/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +40 -7
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +74 -17
- data/lib/saboteur/result.rb +38 -7
- data/lib/saboteur/selection/coverage_map.rb +107 -11
- 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 +105 -13
- 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/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +53 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +29 -1
- metadata +50 -2
data/lib/saboteur/doctor.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
require "coverage"
|
|
5
5
|
require_relative "baseline_health"
|
|
6
|
+
require_relative "engine/sandbox_policy"
|
|
7
|
+
require_relative "execution/sandbox"
|
|
6
8
|
|
|
7
9
|
module Saboteur
|
|
8
10
|
# Reports the current environment, never mutations. The two unchanged suite
|
|
@@ -27,7 +29,8 @@ module Saboteur
|
|
|
27
29
|
@out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
|
|
28
30
|
check_database_hosts
|
|
29
31
|
@out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
|
|
30
|
-
|
|
32
|
+
verify_coverage
|
|
33
|
+
check_sandbox
|
|
31
34
|
check_suite(integration, hooks)
|
|
32
35
|
true
|
|
33
36
|
end
|
|
@@ -63,15 +66,60 @@ module Saboteur
|
|
|
63
66
|
"doctor supports the rspec integration; set integration: rspec"
|
|
64
67
|
end
|
|
65
68
|
|
|
69
|
+
# Printed once; verified twice. Boot must not wake coverage up, so the
|
|
70
|
+
# post-boot check re-reads the state without printing a second line.
|
|
66
71
|
def check_coverage
|
|
67
72
|
state = Coverage.state
|
|
68
73
|
@out.puts "Coverage: #{state}"
|
|
74
|
+
verify_coverage(state)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def verify_coverage(state = Coverage.state)
|
|
69
78
|
return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
|
|
70
79
|
|
|
71
80
|
raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
|
|
72
81
|
"executable, disable SimpleCov and remove other Coverage.start calls"
|
|
73
82
|
end
|
|
74
83
|
|
|
84
|
+
# A kernel capability probe, not verified isolation: what the kernel
|
|
85
|
+
# would let mutant children do, and what the run does with that - the
|
|
86
|
+
# engine's sandbox policy owns the refusal or degradation itself.
|
|
87
|
+
def check_sandbox
|
|
88
|
+
if @config["sandbox"] == false
|
|
89
|
+
@out.puts "Sandbox: off (c.sandbox = false)"
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
abi = sandbox_abi
|
|
93
|
+
if abi >= 3
|
|
94
|
+
@out.puts "Sandbox: kernel Landlock ABI #{abi} - capability probe only, not verified isolation"
|
|
95
|
+
elsif @config["sandbox"] == "strict"
|
|
96
|
+
@out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
|
|
97
|
+
'c.sandbox = "strict" refuses to run - children never run degraded'
|
|
98
|
+
else
|
|
99
|
+
@out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
|
|
100
|
+
"children would run with the hazard guard and canary only"
|
|
101
|
+
if RUBY_PLATFORM.match?(/darwin/i) && @config["sandbox"] == "seatbelt"
|
|
102
|
+
@out.puts %(Seatbelt (opt-in, #436): probe #{Execution::Seatbelt.probe} - capability probe only, ) \
|
|
103
|
+
"not verified isolation"
|
|
104
|
+
elsif RUBY_PLATFORM.match?(/darwin/i)
|
|
105
|
+
@out.puts "macOS: mutants run unsandboxed (Landlock is Linux-only) - a container contains " \
|
|
106
|
+
"filesystem writes outside the project"
|
|
107
|
+
@out.puts "Network: never restricted on any platform, and a plain container keeps outbound " \
|
|
108
|
+
"access - deny or stub the network when a mutant could reach real APIs"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The probe contract the engine's policy already keeps: an errored probe
|
|
114
|
+
# (EPERM under a seccomp policy, ...) is an actionable tool error, not a
|
|
115
|
+
# CLI internal error - doctor stays read-only.
|
|
116
|
+
def sandbox_abi
|
|
117
|
+
Execution::Sandbox.abi_version
|
|
118
|
+
rescue SystemCallError => e
|
|
119
|
+
raise Error, "sandbox is on but the Landlock probe failed (#{e.message}); a run would refuse the same way - " \
|
|
120
|
+
"set c.sandbox = false to check without the sandbox"
|
|
121
|
+
end
|
|
122
|
+
|
|
75
123
|
# Match against all included source definitions, not the current diff or
|
|
76
124
|
# selected tests. A limited run must not make a valid ignore look stale.
|
|
77
125
|
def check_ignore_subjects
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require_relative "../text"
|
|
7
|
+
require_relative "../frameworks/sqlite_memory_database"
|
|
6
8
|
|
|
7
9
|
module Saboteur
|
|
8
10
|
class Engine
|
|
@@ -10,16 +12,28 @@ module Saboteur
|
|
|
10
12
|
# or mutation source is used. This catches observed instability, not all
|
|
11
13
|
# possible flaky behaviour.
|
|
12
14
|
class BaselineGuard
|
|
15
|
+
# How many unchanged-suite checks actually completed and validated:
|
|
16
|
+
# the number of order comparisons a run can claim is one less.
|
|
17
|
+
attr_reader :completed_passes
|
|
18
|
+
|
|
13
19
|
EXCERPT_LINES = 10
|
|
14
20
|
EXCERPT_WIDTH = 200
|
|
15
21
|
EXCERPT_SCAN_LINES = 5000
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
# full_passes: every full-suite pass the run will make, including the
|
|
24
|
+
# coverage build when the cache is cold, so the first timed pass can
|
|
25
|
+
# price the rest (#260).
|
|
26
|
+
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, full_passes: 2, between_passes: nil,
|
|
27
|
+
timeout_cap: nil)
|
|
18
28
|
@config = config
|
|
19
29
|
@integration = integration
|
|
20
30
|
@hooks = hooks
|
|
21
31
|
@supervisor = supervisor
|
|
22
32
|
@diagnostics = diagnostics
|
|
33
|
+
@full_passes = full_passes
|
|
34
|
+
@between_passes = between_passes
|
|
35
|
+
@timeout_cap = timeout_cap
|
|
36
|
+
@completed_passes = 0
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def call
|
|
@@ -27,40 +41,126 @@ module Saboteur
|
|
|
27
41
|
raise Error, "baseline has no tests. Check rspec_args and the suite filters before running saboteur again." if
|
|
28
42
|
expected.empty?
|
|
29
43
|
|
|
30
|
-
|
|
44
|
+
passes = @config["baseline_passes"]
|
|
45
|
+
@diagnostics.inform(announce(passes))
|
|
31
46
|
previous = nil
|
|
32
|
-
|
|
47
|
+
passes.times do |index|
|
|
48
|
+
break if budget_spent?(index)
|
|
49
|
+
|
|
33
50
|
number = index + 1
|
|
51
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
52
|
data = check(number)
|
|
53
|
+
announce_first_pass_cost(number, started)
|
|
35
54
|
unless data["passed"] && data["failed"].empty?
|
|
36
55
|
refuse(number,
|
|
37
|
-
"the suite failed: #{Array(data['failed']).join(', ')}")
|
|
56
|
+
with_hint("the suite failed: #{Array(data['failed']).join(', ')}", data))
|
|
38
57
|
end
|
|
39
58
|
executed = data["executed"]
|
|
40
59
|
refuse(number, "executed tests do not match the loaded suite") unless
|
|
41
60
|
executed.sort == expected.sort
|
|
42
61
|
refuse(number, "test order changed between baseline checks") if previous && executed != previous
|
|
43
62
|
previous = executed
|
|
63
|
+
@completed_passes += 1
|
|
44
64
|
end
|
|
45
65
|
nil
|
|
46
66
|
end
|
|
47
67
|
|
|
48
68
|
private
|
|
49
69
|
|
|
70
|
+
# A spent --max-duration budget skips the remaining passes - including
|
|
71
|
+
# the first, when boot alone already spent it (#245).
|
|
72
|
+
def budget_spent?(_index)
|
|
73
|
+
!@between_passes.nil? && @between_passes.call
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def announce(passes)
|
|
77
|
+
case passes
|
|
78
|
+
when 1
|
|
79
|
+
"checking the unchanged suite once (seed #{@config['seed']}); " \
|
|
80
|
+
"order stability between passes is not checked (baseline_passes: 1)"
|
|
81
|
+
when 2
|
|
82
|
+
"checking the unchanged suite twice (seed #{@config['seed']})"
|
|
83
|
+
else
|
|
84
|
+
"checking the unchanged suite #{passes} times (seed #{@config['seed']})"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Once the first pass is timed, the total cost stops being a guess:
|
|
89
|
+
# the remaining full passes cost roughly the same each. full_passes
|
|
90
|
+
# includes the cold-cache coverage build (#260), so name what actually
|
|
91
|
+
# remains: with baseline_passes 1 the one remaining pass is the coverage
|
|
92
|
+
# build, not another baseline check (#465).
|
|
93
|
+
def announce_first_pass_cost(number, started)
|
|
94
|
+
return unless number == 1
|
|
95
|
+
|
|
96
|
+
coverage_pending = @full_passes > @config["baseline_passes"]
|
|
97
|
+
remaining = @full_passes - number - (coverage_pending ? 1 : 0)
|
|
98
|
+
return if remaining.zero? && !coverage_pending
|
|
99
|
+
|
|
100
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round
|
|
101
|
+
work = []
|
|
102
|
+
work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
|
|
103
|
+
work << "the coverage map build" if coverage_pending
|
|
104
|
+
total = remaining + (coverage_pending ? 1 : 0)
|
|
105
|
+
@diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
|
|
106
|
+
"#{work.join(' and ')} at that rate")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# An admitted pass still gets only the time left in the run budget:
|
|
110
|
+
# without the cap it would overrun a spent --max-duration by its
|
|
111
|
+
# whole coverage_timeout.
|
|
112
|
+
def phase_timeout
|
|
113
|
+
@timeout_cap ? @timeout_cap.call(@config["coverage_timeout"]) : @config["coverage_timeout"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A pass that hit its deadline only because --max-duration shortened
|
|
117
|
+
# the admission below the configured phase timeout: the budget ended
|
|
118
|
+
# the run, the suite is not broken (#296).
|
|
119
|
+
def budget_stopped?(result, admitted)
|
|
120
|
+
result.stopped_by == :timeout && admitted < @config["coverage_timeout"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def refuse_budget_stop(number, admitted)
|
|
124
|
+
raise BudgetPhaseTimeout, "the --max-duration budget stopped baseline check #{number} " \
|
|
125
|
+
"(admitted #{admitted.round(1)}s of the #{@config['coverage_timeout']}s " \
|
|
126
|
+
"phase timeout)"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A containment limit stopping a healthy suite means the limit, not
|
|
130
|
+
# the suite: name the knob and its current value, like the sqlite
|
|
131
|
+
# :memory: hint (#387), so the user is not sent debugging a green
|
|
132
|
+
# suite (#459).
|
|
133
|
+
def stopped_reason(result)
|
|
134
|
+
reason = "the suite stopped: #{result.stopped_by} (#{result.detail})"
|
|
135
|
+
case result.stopped_by
|
|
136
|
+
when :processes
|
|
137
|
+
"#{reason}. If this suite forks heavily by design, raise the limit with " \
|
|
138
|
+
"c.max_processes (currently #{@config['max_processes']})"
|
|
139
|
+
when :memory
|
|
140
|
+
"#{reason}. If this suite legitimately needs more memory, raise the limit with " \
|
|
141
|
+
"c.max_memory_mb (currently #{@config['max_memory_mb']} MB)"
|
|
142
|
+
else
|
|
143
|
+
reason
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
50
147
|
def check(number)
|
|
51
148
|
Execution::ForkReadiness.prepare_heap
|
|
52
149
|
heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
|
|
53
|
-
|
|
150
|
+
admitted = phase_timeout
|
|
151
|
+
result = @supervisor.run(timeout: admitted, on_tick: heartbeat.method(:tick),
|
|
152
|
+
label: "baseline child died during setup/run") do |writer|
|
|
54
153
|
record(writer, number)
|
|
55
154
|
end
|
|
56
|
-
|
|
155
|
+
refuse_budget_stop(number, admitted) if budget_stopped?(result, admitted)
|
|
156
|
+
refuse(number, stopped_reason(result)) if result.stopped_by
|
|
57
157
|
# Before the payload checks: a child killed by a signal (the OOM
|
|
58
158
|
# killer, say) writes nothing, and the signal is the explanation. A
|
|
59
159
|
# child the supervisor itself killed after it had reported is not a
|
|
60
160
|
# failure: its payload is checked below.
|
|
61
161
|
refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
|
|
62
162
|
refuse(number, "the child did not report a result") if result.payload.empty?
|
|
63
|
-
refuse(number,
|
|
163
|
+
refuse(number, leftover_message(result), excerpt: false) if result.leftover_pids.any?
|
|
64
164
|
parse_result(number, result.payload)
|
|
65
165
|
end
|
|
66
166
|
|
|
@@ -78,7 +178,7 @@ module Saboteur
|
|
|
78
178
|
def parse_result(number, payload)
|
|
79
179
|
data = JSON.parse(payload)
|
|
80
180
|
refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
|
|
81
|
-
refuse(number, data["error"]) if data["error"]
|
|
181
|
+
refuse(number, with_hint(data["error"], data)) if data["error"]
|
|
82
182
|
refuse(number, "the child reported invalid test results") unless valid_result?(data)
|
|
83
183
|
data
|
|
84
184
|
rescue JSON::ParserError
|
|
@@ -99,21 +199,42 @@ module Saboteur
|
|
|
99
199
|
@hooks.run(:after_fork)
|
|
100
200
|
Kernel.srand(@config["seed"])
|
|
101
201
|
result = @integration.run_baseline(seed: @config["seed"])
|
|
102
|
-
|
|
202
|
+
payload = { passed: result.passed, failed: result.failed_ids, executed: result.executed_ids }
|
|
203
|
+
payload[:hint] = Frameworks::SqliteMemoryDatabase.fork_loss_hint unless result.passed
|
|
204
|
+
writer.write(JSON.generate(payload))
|
|
103
205
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
104
|
-
writer.write(JSON.generate(error: "#{e.class}: #{e.message}"
|
|
206
|
+
writer.write(JSON.generate(error: "#{e.class}: #{e.message}",
|
|
207
|
+
hint: Frameworks::SqliteMemoryDatabase.fork_loss_hint))
|
|
105
208
|
ensure
|
|
106
209
|
writer.close
|
|
107
210
|
end
|
|
108
211
|
|
|
212
|
+
# A child that recognises its own failure shape (an empty sqlite
|
|
213
|
+
# :memory: database after the fork, #387) says how to fix it.
|
|
214
|
+
def with_hint(reason, data)
|
|
215
|
+
data["hint"] ? "#{reason}. #{data['hint']}" : reason
|
|
216
|
+
end
|
|
217
|
+
|
|
109
218
|
def log_path(number)
|
|
110
219
|
File.join(@config.path("cache_dir"), "baseline-#{number}.log")
|
|
111
220
|
end
|
|
112
221
|
|
|
113
|
-
|
|
222
|
+
# Leftover children are a pass-with-leaks verdict, not a suite
|
|
223
|
+
# failure: the message names the leaked processes (the supervisor's
|
|
224
|
+
# pre-kill notes) and skips the log excerpt, which holds only RSpec
|
|
225
|
+
# output and can never show them (#486).
|
|
226
|
+
def leftover_message(result)
|
|
227
|
+
notes = result.leftover_notes
|
|
228
|
+
detail = notes.empty? ? result.leftover_pids.join(", ") : notes.join(", ")
|
|
229
|
+
count = result.leftover_pids.size
|
|
230
|
+
"the suite passed but left #{count} child #{count == 1 ? 'process' : 'processes'} behind: #{detail}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def refuse(number, reason, excerpt: true)
|
|
114
234
|
raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
|
|
115
|
-
"#{failure_excerpt(number)}See the full log at #{log_path(number)}.
|
|
116
|
-
"setup, then run saboteur again with the same seed.
|
|
235
|
+
"#{failure_excerpt(number) if excerpt}See the full log at #{log_path(number)}. " \
|
|
236
|
+
"Fix the suite or its setup, then run saboteur again with the same seed. " \
|
|
237
|
+
"No mutants were started."
|
|
117
238
|
end
|
|
118
239
|
|
|
119
240
|
# The first lines of the failure, inline, so the cause is readable
|
|
@@ -129,7 +250,8 @@ module Saboteur
|
|
|
129
250
|
end
|
|
130
251
|
|
|
131
252
|
def log_lines(number)
|
|
132
|
-
File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES)
|
|
253
|
+
File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES)
|
|
254
|
+
.map { |line| Text.utf8(line) }.reject { |line| line.strip.empty? }
|
|
133
255
|
rescue SystemCallError
|
|
134
256
|
[]
|
|
135
257
|
end
|
data/lib/saboteur/engine/boot.rb
CHANGED
|
@@ -10,6 +10,10 @@ module Saboteur
|
|
|
10
10
|
# A test suite that (perhaps only under a mutant) starts a saboteur run
|
|
11
11
|
# would otherwise run saboteur inside a saboteur child, recursively.
|
|
12
12
|
def self.refuse_recursion!
|
|
13
|
+
if ENV[Execution::ExternalRunner::CHILD_MARKER]
|
|
14
|
+
raise Error, "refusing to start: this process runs inside a saboteur external test command " \
|
|
15
|
+
"(#{Execution::ExternalRunner::CHILD_MARKER} is set); a nested run could race its tree"
|
|
16
|
+
end
|
|
13
17
|
return unless ENV[Execution::Supervisor::CHILD_ENV]
|
|
14
18
|
|
|
15
19
|
raise Error, "refusing to start: this process is a saboteur child " \
|
|
@@ -83,10 +87,21 @@ module Saboteur
|
|
|
83
87
|
|
|
84
88
|
def load_hooks
|
|
85
89
|
Saboteur.current_hooks = @hooks
|
|
86
|
-
@config["hooks"].each { |file| load
|
|
90
|
+
@config["hooks"].each { |file| load hook_path(file) }
|
|
87
91
|
ensure
|
|
88
92
|
Saboteur.current_hooks = nil
|
|
89
93
|
end
|
|
94
|
+
|
|
95
|
+
# Kernel#load does not append .rb the way Kernel#require does, and
|
|
96
|
+
# doctor accepts the extensionless form (#257), so resolve it the same
|
|
97
|
+
# way here: the exact path first, then path.rb.
|
|
98
|
+
def hook_path(file)
|
|
99
|
+
path = File.expand_path(file, @root)
|
|
100
|
+
rb = "#{path}.rb"
|
|
101
|
+
return rb if !File.exist?(path) && File.extname(path).empty? && File.file?(rb)
|
|
102
|
+
|
|
103
|
+
path
|
|
104
|
+
end
|
|
90
105
|
end
|
|
91
106
|
end
|
|
92
107
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "open3"
|
|
5
|
+
require_relative "../text"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
class Engine
|
|
@@ -15,7 +16,9 @@ module Saboteur
|
|
|
15
16
|
GIT_DIR = ".git"
|
|
16
17
|
|
|
17
18
|
def initialize(root, cache_dir: ".saboteur")
|
|
18
|
-
|
|
19
|
+
# Retag losslessly: path bytes are identity here, and later
|
|
20
|
+
# File.join calls must not mix encodings.
|
|
21
|
+
@root = Text.utf8_identity(root.to_s)
|
|
19
22
|
@cache_dir = cache_dir.to_s
|
|
20
23
|
@snapshot = inventory
|
|
21
24
|
end
|
|
@@ -29,16 +32,40 @@ module Saboteur
|
|
|
29
32
|
|
|
30
33
|
of = " of #{subject.id}" if subject
|
|
31
34
|
culprit = digest ? " The most recent mutant, #{digest}#{of}, is the likely cause." : ""
|
|
32
|
-
raise Saboteur::CanaryTripped,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
raise Saboteur::CanaryTripped, message(missing, culprit)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The cheap per-mutant probe (#235): stats the snapshot paths only -
|
|
39
|
+
# no git, no glob - so it can run after every mutant.
|
|
40
|
+
def probe
|
|
41
|
+
@snapshot.reject { |rel| present?(rel) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Like check!, but for the per-mutant probe: the mutant whose run just
|
|
45
|
+
# ended is named, never the subject's most recent mutant.
|
|
46
|
+
def probe!(subject: nil, digest: nil)
|
|
47
|
+
missing = probe
|
|
48
|
+
return if missing.empty?
|
|
49
|
+
|
|
50
|
+
of = " of #{subject.id}" if subject
|
|
51
|
+
culprit = digest ? " The mutant that just ran, #{digest}#{of}, is the likely cause." : ""
|
|
52
|
+
raise Saboteur::CanaryTripped.new(message(missing, culprit), culprit_digest: digest)
|
|
38
53
|
end
|
|
39
54
|
|
|
40
55
|
private
|
|
41
56
|
|
|
57
|
+
def present?(rel)
|
|
58
|
+
File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def message(missing, culprit)
|
|
62
|
+
"canary: #{missing.size} watched file(s) vanished from the working tree while mutants ran " \
|
|
63
|
+
"(first: #{Text.utf8(missing.first)}).#{culprit} The canary watches tracked files and .git presence " \
|
|
64
|
+
"and only detects the loss after the fact; it cannot restore anything. Recover from your " \
|
|
65
|
+
"own backup, a fresh checkout, or a reclone - a deleted .git or uncommitted work is gone " \
|
|
66
|
+
"unless you have another copy."
|
|
67
|
+
end
|
|
68
|
+
|
|
42
69
|
def inventory
|
|
43
70
|
files = git? ? git_tracked_files : walk_files
|
|
44
71
|
files << GIT_DIR if File.exist?(File.join(@root, GIT_DIR))
|
|
@@ -54,15 +81,19 @@ module Saboteur
|
|
|
54
81
|
return walk_files unless status.success?
|
|
55
82
|
|
|
56
83
|
# ls-files reads the index, which still names a deleted file; keep
|
|
57
|
-
# only paths present in the worktree so a deletion shows up.
|
|
58
|
-
|
|
84
|
+
# only paths present in the worktree so a deletion shows up. Split
|
|
85
|
+
# the raw bytes first and retag each path losslessly: scrubbing a
|
|
86
|
+
# raw-byte name would make the File.exist? probe fail and drop the
|
|
87
|
+
# file from the watch, so its deletion would go unseen.
|
|
88
|
+
out.b.split("\0".b).map { |rel| Text.utf8_identity(rel) }
|
|
89
|
+
.reject { |rel| cache_path?(rel) }
|
|
59
90
|
.select { |rel| File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel)) }
|
|
60
91
|
end
|
|
61
92
|
|
|
62
93
|
def walk_files
|
|
63
94
|
Dir.glob(File.join(@root, "**", "*"), File::FNM_DOTMATCH)
|
|
64
95
|
.select { |path| File.file?(path) }
|
|
65
|
-
.map { |path| path.delete_prefix("#{@root}/") }
|
|
96
|
+
.map { |path| Text.utf8_identity(path).delete_prefix("#{@root}/") }
|
|
66
97
|
.reject { |rel| rel.start_with?("#{GIT_DIR}/") || cache_path?(rel) }
|
|
67
98
|
end
|
|
68
99
|
|
|
@@ -6,7 +6,7 @@ module Saboteur
|
|
|
6
6
|
# Selects a bounded list for each covered subject before workers fork.
|
|
7
7
|
# Subject skips are not mutants and do not enter the mutation score.
|
|
8
8
|
class CandidateSelection
|
|
9
|
-
attr_reader :skipped_subjects, :selected
|
|
9
|
+
attr_reader :skipped_subjects, :selected, :unscoped_digests
|
|
10
10
|
|
|
11
11
|
def initialize(candidates:, map:, limit:, diagnostics:)
|
|
12
12
|
@candidates = candidates
|
|
@@ -15,16 +15,28 @@ module Saboteur
|
|
|
15
15
|
@diagnostics = diagnostics
|
|
16
16
|
@skipped_subjects = {}
|
|
17
17
|
@selected = {}
|
|
18
|
+
@unscoped_digests = {}
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def for(subject, scope: nil)
|
|
21
22
|
reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
|
|
22
23
|
if reason
|
|
23
24
|
@skipped_subjects[subject] = reason
|
|
25
|
+
# In a scoped run every subject is part of the change under
|
|
26
|
+
# review, so a skip is said up front, not only in the end-of-run
|
|
27
|
+
# summary (#574).
|
|
28
|
+
@diagnostics.announce(skip_announcement(subject, reason)) if scope
|
|
24
29
|
return @selected[subject] = []
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
list = @candidates.for(subject)
|
|
33
|
+
# The full pre-scope digests let IgnoreList#apply tell "mutation gone"
|
|
34
|
+
# from "mutation scoped out of this run" (#284).
|
|
35
|
+
if scope
|
|
36
|
+
@unscoped_digests[subject.id] = list.map do |mutation, _|
|
|
37
|
+
mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
28
40
|
list = scope.restrict(subject, list) if scope
|
|
29
41
|
omitted = list.size - @limit
|
|
30
42
|
if omitted.positive?
|
|
@@ -36,6 +48,21 @@ module Saboteur
|
|
|
36
48
|
|
|
37
49
|
private
|
|
38
50
|
|
|
51
|
+
# A scoped no-coverage skip names the specs the run loaded: coverage
|
|
52
|
+
# living in a sibling spec file is otherwise invisible, and the false
|
|
53
|
+
# read is "untestable" when the truth is "not loaded" (#636). Names
|
|
54
|
+
# at most five, like the other selection notes.
|
|
55
|
+
def skip_announcement(subject, reason)
|
|
56
|
+
line = "#{subject.id}: skipped (#{reason}); its change is not verified by this run"
|
|
57
|
+
return line unless reason == "no coverage"
|
|
58
|
+
|
|
59
|
+
loaded = @map.tests.map { |id| id.split("[").first.delete_prefix("./") }.uniq.sort
|
|
60
|
+
more = loaded.size > 5 ? ", ..." : ""
|
|
61
|
+
names = loaded.empty? ? "none" : loaded.first(5).join(", ")
|
|
62
|
+
"#{line} - loaded specs: #{names}#{more}; " \
|
|
63
|
+
"other spec files may cover it, pass --test to load them"
|
|
64
|
+
end
|
|
65
|
+
|
|
39
66
|
# Load-only code is not "no coverage": it ran, just never inside a
|
|
40
67
|
# test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
|
|
41
68
|
def skip_reason_for(subject)
|
|
@@ -10,6 +10,25 @@ module Saboteur
|
|
|
10
10
|
# a subject into runnable candidates: applied, re-parsed, de-duplicated,
|
|
11
11
|
# coarse first. Returns [[mutation, mutated_text]].
|
|
12
12
|
class Candidates
|
|
13
|
+
# The generator for constant hash/array literal subjects (#306); the
|
|
14
|
+
# method operators walk a def body a constant does not have.
|
|
15
|
+
def self.build(config, diagnostics, hazard_guard:)
|
|
16
|
+
new(build_sources(config), diagnostics, hazard_guard: hazard_guard,
|
|
17
|
+
constant_source: build_constant_source(config))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Constants are generated by the builtin operator family, so a
|
|
21
|
+
# config that drops "builtin" from c.sources drops constant mutation
|
|
22
|
+
# too: the configured sources are the boundary for every subject
|
|
23
|
+
# kind (#319). Custom sources never see constants.
|
|
24
|
+
def self.build_constant_source(config)
|
|
25
|
+
return nil unless config["sources"].include?("builtin")
|
|
26
|
+
|
|
27
|
+
MutationSource::ConstantLiterals.new(operators: config["operators"],
|
|
28
|
+
exclude_operators: config["exclude_operators"],
|
|
29
|
+
arid: config["arid"])
|
|
30
|
+
end
|
|
31
|
+
|
|
13
32
|
def self.build_sources(config)
|
|
14
33
|
root = File.realpath(config.root)
|
|
15
34
|
config["sources"].map do |entry|
|
|
@@ -19,15 +38,26 @@ module Saboteur
|
|
|
19
38
|
end
|
|
20
39
|
|
|
21
40
|
file, const = entry.split(":", 2)
|
|
22
|
-
|
|
41
|
+
unless const
|
|
42
|
+
if entry.include?("*")
|
|
43
|
+
raise ConfigError, "mutation source #{entry.inspect} looks like a source glob; " \
|
|
44
|
+
"did you mean c.include? c.sources registers custom operator sources " \
|
|
45
|
+
"(builtin or path.rb:ClassName)"
|
|
46
|
+
end
|
|
47
|
+
raise ConfigError, "mutation source #{entry.inspect} must be builtin or path.rb:ClassName"
|
|
48
|
+
end
|
|
23
49
|
|
|
24
50
|
require File.expand_path(file, root)
|
|
25
51
|
Object.const_get(const).new
|
|
26
52
|
end
|
|
27
53
|
end
|
|
28
54
|
|
|
29
|
-
|
|
55
|
+
# constant_source: the generator for ConstantSubject instances; nil
|
|
56
|
+
# leaves constants with no candidates (a safe empty list).
|
|
57
|
+
def initialize(sources, diagnostics, hazard_guard: nil, bytecode: BytecodeEquivalence.new,
|
|
58
|
+
constant_source: nil)
|
|
30
59
|
@sources = sources
|
|
60
|
+
@constant_source = constant_source
|
|
31
61
|
@diagnostics = diagnostics
|
|
32
62
|
@hazard_guard = hazard_guard
|
|
33
63
|
@bytecode = bytecode
|
|
@@ -40,7 +70,7 @@ module Saboteur
|
|
|
40
70
|
end
|
|
41
71
|
|
|
42
72
|
seen = Set[subject.source]
|
|
43
|
-
list =
|
|
73
|
+
list = sources_for(subject).flat_map { |source| proposals(source, subject) }.filter_map do |mutation|
|
|
44
74
|
candidate(subject, mutation, seen)
|
|
45
75
|
end
|
|
46
76
|
list = suppress_inline(list, subject)
|
|
@@ -50,6 +80,15 @@ module Saboteur
|
|
|
50
80
|
|
|
51
81
|
private
|
|
52
82
|
|
|
83
|
+
# Constant literals have their own generator; external custom sources
|
|
84
|
+
# implement the def-subject contract and never see constants (#306).
|
|
85
|
+
def sources_for(subject)
|
|
86
|
+
return @sources unless subject.is_a?(ConstantSubject)
|
|
87
|
+
return [] unless @constant_source
|
|
88
|
+
|
|
89
|
+
[@constant_source]
|
|
90
|
+
end
|
|
91
|
+
|
|
53
92
|
def suppress_inline(list, subject)
|
|
54
93
|
directives = InlineSuppression.new(subject.file, subject.file_source)
|
|
55
94
|
list.reject do |mutation, _text|
|
|
@@ -95,6 +134,7 @@ module Saboteur
|
|
|
95
134
|
seen << text
|
|
96
135
|
unless parses?(text)
|
|
97
136
|
@diagnostics.count_invalid_mutation
|
|
137
|
+
@diagnostics.warn("#{subject.id}: dropped #{mutation.operator} mutation (did not parse)")
|
|
98
138
|
return record_drop(subject, mutation, "invalid_syntax")
|
|
99
139
|
end
|
|
100
140
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Owns the host process's Coverage state across a run: suspends after
|
|
7
|
+
# boot, and at the end resumes only its own suspension. A CLI process
|
|
8
|
+
# exits right after, so resume matters to hosts that embed the engine -
|
|
9
|
+
# a spec, or the dogfood's own coverage-map build (#254).
|
|
10
|
+
class CoverageGuard
|
|
11
|
+
def initialize(diagnostics)
|
|
12
|
+
@diagnostics = diagnostics
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def suspend
|
|
16
|
+
@was_running = Coverage.state == :running
|
|
17
|
+
Coverage.suspend if @was_running
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Best-effort: a host that disturbed Coverage mid-run gets a warning,
|
|
21
|
+
# never a masked run outcome.
|
|
22
|
+
def resume
|
|
23
|
+
Coverage.resume if @was_running && Coverage.state == :suspended
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
@diagnostics.warn("coverage resume failed: #{e.class}: #{e.message}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|