saboteur 0.6.0 → 0.7.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 +367 -0
- data/README.md +166 -40
- data/exe/saboteur +34 -7
- data/lib/saboteur/baseline/workflow.rb +2 -1
- data/lib/saboteur/baseline_health.rb +40 -6
- data/lib/saboteur/cli/exit_status.rb +49 -4
- data/lib/saboteur/cli/help_options.rb +16 -0
- data/lib/saboteur/cli/presets.rb +36 -0
- data/lib/saboteur/cli/run_budget_options.rb +37 -0
- data/lib/saboteur/cli/run_reporting.rb +74 -5
- data/lib/saboteur/cli.rb +67 -46
- data/lib/saboteur/config.rb +66 -11
- data/lib/saboteur/crash_report.rb +154 -0
- data/lib/saboteur/custom_map.rb +3 -1
- data/lib/saboteur/doctor.rb +40 -1
- data/lib/saboteur/engine/baseline_guard.rb +79 -5
- data/lib/saboteur/engine/boot.rb +12 -1
- data/lib/saboteur/engine/canary.rb +41 -10
- data/lib/saboteur/engine/candidate_selection.rb +9 -1
- data/lib/saboteur/engine/coverage_guard.rb +29 -0
- data/lib/saboteur/engine/coverage_store.rb +48 -6
- data/lib/saboteur/engine/diagnostics.rb +23 -2
- data/lib/saboteur/engine/diff_scope.rb +24 -5
- data/lib/saboteur/engine/distribution.rb +124 -8
- data/lib/saboteur/engine/early_stop_recording.rb +46 -0
- data/lib/saboteur/engine/result_cache.rb +2 -1
- data/lib/saboteur/engine/run_budget.rb +63 -0
- data/lib/saboteur/engine/run_fingerprint.rb +4 -0
- data/lib/saboteur/engine/sandbox_policy.rb +18 -11
- data/lib/saboteur/engine/scheduler.rb +114 -18
- data/lib/saboteur/engine/subject_selection.rb +84 -5
- data/lib/saboteur/engine/suite_files.rb +3 -1
- data/lib/saboteur/engine/test_guess.rb +145 -22
- data/lib/saboteur/engine/thread_warnings.rb +53 -0
- data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
- data/lib/saboteur/engine/verdict_check.rb +54 -18
- data/lib/saboteur/engine.rb +166 -95
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/runner.rb +179 -17
- data/lib/saboteur/execution/sandbox.rb +6 -1
- data/lib/saboteur/execution/supervisor.rb +18 -3
- data/lib/saboteur/execution/worker_pool.rb +87 -7
- data/lib/saboteur/help.rb +55 -11
- data/lib/saboteur/ignore_list.rb +11 -1
- data/lib/saboteur/install.rb +4 -3
- data/lib/saboteur/integration/base.rb +7 -2
- data/lib/saboteur/integration/rspec.rb +40 -4
- data/lib/saboteur/mutation_source/builtin.rb +20 -11
- data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
- data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
- data/lib/saboteur/mutation_source/method_swap.rb +45 -0
- data/lib/saboteur/mutation_source/mutation.rb +5 -3
- data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
- data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
- data/lib/saboteur/preview.rb +2 -1
- data/lib/saboteur/reporter/agent_commands.rb +38 -13
- data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
- data/lib/saboteur/reporter/next_steps.rb +14 -4
- data/lib/saboteur/reporter/score_history.rb +5 -1
- data/lib/saboteur/reporter/stryker.rb +13 -4
- data/lib/saboteur/reporter/survivor_hints.rb +3 -0
- data/lib/saboteur/reporter/terminal.rb +34 -10
- data/lib/saboteur/result.rb +22 -7
- data/lib/saboteur/selection/coverage_map.rb +2 -1
- data/lib/saboteur/selection/git_diff.rb +15 -5
- data/lib/saboteur/selection/untracked_changes.rb +126 -0
- data/lib/saboteur/subject.rb +11 -0
- data/lib/saboteur/text.rb +42 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +16 -1
- metadata +17 -2
|
@@ -27,8 +27,9 @@ module Saboteur
|
|
|
27
27
|
tests.sort_by.with_index { |id, index| [-killers.fetch(id, 0), index] }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil)
|
|
30
|
+
def initialize(config:, runner:, map:, reevaluator:, diagnostics:, cache: nil, deadline: nil)
|
|
31
31
|
@config = config
|
|
32
|
+
@deadline = deadline
|
|
32
33
|
@runner = runner
|
|
33
34
|
@map = map
|
|
34
35
|
@reevaluator = reevaluator
|
|
@@ -73,23 +74,53 @@ module Saboteur
|
|
|
73
74
|
|
|
74
75
|
# Verdicts that did not hold up are warned about once per subject;
|
|
75
76
|
# only checked verdicts are stored for --incremental.
|
|
77
|
+
# A spent budget admits no check: forking the unchanged run with no
|
|
78
|
+
# time left would turn genuine kills into fabricated errors and cache
|
|
79
|
+
# them. Fresh kills stand unverified instead - outside the score,
|
|
80
|
+
# never cached - and the distribution marks the subject so the run
|
|
81
|
+
# reports budget-partial.
|
|
76
82
|
def checked(plan, results)
|
|
83
|
+
return results.map { |result| unverifiable(result) } if budget_spent?
|
|
84
|
+
|
|
77
85
|
subject = plan.subject
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
eval_prefix: eval_prefix(plan))
|
|
81
|
-
.tap { |outcome| note_leftovers(subject, "verdict check", outcome) }
|
|
82
|
-
end
|
|
83
|
-
check = VerdictCheck.new(run_unchanged: run_unchanged, baseline_timeout: @config["baseline_timeout"])
|
|
86
|
+
check = VerdictCheck.new(run_unchanged: unchanged_runner(subject, plan),
|
|
87
|
+
baseline_timeout: @config["baseline_timeout"])
|
|
84
88
|
final = check.call(results)
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
# Unverified verdicts are never cached: a budget-cut check proves
|
|
90
|
+
# nothing, and a stored copy would read as confirmed next run.
|
|
91
|
+
final.reject { |result| result.reused? || result.status == :unverified }.each { |r| @cache&.record(r) }
|
|
92
|
+
overturned = final.zip(results).count { |after, before| after.status == :error && !after.equal?(before) }
|
|
87
93
|
if overturned.positive?
|
|
88
94
|
@diagnostics.warn("#{subject.id}: #{overturned} verdict(s) failed with the method unchanged")
|
|
89
95
|
end
|
|
90
96
|
final
|
|
91
97
|
end
|
|
92
98
|
|
|
99
|
+
# Admission is per sequence, not just at checked() entry: a check
|
|
100
|
+
# admitted with time left can hit its capped timeout mid-check, and a
|
|
101
|
+
# later sequence can arrive after the budget is spent. Neither may
|
|
102
|
+
# fork with no time left, and neither outcome is a rejected kill -
|
|
103
|
+
# the check never got its answer (#296).
|
|
104
|
+
def unchanged_runner(subject, plan)
|
|
105
|
+
lambda do |tests, timeout|
|
|
106
|
+
if budget_spent?
|
|
107
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: 0.0,
|
|
108
|
+
message: "the --max-duration budget is spent")
|
|
109
|
+
else
|
|
110
|
+
capped = budget_capped(timeout)
|
|
111
|
+
outcome = @runner.run(subject: subject, text: subject.source, test_ids: tests, timeout: capped,
|
|
112
|
+
eval_prefix: eval_prefix(plan))
|
|
113
|
+
note_leftovers(subject, "verdict check", outcome)
|
|
114
|
+
if outcome.status == :timeout && capped < timeout
|
|
115
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: outcome.runtime,
|
|
116
|
+
message: "the --max-duration budget cut the verdict check short")
|
|
117
|
+
else
|
|
118
|
+
outcome
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
93
124
|
# When every verdict can be reused, nothing forks: not even the neutral
|
|
94
125
|
# check. Returns the results, or nil at the first one that must run.
|
|
95
126
|
def all_reused(plan, candidates)
|
|
@@ -100,10 +131,16 @@ module Saboteur
|
|
|
100
131
|
end
|
|
101
132
|
end
|
|
102
133
|
|
|
134
|
+
# The budget deadline is checked between mutants, so a spent
|
|
135
|
+
# --max-duration stops inside a long subject too; completed results
|
|
136
|
+
# are kept and the distribution marks what follows as unreached.
|
|
103
137
|
def run_candidates(candidates, &run_one)
|
|
104
138
|
coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
|
|
105
|
-
|
|
106
|
-
|
|
139
|
+
(coarse + fine).each_with_object([]) do |(mutation, text), results|
|
|
140
|
+
break results if @deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
|
|
141
|
+
|
|
142
|
+
results << run_one.call(mutation, text)
|
|
143
|
+
end
|
|
107
144
|
end
|
|
108
145
|
|
|
109
146
|
# A stored verdict that still holds, as a result; nil when it must run.
|
|
@@ -206,19 +243,78 @@ module Saboteur
|
|
|
206
243
|
# baseline_timeout. If the deadline from that real runtime is longer,
|
|
207
244
|
# the mutant runs again with it, and later mutants of the subject get
|
|
208
245
|
# the measured slowdown. A timeout that remains is the mutant's own.
|
|
246
|
+
# A --max-duration deadline caps every admission the scheduler makes:
|
|
247
|
+
# verdict checks and timeout calibrations get only the time left.
|
|
248
|
+
def budget_capped(timeout)
|
|
249
|
+
return timeout unless @deadline
|
|
250
|
+
|
|
251
|
+
remaining = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
252
|
+
remaining.clamp(0.0, timeout)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def budget_spent?
|
|
256
|
+
@deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @deadline
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# A fresh kill whose check the budget skipped proves nothing yet: it
|
|
260
|
+
# becomes unverified, outside the score, and is never cached.
|
|
261
|
+
def unverifiable(result)
|
|
262
|
+
return result if result.reused? || result.status != :killed
|
|
263
|
+
|
|
264
|
+
result.as_unverified("verdict check skipped: the --max-duration budget is spent")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A timeout the budget keeps the calibration from judging proves
|
|
268
|
+
# nothing either: no zero-time forks, and a retry cut short does not
|
|
269
|
+
# count as detected.
|
|
209
270
|
def calibrate(plan, mutation, text, tests, timeout)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
271
|
+
if budget_spent?
|
|
272
|
+
return unverified_outcome(timeout, "timeout calibration skipped: the --max-duration budget is spent")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
unchanged, shortened = unchanged_timing(plan, tests)
|
|
276
|
+
if unchanged.status == :timeout && shortened
|
|
277
|
+
return unverified_outcome(unchanged.runtime, "the --max-duration budget cut the timeout calibration short")
|
|
278
|
+
end
|
|
213
279
|
return unchanged_failed(unchanged) unless unchanged.status == :survived
|
|
214
280
|
|
|
281
|
+
retry_longer(plan, mutation, text, tests, [timeout, unchanged.runtime])
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# The unchanged method under the budget-capped baseline timeout, and
|
|
285
|
+
# whether the cap shortened it.
|
|
286
|
+
def unchanged_timing(plan, tests)
|
|
287
|
+
capped = budget_capped(@config["baseline_timeout"])
|
|
288
|
+
outcome = @runner.run(subject: plan.subject, text: plan.subject.source, test_ids: tests,
|
|
289
|
+
timeout: capped, eval_prefix: eval_prefix(plan))
|
|
290
|
+
note_leftovers(plan.subject, "timeout check", outcome)
|
|
291
|
+
[outcome, capped < @config["baseline_timeout"]]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def retry_longer(plan, mutation, text, tests, limit)
|
|
295
|
+
timeout, unchanged_runtime = limit
|
|
215
296
|
recorded = @map.recorded_runtime(tests)
|
|
216
|
-
plan.slowdown = [plan.slowdown,
|
|
217
|
-
longer = deadline(
|
|
297
|
+
plan.slowdown = [plan.slowdown, unchanged_runtime / recorded].max if recorded&.positive?
|
|
298
|
+
longer = deadline(unchanged_runtime)
|
|
218
299
|
return Execution::Runner::Outcome.new(status: :timeout, runtime: timeout) if longer <= timeout
|
|
300
|
+
# The unchanged timing may have used the final allowance: no
|
|
301
|
+
# zero-time retry fork (#296).
|
|
302
|
+
if budget_spent?
|
|
303
|
+
return unverified_outcome(timeout, "the --max-duration budget is spent before the mutant retry")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
retry_timeout = budget_capped(longer)
|
|
307
|
+
retried = @runner.run(subject: plan.subject, text: text, test_ids: tests, timeout: retry_timeout,
|
|
308
|
+
mutation: mutation, eval_prefix: eval_prefix(plan))
|
|
309
|
+
return retried unless retried.status == :timeout && retry_timeout < longer
|
|
310
|
+
|
|
311
|
+
unverified_outcome(retried.runtime, "the --max-duration budget cut the mutant retry short")
|
|
312
|
+
end
|
|
219
313
|
|
|
220
|
-
|
|
221
|
-
|
|
314
|
+
# A budget-cut calibration proves nothing about the mutant: outside
|
|
315
|
+
# the score, and never cached.
|
|
316
|
+
def unverified_outcome(runtime, message)
|
|
317
|
+
Execution::Runner::Outcome.new(status: :unverified, runtime: runtime, message: message)
|
|
222
318
|
end
|
|
223
319
|
|
|
224
320
|
def unchanged_failed(unchanged)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "../text"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class Engine
|
|
6
8
|
# The subjects to mutate: every def in the included files, narrowed by
|
|
@@ -13,11 +15,12 @@ module Saboteur
|
|
|
13
15
|
@config = config
|
|
14
16
|
@diagnostics = diagnostics
|
|
15
17
|
@root = File.realpath(config.root)
|
|
16
|
-
@changed_lines = nil
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def select(files)
|
|
21
|
+
@files = files
|
|
20
22
|
subjects = find(files)
|
|
23
|
+
@discovered = subjects
|
|
21
24
|
subjects = by_pattern(subjects, @config["subjects"]) if @config["subjects"].any?
|
|
22
25
|
subjects = by_change(subjects, @config["since"]) if @config["since"]
|
|
23
26
|
subjects
|
|
@@ -28,6 +31,22 @@ module Saboteur
|
|
|
28
31
|
id == pattern || %w[# . ::].any? { |separator| id.start_with?("#{pattern}#{separator}") }
|
|
29
32
|
end
|
|
30
33
|
|
|
34
|
+
# The files (absolute paths) holding a subject matching any pattern,
|
|
35
|
+
# returned relative to root. --subject inference guesses specs for
|
|
36
|
+
# exactly these, so one unrelated spec-less file cannot block a
|
|
37
|
+
# one-method run.
|
|
38
|
+
def self.files_matching(root, files, patterns)
|
|
39
|
+
finder = SubjectFinder.new
|
|
40
|
+
prefix = "#{root}/"
|
|
41
|
+
files.filter_map do |file|
|
|
42
|
+
parsed = finder.call(file)
|
|
43
|
+
matching = parsed.subjects.any? do |subject|
|
|
44
|
+
patterns.any? { |pattern| match?(subject.id, pattern) }
|
|
45
|
+
end
|
|
46
|
+
file.delete_prefix(prefix) if matching
|
|
47
|
+
end.sort
|
|
48
|
+
end
|
|
49
|
+
|
|
31
50
|
private
|
|
32
51
|
|
|
33
52
|
def find(files)
|
|
@@ -54,14 +73,74 @@ module Saboteur
|
|
|
54
73
|
end
|
|
55
74
|
|
|
56
75
|
def by_change(subjects, ref)
|
|
57
|
-
@changed_lines = Selection::GitDiff.changed_lines(ref, root: @root
|
|
76
|
+
@changed_lines = Selection::GitDiff.changed_lines(ref, root: @root, include: @files.to_set,
|
|
77
|
+
diagnostics: @diagnostics)
|
|
58
78
|
.transform_keys { |file| Selection::CoverageMap.canonical(file) }
|
|
59
|
-
changed = subjects
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
changed = changed_subjects(subjects)
|
|
80
|
+
warn_changed_files_without_subjects
|
|
81
|
+
warn_excluded_changed_files
|
|
62
82
|
drop_unchanged_syntax(changed, ref)
|
|
63
83
|
end
|
|
64
84
|
|
|
85
|
+
# subject.file carries the locale tag under a non-UTF-8 locale
|
|
86
|
+
# while the diff keys are UTF-8; retag losslessly before lookup so
|
|
87
|
+
# a non-ASCII path matches by its exact bytes (#231).
|
|
88
|
+
def changed_subjects(subjects)
|
|
89
|
+
subjects.select do |subject|
|
|
90
|
+
key = Selection::CoverageMap.canonical(Text.utf8_identity(subject.file))
|
|
91
|
+
lines = @changed_lines[key]
|
|
92
|
+
lines && subject.overlaps_lines?(lines)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A changed Ruby file in scope whose changed lines cover no subject
|
|
97
|
+
# silently contributes nothing to the run (#232): warn, or a typo, an
|
|
98
|
+
# unparseable file or a selection bug passes as a clean selection.
|
|
99
|
+
# Comment-only changes stay quiet: their file matched subjects before
|
|
100
|
+
# drop_unchanged_syntax pruned them, and that prune is reported. A
|
|
101
|
+
# diff touching no included Ruby file is a legitimate empty scope.
|
|
102
|
+
def warn_changed_files_without_subjects
|
|
103
|
+
canonical = ->(file) { Selection::CoverageMap.canonical(Text.utf8_identity(file)) }
|
|
104
|
+
included = @files.map(&canonical)
|
|
105
|
+
matched = changed_subjects(@discovered).map { |subject| canonical.call(subject.file) }
|
|
106
|
+
unmatched = (@changed_lines.keys.select { |file| file.end_with?(".rb") } & included) - matched
|
|
107
|
+
return if unmatched.empty?
|
|
108
|
+
|
|
109
|
+
names = unmatched.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
|
|
110
|
+
more = unmatched.size > 5 ? ", ..." : ""
|
|
111
|
+
@diagnostics.warn("--since: #{unmatched.size} changed Ruby file(s) have no subjects covering " \
|
|
112
|
+
"the changed lines, so nothing is mutated from them: #{names}#{more}. The " \
|
|
113
|
+
"file may not parse, or every change may sit outside a method.")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A changed Ruby file that an include pattern covers but c.exclude
|
|
117
|
+
# removes yields zero candidates with no other sign - this repo's own
|
|
118
|
+
# config excludes lib/saboteur/execution/**, so harness changes once
|
|
119
|
+
# dogfooded as a clean empty scope (#282). Names at most five.
|
|
120
|
+
def warn_excluded_changed_files
|
|
121
|
+
untracked = Selection::UntrackedChanges.paths(@root)
|
|
122
|
+
hits = excluded_territory & (@changed_lines.keys + untracked)
|
|
123
|
+
return if hits.empty?
|
|
124
|
+
|
|
125
|
+
names = hits.sort.first(5).map { |file| file.delete_prefix("#{@root}/") }.join(", ")
|
|
126
|
+
more = hits.size > 5 ? ", ..." : ""
|
|
127
|
+
@diagnostics.warn("--since: #{hits.size} changed Ruby file(s) are covered by c.include but removed by " \
|
|
128
|
+
"c.exclude, so nothing is mutated from them: #{names}#{more}. Narrow or remove the " \
|
|
129
|
+
"c.exclude entry, or pass the file as a positional source operand (configured " \
|
|
130
|
+
"excludes are then ignored).")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Ruby files an include pattern and an exclude pattern both match,
|
|
134
|
+
# canonicalized the way the diff map keys are.
|
|
135
|
+
def excluded_territory
|
|
136
|
+
canonical = lambda do |file|
|
|
137
|
+
Selection::CoverageMap.canonical(File.expand_path(Text.utf8_identity(file), @config.root))
|
|
138
|
+
end
|
|
139
|
+
territory = @config["include"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
|
|
140
|
+
excluded = @config["exclude"].flat_map { |pattern| Dir.glob(pattern, base: @config.root) }
|
|
141
|
+
(territory & excluded).select { |file| file.end_with?(".rb") }.map(&canonical)
|
|
142
|
+
end
|
|
143
|
+
|
|
65
144
|
# A method whose only changes are comments, blank lines or spacing has
|
|
66
145
|
# nothing new to test.
|
|
67
146
|
def drop_unchanged_syntax(subjects, ref)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "shellwords"
|
|
6
|
+
require_relative "../text"
|
|
6
7
|
require_relative "data_inputs"
|
|
7
8
|
|
|
8
9
|
module Saboteur
|
|
@@ -56,7 +57,8 @@ module Saboteur
|
|
|
56
57
|
# `--require name` resolves through RSpec's load path: spec/, lib/ and
|
|
57
58
|
# any -I directory.
|
|
58
59
|
def required_by_options
|
|
59
|
-
tokens = option_files.flat_map { |file| Shellwords.split(File.read(file)) } +
|
|
60
|
+
tokens = option_files.flat_map { |file| Shellwords.split(Text.utf8_identity(File.read(file))) } +
|
|
61
|
+
@config["rspec_args"]
|
|
60
62
|
load_path = LOAD_PATH + option_values(tokens, "-I", "--include")
|
|
61
63
|
option_values(tokens, *REQUIRE_FLAGS).filter_map { |name| resolve(name, load_path) }
|
|
62
64
|
end
|
|
@@ -1,43 +1,167 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
class Engine
|
|
6
|
-
#
|
|
8
|
+
# The specs that sit next to the mutated source files by
|
|
7
9
|
# naming convention (lib/a/b.rb -> spec/a/b_spec.rb). A guess, not a
|
|
8
10
|
# coverage fact: a spec that only reaches the file through another one
|
|
9
11
|
# is not found. Packwerk packs (a directory with a package.yml) map to the
|
|
10
12
|
# spec directory inside the same pack, and controllers also map to request
|
|
11
|
-
# specs.
|
|
13
|
+
# specs. With --since, only files whose changed lines carry a mutation
|
|
14
|
+
# subject need a guessed spec; without --since, every included file with
|
|
15
|
+
# a mutation subject needs one. A file that needs a spec and has none
|
|
16
|
+
# stops the run before boot.
|
|
12
17
|
class TestGuess
|
|
13
|
-
|
|
18
|
+
# hints: unmatched file -> its conventional spec path (which does
|
|
19
|
+
# not exist; that is why the file is unmatched), for the stop-before-boot
|
|
20
|
+
# error. The result also builds its own messages, so a stubbed guess in
|
|
21
|
+
# the engine's specs formats exactly like a real one.
|
|
22
|
+
Result = Data.define(:specs, :unmatched, :subjectless, :changed, :hints) do
|
|
23
|
+
# The stop-before-boot usage error: names each expected spec and the
|
|
24
|
+
# ways out as an append-only instruction - never a reconstructed
|
|
25
|
+
# command, which could carry a hostile ref or a wrong source scope.
|
|
26
|
+
def unmatched_message
|
|
27
|
+
pairs = unmatched.map { |file| "#{file} (expected #{hints.fetch(file)})" }
|
|
28
|
+
"no spec found for #{pairs.join(', ')}, so nothing ran. Re-run the same command with --test " \
|
|
29
|
+
"followed by your existing spec paths, or append --all to use the whole suite without " \
|
|
30
|
+
"changing the source scope."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Why the subjectless files needed no spec.
|
|
34
|
+
def subjectless_message(since:)
|
|
35
|
+
detail = since ? "no changed mutation subjects" : "no mutation subjects"
|
|
36
|
+
"inferred specs: no spec is required for #{subjectless.join(', ')}: they have #{detail}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The warning for a guess-scoped run: the guessed specs now stand as
|
|
40
|
+
# explicit tests, with the cross-file coverage caveat that brings.
|
|
41
|
+
def scope_message(since:)
|
|
42
|
+
what = since ? "a changed file" : "a source file"
|
|
43
|
+
"inferred specs: running only #{specs.join(', ')}. A spec that reaches " \
|
|
44
|
+
"#{what} only through another file does not run, so a mutant only it would kill can " \
|
|
45
|
+
"survive, and a line no inferred spec executes is reported as NoCoverage"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
14
48
|
|
|
15
49
|
SOURCE_ROOTS = %w[lib app].freeze
|
|
16
50
|
|
|
17
|
-
def initialize(config)
|
|
51
|
+
def initialize(config, diagnostics: nil)
|
|
52
|
+
@diagnostics = diagnostics
|
|
18
53
|
@root = File.realpath(config.root)
|
|
19
54
|
@config = config
|
|
20
55
|
end
|
|
21
56
|
|
|
22
|
-
# specs: existing spec files (relative paths); unmatched: changed
|
|
23
|
-
# source files with no guessed spec; changed: how many source files
|
|
24
|
-
# changed.
|
|
25
57
|
def call
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
@config["since"] ? call_diff : call_all
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Subject-scoped inference (--subject runs): only the named subjects'
|
|
62
|
+
# files need a guessed spec; every one of them has a subject by
|
|
63
|
+
# construction, so there is no subjectless bucket. With --since the
|
|
64
|
+
# diff narrows the scope further: a named subject whose file is
|
|
65
|
+
# unchanged, whose changed lines sit in other methods, or whose change
|
|
66
|
+
# is comment or formatting only is out of scope and needs no spec.
|
|
67
|
+
def call_for(files, patterns = [])
|
|
68
|
+
files = effective_subject_files(files, patterns) if @config["since"]
|
|
69
|
+
guesses = files.to_h { |file| [file, guess_for(file)] }
|
|
70
|
+
result(guesses, [], files.size)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# specs: existing spec files (relative paths) guessed for the files in
|
|
74
|
+
# scope; unmatched: such files with no guessed spec; subjectless: files
|
|
75
|
+
# with no mutation subject the run checks, which neither need nor get a
|
|
76
|
+
# spec; changed: how many source files are in scope; hints: each
|
|
77
|
+
# unmatched file's conventional spec path.
|
|
78
|
+
def call_diff
|
|
79
|
+
lines_by_file = changed_lines_by_file
|
|
80
|
+
subjectless = subjectless_files(lines_by_file)
|
|
81
|
+
# Only files with a changed mutation subject get a guessed spec: a
|
|
82
|
+
# subjectless file mutates nothing, so its spec would only narrow the
|
|
83
|
+
# run for no reason.
|
|
84
|
+
guesses = lines_by_file.except(*subjectless).to_h { |file| [file, guess_for(file)] }
|
|
85
|
+
result(guesses, subjectless, lines_by_file.size)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Without --since every included file is in scope: a file needs its
|
|
89
|
+
# guessed spec unless it holds no mutation subject at all.
|
|
90
|
+
def call_all
|
|
91
|
+
finder = SubjectFinder.new
|
|
92
|
+
guesses = {}
|
|
93
|
+
subjectless = []
|
|
94
|
+
included_files.each do |file|
|
|
95
|
+
if finder.call(File.join(@root, file)).subjects.empty?
|
|
96
|
+
subjectless << file
|
|
97
|
+
else
|
|
98
|
+
guesses[file] = guess_for(file)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
result(guesses, subjectless.sort, guesses.size + subjectless.size)
|
|
30
102
|
end
|
|
31
103
|
|
|
32
104
|
private
|
|
33
105
|
|
|
34
|
-
#
|
|
35
|
-
|
|
106
|
+
# The named subjects' files inside the diff scope: the subject must
|
|
107
|
+
# sit on a changed line and survive the comment/formatting prune,
|
|
108
|
+
# mirroring the run's subject selection.
|
|
109
|
+
def effective_subject_files(files, patterns)
|
|
110
|
+
lines_by_file = changed_lines_by_file
|
|
111
|
+
comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
|
|
112
|
+
finder = SubjectFinder.new
|
|
113
|
+
files.select do |file|
|
|
114
|
+
lines = lines_by_file[file]
|
|
115
|
+
next false unless lines
|
|
116
|
+
|
|
117
|
+
finder.call(File.join(@root, file)).subjects.any? do |subject|
|
|
118
|
+
patterns.any? { |pattern| SubjectSelection.match?(subject.id, pattern) } &&
|
|
119
|
+
subject.overlaps_lines?(lines) &&
|
|
120
|
+
!comparison.unchanged?(subject)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def result(guesses, subjectless, changed)
|
|
126
|
+
unmatched = guesses.select { |_, spec| spec.nil? }.keys
|
|
127
|
+
Result.new(specs: guesses.values.compact.uniq.sort,
|
|
128
|
+
unmatched: unmatched,
|
|
129
|
+
subjectless: subjectless,
|
|
130
|
+
changed: changed,
|
|
131
|
+
hints: unmatched.to_h { |file| [file, candidates_for(file).first] })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Included files relative to the root, sorted for stable output.
|
|
135
|
+
def included_files
|
|
136
|
+
prefix = "#{@root}/"
|
|
137
|
+
@config.included_files.filter_map { |file| file.delete_prefix(prefix) if file.start_with?(prefix) }.sort
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Changed files that saboteur would mutate, relative to the root, with
|
|
141
|
+
# their changed line numbers.
|
|
142
|
+
def changed_lines_by_file
|
|
36
143
|
included = @config.included_files.to_set
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
144
|
+
Selection::GitDiff.changed_lines(@config["since"], root: @root, include: included,
|
|
145
|
+
diagnostics: @diagnostics)
|
|
146
|
+
.each_with_object({}) do |(file, lines), map|
|
|
147
|
+
canonical = Selection::CoverageMap.canonical(file)
|
|
148
|
+
map[canonical.delete_prefix("#{@root}/")] = lines if included.include?(canonical)
|
|
149
|
+
end.sort.to_h
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Changed files whose changed lines carry no mutation subject: the run
|
|
153
|
+
# mutates nothing in them, so they cannot require a spec. Mirrors the
|
|
154
|
+
# run's subject selection, including its comment/formatting-only skip:
|
|
155
|
+
# a deletion whose remaining context lines are blank or comments, or a
|
|
156
|
+
# comment-only change inside a method, leaves the file subjectless.
|
|
157
|
+
def subjectless_files(lines_by_file)
|
|
158
|
+
comparison = UnchangedSyntax.new(Selection::GitDiff.merge_base(@config["since"], root: @root), root: @root)
|
|
159
|
+
finder = SubjectFinder.new
|
|
160
|
+
lines_by_file.reject do |file, lines|
|
|
161
|
+
subjects = finder.call(File.join(@root, file)).subjects
|
|
162
|
+
subjects.select { |subject| subject.overlaps_lines?(lines) }
|
|
163
|
+
.any? { |subject| !comparison.unchanged?(subject) }
|
|
164
|
+
end.keys
|
|
41
165
|
end
|
|
42
166
|
|
|
43
167
|
def guess_for(file)
|
|
@@ -71,14 +195,13 @@ module Saboteur
|
|
|
71
195
|
|
|
72
196
|
# The nearest ancestor directory holding a package.yml (a packwerk
|
|
73
197
|
# pack), relative to the root. The root itself is not a pack here:
|
|
74
|
-
#
|
|
198
|
+
# Pathname#ascend stops above "." for these relative paths.
|
|
75
199
|
def pack_of(file)
|
|
76
|
-
|
|
77
|
-
|
|
200
|
+
Pathname(file).ascend do |path|
|
|
201
|
+
dir = path.to_s
|
|
78
202
|
return dir if pack_dirs.include?(dir)
|
|
79
|
-
|
|
80
|
-
dir = File.dirname(dir)
|
|
81
203
|
end
|
|
204
|
+
nil
|
|
82
205
|
end
|
|
83
206
|
|
|
84
207
|
def pack_dirs
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Threads do not survive fork, so a run names the ones alive when the
|
|
7
|
+
# workers fork: by the name the owner gave the thread, else the first
|
|
8
|
+
# frame of its backtrace, so the user can tell whether they matter (a
|
|
9
|
+
# connection pool reaper, Sidekiq, an SDK's background flusher).
|
|
10
|
+
# Known-harmless threads say so inline; an unknown thread gets the
|
|
11
|
+
# generic caveat (#263).
|
|
12
|
+
module ThreadWarnings
|
|
13
|
+
# Well-known fork-time threads, with the condition each assurance
|
|
14
|
+
# hangs on. Evidence: rails-mode children reconnect the database
|
|
15
|
+
# (lib/saboteur/frameworks/rails.rb, after_fork); whether the debug
|
|
16
|
+
# gem's console also attaches to forked children depends on its
|
|
17
|
+
# fork_mode, whose default is :both, i.e. attach (RUBY_DEBUG_FORK_MODE;
|
|
18
|
+
# ruby/debug's config.rb and SESSION.activate on_fork: true in
|
|
19
|
+
# session.rb).
|
|
20
|
+
KNOWN_FORK_THREADS = {
|
|
21
|
+
"AR Pool Reaper" => "ActiveRecord's connection-pool reaper: expected harmless when children " \
|
|
22
|
+
"reconnect the database after fork (rails mode does) - it only maintains " \
|
|
23
|
+
"the parent's pool",
|
|
24
|
+
"DEBUGGER__::SESSION@server" => "the debug gem's remote-console server: what it does in " \
|
|
25
|
+
"children depends on RUBY_DEBUG_FORK_MODE - with parent no " \
|
|
26
|
+
"debugger runs in children; with child or both (the gem's " \
|
|
27
|
+
"default) the debugger activates in children and can pause or " \
|
|
28
|
+
"interfere with supervised mutation runs. For an unattended " \
|
|
29
|
+
"run, do not start the remote debugger at all: drop " \
|
|
30
|
+
"RUBY_DEBUG_OPEN or require \"debug\" without :open"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def self.warn(diagnostics)
|
|
34
|
+
others = Thread.list - [Thread.current]
|
|
35
|
+
return if others.empty?
|
|
36
|
+
|
|
37
|
+
unknown = false
|
|
38
|
+
list = others.map do |thread|
|
|
39
|
+
name = thread.name || thread.backtrace&.first || "unknown"
|
|
40
|
+
explanation = KNOWN_FORK_THREADS[name]
|
|
41
|
+
unknown = true unless explanation
|
|
42
|
+
explanation ? "#{name} (#{explanation})" : name
|
|
43
|
+
end.join(", ")
|
|
44
|
+
message = "#{others.size} other thread(s) alive at fork time; they do not run in children: #{list}"
|
|
45
|
+
if unknown
|
|
46
|
+
message += "; a thread that does not run in a child cannot do background work a mutant's test may " \
|
|
47
|
+
"rely on (a pool reaper, a flusher), so results can differ when one is left behind"
|
|
48
|
+
end
|
|
49
|
+
diagnostics.warn(message)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# A whole-suite run (--all, c.test_selection = :all, or a --since diff
|
|
7
|
+
# with nothing to infer from) runs the loaded suite for the baseline
|
|
8
|
+
# checks; a cold coverage cache adds one more pass. Say so - with the
|
|
9
|
+
# example count and the real pass count - before those hours pass; past
|
|
10
|
+
# a size threshold, add the way to scope it (#260). The check count
|
|
11
|
+
# comes from c.baseline_passes (#55).
|
|
12
|
+
module UnscopedSuiteWarning
|
|
13
|
+
# A suite this size pays hours for an unscoped run; below it the cost
|
|
14
|
+
# summary alone is enough, without the --test lecture (#260).
|
|
15
|
+
COSTLY_SUITE_EXAMPLES = 1_000
|
|
16
|
+
REUSED_MAP_NOTE = "; the cached coverage map is reused"
|
|
17
|
+
|
|
18
|
+
def self.call(config:, diagnostics:, integration:, full_passes:)
|
|
19
|
+
return unless config["tests"].empty?
|
|
20
|
+
return unless config["since"] || config["all"] || config["test_selection"] == "all"
|
|
21
|
+
|
|
22
|
+
examples = integration.all_tests.size
|
|
23
|
+
coverage = full_passes == config["baseline_passes"] ? REUSED_MAP_NOTE : " + 1 coverage map build"
|
|
24
|
+
message = "running the loaded suite unscoped: #{examples} examples in #{integration.test_files.size} " \
|
|
25
|
+
"spec files; #{full_passes} full passes (#{checks_label(config)}#{coverage})."
|
|
26
|
+
if examples >= COSTLY_SUITE_EXAMPLES
|
|
27
|
+
message += " Pass --test to run only the specs you name: faster, but a mutant of a changed method is then " \
|
|
28
|
+
"killed only by those specs, so specs elsewhere that call this code are not run. " \
|
|
29
|
+
"The default inferred selection runs the specs named after the mutated files, " \
|
|
30
|
+
"a guess that can miss specs elsewhere."
|
|
31
|
+
end
|
|
32
|
+
diagnostics.warn(message)
|
|
33
|
+
diagnostics.inform(message)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.checks_label(config)
|
|
37
|
+
"#{config['baseline_passes']} baseline check#{'s' unless config['baseline_passes'] == 1}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|