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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -1
- data/README.md +526 -73
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +305 -30
- data/lib/saboteur/config.rb +65 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +88 -0
- data/lib/saboteur/engine/baseline_guard.rb +116 -0
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +40 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +344 -0
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +125 -25
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +210 -0
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +69 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +14 -3
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +131 -0
- data/lib/saboteur/reporter/html.rb +213 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +21 -4
- data/lib/saboteur/reporter/terminal.rb +76 -2
- data/lib/saboteur/result.rb +61 -9
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +7 -0
- metadata +30 -2
data/lib/saboteur/config.rb
CHANGED
|
@@ -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
|
|
7
|
+
# Settings from config/saboteur.rb, overridden by CLI flags.
|
|
9
8
|
class Config
|
|
10
|
-
FILE = "
|
|
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" => ".
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
data/lib/saboteur/engine/boot.rb
CHANGED
|
@@ -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
|