saboteur 0.2.0 → 0.3.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 +40 -0
- data/README.md +322 -54
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +169 -22
- data/lib/saboteur/config.rb +39 -5
- data/lib/saboteur/doctor.rb +72 -0
- data/lib/saboteur/engine/baseline_guard.rb +99 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +9 -2
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +316 -0
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +53 -14
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/help.rb +194 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +23 -2
- data/lib/saboteur/mutation_source/builtin.rb +6 -1
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/findings.rb +123 -0
- data/lib/saboteur/reporter/html.rb +165 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +11 -1
- data/lib/saboteur/reporter/terminal.rb +11 -0
- data/lib/saboteur/result.rb +16 -5
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +3 -0
- metadata +15 -2
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
class Engine
|
|
9
|
+
# Read-only identity for declared project inputs, not a Ruby dependency graph.
|
|
10
|
+
# Capture before boot; verify before reuse/save. Unknown inputs are unsafe.
|
|
11
|
+
class RunFingerprint
|
|
12
|
+
FORMAT_VERSION = 1
|
|
13
|
+
MANIFESTS = %w[Gemfile gems.rb Gemfile.lock gems.locked .rspec .rspec-local].freeze
|
|
14
|
+
LOCATION_SETTINGS = %w[cache_dir json_report html_report].freeze
|
|
15
|
+
ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra].freeze
|
|
16
|
+
EXCLUDED_DIRECTORIES = %w[.git vendor node_modules].freeze
|
|
17
|
+
Entry = Data.define(:path, :kind, :state, :sha256, :required)
|
|
18
|
+
Change = Data.define(:path, :reason)
|
|
19
|
+
Snapshot = Data.define(:format_version, :digest, :inventory, :settings_digest, :runtime_digest, :complete,
|
|
20
|
+
:issues) do
|
|
21
|
+
def to_h
|
|
22
|
+
super.merge(inventory: inventory.map(&:to_h))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
Verification = Data.define(:unchanged, :complete, :current_digest, :changes, :issues)
|
|
26
|
+
|
|
27
|
+
def self.restore_snapshot(hash)
|
|
28
|
+
fields = canonical_mapping(hash).transform_keys(&:to_sym)
|
|
29
|
+
validate_snapshot_fields(fields)
|
|
30
|
+
inventory = fields.fetch(:inventory).map { |entry| restore_entry(entry) }.freeze
|
|
31
|
+
raise ArgumentError, "duplicate inventory paths" unless inventory.map(&:path).uniq.size == inventory.size
|
|
32
|
+
|
|
33
|
+
issues = restore_issues(fields.fetch(:issues))
|
|
34
|
+
validate_snapshot_identity(fields, inventory, issues)
|
|
35
|
+
Snapshot.new(**fields, inventory: inventory, issues: issues)
|
|
36
|
+
rescue KeyError, TypeError, NoMethodError => e
|
|
37
|
+
raise ArgumentError, "invalid fingerprint snapshot: #{e.class}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.restore_issues(issues)
|
|
41
|
+
raise ArgumentError, "invalid issues" unless
|
|
42
|
+
issues.is_a?(Array) && issues.all?(String) && issues == issues.uniq.sort
|
|
43
|
+
|
|
44
|
+
issues.map { |issue| issue.dup.freeze }.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.validate_snapshot_identity(fields, inventory, issues)
|
|
48
|
+
raise ArgumentError, "invalid complete snapshot" if
|
|
49
|
+
fields[:complete] && (issues.any? || inventory.any? do |entry|
|
|
50
|
+
entry.state == "unreadable" || (entry.required && entry.state == "missing")
|
|
51
|
+
end)
|
|
52
|
+
raise ArgumentError, "unsorted inventory" unless inventory.map(&:path) == inventory.map(&:path).sort
|
|
53
|
+
|
|
54
|
+
inputs = { format: FORMAT_VERSION, files: inventory.map(&:to_h),
|
|
55
|
+
settings: fields[:settings_digest], runtime: fields[:runtime_digest] }
|
|
56
|
+
canonical = canonical_value(inputs)
|
|
57
|
+
raise ArgumentError, "fingerprint identity mismatch" unless
|
|
58
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical)) == fields[:digest]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.validate_snapshot_fields(fields)
|
|
62
|
+
raise ArgumentError, "invalid snapshot fields" unless fields.keys.sort == Snapshot.members.sort
|
|
63
|
+
raise ArgumentError, "unsupported fingerprint format" unless fields[:format_version] == FORMAT_VERSION
|
|
64
|
+
raise ArgumentError, "invalid completeness" unless [true, false].include?(fields[:complete])
|
|
65
|
+
|
|
66
|
+
%i[digest settings_digest runtime_digest].each do |key|
|
|
67
|
+
raise ArgumentError, "invalid fingerprint digest" unless fields.fetch(key).match?(/\A[0-9a-f]{64}\z/)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.restore_entry(entry)
|
|
72
|
+
values = canonical_mapping(entry).transform_keys(&:to_sym)
|
|
73
|
+
raise ArgumentError, "invalid fingerprint entry" unless valid_entry?(values)
|
|
74
|
+
|
|
75
|
+
Entry.new(**values)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.valid_entry?(values)
|
|
79
|
+
values.keys.sort == %i[kind path required sha256 state] && valid_path?(values[:path]) &&
|
|
80
|
+
[true,
|
|
81
|
+
false].include?(values[:required]) && ENTRY_KINDS.include?(values[:kind]) &&
|
|
82
|
+
(!%w[ruby boot hook
|
|
83
|
+
source_plugin].include?(values[:kind]) || values[:required]) && valid_state?(values[:state],
|
|
84
|
+
values[:sha256])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.valid_path?(path)
|
|
88
|
+
unless path.is_a?(String) && !path.empty? && !path.include?("\0") &&
|
|
89
|
+
!path.include?("\\") && !path.end_with?("/")
|
|
90
|
+
return false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
candidate = path.delete_prefix("external:")
|
|
94
|
+
absolute = path.start_with?("external:")
|
|
95
|
+
return false unless candidate.start_with?("/") == absolute
|
|
96
|
+
|
|
97
|
+
segments = candidate.split("/")
|
|
98
|
+
segments.shift if absolute
|
|
99
|
+
segments.none? { |part| part.empty? || %w[. ..].include?(part) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.valid_state?(state, digest)
|
|
103
|
+
return digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) if state == "present"
|
|
104
|
+
|
|
105
|
+
%w[missing unreadable].include?(state) && digest.nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.runtime_identity
|
|
109
|
+
{ engine: RUBY_ENGINE, version: RUBY_VERSION, platform: RUBY_PLATFORM,
|
|
110
|
+
patchlevel: RUBY_PATCHLEVEL, saboteur: VERSION }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def initialize(config:, root: config.root, extra_files: [], runtime_identity: self.class.runtime_identity)
|
|
114
|
+
@config = config
|
|
115
|
+
@root = File.realpath(root)
|
|
116
|
+
raise ArgumentError, "root differs from config.root" unless @root == File.realpath(config.root)
|
|
117
|
+
|
|
118
|
+
@extra_files = extra_files.map do |file|
|
|
119
|
+
options = file.is_a?(Hash) ? file : { path: file, required: true }
|
|
120
|
+
[File.expand_path(options.fetch(:path), @root), options.fetch(:required, true)]
|
|
121
|
+
end.freeze
|
|
122
|
+
@runtime_identity = canonical(runtime_identity)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def snapshot
|
|
126
|
+
issues = output_overlap_issues
|
|
127
|
+
entries = inventory(issues).sort_by(&:path).freeze
|
|
128
|
+
issues = issues.uniq.sort
|
|
129
|
+
settings_digest = hash_value(@config.to_h.except(*LOCATION_SETTINGS))
|
|
130
|
+
runtime_digest = hash_value(@runtime_identity)
|
|
131
|
+
digest = hash_value(format: FORMAT_VERSION, files: entries.map(&:to_h),
|
|
132
|
+
settings: settings_digest, runtime: runtime_digest)
|
|
133
|
+
Snapshot.new(format_version: FORMAT_VERSION, digest: digest, inventory: entries,
|
|
134
|
+
settings_digest: settings_digest, runtime_digest: runtime_digest,
|
|
135
|
+
complete: issues.empty?, issues: issues.freeze)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def verify(previous)
|
|
139
|
+
current = snapshot
|
|
140
|
+
changes = changes_between(previous, current)
|
|
141
|
+
complete = previous.complete && current.complete && previous.format_version == FORMAT_VERSION
|
|
142
|
+
Verification.new(unchanged: complete && previous.digest == current.digest,
|
|
143
|
+
complete: complete, current_digest: current.digest,
|
|
144
|
+
changes: changes.freeze, issues: current.issues)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def self.canonical_value(value)
|
|
148
|
+
case value
|
|
149
|
+
when Hash then canonical_mapping(value)
|
|
150
|
+
when Array then value.map { |entry| canonical_value(entry) }.freeze
|
|
151
|
+
when String then value.dup.freeze
|
|
152
|
+
when NilClass, TrueClass, FalseClass, Integer then value
|
|
153
|
+
when Float
|
|
154
|
+
raise ArgumentError, "non-finite identity number" unless value.finite?
|
|
155
|
+
|
|
156
|
+
value
|
|
157
|
+
else raise ArgumentError, "unsupported identity value"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.canonical_mapping(value)
|
|
162
|
+
raise ArgumentError, "invalid identity key" unless value.keys.all? do |key|
|
|
163
|
+
key.is_a?(String) || key.is_a?(Symbol)
|
|
164
|
+
end
|
|
165
|
+
raise ArgumentError, "duplicate normalized identity key" unless value.keys.map(&:to_s).uniq.size == value.size
|
|
166
|
+
|
|
167
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical_value(value[key])] }.freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def canonical_path(path)
|
|
173
|
+
expanded = File.expand_path(path, @root)
|
|
174
|
+
return File.realpath(expanded) if File.exist?(expanded)
|
|
175
|
+
|
|
176
|
+
parent = File.dirname(expanded)
|
|
177
|
+
File.join(canonical_path(parent), File.basename(expanded))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def output_overlap_issues
|
|
181
|
+
paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) }
|
|
182
|
+
outputs = %w[json_report html_report].filter_map { |key| @config.fetch(key) }
|
|
183
|
+
.map { |path| canonical_path(path) }
|
|
184
|
+
issues = outputs.filter_map do |path|
|
|
185
|
+
if path.end_with?(".rb") || paths.include?(path) || File.directory?(path)
|
|
186
|
+
"output overlaps input: #{display_path(path)}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
issues.concat(cache_overlap_issues(paths))
|
|
190
|
+
issues
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def cache_overlap_issues(paths)
|
|
194
|
+
cache = canonical_path(@config["cache_dir"])
|
|
195
|
+
protected = [@root] + %w[app lib spec].map { |name| File.join(@root, name) } + paths
|
|
196
|
+
overlaps = protected.any? do |path|
|
|
197
|
+
path == cache || path.start_with?("#{cache}/") || (path != @root && cache.start_with?("#{path}/"))
|
|
198
|
+
end
|
|
199
|
+
overlaps ? ["cache directory overlaps inputs: #{display_path(cache)}"] : []
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def inventory(issues)
|
|
203
|
+
paths = project_files(issues).to_h { |path| [path, "ruby"] }
|
|
204
|
+
MANIFESTS.each { |name| paths[File.join(@root, name)] = "manifest" }
|
|
205
|
+
required = merged_required_files
|
|
206
|
+
required.each { |path, (kind, _)| paths[path] = kind }
|
|
207
|
+
paths.map do |path, kind|
|
|
208
|
+
needed = required.key?(path) ? required[path].last : kind != "manifest"
|
|
209
|
+
read_entry(path, kind, needed, issues)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def merged_required_files
|
|
214
|
+
required_files.group_by(&:first).transform_values do |entries|
|
|
215
|
+
roles = entries.map { |_, kind, _| kind }
|
|
216
|
+
role = (roles - ["extra"]).min || "extra"
|
|
217
|
+
[role, entries.any? { |_, _, needed| needed }]
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Walk without following symlink directories. A project symlink is an
|
|
222
|
+
# undeclared dependency boundary, not a silently omitted source tree.
|
|
223
|
+
def project_files(issues, directory = @root)
|
|
224
|
+
Dir.children(directory).sort.flat_map do |name|
|
|
225
|
+
path = File.join(directory, name)
|
|
226
|
+
next [] if excluded?(path, name)
|
|
227
|
+
|
|
228
|
+
if File.symlink?(path)
|
|
229
|
+
issues << "symlink input: #{display_path(path)}".freeze
|
|
230
|
+
next []
|
|
231
|
+
end
|
|
232
|
+
next project_files(issues, path) if File.directory?(path)
|
|
233
|
+
|
|
234
|
+
File.file?(path) && path.end_with?(".rb") ? [path] : []
|
|
235
|
+
end
|
|
236
|
+
rescue SystemCallError
|
|
237
|
+
issues << "unreadable directory: #{display_path(directory)}".freeze
|
|
238
|
+
[]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def excluded?(path, name)
|
|
242
|
+
EXCLUDED_DIRECTORIES.include?(name) || path == canonical_path(@config["cache_dir"]) ||
|
|
243
|
+
%w[json_report html_report].filter_map { |key| @config.fetch(key) }.any? do |output|
|
|
244
|
+
path == canonical_path(output)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def required_files
|
|
249
|
+
paths = %w[require hooks].flat_map do |key|
|
|
250
|
+
@config[key].map { |file| [resolve(file), key == "hooks" ? "hook" : "boot", true] }
|
|
251
|
+
end
|
|
252
|
+
paths += @config["sources"].reject { |entry| entry == "builtin" }.map do |entry|
|
|
253
|
+
[resolve(entry.partition(":").first), "source_plugin", true]
|
|
254
|
+
end
|
|
255
|
+
paths + @extra_files.map { |path, needed| [path, "extra", needed] }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def resolve(file)
|
|
259
|
+
path = File.expand_path(file, @root)
|
|
260
|
+
return path if File.exist?(path) || File.extname(path) == ".rb"
|
|
261
|
+
return "#{path}.rb" if File.exist?("#{path}.rb")
|
|
262
|
+
|
|
263
|
+
path
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def read_entry(path, kind, required, issues)
|
|
267
|
+
name = display_path(path).freeze
|
|
268
|
+
unless File.exist?(path)
|
|
269
|
+
issues << "missing required input: #{name}".freeze if required
|
|
270
|
+
return Entry.new(path: name, kind: kind.freeze, state: "missing", sha256: nil, required: required)
|
|
271
|
+
end
|
|
272
|
+
Entry.new(path: name, kind: kind.freeze, state: "present", sha256: Digest::SHA256.file(path).hexdigest.freeze,
|
|
273
|
+
required: required)
|
|
274
|
+
rescue SystemCallError, IOError
|
|
275
|
+
issues << "unreadable input: #{name}".freeze
|
|
276
|
+
Entry.new(path: name, kind: kind.freeze, state: "unreadable", sha256: nil, required: required)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def display_path(path)
|
|
280
|
+
path.start_with?("#{@root}/") ? path.delete_prefix("#{@root}/") : "external:#{path}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def changes_between(previous, current)
|
|
284
|
+
before = previous.inventory.to_h { |entry| [entry.path, entry] }
|
|
285
|
+
after = current.inventory.to_h { |entry| [entry.path, entry] }
|
|
286
|
+
changes = (before.keys | after.keys).sort.filter_map do |path|
|
|
287
|
+
next if before[path] == after[path]
|
|
288
|
+
|
|
289
|
+
Change.new(path: path.freeze, reason: change_reason(before[path], after[path]))
|
|
290
|
+
end
|
|
291
|
+
if previous.settings_digest != current.settings_digest
|
|
292
|
+
changes << Change.new(path: "settings",
|
|
293
|
+
reason: "changed")
|
|
294
|
+
end
|
|
295
|
+
changes << Change.new(path: "runtime", reason: "changed") if previous.runtime_digest != current.runtime_digest
|
|
296
|
+
changes
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def change_reason(before, after)
|
|
300
|
+
return "added" unless before
|
|
301
|
+
return "removed" unless after
|
|
302
|
+
return "unreadable" if after.state == "unreadable"
|
|
303
|
+
|
|
304
|
+
"changed"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def canonical(value)
|
|
308
|
+
self.class.canonical_value(value)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def hash_value(value)
|
|
312
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical(value))).freeze
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -63,12 +63,14 @@ module Saboteur
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def checked(result, failing_runs, failing)
|
|
66
|
+
return result if result.reused?
|
|
67
|
+
|
|
66
68
|
if named_kill?(result) && failing_runs.key?(sequence(result))
|
|
67
69
|
why = failing_runs[sequence(result)]
|
|
68
70
|
result.as_error("killing test #{result.killed_by.first} also fails with the method unchanged" \
|
|
69
|
-
"#{": #{why}" if why}")
|
|
71
|
+
"#{": #{why}" if why}", rejected_kill: true)
|
|
70
72
|
elsif failing.include?(result)
|
|
71
|
-
result.as_error("tests also fail with the method unchanged")
|
|
73
|
+
result.as_error("tests also fail with the method unchanged", rejected_kill: true)
|
|
72
74
|
else
|
|
73
75
|
result
|
|
74
76
|
end
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -3,29 +3,32 @@
|
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
|
+
require_relative "replay"
|
|
6
7
|
|
|
7
8
|
module Saboteur
|
|
8
|
-
#
|
|
9
|
+
# A single-use instance runs one pipeline: boot the target, select subjects, fetch the
|
|
9
10
|
# coverage map, then schedule each subject's mutants. The parts live in
|
|
10
11
|
# engine/; this class only wires them together.
|
|
11
12
|
class Engine
|
|
12
13
|
# Everything besides sources and tests that can change a verdict.
|
|
13
14
|
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources arid timeout_factor timeout_min
|
|
14
|
-
max_memory_mb max_processes seed].freeze
|
|
15
|
+
max_memory_mb max_processes seed tests].freeze
|
|
15
16
|
|
|
16
17
|
attr_reader :config, :hooks
|
|
17
18
|
|
|
18
|
-
def initialize(config, err: $stderr)
|
|
19
|
+
def initialize(config, err: $stderr, digest: nil)
|
|
19
20
|
@config = config
|
|
20
21
|
@hooks = Hooks.new
|
|
21
22
|
@diagnostics = Diagnostics.new(err)
|
|
22
23
|
@root = File.realpath(config.root)
|
|
24
|
+
@skipped_subjects = {}
|
|
25
|
+
@replay = Replay.new(digest: digest) if digest
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def call
|
|
26
29
|
Boot.refuse_recursion!
|
|
27
30
|
started = monotonic
|
|
28
|
-
ignore_list = IgnoreList.load(config.path("ignore_file"))
|
|
31
|
+
ignore_list = @replay ? IgnoreList.new([]) : IgnoreList.load(config.path("ignore_file"))
|
|
29
32
|
boot = Boot.new(config, hooks)
|
|
30
33
|
integration = boot.load_target
|
|
31
34
|
@rails = boot.rails
|
|
@@ -35,7 +38,7 @@ module Saboteur
|
|
|
35
38
|
@diagnostics.warnings.concat(ignore_list.apply(results))
|
|
36
39
|
Report.new(results: results, subjects: subjects, wall_time: monotonic - started, root: @root,
|
|
37
40
|
invalid_mutations: @diagnostics.invalid_mutations, warnings: @diagnostics.warnings,
|
|
38
|
-
review: !scope.nil?, seed: config["seed"])
|
|
41
|
+
review: !scope.nil?, seed: config["seed"], skipped_subjects: @skipped_subjects)
|
|
39
42
|
end
|
|
40
43
|
|
|
41
44
|
private
|
|
@@ -45,24 +48,45 @@ module Saboteur
|
|
|
45
48
|
config.unmatched_patterns.each { |pattern| @diagnostics.warn("#{pattern} matches no file") }
|
|
46
49
|
selection = SubjectSelection.new(config, @diagnostics)
|
|
47
50
|
subjects = selection.select(files)
|
|
48
|
-
|
|
51
|
+
scope = selection.changed_lines && DiffScope.new(selection.changed_lines)
|
|
52
|
+
return replay_subject(subjects, scope) if @replay
|
|
53
|
+
|
|
54
|
+
[subjects, scope]
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
def mutate(subjects, files, integration, scope)
|
|
58
|
+
check_baseline(integration)
|
|
52
59
|
map = CoverageStore.new(config: config, integration: integration, hooks: hooks,
|
|
53
60
|
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).fetch(files)
|
|
54
|
-
|
|
55
|
-
warn_about_threads
|
|
61
|
+
inform_about_run(subjects, map)
|
|
56
62
|
cache = result_cache
|
|
57
63
|
scheduler = scheduler_for(map, integration, cache)
|
|
64
|
+
lists = candidate_lists(subjects, scope, map)
|
|
65
|
+
work = subjects.zip(lists).reject { |_, list| list.empty? }
|
|
66
|
+
return [] if work.empty?
|
|
67
|
+
|
|
68
|
+
runnable_subjects, runnable_lists = work.transpose
|
|
58
69
|
@rails&.prepare_workers(config["jobs"])
|
|
70
|
+
# The execution phase forks next: the worker batch, or one child per
|
|
71
|
+
# mutant when serial. Pack the heap now that the map and candidates
|
|
72
|
+
# are retained, to reduce later copying of shared pages.
|
|
73
|
+
Execution::ForkReadiness.prepare_heap
|
|
59
74
|
results = Distribution.new(jobs: config["jobs"], scheduler: scheduler, diagnostics: @diagnostics,
|
|
60
|
-
cache: cache, on_start: method(:start_worker)).run(
|
|
61
|
-
scope))
|
|
75
|
+
cache: cache, on_start: method(:start_worker)).run(runnable_subjects, runnable_lists)
|
|
62
76
|
cache&.save
|
|
63
77
|
results
|
|
64
78
|
end
|
|
65
79
|
|
|
80
|
+
def inform_about_run(subjects, map)
|
|
81
|
+
@diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
|
|
82
|
+
warn_about_threads
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_baseline(integration)
|
|
86
|
+
BaselineGuard.new(config: config, integration: integration, hooks: hooks,
|
|
87
|
+
supervisor: supervisor(memory_factor: 4), diagnostics: @diagnostics).call
|
|
88
|
+
end
|
|
89
|
+
|
|
66
90
|
def scheduler_for(map, integration, cache)
|
|
67
91
|
reevaluator = Execution::Reevaluator.new(config["reeval"])
|
|
68
92
|
runner = Execution::Runner.new(integration: integration, reevaluator: reevaluator, hooks: hooks,
|
|
@@ -72,6 +96,7 @@ module Saboteur
|
|
|
72
96
|
end
|
|
73
97
|
|
|
74
98
|
def result_cache
|
|
99
|
+
return nil if @replay
|
|
75
100
|
return nil unless config["incremental"]
|
|
76
101
|
|
|
77
102
|
inputs = JSON.generate(VERDICT_INPUTS.to_h { |key| [key, config[key]] })
|
|
@@ -86,14 +111,24 @@ module Saboteur
|
|
|
86
111
|
end
|
|
87
112
|
|
|
88
113
|
# Each subject's candidates, narrowed to changed lines in diff mode.
|
|
89
|
-
def candidate_lists(subjects, scope)
|
|
114
|
+
def candidate_lists(subjects, scope, map)
|
|
115
|
+
return [@replay_candidate] if @replay
|
|
116
|
+
|
|
90
117
|
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
selection = CandidateSelection.new(candidates: candidates, map: map,
|
|
119
|
+
limit: config["max_mutants_per_method"], diagnostics: @diagnostics)
|
|
120
|
+
subjects.map { |subject| selection.for(subject, scope: scope) }.tap do
|
|
121
|
+
@skipped_subjects = selection.skipped_subjects
|
|
94
122
|
end
|
|
95
123
|
end
|
|
96
124
|
|
|
125
|
+
def replay_subject(subjects, scope)
|
|
126
|
+
candidates = Candidates.new(Candidates.build_sources(config), @diagnostics)
|
|
127
|
+
subject, candidate = @replay.select(subjects, candidates, scope: scope)
|
|
128
|
+
@replay_candidate = [candidate]
|
|
129
|
+
[[subject], nil]
|
|
130
|
+
end
|
|
131
|
+
|
|
97
132
|
def supervisor(memory_factor: 1)
|
|
98
133
|
limits = Execution::Supervisor::Limits.new(max_memory_mb: config["max_memory_mb"] * memory_factor,
|
|
99
134
|
max_processes: config["max_processes"])
|
|
@@ -125,9 +160,13 @@ require_relative "engine/boot"
|
|
|
125
160
|
require_relative "engine/subject_selection"
|
|
126
161
|
require_relative "engine/suite_files"
|
|
127
162
|
require_relative "engine/coverage_store"
|
|
163
|
+
require_relative "engine/baseline_guard"
|
|
128
164
|
require_relative "engine/candidates"
|
|
165
|
+
require_relative "engine/candidate_selection"
|
|
129
166
|
require_relative "engine/diff_scope"
|
|
130
167
|
require_relative "engine/result_cache"
|
|
131
168
|
require_relative "engine/verdict_check"
|
|
132
169
|
require_relative "engine/scheduler"
|
|
133
170
|
require_relative "engine/distribution"
|
|
171
|
+
|
|
172
|
+
require_relative "engine/run_fingerprint"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Execution
|
|
6
|
+
# Gets the heap ready at a fork boundary.
|
|
7
|
+
#
|
|
8
|
+
# Boot and candidate construction can leave garbage, fragmentation and
|
|
9
|
+
# young objects in the heap. Forked children initially share its pages.
|
|
10
|
+
# Later collection and object writes can make pages private. Preparing the
|
|
11
|
+
# heap before a fork batch can reduce that copying. Call it once per phase
|
|
12
|
+
# boundary, not per fork: preparing again while siblings are alive can
|
|
13
|
+
# break sharing with those siblings.
|
|
14
|
+
module ForkReadiness
|
|
15
|
+
# Process.warmup (CRuby 3.3+) is made for this: a full collection that
|
|
16
|
+
# also promotes survivors to the old generation and compacts. Without
|
|
17
|
+
# it, a lone GC.compact still packs the heap (it runs a full collection
|
|
18
|
+
# itself first) but does not promise old-generation promotion. Without
|
|
19
|
+
# either, a full collection is the best that Ruby can do.
|
|
20
|
+
def self.prepare_heap
|
|
21
|
+
return warmup_heap if Process.respond_to?(:warmup)
|
|
22
|
+
return compact_heap if GC.respond_to?(:compact)
|
|
23
|
+
|
|
24
|
+
full_collection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.warmup_heap
|
|
28
|
+
Process.warmup
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
private_class_method :warmup_heap
|
|
32
|
+
|
|
33
|
+
def self.compact_heap
|
|
34
|
+
GC.compact
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
private_class_method :compact_heap
|
|
38
|
+
|
|
39
|
+
def self.full_collection
|
|
40
|
+
GC.start(full_mark: true, immediate_sweep: true)
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
private_class_method :full_collection
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|