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.
@@ -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
@@ -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
- # One run, as a pipeline: boot the target, select subjects, fetch the
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
- [subjects, selection.changed_lines && DiffScope.new(selection.changed_lines)]
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
- @diagnostics.inform("#{subjects.size} subjects, #{map.tests.size} tests in coverage map")
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(subjects, candidate_lists(subjects,
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
- subjects.map do |subject|
92
- list = candidates.for(subject)
93
- scope ? scope.restrict(subject, list) : list
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