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
@@ -1,13 +1,18 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "yaml"
5
4
  require_relative "mutation_source/builtin"
6
5
 
7
6
  module Saboteur
8
- # Settings from .saboteur.yml, overridden by CLI flags.
7
+ # Settings from config/saboteur.rb, overridden by CLI flags.
9
8
  class Config
10
- FILE = ".saboteur.yml"
9
+ FILE = "config/saboteur.rb"
10
+
11
+ # Died with the YAML port: a leftover file means settings are
12
+ # silently ignored, which could turn off a threshold. Fail loud.
13
+ LEGACY_FILE = ".saboteur.yml"
14
+ LEGACY_IGNORE_FILE = ".saboteur-ignore.yml"
15
+ IGNORE_FILE = "config/saboteur_ignore.rb"
11
16
 
12
17
  DEFAULTS = {
13
18
  "integration" => "rspec",
@@ -21,41 +26,52 @@ module Saboteur
21
26
  "coverage_timeout" => 3600.0,
22
27
  "max_memory_mb" => 2048,
23
28
  "max_processes" => 64,
29
+ "max_mutants_per_method" => 100,
24
30
  "jobs" => 1,
25
- "ignore_file" => ".saboteur-ignore.yml",
31
+ "ignore_file" => "config/saboteur_ignore.rb",
32
+ "custom_map" => "config/saboteur_custom_map.rb",
26
33
  "sources" => ["builtin"],
27
34
  "hooks" => [],
28
35
  "rails" => false,
29
36
  "reeval" => "nesting",
30
37
  "cache_dir" => ".saboteur",
31
38
  "json_report" => ".saboteur/report.json",
39
+ "html_report" => ".saboteur/report.html",
32
40
  "rspec_args" => [],
33
41
  "subjects" => [],
34
42
  "since" => nil,
35
43
  "cache" => true,
36
44
  "incremental" => false,
37
- "seed" => 1
45
+ "seed" => 1,
46
+ "tests" => [],
47
+ "fingerprint_extra" => [],
48
+ "threshold" => nil
38
49
  }.freeze
39
50
 
40
- INTEGER_KEYS = %w[jobs max_memory_mb max_processes seed].freeze
51
+ INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
41
52
  FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
42
- LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects].freeze
53
+ THRESHOLD_RANGE = (0.0..100.0)
54
+
55
+ LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
56
+ fingerprint_extra].freeze
43
57
 
44
58
  attr_reader :root
45
59
 
46
60
  def self.load(root: Dir.pwd, path: nil, overrides: {})
47
61
  path ||= File.join(root, FILE)
48
- from_file = File.file?(path) ? parse(path) : {}
49
- new(from_file.merge(stringify(overrides)), root: root)
50
- end
51
-
52
- def self.parse(path)
53
- data = YAML.safe_load_file(path) || {}
54
- raise ConfigError, "#{path}: expected a mapping at the top level" unless data.is_a?(Hash)
62
+ legacy = File.join(root, LEGACY_FILE)
63
+ if File.file?(legacy)
64
+ raise ConfigError,
65
+ "saboteur no longer reads #{LEGACY_FILE}; move its settings to #{FILE} (see README)."
66
+ end
67
+ legacy_ignore = File.join(root, LEGACY_IGNORE_FILE)
68
+ if File.file?(legacy_ignore)
69
+ raise ConfigError,
70
+ "saboteur no longer reads #{LEGACY_IGNORE_FILE}; move its entries to #{IGNORE_FILE} (see README)."
71
+ end
55
72
 
56
- data
57
- rescue Psych::SyntaxError => e
58
- raise ConfigError, "#{path}: #{e.message}"
73
+ from_file = File.file?(path) ? ConfigFile.load(path) : {}
74
+ new(from_file.merge(stringify(overrides)), root: root)
59
75
  end
60
76
 
61
77
  def self.stringify(hash)
@@ -70,6 +86,7 @@ module Saboteur
70
86
  @values = DEFAULTS.merge(values)
71
87
  coerce!
72
88
  validate!
89
+ validate_report_paths!
73
90
  end
74
91
 
75
92
  def [](key)
@@ -111,19 +128,46 @@ module Saboteur
111
128
  INTEGER_KEYS.each { |key| @values[key] = Integer(@values[key]) }
112
129
  FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
113
130
  LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
131
+ @values["threshold"] = Float(@values["threshold"]) unless @values["threshold"].nil?
114
132
  rescue ArgumentError, TypeError => e
115
133
  raise ConfigError, "invalid config value: #{e.message}"
116
134
  end
117
135
 
136
+ def validate_mutant_limit!
137
+ raise ConfigError, "max_mutants_per_method must be > 0" unless self["max_mutants_per_method"].positive?
138
+ end
139
+
140
+ def validate_report_paths!
141
+ json = path("json_report")
142
+ return unless json && json == path("html_report")
143
+
144
+ raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
145
+ end
146
+
147
+ def validate_threshold!
148
+ threshold = self["threshold"]
149
+ return if threshold.nil? || THRESHOLD_RANGE.cover?(threshold)
150
+
151
+ raise ConfigError, "threshold must be between 0 and 100"
152
+ end
153
+
154
+ def validate_reeval!
155
+ raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
156
+ Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
157
+ end
158
+
118
159
  def validate!
119
160
  raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
161
+
162
+ validate_mutant_limit!
163
+ validate_threshold!
120
164
  raise ConfigError, "seed must be >= 0" if self["seed"].negative?
121
165
  raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
122
166
  raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
123
167
  raise ConfigError, "timeout_factor must be > 0" unless self["timeout_factor"].positive?
124
168
  raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
125
- raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
126
- Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
169
+
170
+ validate_reeval!
127
171
 
128
172
  self["arid"].each { |pattern| Regexp.new(pattern) }
129
173
  rescue RegexpError => e
@@ -131,3 +175,5 @@ module Saboteur
131
175
  end
132
176
  end
133
177
  end
178
+
179
+ require_relative "config_file"
@@ -0,0 +1,97 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "did_you_mean"
5
+
6
+ module Saboteur
7
+ # Loads config/saboteur.rb: a plain Ruby file of `Saboteur.configure`
8
+ # blocks assigning settings on a strict object. It runs before the app
9
+ # boots, so it cannot reference app constants - plain Ruby and ENV only.
10
+ module ConfigFile
11
+ # The assignment target. One setter per Config key; anything else is an
12
+ # unknown setting. Records the line of each assignment so validation
13
+ # errors can point at it.
14
+ class Settings
15
+ def initialize
16
+ @values = {}
17
+ @lines = {}
18
+ end
19
+
20
+ attr_reader :values, :lines
21
+
22
+ Config::DEFAULTS.each_key do |key|
23
+ define_method("#{key}=") do |value|
24
+ @values[key] = value
25
+ @lines[key] = caller_locations(1, 1).first.lineno
26
+ end
27
+ end
28
+
29
+ Config::INTEGER_KEYS.each do |key|
30
+ define_method("#{key}=") do |value|
31
+ unless value.is_a?(Integer)
32
+ line = caller_locations(1, 1).first.lineno
33
+ raise ConfigError, "#{line}: #{key} must be an Integer, got #{value.inspect}"
34
+ end
35
+ @values[key] = value
36
+ @lines[key] = caller_locations(1, 1).first.lineno
37
+ end
38
+ end
39
+ end
40
+
41
+ module_function
42
+
43
+ # The collector is process-global on purpose: the config file calls the
44
+ # top-level Saboteur.configure, which must reach the load in progress.
45
+ # Loads are therefore serialized - never load two config files at once.
46
+ def load(path)
47
+ @settings = Settings.new
48
+ Kernel.load(path)
49
+ @settings.values
50
+ rescue ConfigError => e
51
+ raise ConfigError, "#{path}:#{e.message}"
52
+ rescue NoMethodError => e
53
+ raise ConfigError, unknown_setting_message(path, e) if e.receiver.is_a?(Settings) && e.name.to_s.end_with?("=")
54
+
55
+ # A typo'd method call, not a setting: same treatment as a bare
56
+ # constant, or the raw NoMethodError points at our internals.
57
+ raise ConfigError, name_error_message(path, e)
58
+ rescue SyntaxError, LoadError => e
59
+ raise ConfigError, "#{path}: #{e.message.lines.first.to_s.chomp}"
60
+ rescue NameError => e
61
+ raise ConfigError, name_error_message(path, e)
62
+ ensure
63
+ @settings = nil
64
+ end
65
+
66
+ def unknown_setting_message(path, error)
67
+ key = error.name.to_s.delete_suffix("=")
68
+ suggestion = DidYouMean::SpellChecker.new(dictionary: Config::DEFAULTS.keys).correct(key).first
69
+ hint = suggestion ? " (did you mean #{suggestion.inspect}?)" : ""
70
+ line = error.backtrace_locations.find { |loc| loc.path == path }&.lineno
71
+ "#{path}:#{line}: unknown setting #{key.inspect}#{hint}. " \
72
+ "Valid settings: #{Config::DEFAULTS.keys.join(', ')}"
73
+ end
74
+
75
+ def name_error_message(path, error)
76
+ line = error.backtrace_locations&.find { |loc| loc.path == path }&.lineno
77
+ "#{path}:#{line}: #{error.message.lines.first.to_s.chomp} " \
78
+ "- this file loads before the app boots; use plain Ruby or ENV"
79
+ end
80
+
81
+ private_class_method :unknown_setting_message, :name_error_message
82
+
83
+ # The Settings block Saboteur.configure hands to the file being loaded.
84
+ def current_settings
85
+ @settings
86
+ end
87
+ end
88
+
89
+ # Entry point used by config/saboteur.rb.
90
+ def self.configure
91
+ settings = ConfigFile.current_settings
92
+ raise "Saboteur.configure is only valid while config/saboteur.rb loads" unless settings
93
+
94
+ yield settings
95
+ settings
96
+ end
97
+ end
@@ -0,0 +1,170 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # Explicit source-file -> spec-file mapping from config/saboteur_custom_map.rb:
6
+ #
7
+ # SABOTEUR_CUSTOM_MAP = {
8
+ # "app/services/tax_sync.rb" => ["spec/services/tax_sync_spec.rb"],
9
+ # "app/services/**/*.rb" => ["spec/services/**/*_spec.rb"],
10
+ # %r{\Aapp/jobs/(.*)\.rb\z} => ['spec/jobs/\1_spec.rb'],
11
+ # "app/generated/**/*.rb" => [], # intentionally untested
12
+ # }.freeze
13
+ #
14
+ # The file loads before the app boots, so it must be plain Ruby.
15
+ class CustomMap
16
+ CONSTANT = :SABOTEUR_CUSTOM_MAP
17
+
18
+ Entry = Data.define(:key, :specs)
19
+
20
+ attr_reader :entries, :root
21
+
22
+ def self.load(path, root: Dir.pwd)
23
+ return new([], root: root) unless path && File.file?(path)
24
+
25
+ Object.send(:remove_const, CONSTANT) if Object.const_defined?(CONSTANT)
26
+ Kernel.load(path)
27
+ unless Object.const_defined?(CONSTANT)
28
+ raise ConfigError, "#{path}: expected it to define #{CONSTANT} = { ... }.freeze"
29
+ end
30
+
31
+ data = Object.const_get(CONSTANT)
32
+ raise ConfigError, "#{path}: #{CONSTANT} must be a Hash, got #{data.class}" unless data.is_a?(Hash)
33
+
34
+ new(data.map { |key, specs| validate_entry(path, root, key, specs) }, root: root)
35
+ rescue SyntaxError, LoadError => e
36
+ raise ConfigError, "#{path}: #{e.message.lines.first.to_s.chomp}"
37
+ rescue NameError => e
38
+ raise ConfigError, name_error_message(path, e)
39
+ ensure
40
+ Object.send(:remove_const, CONSTANT) if Object.const_defined?(CONSTANT)
41
+ end
42
+
43
+ def self.name_error_message(path, error)
44
+ line = error.backtrace_locations&.find { |loc| loc.path == path }&.lineno
45
+ "#{path}:#{line}: #{error.message.lines.first.to_s.chomp} " \
46
+ "- this file loads before the app boots; use plain Ruby or ENV"
47
+ end
48
+
49
+ def self.validate_entry(path, root, key, specs)
50
+ unless key.is_a?(String) || key.is_a?(Regexp)
51
+ raise ConfigError, "#{path}: key #{key.inspect} must be a String path, glob, or Regexp"
52
+ end
53
+ unless specs.is_a?(Array)
54
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{specs.inspect}; wrap spec paths in an array"
55
+ end
56
+
57
+ specs.each { |spec| validate_spec(path, root, key, spec) }
58
+ Entry.new(key: key, specs: specs)
59
+ end
60
+
61
+ def self.validate_spec(path, root, key, spec)
62
+ unless spec.is_a?(String)
63
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; spec paths must be Strings"
64
+ end
65
+ return if key.is_a?(Regexp) && spec.match?(/\\\d/) # backreference filled in at match time
66
+
67
+ expanded = File.expand_path(spec, root)
68
+ unless expanded.start_with?("#{root}/")
69
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which is outside the project root"
70
+ end
71
+ return if Dir.glob(expanded).any?
72
+
73
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which matches no file"
74
+ end
75
+
76
+ private_class_method :validate_entry, :validate_spec
77
+
78
+ def initialize(entries, root: Dir.pwd)
79
+ @entries = entries
80
+ @root = root
81
+ end
82
+
83
+ def empty?
84
+ @entries.empty?
85
+ end
86
+
87
+ # Spec paths for a source file, nil when no entry matches, :excluded when
88
+ # the winning entry maps to an empty array (intentionally untested).
89
+ def specs_for(file)
90
+ exact = @entries.find { |entry| entry.key == file }
91
+ return resolve(exact, nil) if exact
92
+
93
+ glob = glob_entry(file)
94
+ return resolve(glob, nil) if glob
95
+
96
+ regexp_specs(file)
97
+ end
98
+
99
+ # String keys (exact or glob) that match nothing under the root.
100
+ def stale_keys
101
+ @entries.select { |entry| entry.key.is_a?(String) }
102
+ .reject { |entry| Dir.glob(File.join(@root, entry.key)).any? }
103
+ .map(&:key)
104
+ end
105
+
106
+ # Literal String keys written twice (Ruby silently keeps the last).
107
+ # Detection is line-based and only sees double-quoted one-line keys;
108
+ # single-quoted, multi-line or %q keys that repeat go unreported.
109
+ def duplicate_keys(path)
110
+ seen = Hash.new(0)
111
+ File.readlines(path).each do |line|
112
+ seen[::Regexp.last_match(1)] += 1 if line =~ /^\s*"(.+?)"\s*=>/
113
+ end
114
+ seen.select { |_, count| count > 1 }.keys
115
+ end
116
+
117
+ def self.glob?(pattern, file)
118
+ File.fnmatch?(pattern, file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
119
+ end
120
+
121
+ private
122
+
123
+ def glob_entry(file)
124
+ globs = @entries.select { |entry| entry.key.is_a?(String) && self.class.glob?(entry.key, file) }
125
+ return nil if globs.empty?
126
+
127
+ best = globs.group_by { |entry| specificity(entry.key) }.max_by { |score, _| score }.last
128
+ if best.size > 1
129
+ raise ConfigError,
130
+ "custom map keys #{best.map { |e| e.key.inspect }.join(' and ')} both match #{file} " \
131
+ "with equal specificity; make one more specific"
132
+ end
133
+ best.first
134
+ end
135
+
136
+ def regexp_specs(file)
137
+ matches = @entries.filter_map do |entry|
138
+ next unless entry.key.is_a?(Regexp)
139
+
140
+ match = entry.key.match(file)
141
+ [entry, match] if match
142
+ end
143
+ return nil if matches.empty?
144
+ # Exclusion is unanimous: a file is intentionally untested only when
145
+ # every matching regexp entry maps to an empty array. One mapped entry
146
+ # is enough to keep the file tested; unmapped entries add nothing.
147
+ return :excluded if matches.all? { |entry, _| entry.specs.empty? }
148
+
149
+ matches.reject { |entry, _| entry.specs.empty? }
150
+ .flat_map { |entry, match| resolve(entry, match) }
151
+ end
152
+
153
+ def specificity(key)
154
+ key.count("^*?[]{}\\")
155
+ end
156
+
157
+ def resolve(entry, match)
158
+ return :excluded if entry.specs.empty?
159
+
160
+ specs = entry.specs
161
+ if match
162
+ # A backreference to a group that did not participate expands to "";
163
+ # that is never a real spec path, so drop it here rather than letting
164
+ # a junk entry reach the run.
165
+ specs = specs.map { |spec| spec.gsub(/\\(\d+)/) { match[::Regexp.last_match(1).to_i].to_s } }.reject(&:empty?)
166
+ end
167
+ specs
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "coverage"
5
+ require_relative "baseline_health"
6
+
7
+ module Saboteur
8
+ # Reports the current environment and runs baseline checks, never mutations.
9
+ class Doctor
10
+ def initialize(config, out: $stdout)
11
+ @config = config
12
+ @out = out
13
+ end
14
+
15
+ def call
16
+ Engine::Boot.refuse_recursion!
17
+ check_runtime
18
+ check_coverage
19
+ check_boot_files
20
+ check_custom_map
21
+ hooks = Hooks.new
22
+ integration = boot_target(hooks)
23
+ @out.puts "Integration: #{@config['integration']} (loaded)"
24
+ @out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
25
+ @out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
26
+ check_coverage
27
+ tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
28
+ @out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
29
+ @out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
30
+ true
31
+ end
32
+
33
+ private
34
+
35
+ def boot_target(hooks)
36
+ Engine::Boot.new(@config, hooks).load_target
37
+ rescue LoadError, SyntaxError, SystemExit, StandardError => e
38
+ raise Error, "target boot failed: #{e.class}: #{e.message}; check require files, hooks and rspec_args"
39
+ end
40
+
41
+ def check_runtime
42
+ @out.puts "Ruby: #{RUBY_DESCRIPTION}"
43
+ unless Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.3") && Process.respond_to?(:fork)
44
+ raise Error, "doctor needs Ruby 3.3+ with fork; use supported Ruby on Linux or macOS"
45
+ end
46
+ return if @config["integration"] == "rspec"
47
+
48
+ raise ConfigError,
49
+ "doctor supports the rspec integration; set integration: rspec"
50
+ end
51
+
52
+ def check_coverage
53
+ state = Coverage.state
54
+ @out.puts "Coverage: #{state}"
55
+ return if Coverage.supported?(:lines) && state == :suspended
56
+
57
+ raise Error, "Coverage must be suspended with line support before boot; use the saboteur executable, " \
58
+ "disable SimpleCov and remove other Coverage.start calls"
59
+ end
60
+
61
+ def check_custom_map
62
+ path = @config.path("custom_map")
63
+ map = CustomMap.load(path, root: @config.root)
64
+ return @out.puts("Custom map: none (optional)") if map.empty?
65
+
66
+ duplicates = map.duplicate_keys(path)
67
+ if duplicates.any?
68
+ @out.puts "Warning: custom map keys written twice, Ruby keeps the last: #{duplicates.join(', ')}"
69
+ end
70
+ stale = map.stale_keys
71
+ raise ConfigError, "#{path}: stale keys matching no file: #{stale.join(', ')}" if stale.any?
72
+
73
+ @out.puts "Custom map: #{map.entries.size} entries (loaded)"
74
+ end
75
+
76
+ def check_boot_files
77
+ %w[require hooks].each do |key|
78
+ @config[key].each do |file|
79
+ path = File.expand_path(file, @config.root)
80
+ raise ConfigError, "#{key} file missing: #{file}; fix config/saboteur.rb" unless File.file?(path)
81
+
82
+ @out.puts "#{key}: #{file} (found)"
83
+ end
84
+ end
85
+ @config.unmatched_patterns.each { |pattern| @out.puts "Warning: #{pattern} matches no file" }
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,116 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Saboteur
8
+ class Engine
9
+ # Two fresh children check the unchanged suite before any cached verdict
10
+ # or mutation source is used. This catches observed instability, not all
11
+ # possible flaky behaviour.
12
+ class BaselineGuard
13
+ def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
14
+ @config = config
15
+ @integration = integration
16
+ @hooks = hooks
17
+ @supervisor = supervisor
18
+ @diagnostics = diagnostics
19
+ end
20
+
21
+ def call
22
+ expected = @integration.all_tests.map(&:id)
23
+ raise Error, "baseline has no tests. Check rspec_args and the suite filters before running saboteur again." if
24
+ expected.empty?
25
+
26
+ @diagnostics.inform("checking the unchanged suite twice (seed #{@config['seed']})")
27
+ previous = nil
28
+ 2.times do |index|
29
+ number = index + 1
30
+ data = check(number)
31
+ unless data["passed"] && data["failed"].empty?
32
+ refuse(number,
33
+ "the suite failed: #{Array(data['failed']).join(', ')}")
34
+ end
35
+ executed = data["executed"]
36
+ refuse(number, "executed tests do not match the loaded suite") unless
37
+ executed.sort == expected.sort
38
+ refuse(number, "test order changed between baseline checks") if previous && executed != previous
39
+ previous = executed
40
+ end
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ def check(number)
47
+ Execution::ForkReadiness.prepare_heap
48
+ heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}")
49
+ result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
50
+ record(writer, number)
51
+ end
52
+ refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
53
+ # Before the payload checks: a child killed by a signal (the OOM
54
+ # killer, say) writes nothing, and the signal is the explanation.
55
+ if result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
56
+ refuse(number, unsuccessful_exit(result.exit_status))
57
+ end
58
+ refuse(number, "the child did not report a result") if result.payload.empty?
59
+ refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
60
+ parse_result(number, result.payload)
61
+ end
62
+
63
+ # A signaled child (the OOM killer, say) can leave an empty log,
64
+ # which reads like a suite failure; name the signal and hint the
65
+ # likely cause instead.
66
+ def unsuccessful_exit(status)
67
+ return "the child exited with status #{status.exitstatus}" unless status.signaled?
68
+
69
+ signal = "SIG#{Signal.signame(status.termsig)}"
70
+ hint = signal == "SIGKILL" ? " - possible OOM, check memory" : ""
71
+ "the child was killed by #{signal}#{hint}"
72
+ end
73
+
74
+ def parse_result(number, payload)
75
+ data = JSON.parse(payload)
76
+ refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
77
+ refuse(number, data["error"]) if data["error"]
78
+ refuse(number, "the child reported invalid test results") unless valid_result?(data)
79
+ data
80
+ rescue JSON::ParserError
81
+ refuse(number, "the child reported invalid JSON")
82
+ end
83
+
84
+ def valid_result?(data)
85
+ [true, false].include?(data["passed"]) && data["failed"].is_a?(Array) &&
86
+ data["executed"].is_a?(Array) && data["executed"].all?(String)
87
+ end
88
+
89
+ def record(writer, number)
90
+ path = log_path(number)
91
+ FileUtils.mkdir_p(File.dirname(path))
92
+ $stdout.reopen(path, "w")
93
+ $stdout.sync = true
94
+ $stderr.reopen($stdout)
95
+ @hooks.run(:after_fork)
96
+ Kernel.srand(@config["seed"])
97
+ result = @integration.run_baseline(seed: @config["seed"])
98
+ writer.write(JSON.generate(passed: result.passed, failed: result.failed_ids, executed: result.executed_ids))
99
+ rescue Exception => e # rubocop:disable Lint/RescueException
100
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
101
+ ensure
102
+ writer.close
103
+ end
104
+
105
+ def log_path(number)
106
+ File.join(@config.path("cache_dir"), "baseline-#{number}.log")
107
+ end
108
+
109
+ def refuse(number, reason)
110
+ raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
111
+ "See the log at #{log_path(number)}. Fix the suite or its setup, then run saboteur again " \
112
+ "with the same seed. No mutants were started."
113
+ end
114
+ end
115
+ end
116
+ end
@@ -62,6 +62,7 @@ module Saboteur
62
62
  @rails = rails = Frameworks::Rails.new(@config, @hooks) if @config["rails"]
63
63
  rails&.before_boot
64
64
  @hooks.run(:before_boot)
65
+ add_spec_to_load_path if @config["rails"]
65
66
  @config["require"].each { |file| require File.expand_path(file, @root) }
66
67
  integration = Integration.fetch(@config["integration"]).new(@config)
67
68
  integration.setup
@@ -72,6 +73,14 @@ module Saboteur
72
73
 
73
74
  private
74
75
 
76
+ # rspec puts spec/ on the load path, so a boot file under spec/ (a
77
+ # dummy app's rails_helper, say) can require its siblings. Match
78
+ # that, or the same require list fails to load.
79
+ def add_spec_to_load_path
80
+ path = File.join(@root, "spec")
81
+ $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
82
+ end
83
+
75
84
  def load_hooks
76
85
  Saboteur.current_hooks = @hooks
77
86
  @config["hooks"].each { |file| load File.expand_path(file, @root) }
@@ -0,0 +1,35 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class Engine
6
+ # Selects a bounded list for each covered subject before workers fork.
7
+ # Subject skips are not mutants and do not enter the mutation score.
8
+ class CandidateSelection
9
+ attr_reader :skipped_subjects
10
+
11
+ def initialize(candidates:, map:, limit:, diagnostics:)
12
+ @candidates = candidates
13
+ @map = map
14
+ @limit = limit
15
+ @diagnostics = diagnostics
16
+ @skipped_subjects = {}
17
+ end
18
+
19
+ def for(subject, scope: nil)
20
+ if @map.tests_for(subject).empty?
21
+ @skipped_subjects[subject] = "no coverage"
22
+ return []
23
+ end
24
+
25
+ list = @candidates.for(subject)
26
+ list = scope.restrict(subject, list) if scope
27
+ omitted = list.size - @limit
28
+ if omitted.positive?
29
+ @diagnostics.warn("#{subject.id}: omitted #{omitted} mutant(s) above max_mutants_per_method (#{@limit})")
30
+ end
31
+ list.first(@limit)
32
+ end
33
+ end
34
+ end
35
+ end