saboteur 0.2.0 → 0.4.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -1
  3. data/README.md +526 -73
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/baseline_health.rb +116 -0
  7. data/lib/saboteur/cli.rb +305 -30
  8. data/lib/saboteur/config.rb +65 -19
  9. data/lib/saboteur/config_file.rb +97 -0
  10. data/lib/saboteur/custom_map.rb +170 -0
  11. data/lib/saboteur/doctor.rb +88 -0
  12. data/lib/saboteur/engine/baseline_guard.rb +116 -0
  13. data/lib/saboteur/engine/boot.rb +9 -0
  14. data/lib/saboteur/engine/candidate_selection.rb +35 -0
  15. data/lib/saboteur/engine/coverage_store.rb +40 -6
  16. data/lib/saboteur/engine/data_inputs.rb +43 -0
  17. data/lib/saboteur/engine/diagnostics.rb +28 -0
  18. data/lib/saboteur/engine/diff_scope.rb +8 -0
  19. data/lib/saboteur/engine/distribution.rb +4 -2
  20. data/lib/saboteur/engine/run_fingerprint.rb +344 -0
  21. data/lib/saboteur/engine/scheduler.rb +7 -16
  22. data/lib/saboteur/engine/suite_files.rb +8 -1
  23. data/lib/saboteur/engine/verdict_check.rb +4 -2
  24. data/lib/saboteur/engine.rb +125 -25
  25. data/lib/saboteur/execution/fork_readiness.rb +46 -0
  26. data/lib/saboteur/execution/reevaluator.rb +7 -4
  27. data/lib/saboteur/execution/supervisor.rb +23 -11
  28. data/lib/saboteur/frameworks/rails.rb +329 -13
  29. data/lib/saboteur/help.rb +210 -0
  30. data/lib/saboteur/ignore_list.rb +71 -17
  31. data/lib/saboteur/install.rb +132 -0
  32. data/lib/saboteur/integration/base.rb +8 -0
  33. data/lib/saboteur/integration/rspec.rb +69 -4
  34. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  35. data/lib/saboteur/mutation_source/builtin.rb +14 -3
  36. data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
  37. data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
  38. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  39. data/lib/saboteur/replay.rb +33 -0
  40. data/lib/saboteur/reporter/agent.rb +87 -0
  41. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  42. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  43. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  44. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  45. data/lib/saboteur/reporter/findings.rb +131 -0
  46. data/lib/saboteur/reporter/html.rb +213 -0
  47. data/lib/saboteur/reporter/replay.rb +23 -0
  48. data/lib/saboteur/reporter/stryker.rb +21 -4
  49. data/lib/saboteur/reporter/terminal.rb +76 -2
  50. data/lib/saboteur/result.rb +61 -9
  51. data/lib/saboteur/score.rb +89 -0
  52. data/lib/saboteur/version.rb +1 -1
  53. data/lib/saboteur/working_tree.rb +48 -0
  54. data/lib/saboteur.rb +7 -0
  55. metadata +30 -2
@@ -11,7 +11,7 @@ module Saboteur
11
11
  # unchanged, otherwise built by running the whole suite once in a
12
12
  # supervised child and saved.
13
13
  class CoverageStore
14
- CACHE_INPUTS = %w[integration require rspec_args rails].freeze
14
+ CACHE_INPUTS = %w[integration require rspec_args rails tests].freeze
15
15
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
16
16
  LOG = "coverage-build.log"
17
17
 
@@ -27,9 +27,9 @@ module Saboteur
27
27
  def fetch(files)
28
28
  path = File.join(@config.path("cache_dir"), "coverage-#{cache_key(files)}.json")
29
29
  cached = @config["cache"] ? Selection::CoverageMap.load(path) : nil
30
- return cached if cached
30
+ return refuse_empty_map(cached, files) if cached
31
31
 
32
- map = build(files)
32
+ map = refuse_empty_map(build(files), files)
33
33
  map.save(path)
34
34
  map
35
35
  end
@@ -52,8 +52,15 @@ module Saboteur
52
52
  "Run through the saboteur executable and disable SimpleCov for the run."
53
53
  end
54
54
 
55
- @diagnostics.inform("building coverage map (runs the whole suite once)")
56
- result = @supervisor.run(timeout: @config["coverage_timeout"]) { |writer| record(writer, files) }
55
+ scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
56
+ @diagnostics.inform("building coverage map (runs #{scope} once)")
57
+ # The suite child forked next inherits this heap; pack it first, to
58
+ # reduce later copying of shared pages.
59
+ Execution::ForkReadiness.prepare_heap
60
+ heartbeat = @diagnostics.progress_heartbeat("building coverage map")
61
+ result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
62
+ record(writer, files)
63
+ end
57
64
  raise Error, "coverage map build stopped: #{result.detail}" if result.stopped_by
58
65
  raise Error, "coverage map build failed: #{died(result.exit_status)}" if result.payload.empty?
59
66
 
@@ -63,6 +70,29 @@ module Saboteur
63
70
  Selection::CoverageMap.from_json(result.payload)
64
71
  end
65
72
 
73
+ # A whole-suite build that records no line in any included file fails
74
+ # the run (exit 3): usually the setup hid the app from Coverage
75
+ # (Bootsnap's compile cache is the default Rails case); it can also
76
+ # mean no test in the suite exercises any included file. Either way a
77
+ # run on this map would test nothing, and exit 0 would lie. With
78
+ # explicit tests (--test) an empty map is a legitimate outcome
79
+ # instead: the user narrowed the suite to specs that may not touch
80
+ # these sources, so the map passes with a warning. Applies to a cached
81
+ # map too, so a map cached before this check cannot keep a run green,
82
+ # and the guard runs before saving, so an empty map is never cached.
83
+ def refuse_empty_map(map, files)
84
+ return map if files.empty? || map.lines.any? { |_file, by_line| by_line.any? }
85
+
86
+ if @config["tests"].empty?
87
+ raise Error, "Coverage recorded no lines in #{files.size} included files during a whole-suite " \
88
+ "build. Is Bootsnap's compile cache on? If not, no test may exercise these files. " \
89
+ "Run `saboteur doctor`."
90
+ end
91
+ @diagnostics.warn("Coverage recorded no lines in #{files.size} included files; " \
92
+ "the selected specs may not exercise them.")
93
+ map
94
+ end
95
+
66
96
  def died(status)
67
97
  how = if status&.signaled? then "was killed by signal #{Signal.signame(status.termsig)}"
68
98
  else "exited with status #{status&.exitstatus}"
@@ -86,7 +116,11 @@ module Saboteur
86
116
  recorder = Selection::Recorder.new(files)
87
117
  recorder.start
88
118
  Kernel.srand(@config["seed"])
89
- @integration.run_all(recorder)
119
+ passed = @integration.run_all(recorder)
120
+ if passed == false
121
+ raise Error, "the unchanged suite failed while building coverage. Fix the suite, then run again " \
122
+ "with seed #{@config['seed']}."
123
+ end
90
124
  build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
91
125
  writer.write(recorder.result(build_seconds: build_seconds).to_json)
92
126
  writer.close
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Non-Ruby files a test run can read and that change its outcome: Rails
7
+ # fixtures, schema dumps and database/environment configuration, plus
8
+ # anything listed under `fingerprint_extra`. Ruby files are tracked
9
+ # elsewhere; these have no .rb extension, so the Ruby walk misses them.
10
+ # An absent pattern simply matches nothing, so plain gems are unaffected.
11
+ module DataInputs
12
+ GLOBS = %w[
13
+ spec/fixtures/**/*
14
+ spec/**/fixtures/**/*
15
+ test/fixtures/**/*
16
+ db/structure.sql
17
+ db/schema.rb
18
+ config/database.yml
19
+ config/database.yml.erb
20
+ config/*.yml
21
+ config/environments/*.yml
22
+ config/credentials/*.yml.enc
23
+ ].freeze
24
+
25
+ # True when the absolute path is one of the declared inputs, whether or
26
+ # not the file exists yet. A run must never write there.
27
+ def self.match?(root, path, extra: [])
28
+ return false unless path.start_with?("#{root}/")
29
+
30
+ relative = path.delete_prefix("#{root}/")
31
+ flags = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
32
+ (GLOBS + extra).any? { |pattern| File.fnmatch?(pattern, relative, flags) }
33
+ end
34
+
35
+ # Sorted absolute paths of regular files (symlinks followed) under root.
36
+ def self.files(root, extra: [])
37
+ (GLOBS + extra).flat_map { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH, base: root) }
38
+ .map { |relative| File.join(root, relative) }
39
+ .select { |path| File.file?(path) }.uniq.sort
40
+ end
41
+ end
42
+ end
43
+ end
@@ -6,6 +6,26 @@ module Saboteur
6
6
  # What a run has to say besides results: warnings for the report, the
7
7
  # count of mutations dropped for not parsing, and progress marks.
8
8
  class Diagnostics
9
+ # See progress_heartbeat. Time-gated itself, so the supervisor can
10
+ # call tick on every poll.
11
+ class ProgressHeartbeat
12
+ def initialize(diagnostics, label, interval: 30)
13
+ @diagnostics = diagnostics
14
+ @label = label
15
+ @interval = interval
16
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
+ @last_print = @started
18
+ end
19
+
20
+ def tick
21
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ return if now - @last_print < @interval
23
+
24
+ @last_print = now
25
+ @diagnostics.inform("#{@label}: #{(now - @started).round} s elapsed")
26
+ end
27
+ end
28
+
9
29
  MARKS = { killed: ".", survived: "S", timeout: "T", resource_limit: "L", error: "E" }.freeze
10
30
 
11
31
  attr_reader :warnings, :invalid_mutations
@@ -28,6 +48,14 @@ module Saboteur
28
48
  @err.puts("saboteur: #{message}")
29
49
  end
30
50
 
51
+ # A heartbeat for a long phase: the supervisor's wait loop calls tick
52
+ # in the parent after the fork, so no extra thread is ever alive at
53
+ # fork time. Prints "<label>: N s elapsed" about every interval
54
+ # seconds, so a long phase does not look stuck.
55
+ def progress_heartbeat(label, interval: 30)
56
+ ProgressHeartbeat.new(self, label, interval: interval)
57
+ end
58
+
31
59
  def progress(result)
32
60
  @err.print(MARKS.fetch(result.status, "?"))
33
61
  @err.flush
@@ -20,6 +20,14 @@ module Saboteur
20
20
  end
21
21
  end
22
22
 
23
+ # The changed lines a mutant touches, sorted. A statement that spans
24
+ # several lines is mutated as one piece and reported at its first line,
25
+ # which can sit above the line that changed; this names the changed line.
26
+ def changed_lines_for(subject, mutation)
27
+ changed = @changed_lines.fetch(subject.file, Set.new)
28
+ mutation.edits.flat_map { |edit| edited_lines(subject, edit).to_a }.uniq.select { changed.include?(_1) }.sort
29
+ end
30
+
23
31
  private
24
32
 
25
33
  def edited_lines(subject, edit)
@@ -42,7 +42,8 @@ module Saboteur
42
42
  "position" => list.index { |mutation, _| mutation.equal?(result.mutation) },
43
43
  "status" => result.status.to_s, "reason" => result.reason, "runtime" => result.runtime,
44
44
  "killed_by" => result.killed_by, "covered_by" => result.covered_by,
45
- "kill_cause" => result.kill_cause, "reused" => result.reused?
45
+ "kill_cause" => result.kill_cause, "reused" => result.reused?,
46
+ "rejected_kill" => result.rejected_kill?
46
47
  }
47
48
  end
48
49
 
@@ -55,7 +56,8 @@ module Saboteur
55
56
  result = MutantResult.new(subject: subject, mutation: mutation, status: data["status"].to_sym,
56
57
  mutated_source: text, reason: data["reason"], runtime: data["runtime"],
57
58
  killed_by: data["killed_by"], covered_by: data["covered_by"],
58
- kill_cause: data["kill_cause"], reused: data["reused"])
59
+ kill_cause: data["kill_cause"], reused: data["reused"],
60
+ rejected_kill: data.fetch("rejected_kill", false))
59
61
  @cache&.record(result) unless result.reused?
60
62
  result
61
63
  end
@@ -0,0 +1,344 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+ require_relative "data_inputs"
7
+
8
+ module Saboteur
9
+ class Engine
10
+ # Read-only identity for declared project inputs, not a Ruby dependency graph.
11
+ # Capture before boot; verify before reuse/save. Unknown inputs are unsafe.
12
+ class RunFingerprint
13
+ FORMAT_VERSION = 1
14
+ MANIFESTS = %w[Gemfile gems.rb Gemfile.lock gems.locked .rspec .rspec-local].freeze
15
+ LOCATION_SETTINGS = %w[cache_dir json_report html_report].freeze
16
+ ENTRY_KINDS = %w[ruby manifest boot hook source_plugin extra data].freeze
17
+ EXCLUDED_DIRECTORIES = %w[.git vendor node_modules].freeze
18
+ Entry = Data.define(:path, :kind, :state, :sha256, :required)
19
+ Change = Data.define(:path, :reason)
20
+ Snapshot = Data.define(:format_version, :digest, :inventory, :settings_digest, :runtime_digest, :complete,
21
+ :issues) do
22
+ def to_h
23
+ super.merge(inventory: inventory.map(&:to_h))
24
+ end
25
+ end
26
+ Verification = Data.define(:unchanged, :complete, :current_digest, :changes, :issues)
27
+
28
+ def self.restore_snapshot(hash)
29
+ fields = canonical_mapping(hash).transform_keys(&:to_sym)
30
+ validate_snapshot_fields(fields)
31
+ inventory = fields.fetch(:inventory).map { |entry| restore_entry(entry) }.freeze
32
+ raise ArgumentError, "duplicate inventory paths" unless inventory.map(&:path).uniq.size == inventory.size
33
+
34
+ issues = restore_issues(fields.fetch(:issues))
35
+ validate_snapshot_identity(fields, inventory, issues)
36
+ Snapshot.new(**fields, inventory: inventory, issues: issues)
37
+ rescue KeyError, TypeError, NoMethodError => e
38
+ raise ArgumentError, "invalid fingerprint snapshot: #{e.class}"
39
+ end
40
+
41
+ def self.restore_issues(issues)
42
+ raise ArgumentError, "invalid issues" unless
43
+ issues.is_a?(Array) && issues.all?(String) && issues == issues.uniq.sort
44
+
45
+ issues.map { |issue| issue.dup.freeze }.freeze
46
+ end
47
+
48
+ def self.validate_snapshot_identity(fields, inventory, issues)
49
+ raise ArgumentError, "invalid complete snapshot" if
50
+ fields[:complete] && (issues.any? || inventory.any? do |entry|
51
+ entry.state == "unreadable" || (entry.required && entry.state == "missing")
52
+ end)
53
+ raise ArgumentError, "unsorted inventory" unless inventory.map(&:path) == inventory.map(&:path).sort
54
+
55
+ inputs = { format: FORMAT_VERSION, files: inventory.map(&:to_h),
56
+ settings: fields[:settings_digest], runtime: fields[:runtime_digest] }
57
+ canonical = canonical_value(inputs)
58
+ raise ArgumentError, "fingerprint identity mismatch" unless
59
+ Digest::SHA256.hexdigest(JSON.generate(canonical)) == fields[:digest]
60
+ end
61
+
62
+ def self.validate_snapshot_fields(fields)
63
+ raise ArgumentError, "invalid snapshot fields" unless fields.keys.sort == Snapshot.members.sort
64
+ raise ArgumentError, "unsupported fingerprint format" unless fields[:format_version] == FORMAT_VERSION
65
+ raise ArgumentError, "invalid completeness" unless [true, false].include?(fields[:complete])
66
+
67
+ %i[digest settings_digest runtime_digest].each do |key|
68
+ raise ArgumentError, "invalid fingerprint digest" unless fields.fetch(key).match?(/\A[0-9a-f]{64}\z/)
69
+ end
70
+ end
71
+
72
+ def self.restore_entry(entry)
73
+ values = canonical_mapping(entry).transform_keys(&:to_sym)
74
+ raise ArgumentError, "invalid fingerprint entry" unless valid_entry?(values)
75
+
76
+ Entry.new(**values)
77
+ end
78
+
79
+ def self.valid_entry?(values)
80
+ values.keys.sort == %i[kind path required sha256 state] && valid_path?(values[:path]) &&
81
+ [true,
82
+ false].include?(values[:required]) && ENTRY_KINDS.include?(values[:kind]) &&
83
+ (!%w[ruby boot hook
84
+ source_plugin].include?(values[:kind]) || values[:required]) && valid_state?(values[:state],
85
+ values[:sha256])
86
+ end
87
+
88
+ def self.valid_path?(path)
89
+ unless path.is_a?(String) && !path.empty? && !path.include?("\0") &&
90
+ !path.include?("\\") && !path.end_with?("/")
91
+ return false
92
+ end
93
+
94
+ candidate = path.delete_prefix("external:")
95
+ absolute = path.start_with?("external:")
96
+ return false unless candidate.start_with?("/") == absolute
97
+
98
+ segments = candidate.split("/")
99
+ segments.shift if absolute
100
+ segments.none? { |part| part.empty? || %w[. ..].include?(part) }
101
+ end
102
+
103
+ def self.valid_state?(state, digest)
104
+ return digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) if state == "present"
105
+
106
+ %w[missing unreadable].include?(state) && digest.nil?
107
+ end
108
+
109
+ def self.runtime_identity
110
+ { engine: RUBY_ENGINE, version: RUBY_VERSION, platform: RUBY_PLATFORM,
111
+ patchlevel: RUBY_PATCHLEVEL, saboteur: VERSION }
112
+ end
113
+
114
+ def initialize(config:, root: config.root, extra_files: [], runtime_identity: self.class.runtime_identity)
115
+ @config = config
116
+ @root = File.realpath(root)
117
+ raise ArgumentError, "root differs from config.root" unless @root == File.realpath(config.root)
118
+
119
+ @extra_files = extra_files.map do |file|
120
+ options = file.is_a?(Hash) ? file : { path: file, required: true }
121
+ [File.expand_path(options.fetch(:path), @root), options.fetch(:required, true)]
122
+ end.freeze
123
+ @runtime_identity = canonical(runtime_identity)
124
+ end
125
+
126
+ def snapshot
127
+ issues = output_overlap_issues
128
+ entries = inventory(issues).sort_by(&:path).freeze
129
+ issues = issues.uniq.sort
130
+ settings_digest = hash_value(@config.to_h.except(*LOCATION_SETTINGS))
131
+ runtime_digest = hash_value(@runtime_identity)
132
+ digest = hash_value(format: FORMAT_VERSION, files: entries.map(&:to_h),
133
+ settings: settings_digest, runtime: runtime_digest)
134
+ Snapshot.new(format_version: FORMAT_VERSION, digest: digest, inventory: entries,
135
+ settings_digest: settings_digest, runtime_digest: runtime_digest,
136
+ complete: issues.empty?, issues: issues.freeze)
137
+ end
138
+
139
+ def verify(previous)
140
+ current = snapshot
141
+ changes = changes_between(previous, current)
142
+ complete = previous.complete && current.complete && previous.format_version == FORMAT_VERSION
143
+ Verification.new(unchanged: complete && previous.digest == current.digest,
144
+ complete: complete, current_digest: current.digest,
145
+ changes: changes.freeze, issues: current.issues)
146
+ end
147
+
148
+ def self.canonical_value(value)
149
+ case value
150
+ when Hash then canonical_mapping(value)
151
+ when Array then value.map { |entry| canonical_value(entry) }.freeze
152
+ when String then value.dup.freeze
153
+ when NilClass, TrueClass, FalseClass, Integer then value
154
+ when Float
155
+ raise ArgumentError, "non-finite identity number" unless value.finite?
156
+
157
+ value
158
+ else raise ArgumentError, "unsupported identity value"
159
+ end
160
+ end
161
+
162
+ def self.canonical_mapping(value)
163
+ raise ArgumentError, "invalid identity key" unless value.keys.all? do |key|
164
+ key.is_a?(String) || key.is_a?(Symbol)
165
+ end
166
+ raise ArgumentError, "duplicate normalized identity key" unless value.keys.map(&:to_s).uniq.size == value.size
167
+
168
+ value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical_value(value[key])] }.freeze
169
+ end
170
+
171
+ private
172
+
173
+ def canonical_path(path)
174
+ expanded = File.expand_path(path, @root)
175
+ return File.realpath(expanded) if File.exist?(expanded)
176
+
177
+ parent = File.dirname(expanded)
178
+ File.join(canonical_path(parent), File.basename(expanded))
179
+ end
180
+
181
+ def output_overlap_issues
182
+ paths = required_files.map(&:first) + MANIFESTS.map { |name| File.join(@root, name) } +
183
+ DataInputs.files(@root, extra: @config["fingerprint_extra"])
184
+ outputs = %w[json_report html_report].filter_map { |key| @config.fetch(key) }
185
+ .map { |path| canonical_path(path) }
186
+ issues = outputs.filter_map do |path|
187
+ if path.end_with?(".rb") || paths.include?(path) || File.directory?(path) || data_input?(path)
188
+ "output overlaps input: #{display_path(path)}"
189
+ end
190
+ end
191
+ issues.concat(cache_overlap_issues(paths))
192
+ issues
193
+ end
194
+
195
+ def cache_overlap_issues(paths)
196
+ cache = canonical_path(@config["cache_dir"])
197
+ protected = [@root] + %w[app lib spec test/fixtures].map { |name| File.join(@root, name) } + paths
198
+ inside = protected.any? do |path|
199
+ path == cache || path.start_with?("#{cache}/") || (path != @root && cache.start_with?("#{path}/"))
200
+ end
201
+ overlaps = inside || data_input?(cache)
202
+ overlaps ? ["cache directory overlaps inputs: #{display_path(cache)}"] : []
203
+ end
204
+
205
+ # Declared fixture, schema or config input, or a path inside a fixture tree.
206
+ def data_input?(path)
207
+ DataInputs.match?(@root, path, extra: @config["fingerprint_extra"])
208
+ end
209
+
210
+ def inventory(issues)
211
+ paths = project_files(issues).to_h { |path| [path, "ruby"] }
212
+ MANIFESTS.each { |name| paths[File.join(@root, name)] = "manifest" }
213
+ data_files(issues).each { |path| paths[path] ||= "data" }
214
+ required = merged_required_files
215
+ required.each { |path, (kind, _)| paths[path] = kind }
216
+ paths.map do |path, kind|
217
+ needed = required.key?(path) ? required[path].last : !%w[manifest data].include?(kind)
218
+ read_entry(path, kind, needed, issues)
219
+ end
220
+ end
221
+
222
+ # Fixtures, schema dumps and database config. A pattern with no match is
223
+ # fine; a listed file that vanishes later shows up as "removed". Like Ruby
224
+ # files, data reached through a symlink (the file or a directory above it)
225
+ # is an undeclared boundary: its real path could be an output or lie outside
226
+ # the project, so the snapshot is incomplete instead of guessing.
227
+ def data_files(issues)
228
+ DataInputs.files(@root, extra: @config["fingerprint_extra"]).reject do |path|
229
+ next false unless symlinked?(path)
230
+
231
+ issues << "symlink input: #{display_path(path)}".freeze
232
+ true
233
+ end
234
+ end
235
+
236
+ def symlinked?(path)
237
+ current = @root
238
+ path.delete_prefix("#{@root}/").split("/").any? { |part| File.symlink?(current = File.join(current, part)) }
239
+ end
240
+
241
+ def merged_required_files
242
+ required_files.group_by(&:first).transform_values do |entries|
243
+ roles = entries.map { |_, kind, _| kind }
244
+ role = (roles - ["extra"]).min || "extra"
245
+ [role, entries.any? { |_, _, needed| needed }]
246
+ end
247
+ end
248
+
249
+ # Walk without following symlink directories. A project symlink is an
250
+ # undeclared dependency boundary, not a silently omitted source tree.
251
+ def project_files(issues, directory = @root)
252
+ Dir.children(directory).sort.flat_map do |name|
253
+ path = File.join(directory, name)
254
+ next [] if excluded?(path, name)
255
+
256
+ if File.symlink?(path)
257
+ issues << "symlink input: #{display_path(path)}".freeze
258
+ next []
259
+ end
260
+ next project_files(issues, path) if File.directory?(path)
261
+
262
+ File.file?(path) && path.end_with?(".rb") ? [path] : []
263
+ end
264
+ rescue SystemCallError
265
+ issues << "unreadable directory: #{display_path(directory)}".freeze
266
+ []
267
+ end
268
+
269
+ def excluded?(path, name)
270
+ EXCLUDED_DIRECTORIES.include?(name) || path == canonical_path(@config["cache_dir"]) ||
271
+ %w[json_report html_report].filter_map { |key| @config.fetch(key) }.any? do |output|
272
+ path == canonical_path(output)
273
+ end
274
+ end
275
+
276
+ def required_files
277
+ paths = %w[require hooks].flat_map do |key|
278
+ @config[key].map { |file| [resolve(file), key == "hooks" ? "hook" : "boot", true] }
279
+ end
280
+ paths += @config["sources"].reject { |entry| entry == "builtin" }.map do |entry|
281
+ [resolve(entry.partition(":").first), "source_plugin", true]
282
+ end
283
+ paths + @extra_files.map { |path, needed| [path, "extra", needed] }
284
+ end
285
+
286
+ def resolve(file)
287
+ path = File.expand_path(file, @root)
288
+ return path if File.exist?(path) || File.extname(path) == ".rb"
289
+ return "#{path}.rb" if File.exist?("#{path}.rb")
290
+
291
+ path
292
+ end
293
+
294
+ def read_entry(path, kind, required, issues)
295
+ name = display_path(path).freeze
296
+ unless File.exist?(path)
297
+ issues << "missing required input: #{name}".freeze if required
298
+ return Entry.new(path: name, kind: kind.freeze, state: "missing", sha256: nil, required: required)
299
+ end
300
+ Entry.new(path: name, kind: kind.freeze, state: "present", sha256: Digest::SHA256.file(path).hexdigest.freeze,
301
+ required: required)
302
+ rescue SystemCallError, IOError
303
+ issues << "unreadable input: #{name}".freeze
304
+ Entry.new(path: name, kind: kind.freeze, state: "unreadable", sha256: nil, required: required)
305
+ end
306
+
307
+ def display_path(path)
308
+ path.start_with?("#{@root}/") ? path.delete_prefix("#{@root}/") : "external:#{path}"
309
+ end
310
+
311
+ def changes_between(previous, current)
312
+ before = previous.inventory.to_h { |entry| [entry.path, entry] }
313
+ after = current.inventory.to_h { |entry| [entry.path, entry] }
314
+ changes = (before.keys | after.keys).sort.filter_map do |path|
315
+ next if before[path] == after[path]
316
+
317
+ Change.new(path: path.freeze, reason: change_reason(before[path], after[path]))
318
+ end
319
+ if previous.settings_digest != current.settings_digest
320
+ changes << Change.new(path: "settings",
321
+ reason: "changed")
322
+ end
323
+ changes << Change.new(path: "runtime", reason: "changed") if previous.runtime_digest != current.runtime_digest
324
+ changes
325
+ end
326
+
327
+ def change_reason(before, after)
328
+ return "added" unless before
329
+ return "removed" unless after
330
+ return "unreadable" if after.state == "unreadable"
331
+
332
+ "changed"
333
+ end
334
+
335
+ def canonical(value)
336
+ self.class.canonical_value(value)
337
+ end
338
+
339
+ def hash_value(value)
340
+ Digest::SHA256.hexdigest(JSON.generate(canonical(value))).freeze
341
+ end
342
+ end
343
+ end
344
+ end
@@ -7,11 +7,10 @@ module Saboteur
7
7
  # unit of work parallel workers will share out.
8
8
  #
9
9
  # Order of business: skip what cannot be run (refinements, not loaded,
10
- # shadowed, no covering test); coarse mutants; fine mutants only if no
11
- # coarse mutant survived; then check every kill and timeout against the
12
- # unchanged method (VerdictCheck). Each mutant runs only the tests that
13
- # executed its edit, likeliest killers first, with a timeout from those
14
- # tests' recorded durations.
10
+ # shadowed, no covering test); coarse mutants; all eligible fine mutants;
11
+ # then check every kill and timeout against the unchanged method
12
+ # (VerdictCheck). Each mutant runs only the tests that executed its edit,
13
+ # likeliest killers first, with a timeout from their recorded durations.
15
14
  class Scheduler
16
15
  # A subject to run, with what its mutants need. killers counts the
17
16
  # tests that killed earlier mutants of this subject; slowdown is how
@@ -45,7 +44,7 @@ module Saboteur
45
44
  reused = all_reused(plan, candidates)
46
45
  return reused if reused
47
46
 
48
- results = run_candidates(plan, candidates) do |mutation, text|
47
+ results = run_candidates(candidates) do |mutation, text|
49
48
  reuse(plan, mutation, text) || execute(plan, mutation, text)
50
49
  end
51
50
  checked(subject, results)
@@ -86,21 +85,13 @@ module Saboteur
86
85
  return nil unless @cache
87
86
 
88
87
  catch(:must_run) do
89
- run_candidates(plan, candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
88
+ run_candidates(candidates) { |mutation, text| reuse(plan, mutation, text) || throw(:must_run) }
90
89
  end
91
90
  end
92
91
 
93
- def run_candidates(plan, candidates, &run_one)
92
+ def run_candidates(candidates, &run_one)
94
93
  coarse, fine = candidates.partition { |mutation, _| mutation.coarse }
95
94
  results = coarse.map { |mutation, text| run_one.call(mutation, text) }
96
- # A surviving coarse mutant means nothing checks the body, so the fine
97
- # mutants would only add noise. Not so when a rescue or else can
98
- # return a value of its own: fine mutants that make the body raise
99
- # can still be caught.
100
- if results.any?(&:survived?) && !plan.subject.recovers?
101
- return results + skip_all(fine, plan.subject, "coarse mutant survived", covered_by: plan.tests)
102
- end
103
-
104
95
  results + fine.map { |mutation, text| run_one.call(mutation, text) }
105
96
  end
106
97
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "digest"
5
5
  require "shellwords"
6
+ require_relative "data_inputs"
6
7
 
7
8
  module Saboteur
8
9
  class Engine
@@ -22,7 +23,7 @@ module Saboteur
22
23
  end
23
24
 
24
25
  def files
25
- (shared_spec_files + boot_and_hook_files + option_files + required_by_options).uniq.sort
26
+ (shared_spec_files + boot_and_hook_files + option_files + required_by_options + data_files).uniq.sort
26
27
  end
27
28
 
28
29
  def digest
@@ -33,6 +34,12 @@ module Saboteur
33
34
 
34
35
  private
35
36
 
37
+ # Fixtures, schema dumps and database config: not Ruby, but they change
38
+ # what the tests see, so a stale cache must not survive an edit to them.
39
+ def data_files
40
+ DataInputs.files(@root, extra: @config["fingerprint_extra"])
41
+ end
42
+
36
43
  def shared_spec_files
37
44
  Dir.glob("spec/**/*.rb", base: @root).reject { |file| file.end_with?("_spec.rb") }
38
45
  .map { |file| File.join(@root, file) }
@@ -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