saboteur 0.3.0 → 0.5.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
|
@@ -5,7 +5,7 @@ module Saboteur
|
|
|
5
5
|
class Engine
|
|
6
6
|
# Checks each kill against the method re-evaluated unchanged: a kill
|
|
7
7
|
# stands only if the same tests, in the same order, up to and including
|
|
8
|
-
# the killer, pass without the mutant (so a test that fails only after
|
|
8
|
+
# the last killer, pass without the mutant (so a test that fails only after
|
|
9
9
|
# another test left state behind cannot claim a kill). A kill with no
|
|
10
10
|
# failing test named (an exit, a suite-level failure) is checked on all
|
|
11
11
|
# the mutant's tests. A kill that fails the check becomes an error.
|
|
@@ -39,11 +39,12 @@ module Saboteur
|
|
|
39
39
|
result.status == :killed && result.killed_by.empty?
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# The tests the mutant ran, in order, up to and including the
|
|
42
|
+
# The tests the mutant ran, in order, up to and including the last
|
|
43
|
+
# killer: a matrix run lists every failing test, and each of them must
|
|
44
|
+
# pass under the unchanged method in this same sequence.
|
|
43
45
|
def sequence(result)
|
|
44
|
-
|
|
45
|
-
position
|
|
46
|
-
position ? result.covered_by.first(position + 1) : [killer]
|
|
46
|
+
position = result.killed_by.filter_map { |test| result.covered_by.index(test) }.max
|
|
47
|
+
position ? result.covered_by.first(position + 1) : [result.killed_by.first]
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
# Runs each distinct kill sequence once. Returns those that fail, each
|
|
@@ -67,7 +68,7 @@ module Saboteur
|
|
|
67
68
|
|
|
68
69
|
if named_kill?(result) && failing_runs.key?(sequence(result))
|
|
69
70
|
why = failing_runs[sequence(result)]
|
|
70
|
-
result.as_error("killing test #{result.
|
|
71
|
+
result.as_error("killing test #{sequence(result).last} also fails with the method unchanged" \
|
|
71
72
|
"#{": #{why}" if why}", rejected_kill: true)
|
|
72
73
|
elsif failing.include?(result)
|
|
73
74
|
result.as_error("tests also fail with the method unchanged", rejected_kill: true)
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "replay"
|
|
7
|
+
require_relative "mutation_source/hazard_guard"
|
|
8
|
+
require_relative "baseline/run_evidence"
|
|
7
9
|
|
|
8
10
|
module Saboteur
|
|
9
11
|
# A single-use instance runs one pipeline: boot the target, select subjects, fetch the
|
|
@@ -11,37 +13,122 @@ module Saboteur
|
|
|
11
13
|
# engine/; this class only wires them together.
|
|
12
14
|
class Engine
|
|
13
15
|
# Everything besides sources and tests that can change a verdict.
|
|
14
|
-
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources arid timeout_factor timeout_min
|
|
15
|
-
max_memory_mb max_processes seed tests].freeze
|
|
16
|
+
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators arid timeout_factor timeout_min
|
|
17
|
+
max_memory_mb max_processes seed tests whole_file].freeze
|
|
16
18
|
|
|
17
19
|
attr_reader :config, :hooks
|
|
20
|
+
# The specs --guess-tests narrowed the run to, or nil when it did not.
|
|
21
|
+
attr_reader :guessed_tests
|
|
18
22
|
|
|
19
|
-
def initialize(config, err: $stderr, digest: nil)
|
|
23
|
+
def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [])
|
|
20
24
|
@config = config
|
|
21
25
|
@hooks = Hooks.new
|
|
22
26
|
@diagnostics = Diagnostics.new(err)
|
|
23
27
|
@root = File.realpath(config.root)
|
|
24
28
|
@skipped_subjects = {}
|
|
25
29
|
@replay = Replay.new(digest: digest) if digest
|
|
30
|
+
if capture_evidence && (digest || config["since"] || config["incremental"])
|
|
31
|
+
raise ConfigError, "baseline evidence requires fresh whole-scope execution: no since, replay or incremental"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@capture_evidence = capture_evidence
|
|
35
|
+
@evidence_files = evidence_files
|
|
36
|
+
@stable_suite = false
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
def call
|
|
29
40
|
Boot.refuse_recursion!
|
|
41
|
+
refuse_fail_fast_evidence
|
|
30
42
|
started = monotonic
|
|
43
|
+
guess_tests
|
|
44
|
+
@run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
|
|
31
45
|
ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
|
|
46
|
+
warn_custom_map
|
|
32
47
|
boot = Boot.new(config, hooks)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
begin
|
|
49
|
+
integration, files, load_lines = boot_target(boot)
|
|
50
|
+
@rails = boot.rails
|
|
51
|
+
subjects, scope = select_subjects(files)
|
|
52
|
+
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
|
|
53
|
+
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
54
|
+
build_report(results, subjects, scope, started)
|
|
55
|
+
ensure
|
|
56
|
+
# The run boundary for the per-worker databases: cleanup runs however
|
|
57
|
+
# the call ends - a boot that raised partway through, a baseline or
|
|
58
|
+
# coverage failure, no subjects, no runnable work - so a host that
|
|
59
|
+
# embeds Engine and calls it repeatedly never holds copies or
|
|
60
|
+
# snapshots until process exit. cleanup_workers is idempotent; the
|
|
61
|
+
# Rails profile's at_exit handler stays as the fallback for an
|
|
62
|
+
# abrupt process exit.
|
|
63
|
+
safely_cleanup_workers(boot.rails)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def warn_custom_map
|
|
68
|
+
@diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
72
|
+
# 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
|
|
76
|
+
|
|
77
|
+
def custom_map_warnings(map)
|
|
78
|
+
path = config.path("custom_map")
|
|
79
|
+
warnings = map.stale_keys.map { |key| "stale custom map key #{key}: matches no file" }
|
|
80
|
+
return warnings unless path && File.file?(path)
|
|
81
|
+
|
|
82
|
+
warnings + map.duplicate_keys(path).map { |key| "duplicate custom map key #{key}: Ruby keeps the last" }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# --guess-tests with --since: scope the run to the specs found next to
|
|
88
|
+
# the changed files, as --test would, but only when each changed file
|
|
89
|
+
# has one. Otherwise the whole suite runs, as without the flag.
|
|
90
|
+
def guess_tests
|
|
91
|
+
return unless config["guess_tests"]
|
|
92
|
+
|
|
93
|
+
guess = TestGuess.new(config).call
|
|
94
|
+
message =
|
|
95
|
+
if guess.changed.zero?
|
|
96
|
+
"--guess-tests: no changed source file to guess specs for, so the whole suite runs"
|
|
97
|
+
elsif guess.unmatched.any?
|
|
98
|
+
"--guess-tests: no spec found for #{guess.unmatched.join(', ')}, so the whole suite runs " \
|
|
99
|
+
"(name specs after their source files or pass --test)"
|
|
100
|
+
else
|
|
101
|
+
# The guesses now stand as explicit tests, like --test.
|
|
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"
|
|
107
|
+
end
|
|
108
|
+
@diagnostics.warn(message)
|
|
109
|
+
@diagnostics.inform(message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The run's immutable report, built once the last result is in.
|
|
113
|
+
def build_report(results, subjects, scope, started)
|
|
39
114
|
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
40
115
|
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
41
|
-
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects
|
|
116
|
+
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
|
|
117
|
+
diff_scope: scope, generation_events: @diagnostics.generation_events,
|
|
118
|
+
omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
|
|
119
|
+
hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
|
|
120
|
+
matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions)
|
|
42
121
|
end
|
|
43
122
|
|
|
44
|
-
|
|
123
|
+
def complete_evidence(results, subjects)
|
|
124
|
+
return nil unless @run_evidence
|
|
125
|
+
|
|
126
|
+
@run_evidence.plan(subjects, subjects.map { [] }) unless @evidence_planned
|
|
127
|
+
@run_evidence.finish(results: results, stable_suite: @stable_suite, skipped_subjects: @skipped_subjects,
|
|
128
|
+
generation_events: @diagnostics.generation_events, omissions: @diagnostics.omissions,
|
|
129
|
+
discovery_failures: @diagnostics.discovery_failures,
|
|
130
|
+
inline_suppressions: @diagnostics.inline_suppressions)
|
|
131
|
+
end
|
|
45
132
|
|
|
46
133
|
# The subjects to mutate and, in diff mode, the scope of changed lines.
|
|
47
134
|
def select_subjects(files)
|
|
@@ -54,9 +141,10 @@ module Saboteur
|
|
|
54
141
|
[subjects, scope]
|
|
55
142
|
end
|
|
56
143
|
|
|
57
|
-
def mutate(subjects, files, integration, scope)
|
|
144
|
+
def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
|
|
145
|
+
warn_about_unscoped_suite(integration)
|
|
58
146
|
check_baseline(integration)
|
|
59
|
-
map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
|
|
147
|
+
map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
|
|
60
148
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
|
|
61
149
|
inform_about_run(subjects, map)
|
|
62
150
|
cache = result_cache
|
|
@@ -71,30 +159,92 @@ module Saboteur
|
|
|
71
159
|
# mutant when serial. Pack the heap now that the map and candidates
|
|
72
160
|
# are retained, to reduce later copying of shared pages.
|
|
73
161
|
Execution::ForkReadiness.prepare_heap
|
|
74
|
-
results =
|
|
75
|
-
cache: cache, on_start: method(:start_worker)).run(runnable_subjects, runnable_lists)
|
|
162
|
+
results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
|
|
76
163
|
cache&.save
|
|
77
164
|
results
|
|
78
165
|
end
|
|
79
166
|
|
|
167
|
+
def run_candidates(subjects, lists, scheduler, cache, ignore_list)
|
|
168
|
+
@diagnostics.start_progress(lists.sum(&:size))
|
|
169
|
+
distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
|
|
170
|
+
cache: cache, on_start: method(:start_worker),
|
|
171
|
+
after_subject: canary_check, stop_after: fail_fast_check(ignore_list))
|
|
172
|
+
distribution.run(subjects, lists).tap { record_early_stop(distribution.unreached) }
|
|
173
|
+
ensure
|
|
174
|
+
@diagnostics.finish_progress
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Baseline evidence certifies a whole selection; a run that may stop early
|
|
178
|
+
# cannot.
|
|
179
|
+
def refuse_fail_fast_evidence
|
|
180
|
+
return unless @capture_evidence && config["fail_fast"]
|
|
181
|
+
|
|
182
|
+
raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
|
|
183
|
+
end
|
|
184
|
+
|
|
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
|
+
# --fail-fast: stop after the first subject that has a survivor the
|
|
194
|
+
# ignore list does not accept. Granularity is one subject.
|
|
195
|
+
def fail_fast_check(ignore_list)
|
|
196
|
+
return nil unless config["fail_fast"]
|
|
197
|
+
|
|
198
|
+
lambda do |results|
|
|
199
|
+
survivor = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
200
|
+
@diagnostics.inform("--fail-fast: stopping after the first subject with a survivor") if survivor
|
|
201
|
+
survivor
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
80
205
|
def inform_about_run(subjects, map)
|
|
81
206
|
@diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
|
|
82
207
|
warn_about_threads
|
|
83
208
|
end
|
|
84
209
|
|
|
210
|
+
# A --since run without --test runs the loaded suite twice for the
|
|
211
|
+
# baselines; a cold coverage cache adds one more pass. Say so
|
|
212
|
+
# before those hours pass, with the way to scope it.
|
|
213
|
+
def warn_about_unscoped_suite(integration)
|
|
214
|
+
return unless config["since"] && config["tests"].empty?
|
|
215
|
+
|
|
216
|
+
message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
|
|
217
|
+
"(#{integration.test_files.size} spec files). " \
|
|
218
|
+
"Pass --test to run only the specs you name: faster, but a mutant of a changed method is then killed " \
|
|
219
|
+
"only by those specs, so specs elsewhere that call this code are not run. " \
|
|
220
|
+
"--guess-tests does the same with the specs named after the changed files, " \
|
|
221
|
+
"a guess that can miss specs elsewhere."
|
|
222
|
+
@diagnostics.warn(message)
|
|
223
|
+
@diagnostics.inform(message)
|
|
224
|
+
end
|
|
225
|
+
|
|
85
226
|
def check_baseline(integration)
|
|
86
227
|
BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
87
228
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
|
|
229
|
+
@stable_suite = true
|
|
88
230
|
end
|
|
89
231
|
|
|
90
232
|
def scheduler_for(map, integration, cache)
|
|
91
|
-
reevaluator = Execution::Reevaluator.new(config["reeval"])
|
|
233
|
+
reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
|
|
92
234
|
runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
|
|
93
|
-
supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"]
|
|
235
|
+
supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
|
|
236
|
+
sandbox: sandbox_hook)
|
|
94
237
|
Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
|
|
95
238
|
cache: cache)
|
|
96
239
|
end
|
|
97
240
|
|
|
241
|
+
# The sandbox hook for each mutant child (#152); the policy and its
|
|
242
|
+
# one-time note live in Engine::SandboxPolicy.
|
|
243
|
+
def sandbox_hook
|
|
244
|
+
@sandbox_policy ||= SandboxPolicy.new(config: config, root: @root, diagnostics: @diagnostics)
|
|
245
|
+
@sandbox_policy.hook
|
|
246
|
+
end
|
|
247
|
+
|
|
98
248
|
def result_cache
|
|
99
249
|
return nil if @replay
|
|
100
250
|
return nil unless config["incremental"]
|
|
@@ -104,6 +254,14 @@ module Saboteur
|
|
|
104
254
|
suite_digest: SuiteFiles.new(config).digest)
|
|
105
255
|
end
|
|
106
256
|
|
|
257
|
+
# A cleanup failure must never mask the run's own outcome - not even
|
|
258
|
+
# one cleanup_workers did not anticipate.
|
|
259
|
+
def safely_cleanup_workers(rails)
|
|
260
|
+
rails&.cleanup_workers
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
@diagnostics.warn(e.message)
|
|
263
|
+
end
|
|
264
|
+
|
|
107
265
|
# In each worker: its own database, then the user's worker_start hooks.
|
|
108
266
|
def start_worker(number)
|
|
109
267
|
@rails&.start_worker(number)
|
|
@@ -111,19 +269,43 @@ module Saboteur
|
|
|
111
269
|
end
|
|
112
270
|
|
|
113
271
|
# Each subject's candidates, narrowed to changed lines in diff mode.
|
|
272
|
+
# The hazard guard is off only when the user explicitly asks for the
|
|
273
|
+
# dangerous mutants (issue #102); the canary then still watches the tree.
|
|
274
|
+
def hazard_guard
|
|
275
|
+
return nil if config["allow_hazardous"]
|
|
276
|
+
|
|
277
|
+
MutationSource::HazardGuard.new(config["hazard"])
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Checks the tree after each subject's mutants; a vanished file means a
|
|
281
|
+
# mutant deleted it, so abort with the most recent mutant's digest.
|
|
282
|
+
def canary_check
|
|
283
|
+
canary = Canary.new(@root, cache_dir: config["cache_dir"])
|
|
284
|
+
lambda do |subject, results|
|
|
285
|
+
last = results.reject(&:reused?).last || results.last
|
|
286
|
+
digest = last&.mutation&.digest(subject_start: subject.start_byte,
|
|
287
|
+
original_source: subject.file_source)
|
|
288
|
+
canary.check!(subject: subject, digest: digest)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
114
292
|
def candidate_lists(subjects, scope, map)
|
|
115
293
|
return [@replay_candidate] if @replay
|
|
116
294
|
|
|
117
|
-
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
295
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
|
|
118
296
|
selection = CandidateSelection.new(candidates: candidates, map: map,
|
|
119
297
|
limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
|
|
120
298
|
subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
|
|
121
299
|
@skipped_subjects = selection.skipped_subjects
|
|
300
|
+
if @run_evidence
|
|
301
|
+
@run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
|
|
302
|
+
@evidence_planned = true
|
|
303
|
+
end
|
|
122
304
|
end
|
|
123
305
|
end
|
|
124
306
|
|
|
125
307
|
def replay_subject(subjects, scope)
|
|
126
|
-
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
308
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
|
|
127
309
|
subject, candidate = @replay.select(subjects, candidates, scope: scope)
|
|
128
310
|
@replay_candidate = [candidate]
|
|
129
311
|
[[subject], nil]
|
|
@@ -143,10 +325,58 @@ module Saboteur
|
|
|
143
325
|
end
|
|
144
326
|
end
|
|
145
327
|
|
|
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).
|
|
146
332
|
def warn_about_threads
|
|
147
|
-
others = Thread.list
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
# The lines that ran while the app and spec files loaded: the executable
|
|
342
|
+
# counts from its first line, so this snapshot holds the whole boot.
|
|
343
|
+
# Read here, in the parent: a forked child does not inherit Coverage
|
|
344
|
+
# counters. They become the map's load lines, which tell ran-at-load
|
|
345
|
+
# mutants apart from uncovered ones.
|
|
346
|
+
def capture_load_lines(files)
|
|
347
|
+
return {} unless Coverage.state == :suspended
|
|
348
|
+
|
|
349
|
+
wanted = files.to_set { |file| Selection::CoverageMap.canonical(file) }
|
|
350
|
+
Coverage.result(stop: false, clear: false).each_with_object({}) do |(path, data), hits|
|
|
351
|
+
file = Selection::CoverageMap.canonical(path)
|
|
352
|
+
next unless wanted.include?(file)
|
|
353
|
+
|
|
354
|
+
lines = data[:lines].each_with_index.filter_map { |count, index| index + 1 if count&.positive? }
|
|
355
|
+
lines -= uncalled_shared_lines(data)
|
|
356
|
+
hits[file] = lines unless lines.empty?
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# A one-line or endless definition executes its def line even when its
|
|
361
|
+
# body is never called. If any sibling on that physical line is uncalled,
|
|
362
|
+
# discard its ambiguous line evidence rather than crediting every body.
|
|
363
|
+
def uncalled_shared_lines(data)
|
|
364
|
+
shared = data.fetch(:methods, {}).select { |(_owner, _name, first, _column, last, *), _| first == last }
|
|
365
|
+
calls = shared.group_by { |(_owner, _name, first, *), _count| first }
|
|
366
|
+
calls.filter_map { |line, entries| line if entries.any? { |_key, count| count.zero? } }
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Loads the target and stops coverage, returning what the rest of the
|
|
370
|
+
# run needs from that boot.
|
|
371
|
+
def boot_target(boot)
|
|
372
|
+
integration = boot.load_target
|
|
373
|
+
files = config.included_files
|
|
374
|
+
[integration, files, suspend_coverage(files)]
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def suspend_coverage(files)
|
|
378
|
+
Coverage.suspend if Coverage.state == :running
|
|
379
|
+
capture_load_lines(files)
|
|
150
380
|
end
|
|
151
381
|
|
|
152
382
|
def monotonic
|
|
@@ -158,15 +388,20 @@ end
|
|
|
158
388
|
require_relative "engine/diagnostics"
|
|
159
389
|
require_relative "engine/boot"
|
|
160
390
|
require_relative "engine/subject_selection"
|
|
391
|
+
require_relative "engine/unchanged_syntax"
|
|
161
392
|
require_relative "engine/suite_files"
|
|
393
|
+
require_relative "engine/test_guess"
|
|
394
|
+
require_relative "engine/sandbox_policy"
|
|
162
395
|
require_relative "engine/coverage_store"
|
|
163
396
|
require_relative "engine/baseline_guard"
|
|
397
|
+
require_relative "engine/bytecode_equivalence"
|
|
164
398
|
require_relative "engine/candidates"
|
|
165
399
|
require_relative "engine/candidate_selection"
|
|
166
400
|
require_relative "engine/diff_scope"
|
|
167
401
|
require_relative "engine/result_cache"
|
|
168
402
|
require_relative "engine/verdict_check"
|
|
169
403
|
require_relative "engine/scheduler"
|
|
404
|
+
require_relative "engine/canary"
|
|
170
405
|
require_relative "engine/distribution"
|
|
171
406
|
|
|
172
407
|
require_relative "engine/run_fingerprint"
|
|
@@ -8,9 +8,10 @@ module Saboteur
|
|
|
8
8
|
# What a mutant child tells its supervisor, one JSON object per line on
|
|
9
9
|
# a pipe. Every message carries the protocol version.
|
|
10
10
|
#
|
|
11
|
+
# captures a stale Method capture of the original def exists (site, stale)
|
|
11
12
|
# started the mutated method is in place; tests are about to run
|
|
12
13
|
# result passed, failed (ids), executed (ids), runtime, exited (status
|
|
13
|
-
# when a test called exit)
|
|
14
|
+
# when a test called exit), replacement_called (false only after a verified trace)
|
|
14
15
|
# error message, backtrace: the harness failed, not the tests
|
|
15
16
|
#
|
|
16
17
|
# A child that dies after "started" without "result" crashed while its
|
|
@@ -12,28 +12,76 @@ module Saboteur
|
|
|
12
12
|
# nesting - rebuild the original `module A; class B` nesting on one line
|
|
13
13
|
# and eval at top level, so constant lookup matches the file.
|
|
14
14
|
#
|
|
15
|
+
# A subject named in whole_file skips both: the child evaluates the whole
|
|
16
|
+
# file with the mutated method spliced in, the way mutineer does. That is
|
|
17
|
+
# the way in for methods surgical re-evaluation refuses, at a price: the
|
|
18
|
+
# file's top-level code runs again in the child, so load-time side effects
|
|
19
|
+
# repeat and constants it assigns are rebuilt. It reaches only methods
|
|
20
|
+
# SubjectFinder already finds.
|
|
21
|
+
#
|
|
15
22
|
# Both keep the original file and line numbers, carry a frozen_string_literal
|
|
16
23
|
# magic comment, and restore the method's visibility afterwards.
|
|
17
24
|
class Reevaluator
|
|
18
25
|
STRATEGIES = %w[class_eval nesting].freeze
|
|
19
26
|
FROZEN_HEADER = "# frozen_string_literal: true\n"
|
|
20
27
|
|
|
21
|
-
def initialize(strategy = "nesting")
|
|
28
|
+
def initialize(strategy = "nesting", whole_file: [])
|
|
22
29
|
raise Error, "unknown reeval strategy: #{strategy}" unless STRATEGIES.include?(strategy.to_s)
|
|
23
30
|
|
|
24
31
|
@strategy = strategy.to_s
|
|
32
|
+
@whole_file = whole_file
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# "file:line" of another def in the subject's own file that replaced it:
|
|
36
|
+
# a later definition there won, so the mutant could never run. Only a
|
|
37
|
+
# positively identified different def counts. nil when the subject is not
|
|
38
|
+
# whole-file, its def is the one in place, or this cannot be told (a
|
|
39
|
+
# method from another file, such as an unknown forwarding wrapper, no
|
|
40
|
+
# Ruby source, or no method): the caller then keeps the verdict.
|
|
41
|
+
def replaced_definition(subject)
|
|
42
|
+
return nil unless whole_file?(subject)
|
|
43
|
+
|
|
44
|
+
owner = resolve_owner(subject)
|
|
45
|
+
target = subject.singleton ? owner.singleton_class : owner
|
|
46
|
+
method = target.instance_method(subject.name)
|
|
47
|
+
# A def-time wrapper (a sorbet sig) replaces the method; its own
|
|
48
|
+
# source_location is the wrapper's, not the def's.
|
|
49
|
+
file, line = (Wrapper.original_def(method) || method).source_location
|
|
50
|
+
return nil unless file == subject.file && line != subject.start_line
|
|
51
|
+
|
|
52
|
+
"#{file}:#{line}"
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
25
55
|
end
|
|
26
56
|
|
|
27
|
-
|
|
57
|
+
# True when the subject's id matches a whole_file entry (exact or a
|
|
58
|
+
# File.fnmatch glob).
|
|
59
|
+
def whole_file?(subject)
|
|
60
|
+
@whole_file.any? { |pattern| File.fnmatch?(pattern, subject.id) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# prefix: source evaluated right before the def text, at the lines it
|
|
64
|
+
# occupies in the file - a sorbet `sig { ... }` call, so the wrapper
|
|
65
|
+
# gem re-wraps the mutant and its runtime checks stay on.
|
|
66
|
+
def call(subject, text, prefix: nil)
|
|
67
|
+
return eval_whole_file(subject, text) if whole_file?(subject)
|
|
68
|
+
|
|
28
69
|
owner = resolve_owner(subject)
|
|
29
70
|
target = subject.singleton ? owner.singleton_class : owner
|
|
30
71
|
visibility = visibility_of(target, subject.name)
|
|
31
72
|
module_function = module_function?(owner, subject)
|
|
73
|
+
copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
|
|
32
74
|
aliases = aliases_of(target, subject.name)
|
|
33
75
|
|
|
34
|
-
|
|
76
|
+
extra_lines = prefix ? prefix.count("\n") + 1 : 0
|
|
77
|
+
full_text = prefix ? "#{prefix}\n#{text}" : text
|
|
78
|
+
if @strategy == "nesting"
|
|
79
|
+
eval_nested(subject, full_text, extra_lines)
|
|
80
|
+
else
|
|
81
|
+
eval_in_owner(owner, subject, full_text, extra_lines)
|
|
82
|
+
end
|
|
35
83
|
|
|
36
|
-
restore(owner, target, subject.name, visibility,
|
|
84
|
+
restore(owner, target, subject.name, visibility, copy_visibility)
|
|
37
85
|
repoint(target, subject.name, aliases)
|
|
38
86
|
end
|
|
39
87
|
|
|
@@ -82,29 +130,45 @@ module Saboteur
|
|
|
82
130
|
def module_function?(owner, subject)
|
|
83
131
|
return false if subject.singleton || owner.is_a?(Class)
|
|
84
132
|
return false unless owner.private_method_defined?(subject.name, false)
|
|
85
|
-
return false unless owner.singleton_class.
|
|
133
|
+
return false unless owner.singleton_class.method_defined?(subject.name, false) ||
|
|
134
|
+
owner.singleton_class.private_method_defined?(subject.name, false)
|
|
86
135
|
|
|
87
136
|
owner.instance_method(subject.name).source_location ==
|
|
88
137
|
owner.singleton_class.instance_method(subject.name).source_location
|
|
89
138
|
end
|
|
90
139
|
|
|
91
|
-
|
|
92
|
-
|
|
140
|
+
# The file as it is on disk, with the method's bytes replaced by text,
|
|
141
|
+
# evaluated at top level under the file's own name and first line.
|
|
142
|
+
# Redefinition warnings are expected and silenced.
|
|
143
|
+
def eval_whole_file(subject, text)
|
|
144
|
+
source = subject.file_source.dup.force_encoding(Encoding::UTF_8)
|
|
145
|
+
spliced = source.byteslice(0, subject.start_byte) + text + source.byteslice(subject.end_byte..)
|
|
146
|
+
verbose = $VERBOSE
|
|
147
|
+
$VERBOSE = nil
|
|
148
|
+
TOPLEVEL_BINDING.eval(spliced, subject.file, 1)
|
|
149
|
+
ensure
|
|
150
|
+
$VERBOSE = verbose
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def eval_in_owner(owner, subject, text, extra_lines)
|
|
154
|
+
code, line = with_magic_comment(subject, text, extra_lines)
|
|
93
155
|
eval_target = subject.sclass ? owner.singleton_class : owner
|
|
94
156
|
eval_target.class_eval(code, subject.file, line)
|
|
95
157
|
end
|
|
96
158
|
|
|
97
|
-
def eval_nested(subject, text)
|
|
159
|
+
def eval_nested(subject, text, extra_lines)
|
|
98
160
|
headers = subject.nesting.map(&:header)
|
|
99
161
|
wrapped = "#{headers.join('; ')}; #{text}; #{(['end'] * headers.size).join('; ')}"
|
|
100
|
-
code, line = with_magic_comment(subject, wrapped)
|
|
162
|
+
code, line = with_magic_comment(subject, wrapped, extra_lines)
|
|
101
163
|
TOPLEVEL_BINDING.eval(code, subject.file, line)
|
|
102
164
|
end
|
|
103
165
|
|
|
104
|
-
def
|
|
105
|
-
|
|
166
|
+
# The first line of code sits extra_lines above the def, so the def
|
|
167
|
+
# keeps its file line numbers.
|
|
168
|
+
def with_magic_comment(subject, code, extra_lines)
|
|
169
|
+
return [code, subject.start_line - extra_lines] unless subject.frozen_literals
|
|
106
170
|
|
|
107
|
-
[FROZEN_HEADER + code, subject.start_line - 1]
|
|
171
|
+
[FROZEN_HEADER + code, subject.start_line - extra_lines - 1]
|
|
108
172
|
end
|
|
109
173
|
|
|
110
174
|
# Methods defined with alias/alias_method copy the old body. Callers
|
|
@@ -132,9 +196,10 @@ module Saboteur
|
|
|
132
196
|
end
|
|
133
197
|
end
|
|
134
198
|
|
|
135
|
-
def restore(owner, target, name, visibility,
|
|
136
|
-
if
|
|
199
|
+
def restore(owner, target, name, visibility, copy_visibility)
|
|
200
|
+
if copy_visibility
|
|
137
201
|
owner.send(:module_function, name)
|
|
202
|
+
owner.singleton_class.send(copy_visibility, name)
|
|
138
203
|
elsif visibility != :public
|
|
139
204
|
target.send(visibility, name)
|
|
140
205
|
end
|