saboteur 0.3.0 → 0.5.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
|
@@ -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
|
data/lib/saboteur/doctor.rb
CHANGED
|
@@ -5,11 +5,13 @@ require "coverage"
|
|
|
5
5
|
require_relative "baseline_health"
|
|
6
6
|
|
|
7
7
|
module Saboteur
|
|
8
|
-
# Reports the current environment
|
|
8
|
+
# Reports the current environment, never mutations. The two unchanged suite
|
|
9
|
+
# passes are slow on a large suite, so they run only when asked for.
|
|
9
10
|
class Doctor
|
|
10
|
-
def initialize(config, out: $stdout)
|
|
11
|
+
def initialize(config, out: $stdout, suite: false)
|
|
11
12
|
@config = config
|
|
12
13
|
@out = out
|
|
14
|
+
@suite = suite
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def call
|
|
@@ -17,20 +19,32 @@ module Saboteur
|
|
|
17
19
|
check_runtime
|
|
18
20
|
check_coverage
|
|
19
21
|
check_boot_files
|
|
22
|
+
check_custom_map
|
|
23
|
+
check_ignore_subjects
|
|
20
24
|
hooks = Hooks.new
|
|
21
25
|
integration = boot_target(hooks)
|
|
22
26
|
@out.puts "Integration: #{@config['integration']} (loaded)"
|
|
23
27
|
@out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
|
|
24
28
|
@out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
|
|
25
29
|
check_coverage
|
|
26
|
-
|
|
27
|
-
@out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
|
|
28
|
-
@out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
|
|
30
|
+
check_suite(integration, hooks)
|
|
29
31
|
true
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
private
|
|
33
35
|
|
|
36
|
+
def check_suite(integration, hooks)
|
|
37
|
+
unless @suite
|
|
38
|
+
@out.puts "Baseline: not run. Pass --suite to run the unchanged suite twice " \
|
|
39
|
+
"(add --test PATH to limit it to some specs)."
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
|
|
44
|
+
@out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
|
|
45
|
+
@out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
|
|
46
|
+
end
|
|
47
|
+
|
|
34
48
|
def boot_target(hooks)
|
|
35
49
|
Engine::Boot.new(@config, hooks).load_target
|
|
36
50
|
rescue LoadError, SyntaxError, SystemExit, StandardError => e
|
|
@@ -51,17 +65,51 @@ module Saboteur
|
|
|
51
65
|
def check_coverage
|
|
52
66
|
state = Coverage.state
|
|
53
67
|
@out.puts "Coverage: #{state}"
|
|
54
|
-
return if Coverage.supported?(:lines) && state == :suspended
|
|
68
|
+
return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
|
|
69
|
+
|
|
70
|
+
raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
|
|
71
|
+
"executable, disable SimpleCov and remove other Coverage.start calls"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Match against all included source definitions, not the current diff or
|
|
75
|
+
# selected tests. A limited run must not make a valid ignore look stale.
|
|
76
|
+
def check_ignore_subjects
|
|
77
|
+
ignores = IgnoreList.load(@config.path("ignore_file"))
|
|
78
|
+
return if ignores.entries.empty?
|
|
79
|
+
|
|
80
|
+
finder = SubjectFinder.new
|
|
81
|
+
parsed = @config.included_files.map { |file| finder.call(file) }
|
|
82
|
+
if parsed.any? { |result| result.errors.any? }
|
|
83
|
+
@out.puts "Warning: ignore subject check incomplete: an included file does not parse"
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
subjects = parsed.flat_map(&:subjects).to_set(&:id)
|
|
87
|
+
ignores.entries.map(&:subject).uniq.reject { |subject| subjects.include?(subject) }.each do |subject|
|
|
88
|
+
@out.puts "Warning: ignore subject #{subject} matches no discovered subject in included files; " \
|
|
89
|
+
"check include/exclude settings and the exact Class#method or Class.method form"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_custom_map
|
|
94
|
+
path = @config.path("custom_map")
|
|
95
|
+
map = CustomMap.load(path, root: @config.root)
|
|
96
|
+
return @out.puts("Custom map: none (optional)") if map.empty?
|
|
97
|
+
|
|
98
|
+
duplicates = map.duplicate_keys(path)
|
|
99
|
+
if duplicates.any?
|
|
100
|
+
@out.puts "Warning: custom map keys written twice, Ruby keeps the last: #{duplicates.join(', ')}"
|
|
101
|
+
end
|
|
102
|
+
stale = map.stale_keys
|
|
103
|
+
raise ConfigError, "#{path}: stale keys matching no file: #{stale.join(', ')}" if stale.any?
|
|
55
104
|
|
|
56
|
-
|
|
57
|
-
"disable SimpleCov and remove other Coverage.start calls"
|
|
105
|
+
@out.puts "Custom map: #{map.entries.size} entries (loaded)"
|
|
58
106
|
end
|
|
59
107
|
|
|
60
108
|
def check_boot_files
|
|
61
109
|
%w[require hooks].each do |key|
|
|
62
110
|
@config[key].each do |file|
|
|
63
111
|
path = File.expand_path(file, @config.root)
|
|
64
|
-
raise ConfigError, "#{key} file missing: #{file}; fix
|
|
112
|
+
raise ConfigError, "#{key} file missing: #{file}; fix config/saboteur.rb" unless File.file?(path)
|
|
65
113
|
|
|
66
114
|
@out.puts "#{key}: #{file} (found)"
|
|
67
115
|
end
|
|
@@ -10,6 +10,10 @@ module Saboteur
|
|
|
10
10
|
# or mutation source is used. This catches observed instability, not all
|
|
11
11
|
# possible flaky behaviour.
|
|
12
12
|
class BaselineGuard
|
|
13
|
+
EXCERPT_LINES = 10
|
|
14
|
+
EXCERPT_WIDTH = 200
|
|
15
|
+
EXCERPT_SCAN_LINES = 5000
|
|
16
|
+
|
|
13
17
|
def initialize(config:, integration:, hooks:, supervisor:, diagnostics:)
|
|
14
18
|
@config = config
|
|
15
19
|
@integration = integration
|
|
@@ -45,15 +49,32 @@ module Saboteur
|
|
|
45
49
|
|
|
46
50
|
def check(number)
|
|
47
51
|
Execution::ForkReadiness.prepare_heap
|
|
48
|
-
|
|
52
|
+
heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
|
|
53
|
+
result = @supervisor.run(timeout: @config["coverage_timeout"], on_tick: heartbeat.method(:tick)) do |writer|
|
|
54
|
+
record(writer, number)
|
|
55
|
+
end
|
|
49
56
|
refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
|
|
57
|
+
# Before the payload checks: a child killed by a signal (the OOM
|
|
58
|
+
# killer, say) writes nothing, and the signal is the explanation. A
|
|
59
|
+
# child the supervisor itself killed after it had reported is not a
|
|
60
|
+
# failure: its payload is checked below.
|
|
61
|
+
refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
|
|
50
62
|
refuse(number, "the child did not report a result") if result.payload.empty?
|
|
51
|
-
refuse(number, "the child exited unsuccessfully") if
|
|
52
|
-
result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
|
|
53
63
|
refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
|
|
54
64
|
parse_result(number, result.payload)
|
|
55
65
|
end
|
|
56
66
|
|
|
67
|
+
# A signaled child (the OOM killer, say) can leave an empty log,
|
|
68
|
+
# which reads like a suite failure; name the signal. The OOM hint is
|
|
69
|
+
# printed only when the supervisor saw the process tree's memory grow.
|
|
70
|
+
def unsuccessful_exit(status, memory_pressure)
|
|
71
|
+
return "the child exited with status #{status.exitstatus}" unless status.signaled?
|
|
72
|
+
|
|
73
|
+
signal = "SIG#{Signal.signame(status.termsig)}"
|
|
74
|
+
hint = signal == "SIGKILL" && memory_pressure ? " - possible OOM, check memory" : ""
|
|
75
|
+
"the child was killed by #{signal}#{hint}"
|
|
76
|
+
end
|
|
77
|
+
|
|
57
78
|
def parse_result(number, payload)
|
|
58
79
|
data = JSON.parse(payload)
|
|
59
80
|
refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
|
|
@@ -91,8 +112,26 @@ module Saboteur
|
|
|
91
112
|
|
|
92
113
|
def refuse(number, reason)
|
|
93
114
|
raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
|
|
94
|
-
"See the log at #{log_path(number)}. Fix the suite or its
|
|
95
|
-
"with the same seed. No mutants were started."
|
|
115
|
+
"#{failure_excerpt(number)}See the full log at #{log_path(number)}. Fix the suite or its " \
|
|
116
|
+
"setup, then run saboteur again with the same seed. No mutants were started."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The first lines of the failure, inline, so the cause is readable
|
|
120
|
+
# without opening the log: RSpec's "Failures:" section when the log has
|
|
121
|
+
# one, otherwise the last lines of the log.
|
|
122
|
+
def failure_excerpt(number)
|
|
123
|
+
lines = log_lines(number)
|
|
124
|
+
return "" if lines.empty?
|
|
125
|
+
|
|
126
|
+
start = lines.index { |line| line.start_with?("Failures:") }
|
|
127
|
+
shown = start ? lines[start, EXCERPT_LINES] : lines.last(EXCERPT_LINES)
|
|
128
|
+
"The log says:\n#{shown.map { |line| " #{line.slice(0, EXCERPT_WIDTH)}" }.join("\n")}\n"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def log_lines(number)
|
|
132
|
+
File.foreach(log_path(number), chomp: true).first(EXCERPT_SCAN_LINES).reject { |line| line.strip.empty? }
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
[]
|
|
96
135
|
end
|
|
97
136
|
end
|
|
98
137
|
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,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
class Engine
|
|
6
|
+
# Trivial compiler equivalence: a mutant whose method compiles to the
|
|
7
|
+
# same instruction sequence as the original cannot change behaviour, so
|
|
8
|
+
# running it can only produce a false survivor. The comparison walks the
|
|
9
|
+
# compiled instruction tree and drops only position metadata (line
|
|
10
|
+
# numbers, line events, source spans, node ids, file paths). Operands,
|
|
11
|
+
# including string and numeric literals, are compared exactly (2 and 2.0 differ, and so do +0.0 and -0.0).
|
|
12
|
+
class BytecodeEquivalence
|
|
13
|
+
ISEQ_MAGIC = "YARVInstructionSequence/SimpleDataFormat"
|
|
14
|
+
# Operand types whose eql? is exact. Anything else (an unknown object,
|
|
15
|
+
# a type that could hold a Float) is never called equal, so its
|
|
16
|
+
# mutant still runs.
|
|
17
|
+
PLAIN = [Integer, String, Symbol, Rational, Regexp, NilClass, TrueClass, FalseClass].freeze
|
|
18
|
+
POSITION_EVENT = /\ARUBY_EVENT_/
|
|
19
|
+
|
|
20
|
+
# True when mutated_text compiles to the same instructions as the
|
|
21
|
+
# original subject source under the same compile options. Anything the
|
|
22
|
+
# compiler cannot judge counts as not equivalent, so the mutant still
|
|
23
|
+
# runs. frozen_string_literal: whether the file carries that magic
|
|
24
|
+
# comment, which changes how string literals compile.
|
|
25
|
+
def equivalent?(original_text, mutated_text, frozen_string_literal: false)
|
|
26
|
+
original = fingerprint_of_original(original_text, frozen_string_literal)
|
|
27
|
+
return false unless original
|
|
28
|
+
|
|
29
|
+
exact?(original, fingerprint(mutated_text, frozen_string_literal))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Mutants of one method arrive together, so the last original is enough.
|
|
35
|
+
def fingerprint_of_original(text, frozen)
|
|
36
|
+
key = [text, frozen]
|
|
37
|
+
return @original_fingerprint if key == @original_key
|
|
38
|
+
|
|
39
|
+
@original_key = key
|
|
40
|
+
@original_fingerprint = fingerprint(text, frozen)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fingerprint(text, frozen)
|
|
44
|
+
options = { frozen_string_literal: frozen }
|
|
45
|
+
strip_positions(RubyVM::InstructionSequence.compile(text, nil, nil, 1, options).to_a)
|
|
46
|
+
rescue SyntaxError, StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Structural equality that never coerces: an Integer is not a Float,
|
|
51
|
+
# and +0.0 is not -0.0 (1.0 / +0.0 and 1.0 / -0.0 are different
|
|
52
|
+
# infinities), which eql? alone would not tell apart.
|
|
53
|
+
def exact?(left, right)
|
|
54
|
+
case left
|
|
55
|
+
when Array then right.is_a?(Array) && left.size == right.size && left.zip(right).all? { |l, r| exact?(l, r) }
|
|
56
|
+
when Hash, Complex, Range then right.instance_of?(left.class) && exact?(parts(left), parts(right))
|
|
57
|
+
when Float then right.is_a?(Float) && [left].pack("G") == [right].pack("G")
|
|
58
|
+
when *PLAIN then left.eql?(right)
|
|
59
|
+
else false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The values a composite operand is made of, compared one by one.
|
|
64
|
+
def parts(value)
|
|
65
|
+
case value
|
|
66
|
+
when Hash then value.to_a
|
|
67
|
+
when Complex then value.rectangular
|
|
68
|
+
else [value.begin, value.end, value.exclude_end?]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def strip_positions(node)
|
|
73
|
+
return node unless node.is_a?(Array)
|
|
74
|
+
return node.map { |child| strip_positions(child) } unless node.first == ISEQ_MAGIC
|
|
75
|
+
|
|
76
|
+
_magic, major, minor, format, _misc, name, _path, _real_path, _first_line, type, locals, params, catches,
|
|
77
|
+
body = node
|
|
78
|
+
[major, minor, format, name, type, locals, strip_positions(params), strip_positions(catches),
|
|
79
|
+
strip_body(body)]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def strip_body(body)
|
|
83
|
+
body.reject { |entry| position?(entry) }.map { |entry| strip_positions(entry) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Line numbers and line/call/return events depend only on layout.
|
|
87
|
+
def position?(entry)
|
|
88
|
+
case entry
|
|
89
|
+
when Integer, POSITION_EVENT then true
|
|
90
|
+
else false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
class Engine
|
|
8
|
+
# Watches the working tree while mutants run (issue #102). A mutant that
|
|
9
|
+
# slips past the hazard guard and deletes tracked files must abort the
|
|
10
|
+
# run, not be reported as a survivor. The snapshot is the tracked files
|
|
11
|
+
# (git ls-files) when the tree is a repository, else every file under the
|
|
12
|
+
# root; the tool's own cache directory is excluded in both cases, and the
|
|
13
|
+
# presence of .git is watched separately.
|
|
14
|
+
class Canary
|
|
15
|
+
GIT_DIR = ".git"
|
|
16
|
+
|
|
17
|
+
def initialize(root, cache_dir: ".saboteur")
|
|
18
|
+
@root = root
|
|
19
|
+
@cache_dir = cache_dir.to_s
|
|
20
|
+
@snapshot = inventory
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# After a subject's mutants: any file present at snapshot time and gone
|
|
24
|
+
# now means a mutant deleted it. digest names the most recent mutant,
|
|
25
|
+
# the likely cause.
|
|
26
|
+
def check!(subject: nil, digest: nil)
|
|
27
|
+
missing = @snapshot - inventory
|
|
28
|
+
return if missing.empty?
|
|
29
|
+
|
|
30
|
+
of = " of #{subject.id}" if subject
|
|
31
|
+
culprit = digest ? " The most recent mutant, #{digest}#{of}, is the likely cause." : ""
|
|
32
|
+
raise Saboteur::CanaryTripped,
|
|
33
|
+
"canary: #{missing.size} watched file(s) vanished from the working tree while mutants ran " \
|
|
34
|
+
"(first: #{missing.first}).#{culprit} The canary watches tracked files and .git presence " \
|
|
35
|
+
"and only detects the loss after the fact; it cannot restore anything. Recover from your " \
|
|
36
|
+
"own backup, a fresh checkout, or a reclone - a deleted .git or uncommitted work is gone " \
|
|
37
|
+
"unless you have another copy."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def inventory
|
|
43
|
+
files = git? ? git_tracked_files : walk_files
|
|
44
|
+
files << GIT_DIR if File.exist?(File.join(@root, GIT_DIR))
|
|
45
|
+
files.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def git?
|
|
49
|
+
File.directory?(File.join(@root, GIT_DIR)) && system("git", "--version", %i[out err] => File::NULL)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def git_tracked_files
|
|
53
|
+
out, status = Open3.capture2("git", "ls-files", "-z", chdir: @root)
|
|
54
|
+
return walk_files unless status.success?
|
|
55
|
+
|
|
56
|
+
# ls-files reads the index, which still names a deleted file; keep
|
|
57
|
+
# only paths present in the worktree so a deletion shows up.
|
|
58
|
+
out.split("\0").reject { |rel| cache_path?(rel) }
|
|
59
|
+
.select { |rel| File.exist?(File.join(@root, rel)) || File.symlink?(File.join(@root, rel)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def walk_files
|
|
63
|
+
Dir.glob(File.join(@root, "**", "*"), File::FNM_DOTMATCH)
|
|
64
|
+
.select { |path| File.file?(path) }
|
|
65
|
+
.map { |path| path.delete_prefix("#{@root}/") }
|
|
66
|
+
.reject { |rel| rel.start_with?("#{GIT_DIR}/") || cache_path?(rel) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cache_path?(rel)
|
|
70
|
+
rel.start_with?("#{@cache_dir}/")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -6,7 +6,7 @@ module Saboteur
|
|
|
6
6
|
# Selects a bounded list for each covered subject before workers fork.
|
|
7
7
|
# Subject skips are not mutants and do not enter the mutation score.
|
|
8
8
|
class CandidateSelection
|
|
9
|
-
attr_reader :skipped_subjects
|
|
9
|
+
attr_reader :skipped_subjects, :selected
|
|
10
10
|
|
|
11
11
|
def initialize(candidates:, map:, limit:, diagnostics:)
|
|
12
12
|
@candidates = candidates
|
|
@@ -14,21 +14,34 @@ module Saboteur
|
|
|
14
14
|
@limit = limit
|
|
15
15
|
@diagnostics = diagnostics
|
|
16
16
|
@skipped_subjects = {}
|
|
17
|
+
@selected = {}
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def for(subject, scope: nil)
|
|
20
|
-
if @map.tests_for(subject).empty?
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
reason = subject.skip_reason || (skip_reason_for(subject) if @map.tests_for(subject).empty?)
|
|
22
|
+
if reason
|
|
23
|
+
@skipped_subjects[subject] = reason
|
|
24
|
+
return @selected[subject] = []
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
list = @candidates.for(subject)
|
|
26
28
|
list = scope.restrict(subject, list) if scope
|
|
27
29
|
omitted = list.size - @limit
|
|
28
30
|
if omitted.positive?
|
|
31
|
+
@diagnostics.record_omission(subject, count: omitted, limit: @limit)
|
|
29
32
|
@diagnostics.warn("#{subject.id}: omitted #{omitted} mutant(s) above max_mutants_per_method (#{@limit})")
|
|
30
33
|
end
|
|
31
|
-
list.first(@limit)
|
|
34
|
+
@selected[subject] = list.first(@limit)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Load-only code is not "no coverage": it ran, just never inside a
|
|
40
|
+
# test, and no forked test can re-run it (see CoverageMap::RAN_AT_LOAD).
|
|
41
|
+
def skip_reason_for(subject)
|
|
42
|
+
return "no coverage" unless @map.ran_at_load?(subject.file, subject.coverage_lines)
|
|
43
|
+
|
|
44
|
+
Selection::CoverageMap::RAN_AT_LOAD
|
|
32
45
|
end
|
|
33
46
|
end
|
|
34
47
|
end
|