saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -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 +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- 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 +62 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -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 +7 -5
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- 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 +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- 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_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- 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 +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- 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 +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -6
- 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 +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +46 -2
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,32 +13,45 @@ 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
|
|
15
|
-
|
|
16
|
+
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
|
|
17
|
+
timeout_factor timeout_min
|
|
18
|
+
max_memory_mb max_processes seed tests whole_file].freeze
|
|
16
19
|
|
|
17
20
|
attr_reader :config, :hooks
|
|
21
|
+
# The specs --guess-tests narrowed the run to, or nil when it did not.
|
|
22
|
+
attr_reader :guessed_tests
|
|
18
23
|
|
|
19
|
-
def initialize(config, err: $stderr, digest: nil)
|
|
24
|
+
def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [])
|
|
20
25
|
@config = config
|
|
21
26
|
@hooks = Hooks.new
|
|
22
27
|
@diagnostics = Diagnostics.new(err)
|
|
23
28
|
@root = File.realpath(config.root)
|
|
24
29
|
@skipped_subjects = {}
|
|
25
30
|
@replay = Replay.new(digest: digest) if digest
|
|
31
|
+
if capture_evidence && (digest || config["since"] || config["incremental"])
|
|
32
|
+
raise ConfigError, "baseline evidence requires fresh whole-scope execution: no since, replay or incremental"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@capture_evidence = capture_evidence
|
|
36
|
+
@evidence_files = evidence_files
|
|
37
|
+
@stable_suite = false
|
|
26
38
|
end
|
|
27
39
|
|
|
28
40
|
def call
|
|
29
41
|
Boot.refuse_recursion!
|
|
42
|
+
refuse_fail_fast_evidence
|
|
30
43
|
started = monotonic
|
|
44
|
+
guess_tests
|
|
45
|
+
announce_operators
|
|
46
|
+
@run_evidence = Baseline::RunEvidence.new(config: config, extra_files: @evidence_files) if @capture_evidence
|
|
31
47
|
ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
|
|
32
|
-
|
|
48
|
+
warn_custom_map
|
|
33
49
|
boot = Boot.new(config, hooks)
|
|
34
50
|
begin
|
|
35
|
-
integration = boot
|
|
51
|
+
integration, files, load_lines = boot_target(boot)
|
|
36
52
|
@rails = boot.rails
|
|
37
|
-
files = config.included_files
|
|
38
53
|
subjects, scope = select_subjects(files)
|
|
39
|
-
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
|
|
54
|
+
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
|
|
40
55
|
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
41
56
|
build_report(results, subjects, scope, started)
|
|
42
57
|
ensure
|
|
@@ -51,6 +66,10 @@ module Saboteur
|
|
|
51
66
|
end
|
|
52
67
|
end
|
|
53
68
|
|
|
69
|
+
def warn_custom_map
|
|
70
|
+
@diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
|
|
71
|
+
end
|
|
72
|
+
|
|
54
73
|
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
55
74
|
# map does not rot silently. doctor fails on stale keys.
|
|
56
75
|
def load_custom_map
|
|
@@ -67,12 +86,55 @@ module Saboteur
|
|
|
67
86
|
|
|
68
87
|
private
|
|
69
88
|
|
|
89
|
+
# --guess-tests with --since: scope the run to the specs found next to
|
|
90
|
+
# the changed files, as --test would, but only when each changed file
|
|
91
|
+
# has one. Otherwise the whole suite runs, as without the flag.
|
|
92
|
+
def guess_tests
|
|
93
|
+
return unless config["guess_tests"]
|
|
94
|
+
|
|
95
|
+
guess = TestGuess.new(config).call
|
|
96
|
+
message =
|
|
97
|
+
if guess.changed.zero?
|
|
98
|
+
"--guess-tests: no changed source file to guess specs for, so the whole suite runs"
|
|
99
|
+
elsif guess.unmatched.any?
|
|
100
|
+
"--guess-tests: no spec found for #{guess.unmatched.join(', ')}, so the whole suite runs " \
|
|
101
|
+
"(name specs after their source files or pass --test)"
|
|
102
|
+
else
|
|
103
|
+
# The guesses now stand as explicit tests, like --test.
|
|
104
|
+
@config = Config.new(config.to_h.merge("tests" => guess.specs, "guess_tests" => false), root: config.root)
|
|
105
|
+
@guessed_tests = guess.specs
|
|
106
|
+
"--guess-tests: running only #{guess.specs.join(', ')}. A spec that reaches a changed file only " \
|
|
107
|
+
"through another file does not run, so a mutant only it would kill can survive, and a line " \
|
|
108
|
+
"no guessed spec executes is reported as NoCoverage"
|
|
109
|
+
end
|
|
110
|
+
@diagnostics.warn(message)
|
|
111
|
+
@diagnostics.inform(message)
|
|
112
|
+
end
|
|
113
|
+
|
|
70
114
|
# The run's immutable report, built once the last result is in.
|
|
115
|
+
def announce_operators
|
|
116
|
+
@diagnostics.inform("Operators: #{config.operator_policy['effective'].join(', ')}")
|
|
117
|
+
end
|
|
118
|
+
|
|
71
119
|
def build_report(results, subjects, scope, started)
|
|
72
120
|
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
73
121
|
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
74
122
|
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
|
|
75
|
-
diff_scope: scope
|
|
123
|
+
diff_scope: scope, generation_events: @diagnostics.generation_events,
|
|
124
|
+
omissions: @diagnostics.omissions, run_evidence: complete_evidence(results, subjects),
|
|
125
|
+
hazard_suppressions: @diagnostics.hazard_suppressions, early_stop: @early_stop,
|
|
126
|
+
matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
|
|
127
|
+
operator_policy: config.operator_policy)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def complete_evidence(results, subjects)
|
|
131
|
+
return nil unless @run_evidence
|
|
132
|
+
|
|
133
|
+
@run_evidence.plan(subjects, subjects.map { [] }) unless @evidence_planned
|
|
134
|
+
@run_evidence.finish(results: results, stable_suite: @stable_suite, skipped_subjects: @skipped_subjects,
|
|
135
|
+
generation_events: @diagnostics.generation_events, omissions: @diagnostics.omissions,
|
|
136
|
+
discovery_failures: @diagnostics.discovery_failures,
|
|
137
|
+
inline_suppressions: @diagnostics.inline_suppressions)
|
|
76
138
|
end
|
|
77
139
|
|
|
78
140
|
# The subjects to mutate and, in diff mode, the scope of changed lines.
|
|
@@ -86,10 +148,10 @@ module Saboteur
|
|
|
86
148
|
[subjects, scope]
|
|
87
149
|
end
|
|
88
150
|
|
|
89
|
-
def mutate(subjects, files, integration, scope)
|
|
151
|
+
def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
|
|
90
152
|
warn_about_unscoped_suite(integration)
|
|
91
153
|
check_baseline(integration)
|
|
92
|
-
map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
|
|
154
|
+
map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
|
|
93
155
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
|
|
94
156
|
inform_about_run(subjects, map)
|
|
95
157
|
cache = result_cache
|
|
@@ -104,13 +166,49 @@ module Saboteur
|
|
|
104
166
|
# mutant when serial. Pack the heap now that the map and candidates
|
|
105
167
|
# are retained, to reduce later copying of shared pages.
|
|
106
168
|
Execution::ForkReadiness.prepare_heap
|
|
107
|
-
results =
|
|
108
|
-
cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
|
|
109
|
-
runnable_lists)
|
|
169
|
+
results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
|
|
110
170
|
cache&.save
|
|
111
171
|
results
|
|
112
172
|
end
|
|
113
173
|
|
|
174
|
+
def run_candidates(subjects, lists, scheduler, cache, ignore_list)
|
|
175
|
+
@diagnostics.start_progress(lists.sum(&:size))
|
|
176
|
+
distribution = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
|
|
177
|
+
cache: cache, on_start: method(:start_worker),
|
|
178
|
+
after_subject: canary_check, stop_after: fail_fast_check(ignore_list))
|
|
179
|
+
distribution.run(subjects, lists).tap { record_early_stop(distribution.unreached) }
|
|
180
|
+
ensure
|
|
181
|
+
@diagnostics.finish_progress
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Baseline evidence certifies a whole selection; a run that may stop early
|
|
185
|
+
# cannot.
|
|
186
|
+
def refuse_fail_fast_evidence
|
|
187
|
+
return unless @capture_evidence && config["fail_fast"]
|
|
188
|
+
|
|
189
|
+
raise ConfigError, "fail_fast cannot be used with baseline evidence: it certifies the whole selection"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Subjects that were selected but never ran, with candidates, are part of
|
|
193
|
+
# the report: the verdict covers less than the selection.
|
|
194
|
+
def record_early_stop(unreached)
|
|
195
|
+
return if unreached.empty?
|
|
196
|
+
|
|
197
|
+
@early_stop = EarlyStop.new(reason: "fail_fast", unreached: unreached.map(&:id))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# --fail-fast: stop after the first subject that has a survivor the
|
|
201
|
+
# ignore list does not accept. Granularity is one subject.
|
|
202
|
+
def fail_fast_check(ignore_list)
|
|
203
|
+
return nil unless config["fail_fast"]
|
|
204
|
+
|
|
205
|
+
lambda do |results|
|
|
206
|
+
survivor = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
207
|
+
@diagnostics.inform("--fail-fast: stopping after the first subject with a survivor") if survivor
|
|
208
|
+
survivor
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
114
212
|
def inform_about_run(subjects, map)
|
|
115
213
|
@diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
|
|
116
214
|
warn_about_threads
|
|
@@ -123,7 +221,11 @@ module Saboteur
|
|
|
123
221
|
return unless config["since"] && config["tests"].empty?
|
|
124
222
|
|
|
125
223
|
message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
|
|
126
|
-
"(#{integration.test_files.size} spec files).
|
|
224
|
+
"(#{integration.test_files.size} spec files). " \
|
|
225
|
+
"Pass --test to run only the specs you name: faster, but a mutant of a changed method is then killed " \
|
|
226
|
+
"only by those specs, so specs elsewhere that call this code are not run. " \
|
|
227
|
+
"--guess-tests does the same with the specs named after the changed files, " \
|
|
228
|
+
"a guess that can miss specs elsewhere."
|
|
127
229
|
@diagnostics.warn(message)
|
|
128
230
|
@diagnostics.inform(message)
|
|
129
231
|
end
|
|
@@ -131,21 +233,31 @@ module Saboteur
|
|
|
131
233
|
def check_baseline(integration)
|
|
132
234
|
BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
133
235
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
|
|
236
|
+
@stable_suite = true
|
|
134
237
|
end
|
|
135
238
|
|
|
136
239
|
def scheduler_for(map, integration, cache)
|
|
137
|
-
reevaluator = Execution::Reevaluator.new(config["reeval"])
|
|
240
|
+
reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
|
|
138
241
|
runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
|
|
139
|
-
supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"]
|
|
242
|
+
supervisor: supervisor, log_dir: fresh_log_dir, seed: config["seed"],
|
|
243
|
+
sandbox: sandbox_hook)
|
|
140
244
|
Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
|
|
141
245
|
cache: cache)
|
|
142
246
|
end
|
|
143
247
|
|
|
248
|
+
# The sandbox hook for each mutant child (#152); the policy and its
|
|
249
|
+
# one-time note live in Engine::SandboxPolicy.
|
|
250
|
+
def sandbox_hook
|
|
251
|
+
@sandbox_policy ||= SandboxPolicy.new(config: config, root: @root, diagnostics: @diagnostics)
|
|
252
|
+
@sandbox_policy.hook
|
|
253
|
+
end
|
|
254
|
+
|
|
144
255
|
def result_cache
|
|
145
256
|
return nil if @replay
|
|
146
257
|
return nil unless config["incremental"]
|
|
147
258
|
|
|
148
|
-
inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
|
|
259
|
+
inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] }
|
|
260
|
+
.merge("operator_policy" => config.operator_policy))
|
|
149
261
|
ResultCache.new(path: File.join(config.path("cache_dir"), "results.json"), root: @root, inputs: inputs,
|
|
150
262
|
suite_digest: SuiteFiles.new(config).digest)
|
|
151
263
|
end
|
|
@@ -165,19 +277,43 @@ module Saboteur
|
|
|
165
277
|
end
|
|
166
278
|
|
|
167
279
|
# Each subject's candidates, narrowed to changed lines in diff mode.
|
|
280
|
+
# The hazard guard is off only when the user explicitly asks for the
|
|
281
|
+
# dangerous mutants (issue #102); the canary then still watches the tree.
|
|
282
|
+
def hazard_guard
|
|
283
|
+
return nil if config["allow_hazardous"]
|
|
284
|
+
|
|
285
|
+
MutationSource::HazardGuard.new(config["hazard"])
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Checks the tree after each subject's mutants; a vanished file means a
|
|
289
|
+
# mutant deleted it, so abort with the most recent mutant's digest.
|
|
290
|
+
def canary_check
|
|
291
|
+
canary = Canary.new(@root, cache_dir: config["cache_dir"])
|
|
292
|
+
lambda do |subject, results|
|
|
293
|
+
last = results.reject(&:reused?).last || results.last
|
|
294
|
+
digest = last&.mutation&.digest(subject_start: subject.start_byte,
|
|
295
|
+
original_source: subject.file_source)
|
|
296
|
+
canary.check!(subject: subject, digest: digest)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
168
300
|
def candidate_lists(subjects, scope, map)
|
|
169
301
|
return [@replay_candidate] if @replay
|
|
170
302
|
|
|
171
|
-
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
303
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
|
|
172
304
|
selection = CandidateSelection.new(candidates: candidates, map: map,
|
|
173
305
|
limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
|
|
174
306
|
subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
|
|
175
307
|
@skipped_subjects = selection.skipped_subjects
|
|
308
|
+
if @run_evidence
|
|
309
|
+
@run_evidence.plan(subjects, subjects.map { |subject| selection.selected.fetch(subject) })
|
|
310
|
+
@evidence_planned = true
|
|
311
|
+
end
|
|
176
312
|
end
|
|
177
313
|
end
|
|
178
314
|
|
|
179
315
|
def replay_subject(subjects, scope)
|
|
180
|
-
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
316
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
|
|
181
317
|
subject, candidate = @replay.select(subjects, candidates, scope: scope)
|
|
182
318
|
@replay_candidate = [candidate]
|
|
183
319
|
[[subject], nil]
|
|
@@ -210,6 +346,47 @@ module Saboteur
|
|
|
210
346
|
@diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
|
|
211
347
|
end
|
|
212
348
|
|
|
349
|
+
# The lines that ran while the app and spec files loaded: the executable
|
|
350
|
+
# counts from its first line, so this snapshot holds the whole boot.
|
|
351
|
+
# Read here, in the parent: a forked child does not inherit Coverage
|
|
352
|
+
# counters. They become the map's load lines, which tell ran-at-load
|
|
353
|
+
# mutants apart from uncovered ones.
|
|
354
|
+
def capture_load_lines(files)
|
|
355
|
+
return {} unless Coverage.state == :suspended
|
|
356
|
+
|
|
357
|
+
wanted = files.to_set { |file| Selection::CoverageMap.canonical(file) }
|
|
358
|
+
Coverage.result(stop: false, clear: false).each_with_object({}) do |(path, data), hits|
|
|
359
|
+
file = Selection::CoverageMap.canonical(path)
|
|
360
|
+
next unless wanted.include?(file)
|
|
361
|
+
|
|
362
|
+
lines = data[:lines].each_with_index.filter_map { |count, index| index + 1 if count&.positive? }
|
|
363
|
+
lines -= uncalled_shared_lines(data)
|
|
364
|
+
hits[file] = lines unless lines.empty?
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# A one-line or endless definition executes its def line even when its
|
|
369
|
+
# body is never called. If any sibling on that physical line is uncalled,
|
|
370
|
+
# discard its ambiguous line evidence rather than crediting every body.
|
|
371
|
+
def uncalled_shared_lines(data)
|
|
372
|
+
shared = data.fetch(:methods, {}).select { |(_owner, _name, first, _column, last, *), _| first == last }
|
|
373
|
+
calls = shared.group_by { |(_owner, _name, first, *), _count| first }
|
|
374
|
+
calls.filter_map { |line, entries| line if entries.any? { |_key, count| count.zero? } }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Loads the target and stops coverage, returning what the rest of the
|
|
378
|
+
# run needs from that boot.
|
|
379
|
+
def boot_target(boot)
|
|
380
|
+
integration = boot.load_target
|
|
381
|
+
files = config.included_files
|
|
382
|
+
[integration, files, suspend_coverage(files)]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def suspend_coverage(files)
|
|
386
|
+
Coverage.suspend if Coverage.state == :running
|
|
387
|
+
capture_load_lines(files)
|
|
388
|
+
end
|
|
389
|
+
|
|
213
390
|
def monotonic
|
|
214
391
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
215
392
|
end
|
|
@@ -219,15 +396,20 @@ end
|
|
|
219
396
|
require_relative "engine/diagnostics"
|
|
220
397
|
require_relative "engine/boot"
|
|
221
398
|
require_relative "engine/subject_selection"
|
|
399
|
+
require_relative "engine/unchanged_syntax"
|
|
222
400
|
require_relative "engine/suite_files"
|
|
401
|
+
require_relative "engine/test_guess"
|
|
402
|
+
require_relative "engine/sandbox_policy"
|
|
223
403
|
require_relative "engine/coverage_store"
|
|
224
404
|
require_relative "engine/baseline_guard"
|
|
405
|
+
require_relative "engine/bytecode_equivalence"
|
|
225
406
|
require_relative "engine/candidates"
|
|
226
407
|
require_relative "engine/candidate_selection"
|
|
227
408
|
require_relative "engine/diff_scope"
|
|
228
409
|
require_relative "engine/result_cache"
|
|
229
410
|
require_relative "engine/verdict_check"
|
|
230
411
|
require_relative "engine/scheduler"
|
|
412
|
+
require_relative "engine/canary"
|
|
231
413
|
require_relative "engine/distribution"
|
|
232
414
|
|
|
233
415
|
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,19 +12,60 @@ 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)
|
|
@@ -32,7 +73,13 @@ module Saboteur
|
|
|
32
73
|
copy_visibility = visibility_of(owner.singleton_class, subject.name) if module_function
|
|
33
74
|
aliases = aliases_of(target, subject.name)
|
|
34
75
|
|
|
35
|
-
|
|
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
|
|
36
83
|
|
|
37
84
|
restore(owner, target, subject.name, visibility, copy_visibility)
|
|
38
85
|
repoint(target, subject.name, aliases)
|
|
@@ -90,23 +137,38 @@ module Saboteur
|
|
|
90
137
|
owner.singleton_class.instance_method(subject.name).source_location
|
|
91
138
|
end
|
|
92
139
|
|
|
93
|
-
|
|
94
|
-
|
|
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)
|
|
95
155
|
eval_target = subject.sclass ? owner.singleton_class : owner
|
|
96
156
|
eval_target.class_eval(code, subject.file, line)
|
|
97
157
|
end
|
|
98
158
|
|
|
99
|
-
def eval_nested(subject, text)
|
|
159
|
+
def eval_nested(subject, text, extra_lines)
|
|
100
160
|
headers = subject.nesting.map(&:header)
|
|
101
161
|
wrapped = "#{headers.join('; ')}; #{text}; #{(['end'] * headers.size).join('; ')}"
|
|
102
|
-
code, line = with_magic_comment(subject, wrapped)
|
|
162
|
+
code, line = with_magic_comment(subject, wrapped, extra_lines)
|
|
103
163
|
TOPLEVEL_BINDING.eval(code, subject.file, line)
|
|
104
164
|
end
|
|
105
165
|
|
|
106
|
-
def
|
|
107
|
-
|
|
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
|
|
108
170
|
|
|
109
|
-
[FROZEN_HEADER + code, subject.start_line - 1]
|
|
171
|
+
[FROZEN_HEADER + code, subject.start_line - extra_lines - 1]
|
|
110
172
|
end
|
|
111
173
|
|
|
112
174
|
# Methods defined with alias/alias_method copy the old body. Callers
|