saboteur 0.4.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 +167 -0
- data/README.md +393 -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 +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 +101 -121
- data/lib/saboteur/config.rb +76 -6
- data/lib/saboteur/doctor.rb +40 -8
- 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 +59 -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 +6 -4
- 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 +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 +192 -18
- 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/gates.rb +83 -0
- data/lib/saboteur/help.rb +31 -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_registry.rb +57 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/reporter/agent_commands.rb +7 -1
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +11 -5
- data/lib/saboteur/reporter/findings.rb +4 -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 +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 +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +15 -2
- 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 +131 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +41 -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 +43 -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,43 @@ 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"))
|
|
32
|
-
|
|
46
|
+
warn_custom_map
|
|
33
47
|
boot = Boot.new(config, hooks)
|
|
34
48
|
begin
|
|
35
|
-
integration = boot
|
|
49
|
+
integration, files, load_lines = boot_target(boot)
|
|
36
50
|
@rails = boot.rails
|
|
37
|
-
files = config.included_files
|
|
38
51
|
subjects, scope = select_subjects(files)
|
|
39
|
-
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope)
|
|
52
|
+
results = subjects.empty? ? [] : mutate(subjects, files, integration, scope, ignore_list, load_lines:)
|
|
40
53
|
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
41
54
|
build_report(results, subjects, scope, started)
|
|
42
55
|
ensure
|
|
@@ -51,6 +64,10 @@ module Saboteur
|
|
|
51
64
|
end
|
|
52
65
|
end
|
|
53
66
|
|
|
67
|
+
def warn_custom_map
|
|
68
|
+
@diagnostics.warnings.concat(custom_map_warnings(load_custom_map))
|
|
69
|
+
end
|
|
70
|
+
|
|
54
71
|
# Stale entries and duplicate literals never fail a run; they warn so the
|
|
55
72
|
# map does not rot silently. doctor fails on stale keys.
|
|
56
73
|
def load_custom_map
|
|
@@ -67,12 +84,50 @@ module Saboteur
|
|
|
67
84
|
|
|
68
85
|
private
|
|
69
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
|
+
|
|
70
112
|
# The run's immutable report, built once the last result is in.
|
|
71
113
|
def build_report(results, subjects, scope, started)
|
|
72
114
|
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
73
115
|
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
74
116
|
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects,
|
|
75
|
-
diff_scope: scope
|
|
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)
|
|
121
|
+
end
|
|
122
|
+
|
|
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)
|
|
76
131
|
end
|
|
77
132
|
|
|
78
133
|
# The subjects to mutate and, in diff mode, the scope of changed lines.
|
|
@@ -86,10 +141,10 @@ module Saboteur
|
|
|
86
141
|
[subjects, scope]
|
|
87
142
|
end
|
|
88
143
|
|
|
89
|
-
def mutate(subjects, files, integration, scope)
|
|
144
|
+
def mutate(subjects, files, integration, scope, ignore_list, load_lines: {})
|
|
90
145
|
warn_about_unscoped_suite(integration)
|
|
91
146
|
check_baseline(integration)
|
|
92
|
-
map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
|
|
147
|
+
map = CoverageStore.new(config: config, integration: integration, hooks: hooks, load_lines:,
|
|
93
148
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
|
|
94
149
|
inform_about_run(subjects, map)
|
|
95
150
|
cache = result_cache
|
|
@@ -104,13 +159,49 @@ module Saboteur
|
|
|
104
159
|
# mutant when serial. Pack the heap now that the map and candidates
|
|
105
160
|
# are retained, to reduce later copying of shared pages.
|
|
106
161
|
Execution::ForkReadiness.prepare_heap
|
|
107
|
-
results =
|
|
108
|
-
cache: cache, on_start: method(:start_worker)).run(runnable_subjects,
|
|
109
|
-
runnable_lists)
|
|
162
|
+
results = run_candidates(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
|
|
110
163
|
cache&.save
|
|
111
164
|
results
|
|
112
165
|
end
|
|
113
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
|
+
|
|
114
205
|
def inform_about_run(subjects, map)
|
|
115
206
|
@diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
|
|
116
207
|
warn_about_threads
|
|
@@ -123,7 +214,11 @@ module Saboteur
|
|
|
123
214
|
return unless config["since"] && config["tests"].empty?
|
|
124
215
|
|
|
125
216
|
message = "running the loaded suite unscoped: two baseline passes; coverage may add a pass " \
|
|
126
|
-
"(#{integration.test_files.size} spec files).
|
|
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."
|
|
127
222
|
@diagnostics.warn(message)
|
|
128
223
|
@diagnostics.inform(message)
|
|
129
224
|
end
|
|
@@ -131,16 +226,25 @@ module Saboteur
|
|
|
131
226
|
def check_baseline(integration)
|
|
132
227
|
BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
133
228
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
|
|
229
|
+
@stable_suite = true
|
|
134
230
|
end
|
|
135
231
|
|
|
136
232
|
def scheduler_for(map, integration, cache)
|
|
137
|
-
reevaluator = Execution::Reevaluator.new(config["reeval"])
|
|
233
|
+
reevaluator = Execution::Reevaluator.new(config["reeval"], whole_file: config["whole_file"])
|
|
138
234
|
runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
|
|
139
|
-
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)
|
|
140
237
|
Scheduler.new(config: config, runner: runner, map: map, reevaluator: reevaluator, diagnostics: @diagnostics,
|
|
141
238
|
cache: cache)
|
|
142
239
|
end
|
|
143
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
|
+
|
|
144
248
|
def result_cache
|
|
145
249
|
return nil if @replay
|
|
146
250
|
return nil unless config["incremental"]
|
|
@@ -165,19 +269,43 @@ module Saboteur
|
|
|
165
269
|
end
|
|
166
270
|
|
|
167
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
|
+
|
|
168
292
|
def candidate_lists(subjects, scope, map)
|
|
169
293
|
return [@replay_candidate] if @replay
|
|
170
294
|
|
|
171
|
-
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
295
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
|
|
172
296
|
selection = CandidateSelection.new(candidates: candidates, map: map,
|
|
173
297
|
limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
|
|
174
298
|
subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
|
|
175
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
|
|
176
304
|
end
|
|
177
305
|
end
|
|
178
306
|
|
|
179
307
|
def replay_subject(subjects, scope)
|
|
180
|
-
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
308
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics, hazard_guard: hazard_guard)
|
|
181
309
|
subject, candidate = @replay.select(subjects, candidates, scope: scope)
|
|
182
310
|
@replay_candidate = [candidate]
|
|
183
311
|
[[subject], nil]
|
|
@@ -210,6 +338,47 @@ module Saboteur
|
|
|
210
338
|
@diagnostics.warn("#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}")
|
|
211
339
|
end
|
|
212
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)
|
|
380
|
+
end
|
|
381
|
+
|
|
213
382
|
def monotonic
|
|
214
383
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
215
384
|
end
|
|
@@ -219,15 +388,20 @@ end
|
|
|
219
388
|
require_relative "engine/diagnostics"
|
|
220
389
|
require_relative "engine/boot"
|
|
221
390
|
require_relative "engine/subject_selection"
|
|
391
|
+
require_relative "engine/unchanged_syntax"
|
|
222
392
|
require_relative "engine/suite_files"
|
|
393
|
+
require_relative "engine/test_guess"
|
|
394
|
+
require_relative "engine/sandbox_policy"
|
|
223
395
|
require_relative "engine/coverage_store"
|
|
224
396
|
require_relative "engine/baseline_guard"
|
|
397
|
+
require_relative "engine/bytecode_equivalence"
|
|
225
398
|
require_relative "engine/candidates"
|
|
226
399
|
require_relative "engine/candidate_selection"
|
|
227
400
|
require_relative "engine/diff_scope"
|
|
228
401
|
require_relative "engine/result_cache"
|
|
229
402
|
require_relative "engine/verdict_check"
|
|
230
403
|
require_relative "engine/scheduler"
|
|
404
|
+
require_relative "engine/canary"
|
|
231
405
|
require_relative "engine/distribution"
|
|
232
406
|
|
|
233
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,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
|