saboteur 0.7.0 → 0.8.1
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 +772 -0
- data/README.md +221 -1072
- data/lib/saboteur/attribute_finder.rb +113 -0
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +47 -3
- data/lib/saboteur/cli/run_budget_options.rb +3 -1
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +35 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +74 -55
- data/lib/saboteur/config.rb +102 -6
- data/lib/saboteur/config_file.rb +19 -7
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/custom_map.rb +12 -1
- data/lib/saboteur/dirty_tree_warning.rb +130 -0
- data/lib/saboteur/doctor.rb +13 -0
- data/lib/saboteur/engine/baseline_guard.rb +135 -21
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +20 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +58 -14
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +19 -1
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +37 -0
- data/lib/saboteur/engine/load_coverage.rb +63 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +115 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +32 -2
- data/lib/saboteur/engine/sandbox_policy.rb +59 -3
- data/lib/saboteur/engine/scheduler.rb +131 -17
- data/lib/saboteur/engine/selection_announcement.rb +59 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +380 -21
- data/lib/saboteur/engine/test_guess.rb +407 -27
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +103 -129
- data/lib/saboteur/execution/external_runner.rb +489 -0
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/marker_sweep.rb +80 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +19 -3
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +75 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +25 -6
- data/lib/saboteur/execution/seatbelt.rb +233 -0
- data/lib/saboteur/execution/supervisor.rb +29 -11
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +223 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +28 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +125 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +56 -8
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +120 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +14 -0
- metadata +39 -16
|
@@ -15,7 +15,8 @@ module Saboteur
|
|
|
15
15
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
16
16
|
LOG = "coverage-build.log"
|
|
17
17
|
|
|
18
|
-
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {},
|
|
18
|
+
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:, load_lines: {}, load_methods: {},
|
|
19
|
+
timeout_cap: nil)
|
|
19
20
|
@config = config
|
|
20
21
|
@integration = integration
|
|
21
22
|
@hooks = hooks
|
|
@@ -23,7 +24,9 @@ module Saboteur
|
|
|
23
24
|
@diagnostics = diagnostics
|
|
24
25
|
@timeout_cap = timeout_cap
|
|
25
26
|
@load_lines = load_lines
|
|
27
|
+
@load_methods = load_methods
|
|
26
28
|
@root = File.realpath(config.root)
|
|
29
|
+
@root_prefixes = Selection::CoverageMap.root_prefixes(config.root)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
# True when fetch would reuse the cached map instead of running the
|
|
@@ -37,10 +40,11 @@ module Saboteur
|
|
|
37
40
|
|
|
38
41
|
def fetch(files)
|
|
39
42
|
path = cache_path(files)
|
|
43
|
+
preloaded = LoadCoverage.preloaded_uninstrumented(files)
|
|
40
44
|
cached = cached_map(files)
|
|
41
|
-
return refuse_empty_map(cached, files) if cached
|
|
45
|
+
return refuse_empty_map(cached.with_preloaded(preloaded), files, preloaded) if cached
|
|
42
46
|
|
|
43
|
-
map = refuse_empty_map(build(files), files)
|
|
47
|
+
map = refuse_empty_map(build(files).with_preloaded(preloaded), files, preloaded)
|
|
44
48
|
map.save(path)
|
|
45
49
|
File.write(File.join(@config.path("cache_dir"), "preview.json"),
|
|
46
50
|
JSON.generate(path: path, fingerprint: RunFingerprint.new(config: @config).snapshot.digest))
|
|
@@ -49,6 +53,21 @@ module Saboteur
|
|
|
49
53
|
|
|
50
54
|
private
|
|
51
55
|
|
|
56
|
+
def write_map(writer, recorder, build_seconds)
|
|
57
|
+
writer.write(recorder.result(build_seconds: build_seconds, loaded_files: loaded_project_files).to_json)
|
|
58
|
+
writer.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Every project source this child loaded, even a lazy require outside the
|
|
62
|
+
# include list that the recorder drops: --incremental must watch it, or a
|
|
63
|
+
# change there keeps stale verdicts (#353).
|
|
64
|
+
def loaded_project_files
|
|
65
|
+
$LOADED_FEATURES.filter_map do |file|
|
|
66
|
+
path = Text.utf8_strict(Selection::CoverageMap.canonical(file))
|
|
67
|
+
path if path.end_with?(".rb") && @root_prefixes.any? { |prefix| path.start_with?(prefix) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
52
71
|
def cache_path(files)
|
|
53
72
|
File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
|
|
54
73
|
end
|
|
@@ -122,13 +141,16 @@ module Saboteur
|
|
|
122
141
|
# The parent's load lines merge into the freshly built map: the forked
|
|
123
142
|
# build child cannot inherit the parent's Coverage counters.
|
|
124
143
|
def with_parent_load_lines(map)
|
|
125
|
-
@load_lines.empty? ? map : map.with_load_lines(@load_lines)
|
|
144
|
+
@load_lines.empty? && @load_methods.empty? ? map : map.with_load_lines(@load_lines, @load_methods)
|
|
126
145
|
end
|
|
127
146
|
|
|
128
147
|
# A whole-suite build that records nothing - no line hit and no method
|
|
129
|
-
# call - in any included file fails the run (exit 3)
|
|
130
|
-
#
|
|
131
|
-
#
|
|
148
|
+
# call - in any included file fails the run (exit 3). The recorder's map
|
|
149
|
+
# also holds the suite's spec files, so the check is per included file,
|
|
150
|
+
# not whole-map: a suite that tests nothing here still records its own
|
|
151
|
+
# spec code. Usually no test in the suite exercises these files, or
|
|
152
|
+
# the setup hid the app from Coverage (Bootsnap's compile cache is the
|
|
153
|
+
# default Rails case). An
|
|
132
154
|
# all-endless-def file records method calls only and passes. Either way
|
|
133
155
|
# a run on a truly empty map would test nothing, and exit 0 would lie.
|
|
134
156
|
# With explicit tests (--test) an empty map is a legitimate outcome
|
|
@@ -136,9 +158,20 @@ module Saboteur
|
|
|
136
158
|
# these sources, so the map passes with a warning. Applies to a cached
|
|
137
159
|
# map too, so a map cached before this check cannot keep a run green,
|
|
138
160
|
# and the guard runs before saving, so an empty map is never cached.
|
|
139
|
-
def refuse_empty_map(map, files)
|
|
140
|
-
return map if files.empty? || !map.
|
|
141
|
-
|
|
161
|
+
def refuse_empty_map(map, files, preloaded)
|
|
162
|
+
return map if files.empty? || !map.empty_for?(files)
|
|
163
|
+
|
|
164
|
+
# A file loaded before the executable's Coverage.start can never be
|
|
165
|
+
# instrumented, so emptiness there is a setup defect, not untested
|
|
166
|
+
# code: name it apart from the generic no-coverage refusal.
|
|
167
|
+
preloaded_empty = preloaded & files.map { |file| Selection::CoverageMap.canonical(file) }
|
|
168
|
+
if preloaded_empty.any?
|
|
169
|
+
message = preloaded_message(preloaded_empty)
|
|
170
|
+
raise Error, message if @config["tests"].empty?
|
|
171
|
+
|
|
172
|
+
@diagnostics.warn(message)
|
|
173
|
+
return map
|
|
174
|
+
end
|
|
142
175
|
if @config["tests"].empty?
|
|
143
176
|
raise Error, "Coverage recorded nothing in #{files.size} included files during a whole-suite " \
|
|
144
177
|
"build. No test in the suite may exercise these files, or the setup hid the app " \
|
|
@@ -149,6 +182,16 @@ module Saboteur
|
|
|
149
182
|
map
|
|
150
183
|
end
|
|
151
184
|
|
|
185
|
+
def preloaded_message(files)
|
|
186
|
+
names = files.first(5).map { |file| file.delete_prefix("#{@root}/") }
|
|
187
|
+
more = files.size > 5 ? ", ..." : ""
|
|
188
|
+
"#{names.join(', ')}#{more} loaded before saboteur started coverage (a require ran before the " \
|
|
189
|
+
"executable's Coverage.start - commonly the gemspec requiring the library while bundler/setup " \
|
|
190
|
+
"evaluates it, or RUBYOPT -r), so no test can ever cover #{files.size > 1 ? 'them' : 'it'}. " \
|
|
191
|
+
"Let the test suite load these " \
|
|
192
|
+
"files; a gemspec should require only its version file."
|
|
193
|
+
end
|
|
194
|
+
|
|
152
195
|
# Bootsnap is named only when it is loaded: blaming a library the app
|
|
153
196
|
# does not use sends the reader to the wrong place.
|
|
154
197
|
def bootsnap_note
|
|
@@ -175,7 +218,10 @@ module Saboteur
|
|
|
175
218
|
$stderr.reopen($stdout)
|
|
176
219
|
@hooks.run(:after_fork)
|
|
177
220
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
178
|
-
|
|
221
|
+
# The suite's own files are recorded too: a spec can read a
|
|
222
|
+
# constant directly, and without its line hits no test maps to the
|
|
223
|
+
# read (#319).
|
|
224
|
+
recorder = Selection::Recorder.new(files + @integration.test_files)
|
|
179
225
|
recorder.start
|
|
180
226
|
Kernel.srand(@config["seed"])
|
|
181
227
|
passed = @integration.run_all(recorder)
|
|
@@ -183,9 +229,7 @@ module Saboteur
|
|
|
183
229
|
raise Error, "the unchanged suite failed while building coverage. Fix the suite, then run again " \
|
|
184
230
|
"with seed #{@config['seed']}."
|
|
185
231
|
end
|
|
186
|
-
|
|
187
|
-
writer.write(recorder.result(build_seconds: build_seconds).to_json)
|
|
188
|
-
writer.close
|
|
232
|
+
write_map(writer, recorder, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
189
233
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
190
234
|
writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
|
|
191
235
|
exit!(1)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# The examples an order-dependent spec file needs, in the order RSpec
|
|
7
|
+
# defines them: for each file with a covering example, every example from
|
|
8
|
+
# the start of the file through its last covering example. Later examples
|
|
9
|
+
# cannot change earlier ones, so this is the smallest run that keeps
|
|
10
|
+
# whatever state the earlier examples leave behind.
|
|
11
|
+
class DefinedOrderPrefix
|
|
12
|
+
ID_PATTERN = /\A(?<file>.*)\[(?<path>[\d:]+)\]\z/
|
|
13
|
+
|
|
14
|
+
# every_test: all example ids the map knows, in any order.
|
|
15
|
+
def initialize(every_test)
|
|
16
|
+
@every_test = every_test
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The prefix for the covering examples; nil when it adds nothing to
|
|
20
|
+
# what already ran (an id that is not an RSpec id included).
|
|
21
|
+
def for(covering)
|
|
22
|
+
parsed = covering.map { |id| parse(id) }
|
|
23
|
+
return nil if parsed.any?(&:nil?)
|
|
24
|
+
|
|
25
|
+
prefix = parsed.group_by(&:first).flat_map { |file, entries| file_prefix(file, entries.map(&:last).max) }
|
|
26
|
+
prefix == covering ? nil : prefix
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def file_prefix(file, last_position)
|
|
32
|
+
in_file = @every_test.filter_map { |id| (entry = parse(id)) && entry.first == file ? entry : nil }
|
|
33
|
+
through = in_file.select { |entry| (entry.last <=> last_position) <= 0 }.sort_by(&:last)
|
|
34
|
+
through.map { |entry| "#{file}[#{entry.last.join(':')}]" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse(id)
|
|
38
|
+
match = ID_PATTERN.match(id)
|
|
39
|
+
match && [match[:file], match[:path].split(":").map(&:to_i)]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -74,7 +74,7 @@ module Saboteur
|
|
|
74
74
|
DiscoveryFailure = Data.define(:file, :reason)
|
|
75
75
|
|
|
76
76
|
attr_reader :warnings, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
|
|
77
|
-
:hazard_suppressions, :inline_suppressions
|
|
77
|
+
:hazard_suppressions, :inline_suppressions, :verbose
|
|
78
78
|
|
|
79
79
|
def initialize(err, verbose: false)
|
|
80
80
|
@err = err
|
|
@@ -86,12 +86,30 @@ module Saboteur
|
|
|
86
86
|
@discovery_failures = []
|
|
87
87
|
@hazard_suppressions = 0
|
|
88
88
|
@inline_suppressions = []
|
|
89
|
+
@erb_template_skips = []
|
|
90
|
+
@erb_template_note_emitted = false
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
def warn(message)
|
|
92
94
|
@warnings << message
|
|
93
95
|
end
|
|
94
96
|
|
|
97
|
+
# ERB generator templates (Rails generators carry `.rb` files with ERB
|
|
98
|
+
# interpolation, conventionally under `lib/generators/**/templates/`)
|
|
99
|
+
# can never parse as plain Ruby, so they can never hold mutation
|
|
100
|
+
# subjects (#372). One summarized note per run replaces the per-file
|
|
101
|
+
# parse warning every discovery pass used to print; a real syntax
|
|
102
|
+
# error in a plain Ruby file still warns per file.
|
|
103
|
+
def record_erb_template_skip(file, root:)
|
|
104
|
+
relative = file.to_s.delete_prefix("#{root}/")
|
|
105
|
+
@erb_template_skips << relative.freeze unless @erb_template_skips.include?(relative)
|
|
106
|
+
return if @erb_template_note_emitted
|
|
107
|
+
|
|
108
|
+
@erb_template_note_emitted = true
|
|
109
|
+
warn("skipping ERB template files that cannot parse as Ruby " \
|
|
110
|
+
"(for example #{relative}); templates are not mutation subjects")
|
|
111
|
+
end
|
|
112
|
+
|
|
95
113
|
def count_invalid_mutation
|
|
96
114
|
@invalid_mutations += 1
|
|
97
115
|
end
|
|
@@ -15,6 +15,16 @@ module Saboteur
|
|
|
15
15
|
# whose fresh kills the spent budget left unchecked (#296).
|
|
16
16
|
attr_reader :unreached, :truncated, :unverified
|
|
17
17
|
|
|
18
|
+
# The run's peak-memory record for the JSON report (#202): each
|
|
19
|
+
# worker's kernel peak RSS and the supervisor's own, nil when the
|
|
20
|
+
# platform measured neither so the report omits the section. No total:
|
|
21
|
+
# fork copy-on-write makes per-process RSS non-additive.
|
|
22
|
+
def memory_report
|
|
23
|
+
workers = (@pool&.worker_peaks || {}).sort.map { |number, mb| { worker: number, peakMb: mb } }
|
|
24
|
+
supervisor = Execution::PeakMemory.peak_mb
|
|
25
|
+
workers.empty? && supervisor.nil? ? nil : { workers: workers, supervisorPeakMb: supervisor }.compact
|
|
26
|
+
end
|
|
27
|
+
|
|
18
28
|
# stop_after: nil, or a callable that gets one subject's results and
|
|
19
29
|
# returns true to stop before the next subject (--fail-fast). It only
|
|
20
30
|
# works on one process: parallel workers have already started the
|
|
@@ -10,12 +10,13 @@ module Saboteur
|
|
|
10
10
|
private
|
|
11
11
|
|
|
12
12
|
# The subjects a stop left unrun or mid-way: the verdict covers less than the selection.
|
|
13
|
-
def record_early_stop(distribution)
|
|
13
|
+
def record_early_stop(distribution, planned_mutants: nil)
|
|
14
14
|
return if distribution.unreached.empty? && distribution.truncated.empty? && distribution.unverified.empty?
|
|
15
15
|
|
|
16
16
|
truncated = distribution.truncated.map { |subject, omitted| { subject: subject.id, omitted: omitted } }
|
|
17
17
|
@early_stop = EarlyStop.new(reason: stop_reason(distribution), unreached: distribution.unreached.map(&:id),
|
|
18
|
-
truncated: truncated, unverified: distribution.unverified.map(&:id)
|
|
18
|
+
truncated: truncated, unverified: distribution.unverified.map(&:id),
|
|
19
|
+
planned_mutants: planned_mutants)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
# Mid-subject omissions and skipped checks are budget work: only a
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# A threshold or mutant-count gate judges the score, so a path that
|
|
7
|
+
# narrows what the score covers (a partially mapped --since run, #370)
|
|
8
|
+
# must keep its hard stop instead of green-lighting the change.
|
|
9
|
+
module GatedRun
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def gated?
|
|
13
|
+
config["threshold"] || Gates.new(config).any?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Reports the guess's skipped and subjectless files, then stops or
|
|
17
|
+
# warns on unmapped files per the gate (#370).
|
|
18
|
+
def report_unmapped(guess)
|
|
19
|
+
@diagnostics.inform(guess.subjectless_message(since: config["since"])) if guess.subjectless.any?
|
|
20
|
+
inform_skipped_specs(guess)
|
|
21
|
+
stop_or_warn_unmapped(guess)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A --since run with a partial mapping runs the mapped subset with a
|
|
25
|
+
# loud warning per unmapped file (#370); a full run, a zero guess and
|
|
26
|
+
# a gated run keep the hard stop.
|
|
27
|
+
def stop_or_warn_unmapped(guess)
|
|
28
|
+
return if guess.unmatched.empty?
|
|
29
|
+
if !config["since"] || guess.specs.empty? || gated?
|
|
30
|
+
raise ConfigError, guess.unmatched_message(verbose: @diagnostics.verbose)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
guess.unmatched.each { |file| @diagnostics.announce(guess.unmatched_warning(file)) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "coverage"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# The boot's coverage snapshot, read in the parent because a forked
|
|
9
|
+
# child does not inherit Coverage counters: the lines and the def
|
|
10
|
+
# calls that ran while the app and spec files loaded. The executable
|
|
11
|
+
# counts from its first line, so this snapshot holds the whole boot.
|
|
12
|
+
# The lines tell ran-at-load mutants apart from uncovered ones; the
|
|
13
|
+
# def calls name def-time macros whose only caller ran here, so their
|
|
14
|
+
# mutants read as ran at load instead of surviving on the pristine
|
|
15
|
+
# closure (#409).
|
|
16
|
+
module LoadCoverage
|
|
17
|
+
# Returns [load_lines, load_methods]: file => line numbers for each.
|
|
18
|
+
def self.capture(files)
|
|
19
|
+
return [{}, {}] unless Coverage.state == :suspended
|
|
20
|
+
|
|
21
|
+
wanted = files.to_set { |file| Selection::CoverageMap.canonical(file) }
|
|
22
|
+
CoverageReads.result(stop: false, clear: false)
|
|
23
|
+
.each_with_object([{}, {}]) do |(path, data), (lines_h, methods_h)|
|
|
24
|
+
file = Selection::CoverageMap.canonical(path)
|
|
25
|
+
next unless wanted.include?(file)
|
|
26
|
+
|
|
27
|
+
lines = data[:lines].each_with_index.filter_map { |count, index| index + 1 if count&.positive? }
|
|
28
|
+
lines -= uncalled_shared_lines(data)
|
|
29
|
+
lines_h[file] = lines unless lines.empty?
|
|
30
|
+
defs = data.fetch(:methods, {}).filter_map { |(_owner, _name, first, *), count| first if count.positive? }
|
|
31
|
+
methods_h[file] = defs.uniq.sort unless defs.empty?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Include-listed files that are already loaded yet carry no coverage
|
|
36
|
+
# instrumentation: something required them before the executable's
|
|
37
|
+
# Coverage.start (a gemspec requiring the library while bundler/setup
|
|
38
|
+
# evaluates it, or RUBYOPT -r), so no test can ever be attributed to
|
|
39
|
+
# them. Read in the parent, where the snapshot still reflects the boot.
|
|
40
|
+
def self.preloaded_uninstrumented(files, loaded: $LOADED_FEATURES)
|
|
41
|
+
return [] unless Coverage.state == :suspended
|
|
42
|
+
|
|
43
|
+
instrumented = CoverageReads.result(stop: false, clear: false)
|
|
44
|
+
.each_key
|
|
45
|
+
.to_set { |path| Selection::CoverageMap.canonical(path) }
|
|
46
|
+
loaded_set = loaded.to_set { |path| Selection::CoverageMap.canonical(path) }
|
|
47
|
+
files.map { |file| Selection::CoverageMap.canonical(file) }
|
|
48
|
+
.uniq
|
|
49
|
+
.reject { |file| instrumented.include?(file) }
|
|
50
|
+
.select { |file| loaded_set.include?(file) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A one-line or endless definition executes its def line even when its
|
|
54
|
+
# body is never called. If any sibling on that physical line is uncalled,
|
|
55
|
+
# discard its ambiguous line evidence rather than crediting every body.
|
|
56
|
+
def self.uncalled_shared_lines(data)
|
|
57
|
+
shared = data.fetch(:methods, {}).select { |(_owner, _name, first, _column, last, *), _| first == last }
|
|
58
|
+
calls = shared.group_by { |(_owner, _name, first, *), _count| first }
|
|
59
|
+
calls.filter_map { |line, entries| line if entries.any? { |_key, count| count.zero? } }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# The --since stop whose changed source files all sit outside the
|
|
7
|
+
# include scope (#483), kept apart from the guess flow it guards.
|
|
8
|
+
module OutOfScopeGuard
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Every changed source file sits outside the include scope (#483): the
|
|
12
|
+
# whole-suite fallback would run the slowest possible suite against
|
|
13
|
+
# zero subjects, so stop and name what the scope hid.
|
|
14
|
+
def refuse_out_of_scope_changes(guess)
|
|
15
|
+
return unless guess.changed.zero? && guess.out_of_scope.any?
|
|
16
|
+
|
|
17
|
+
raise ConfigError, guess.out_of_scope_message
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../execution/test_command_shape"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# The two run pipelines: in-process (boot, coverage, workers) and the
|
|
9
|
+
# --test-command external mode (#140).
|
|
10
|
+
module Pipelines
|
|
11
|
+
# The in-process pipeline: boot the target, mutate under coverage, and
|
|
12
|
+
# clean the workers up however the run ends.
|
|
13
|
+
def run_pipeline(ignore_list, started)
|
|
14
|
+
return call_external(ignore_list, started) if external_command?
|
|
15
|
+
|
|
16
|
+
boot = Boot.new(config, hooks)
|
|
17
|
+
begin
|
|
18
|
+
integration, files, load_lines, load_methods = boot_target(boot)
|
|
19
|
+
@rails = boot.rails
|
|
20
|
+
subjects, scope = select_subjects(files, test_files: integration.test_files)
|
|
21
|
+
results = if subjects.empty?
|
|
22
|
+
[]
|
|
23
|
+
else
|
|
24
|
+
mutate(subjects, files, integration, scope, ignore_list,
|
|
25
|
+
load_lines:, load_methods:)
|
|
26
|
+
end
|
|
27
|
+
@diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
|
|
28
|
+
build_report(results, subjects, scope, started)
|
|
29
|
+
ensure
|
|
30
|
+
# Cleanup runs however the call ends - a partway boot failure, no
|
|
31
|
+
# subjects, no runnable work - so a host embedding Engine never
|
|
32
|
+
# holds worker copies until process exit. Idempotent; the Rails
|
|
33
|
+
# profile's at_exit handler stays the fallback for abrupt exit.
|
|
34
|
+
begin
|
|
35
|
+
@coverage_guard&.resume # an embedded host gets its coverage back (#254)
|
|
36
|
+
ensure
|
|
37
|
+
safely_cleanup_workers(boot.rails)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The --test-command pipeline (#140): no boot, no coverage, no
|
|
43
|
+
# baseline - every mutant runs the command on the mutated tree,
|
|
44
|
+
# serially, with no sandbox. The command is arbitrary user code.
|
|
45
|
+
def call_external(ignore_list, started)
|
|
46
|
+
@diagnostics.inform("external test command: #{config['test_command']} " \
|
|
47
|
+
"(serial, no coverage, unsandboxed, mutants applied in a disposable copy)")
|
|
48
|
+
if Execution::TestCommandShape.multi_file_ruby?(config["test_command"])
|
|
49
|
+
@diagnostics.warn("the test command hands several test files to one ruby: ruby executes only " \
|
|
50
|
+
"the first, so the rest never run and their mutants silently survive (#611) - " \
|
|
51
|
+
"use the framework's multi-file form (bin/rails test f1 f2, rspec f1 f2) or a " \
|
|
52
|
+
"shell loop (for f in ...; do ruby \"$f\" || exit 1; done)")
|
|
53
|
+
end
|
|
54
|
+
files = config.included_files
|
|
55
|
+
subjects, scope = select_subjects(files)
|
|
56
|
+
results = subjects.empty? ? [] : mutate_external(subjects, scope, ignore_list)
|
|
57
|
+
@diagnostics.warnings.concat(ignore_list.apply(results, unscoped_digests: @unscoped_digests))
|
|
58
|
+
build_report(results, subjects, scope, started)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mutate_external(subjects, scope, ignore_list)
|
|
62
|
+
runner = external_runner
|
|
63
|
+
# One neutral run before any mutant exists: a command that fails on
|
|
64
|
+
# the unchanged tree can never claim kills (#305). It answers to the
|
|
65
|
+
# run budget with an ABSOLUTE deadline, admitted after the tree copy:
|
|
66
|
+
# a budget already spent is a budget stop, not a command error.
|
|
67
|
+
begin
|
|
68
|
+
runner.preflight!(deadline: preflight_deadline, budget_deadline: budget_deadline)
|
|
69
|
+
rescue BudgetPhaseTimeout => e
|
|
70
|
+
return budget_phase_stop(e, subjects)
|
|
71
|
+
end
|
|
72
|
+
map = Selection::ExternalTestMap.new
|
|
73
|
+
scheduler = Scheduler.new(config:, runner:, map:,
|
|
74
|
+
reevaluator: Execution::Reevaluator.new(config["reeval"],
|
|
75
|
+
whole_file: config["whole_file"]),
|
|
76
|
+
diagnostics: @diagnostics, deadline: budget_deadline, verify_loaded: false)
|
|
77
|
+
lists = candidate_lists(subjects, scope, map)
|
|
78
|
+
work = subjects.zip(lists).reject { |_, list| list.empty? }
|
|
79
|
+
return [] if work.empty?
|
|
80
|
+
|
|
81
|
+
runnable_subjects, runnable_lists = work.transpose
|
|
82
|
+
begin
|
|
83
|
+
warn_unless_mutants_visible(runner, runnable_subjects.first)
|
|
84
|
+
rescue BudgetPhaseTimeout => e
|
|
85
|
+
return budget_phase_stop(e, subjects)
|
|
86
|
+
end
|
|
87
|
+
run_candidates(runnable_subjects, runnable_lists, scheduler, nil, ignore_list)
|
|
88
|
+
ensure
|
|
89
|
+
runner.close
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Warn loudly when the command cannot see mutants at all: an
|
|
93
|
+
# anchored or never-loaded file reads as 100 % survivors (#441).
|
|
94
|
+
def warn_unless_mutants_visible(runner, subject)
|
|
95
|
+
warning = runner.visibility_check(subject: subject, deadline: preflight_deadline,
|
|
96
|
+
budget_deadline: budget_deadline)
|
|
97
|
+
@diagnostics.warn(warning) if warning
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def external_command? = !config["test_command"].to_s.empty?
|
|
101
|
+
|
|
102
|
+
# The preflight's absolute monotonic deadline: its own
|
|
103
|
+
# baseline_timeout slice, or the run budget's end, whichever is
|
|
104
|
+
# sooner. Absolute, so the tree copy before admission cannot eat the
|
|
105
|
+
# budget while the command still gets a full timeout (#305 Architect).
|
|
106
|
+
def preflight_deadline
|
|
107
|
+
own = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config["baseline_timeout"]
|
|
108
|
+
deadline = budget_deadline
|
|
109
|
+
deadline ? [own, deadline].min : own
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def external_runner = Execution::ExternalRunner.new(command: config["test_command"], root: @root)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -15,25 +15,36 @@ module Saboteur
|
|
|
15
15
|
# same: the method's source, the whole file the method lives in (so a
|
|
16
16
|
# constant or helper changed elsewhere in that file is seen), the mutant,
|
|
17
17
|
# the set of covering tests, the content of those tests' files, the
|
|
18
|
-
# lockfile, the run's inputs (config, saboteur version)
|
|
19
|
-
#
|
|
18
|
+
# lockfile, the run's inputs (config, saboteur version), and every source
|
|
19
|
+
# file a covering test touched (a method it calls, a constant defined
|
|
20
|
+
# elsewhere - #197). Only killed, survived and timeout are stored;
|
|
20
21
|
# errors and skips always run again.
|
|
21
22
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
23
|
+
# When the dependency set is unknown - no coverage map, or an empty one -
|
|
24
|
+
# every source file is watched instead: reuse turns conservative, never
|
|
25
|
+
# stale.
|
|
25
26
|
class ResultCache
|
|
26
27
|
FORMAT = 1
|
|
27
28
|
STORED = %i[killed survived timeout].freeze
|
|
28
29
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
30
|
+
# Dependency declarations themselves, so the no-lockfile library
|
|
31
|
+
# shape (bundler's own convention: libraries gitignore the lockfile)
|
|
32
|
+
# still invalidates on a dependency edit (#408).
|
|
33
|
+
MANIFESTS = %w[Gemfile gems.rb].freeze
|
|
34
|
+
|
|
35
|
+
SOURCE_EXCLUDES = %w[.git vendor node_modules].freeze
|
|
29
36
|
|
|
30
37
|
# suite_digest: SuiteFiles#digest, so an edit to a spec helper or
|
|
31
38
|
# support file invalidates every verdict.
|
|
32
|
-
def initialize(path:, root:, inputs:, suite_digest: "")
|
|
39
|
+
def initialize(path:, root:, inputs:, suite_digest: "", coverage: nil, fallback_files: [])
|
|
33
40
|
@path = path
|
|
34
41
|
@root = root
|
|
35
42
|
@inputs = inputs
|
|
36
43
|
@suite_digest = suite_digest
|
|
44
|
+
# Engine always passes the built map; a missing one behaves as an
|
|
45
|
+
# empty one, which watches every fallback and child-loaded source (#197).
|
|
46
|
+
@coverage = coverage || Selection::CoverageMap.new(lines: {}, tests: [])
|
|
47
|
+
@fallback_files = fallback_files.map { |file| File.expand_path(file, root) }.sort
|
|
37
48
|
@entries = load
|
|
38
49
|
@file_digests = {}
|
|
39
50
|
end
|
|
@@ -65,6 +76,16 @@ module Saboteur
|
|
|
65
76
|
|
|
66
77
|
private
|
|
67
78
|
|
|
79
|
+
# The current project source files, relative and sorted. A file added
|
|
80
|
+
# after the map was built changes the set and invalidates every stored
|
|
81
|
+
# verdict, whether or not any watched file references it yet.
|
|
82
|
+
def source_set
|
|
83
|
+
@source_set ||= Dir.glob("**/*.rb", base: @root)
|
|
84
|
+
.reject { |rel| SOURCE_EXCLUDES.include?(rel.split("/").first) }
|
|
85
|
+
.sort
|
|
86
|
+
.join("\0")
|
|
87
|
+
end
|
|
88
|
+
|
|
68
89
|
def load
|
|
69
90
|
data = JSON.parse(Text.utf8_strict(File.read(@path)))
|
|
70
91
|
data["format"] == FORMAT ? data["entries"] : {}
|
|
@@ -78,12 +99,33 @@ module Saboteur
|
|
|
78
99
|
|
|
79
100
|
def fingerprint(subject, tests)
|
|
80
101
|
digest = Digest::SHA256.new
|
|
81
|
-
digest << VERSION
|
|
102
|
+
digest << [VERSION, @inputs, @suite_digest, source_set, subject.source].join("\0") << "\0"
|
|
82
103
|
# A hex digest has a fixed width, so it needs no separator after it.
|
|
83
104
|
digest << Digest::SHA256.hexdigest(subject.file_source)
|
|
105
|
+
digest_tests(digest, tests)
|
|
106
|
+
hash_dependency_inputs(digest)
|
|
107
|
+
digest.hexdigest
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def digest_tests(digest, tests)
|
|
84
111
|
tests.sort.each { |test| digest << test << "\0" << file_digest(test_file(test)) << "\0" }
|
|
112
|
+
dependencies(tests).each { |dep| digest << dep << "\0" << file_digest(dep) << "\0" }
|
|
85
113
|
LOCKFILES.each { |name| digest << file_digest(File.join(@root, name)) << "\0" }
|
|
86
|
-
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Files a change in which can flip the verdict (#197): every source
|
|
117
|
+
# file a covering test touched, from the run's coverage map, plus the
|
|
118
|
+
# loaded sources the map never observed (#344, #353).
|
|
119
|
+
def dependencies(tests)
|
|
120
|
+
(@coverage.files_for(tests) | @coverage.boot_files | unobserved_files).sort
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Loaded sources the map never observed (no line, method, or load
|
|
124
|
+
# evidence): coverage cannot say whether they matter, so a change in
|
|
125
|
+
# any of them must invalidate (#344).
|
|
126
|
+
def unobserved_files
|
|
127
|
+
@unobserved_files ||=
|
|
128
|
+
(@fallback_files | @coverage.loaded_files).select { |file| @coverage.empty_for?([file]) }
|
|
87
129
|
end
|
|
88
130
|
|
|
89
131
|
# RSpec ids name their file: "./spec/a_spec.rb[1:2]".
|
|
@@ -91,6 +133,21 @@ module Saboteur
|
|
|
91
133
|
File.expand_path(test[/\A(.+?\.rb)(?:\[|:|\z)/, 1] || test, @root)
|
|
92
134
|
end
|
|
93
135
|
|
|
136
|
+
def hash_dependency_inputs(digest)
|
|
137
|
+
dependency_files.each { |path| digest << path.delete_prefix("#{@root}/") << "\0" << file_digest(path) << "\0" }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Manifests and gemspecs anywhere below the root, since a monorepo
|
|
141
|
+
# sub-gem carries its own dependency declarations (#561); keyed
|
|
142
|
+
# root-relative so same-named sub-gem files cannot collide. Vendored
|
|
143
|
+
# installs and dependencies' own checkouts hold gemspecs the project
|
|
144
|
+
# does not declare, so the scan mirrors RunFingerprint's exclusions.
|
|
145
|
+
def dependency_files
|
|
146
|
+
@dependency_files ||= Dir[File.join(@root, "**", "{#{MANIFESTS.join(',')},*.gemspec}")].reject do |path|
|
|
147
|
+
path.delete_prefix("#{@root}/").split("/").any? { |part| RunFingerprint::EXCLUDED_DIRECTORIES.include?(part) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
94
151
|
def file_digest(path)
|
|
95
152
|
@file_digests[path] ||= File.file?(path) ? Digest::SHA256.file(path).hexdigest : "missing"
|
|
96
153
|
end
|
|
@@ -35,12 +35,42 @@ module Saboteur
|
|
|
35
35
|
|
|
36
36
|
# Absolute monotonic deadline shared with the schedulers (forked
|
|
37
37
|
# workers inherit it): a subject's remaining mutants stop once it
|
|
38
|
-
# passes
|
|
39
|
-
#
|
|
38
|
+
# passes. New mutation work never overshoots more than the fork in
|
|
39
|
+
# flight; a subject the deadline cut mid-list still confirms the
|
|
40
|
+
# kills it already measured (#588), and those checks and retries
|
|
41
|
+
# fork uncapped - the overshoot is bounded by the subject's
|
|
42
|
+
# distinct killing sequences times their full timeout.
|
|
40
43
|
def budget_deadline
|
|
41
44
|
config["max_duration"] && (@run_started + config["max_duration"])
|
|
42
45
|
end
|
|
43
46
|
|
|
47
|
+
# Once the baselines are timed, a cold coverage build is the last
|
|
48
|
+
# unpriced phase before the mutants: it costs about one more full pass
|
|
49
|
+
# and keeps nothing when the budget cuts it off, so a resume starts it
|
|
50
|
+
# from zero again. Refuse up front when the remaining budget cannot
|
|
51
|
+
# cover that pass, naming the shortfall and the remedies (#427).
|
|
52
|
+
def refuse_unaffordable_coverage_build(store, files, baseline_elapsed = @baseline_elapsed,
|
|
53
|
+
completed_passes = @completed_baseline_passes)
|
|
54
|
+
return unless config["max_duration"]
|
|
55
|
+
return if store.reusable?(files)
|
|
56
|
+
return if completed_passes.to_i.zero?
|
|
57
|
+
|
|
58
|
+
per_pass = baseline_elapsed / completed_passes
|
|
59
|
+
remaining = config["max_duration"] - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run_started)
|
|
60
|
+
# A spent budget is the existing stop path's news, and an untimed
|
|
61
|
+
# (zero-second) baseline carries no estimate to project from.
|
|
62
|
+
return if remaining <= 0 || per_pass <= 0
|
|
63
|
+
return if remaining >= per_pass
|
|
64
|
+
|
|
65
|
+
needed = per_pass - remaining
|
|
66
|
+
raise BudgetPhaseTimeout,
|
|
67
|
+
"the --max-duration budget cannot cover the coverage map build: baseline passes took about " \
|
|
68
|
+
"#{per_pass.round}s each, only #{remaining.round(1)}s remain, and a cold build needs about one " \
|
|
69
|
+
"more full pass and keeps nothing when it is cut off. Re-run with --max-duration about " \
|
|
70
|
+
"#{needed.round}s higher, fewer --baseline-passes, or once without --max-duration so the " \
|
|
71
|
+
"coverage cache is built for later --incremental runs"
|
|
72
|
+
end
|
|
73
|
+
|
|
44
74
|
# Boot and baselines can spend the budget by themselves: stop before
|
|
45
75
|
# an expensive cold coverage build and mark every selected subject
|
|
46
76
|
# unrun.
|