saboteur 0.3.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 +113 -1
- data/README.md +248 -63
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/cli.rb +150 -22
- data/lib/saboteur/config.rb +27 -15
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +17 -1
- data/lib/saboteur/engine/baseline_guard.rb +20 -3
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/coverage_store.rb +31 -4
- 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/run_fingerprint.rb +34 -6
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine.rb +74 -13
- 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 +33 -17
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/rspec.rb +47 -3
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +8 -2
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -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 +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 +8 -0
- data/lib/saboteur/reporter/html.rb +49 -1
- data/lib/saboteur/reporter/stryker.rb +11 -4
- data/lib/saboteur/reporter/terminal.rb +65 -2
- data/lib/saboteur/result.rb +46 -5
- 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 +4 -0
- metadata +17 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# The instructions for an AI coding agent, printed by `saboteur agent-skill`.
|
|
6
|
+
# Paste them into AGENTS.md or CLAUDE.md. docs/agent-skill.md carries the same
|
|
7
|
+
# block; a spec keeps the two equal.
|
|
8
|
+
module AgentSkill
|
|
9
|
+
TEXT = <<~MARKDOWN
|
|
10
|
+
## Mutation testing with saboteur
|
|
11
|
+
|
|
12
|
+
After you change covered Ruby behavior or its specs, and RSpec is green, run saboteur
|
|
13
|
+
on your diff before you say the work is done:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
saboteur run --agent --since origin/main
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Read the JSON on stdout. `status` is `complete`, `partial` or `not_applicable`.
|
|
20
|
+
The exit code is strict: 0 means complete and clean, 1 means a survivor needs
|
|
21
|
+
review, 2 means a bad command or config, 3 means the run was incomplete or failed.
|
|
22
|
+
|
|
23
|
+
When a mutant survives:
|
|
24
|
+
|
|
25
|
+
1. Pick one live survivor from `findings.shown`.
|
|
26
|
+
2. Add the smallest test that checks the observable behavior the mutant changed.
|
|
27
|
+
3. Replay it with the command in `next.replay` (it uses the recorded seed) and
|
|
28
|
+
confirm the mutant is now killed.
|
|
29
|
+
4. Run the diff again. Repeat until the exit code is 0.
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
|
|
33
|
+
- Report `partial` and `not_applicable` results as they are. Say what had no
|
|
34
|
+
coverage or was capped; do not call an incomplete run clean.
|
|
35
|
+
- Never change production behavior only to kill a mutant.
|
|
36
|
+
- Never add survivors to the ignore list yourself. Ask the owner.
|
|
37
|
+
- Run `saboteur help` for every command, option and exit code.
|
|
38
|
+
MARKDOWN
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/saboteur/cli.rb
CHANGED
|
@@ -18,7 +18,6 @@ module Saboteur
|
|
|
18
18
|
EXIT_SURVIVORS = 1
|
|
19
19
|
EXIT_USAGE = 2
|
|
20
20
|
EXIT_TOOL = 3
|
|
21
|
-
|
|
22
21
|
class UsageError < ConfigError; end
|
|
23
22
|
|
|
24
23
|
attr_reader :options
|
|
@@ -28,6 +27,7 @@ module Saboteur
|
|
|
28
27
|
@out = out
|
|
29
28
|
@err = err
|
|
30
29
|
@options = {}
|
|
30
|
+
@format = "agent" if agent_requested?
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def call
|
|
@@ -36,6 +36,7 @@ module Saboteur
|
|
|
36
36
|
when "doctor" then help_requested? ? show_help("doctor") : doctor
|
|
37
37
|
when "version", "--version" then @out.puts(VERSION) || EXIT_OK
|
|
38
38
|
when "help" then show_help(@argv.shift)
|
|
39
|
+
when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
|
|
39
40
|
when "--help", "-h" then show_help(nil)
|
|
40
41
|
else usage_error
|
|
41
42
|
end
|
|
@@ -65,7 +66,7 @@ module Saboteur
|
|
|
65
66
|
def doctor_parser
|
|
66
67
|
OptionParser.new do |opts|
|
|
67
68
|
opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
|
|
68
|
-
opts.on("--config PATH", "Config file (default
|
|
69
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
|
|
69
70
|
opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
|
|
70
71
|
end
|
|
71
72
|
end
|
|
@@ -88,28 +89,61 @@ module Saboteur
|
|
|
88
89
|
def usage_error(message = nil)
|
|
89
90
|
@err.puts(message) if message
|
|
90
91
|
@err.puts(USAGE)
|
|
91
|
-
EXIT_USAGE
|
|
92
|
+
agent_failure(message || USAGE, EXIT_USAGE)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def agent?
|
|
96
|
+
@format == "agent"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Found before parsing, so any error, even a bad option that comes first, still
|
|
100
|
+
# answers an agent with JSON.
|
|
101
|
+
def agent_requested?
|
|
102
|
+
@argv.include?("--agent") || @argv.include?("--format=agent") ||
|
|
103
|
+
@argv.each_cons(2).any? { |flag, value| flag == "--format" && value == "agent" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# In agent mode the failure is also on stdout, where the agent reads its answer.
|
|
107
|
+
def agent_failure(message, exit_code)
|
|
108
|
+
@out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
|
|
109
|
+
exit_code
|
|
92
110
|
end
|
|
93
111
|
|
|
94
112
|
def run
|
|
95
113
|
parser.parse!(@argv)
|
|
96
114
|
use_explicit_sources
|
|
115
|
+
reject_threshold_with_agent_format
|
|
97
116
|
|
|
98
117
|
arguments = { err: @err }
|
|
99
118
|
arguments[:digest] = options[:id] if options[:id]
|
|
100
|
-
|
|
101
|
-
@err.puts
|
|
102
|
-
Reporter::Terminal.new(@out).call(report)
|
|
103
|
-
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
104
|
-
write_json(report)
|
|
105
|
-
write_html(report)
|
|
106
|
-
exit_code(report)
|
|
119
|
+
agent? ? agent_run(arguments) : text_run(Engine.new(config, **arguments).call)
|
|
107
120
|
rescue ConfigError => e
|
|
108
121
|
@err.puts("saboteur: #{e.message}")
|
|
109
|
-
EXIT_USAGE
|
|
122
|
+
agent_failure(e.message, EXIT_USAGE)
|
|
110
123
|
rescue Error => e
|
|
111
124
|
@err.puts("saboteur: #{e.message}")
|
|
112
|
-
EXIT_TOOL
|
|
125
|
+
agent_failure(e.message, EXIT_TOOL)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Agent mode has one strict meaning for exit 0, so a score threshold cannot also decide it.
|
|
129
|
+
def reject_threshold_with_agent_format
|
|
130
|
+
return unless agent? && config["threshold"]
|
|
131
|
+
|
|
132
|
+
raise UsageError, "--threshold cannot be combined with --agent: agent mode exits 0 only for a complete, clean run"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def text_run(report)
|
|
136
|
+
@err.puts
|
|
137
|
+
Reporter::Terminal.new(@out).call(report)
|
|
138
|
+
Reporter::Replay.new(@out).call(report) if options[:id]
|
|
139
|
+
code = exit_code(report)
|
|
140
|
+
# The JSON carries the exit cause an outer harness trusts, so it is
|
|
141
|
+
# the completion evidence for the whole run: write it only after
|
|
142
|
+
# every other fallible reporting stage, or a late failure would
|
|
143
|
+
# leave a fresh, misleading report behind.
|
|
144
|
+
write_html(report)
|
|
145
|
+
write_json(report, exit_cause(report, code))
|
|
146
|
+
code
|
|
113
147
|
end
|
|
114
148
|
|
|
115
149
|
def doctor
|
|
@@ -126,31 +160,115 @@ module Saboteur
|
|
|
126
160
|
EXIT_TOOL
|
|
127
161
|
end
|
|
128
162
|
|
|
129
|
-
|
|
163
|
+
# Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
|
|
164
|
+
# files, hooks, spec loading, child processes) goes to stderr while the engine runs.
|
|
165
|
+
def agent_run(arguments)
|
|
166
|
+
tree = WorkingTree.new(config.root, ignored: output_paths).state
|
|
167
|
+
report = stdout_on_stderr { Engine.new(config, **arguments).call }
|
|
168
|
+
payload = Reporter::Agent.call(report, agent_provenance(report, tree))
|
|
169
|
+
rendered = Reporter::Agent.render(payload)
|
|
170
|
+
# Same publish order as text_run: the JSON is the completion
|
|
171
|
+
# evidence, so it is written only after every other fallible stage,
|
|
172
|
+
# including the agent payload itself.
|
|
173
|
+
write_html(report, announce: false)
|
|
174
|
+
write_json(report, agent_exit_cause(report, payload), announce: false)
|
|
175
|
+
@out.print(rendered)
|
|
176
|
+
Reporter::Agent.exit_code(payload)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Paths of saboteur's own output, relative to the root, that do not count as source changes.
|
|
180
|
+
def output_paths
|
|
181
|
+
root = File.join(File.realpath(config.root), "")
|
|
182
|
+
%w[cache_dir json_report html_report].filter_map { |key| config.path(key) }
|
|
183
|
+
.map { |path| File.expand_path(path) }
|
|
184
|
+
.select { |path| path.start_with?(root) }
|
|
185
|
+
.map { |path| path.delete_prefix(root) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
|
|
189
|
+
# children cannot write into the JSON on stdout.
|
|
190
|
+
def stdout_on_stderr
|
|
191
|
+
saved = $stdout.dup
|
|
192
|
+
$stdout.reopen($stderr)
|
|
193
|
+
yield
|
|
194
|
+
ensure
|
|
195
|
+
$stdout.reopen(saved) if saved
|
|
196
|
+
saved&.close
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def agent_provenance(report, tree)
|
|
200
|
+
Reporter::Agent::Provenance.new(
|
|
201
|
+
ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
|
|
202
|
+
changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
|
|
203
|
+
subjects: Array(config["subjects"]), tests: Array(options[:tests]), sources: @sources || [],
|
|
204
|
+
config_path: options[:config], digest: options[:id], cache: config["cache"],
|
|
205
|
+
incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def agent_mode
|
|
210
|
+
return "replay" if options[:id]
|
|
211
|
+
return "diff" if config["since"]
|
|
212
|
+
|
|
213
|
+
@sources&.any? ? "files" : "full"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def write_json(report, cause, announce: true)
|
|
130
217
|
path = config.path("json_report")
|
|
131
218
|
return unless path
|
|
132
219
|
|
|
133
|
-
Reporter::Stryker.new(path).call(report)
|
|
134
|
-
@out.puts "\nJSON report: #{path}"
|
|
220
|
+
Reporter::Stryker.new(path).call(report, exit_cause: cause)
|
|
221
|
+
@out.puts "\nJSON report: #{path}" if announce
|
|
135
222
|
end
|
|
136
223
|
|
|
137
|
-
def write_html(report)
|
|
224
|
+
def write_html(report, announce: true)
|
|
138
225
|
path = config.path("html_report")
|
|
139
226
|
return unless path
|
|
140
227
|
|
|
141
228
|
Reporter::Html.new(path).call(report)
|
|
142
|
-
@out.puts "HTML report: #{path}"
|
|
229
|
+
@out.puts "HTML report: #{path}" if announce
|
|
143
230
|
end
|
|
144
231
|
|
|
145
232
|
def exit_code(report)
|
|
146
|
-
|
|
147
|
-
return EXIT_TOOL if executed.any? && executed.all? { |result| result.status == :error }
|
|
233
|
+
return EXIT_TOOL if mostly_errored?(report)
|
|
148
234
|
|
|
149
235
|
return EXIT_TOOL if incomplete_under_threshold?(report)
|
|
150
236
|
|
|
151
237
|
failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
|
|
152
238
|
end
|
|
153
239
|
|
|
240
|
+
def mostly_errored?(report)
|
|
241
|
+
return false unless report.majority_errored?
|
|
242
|
+
|
|
243
|
+
executed = report.executed
|
|
244
|
+
errors = executed.count { |result| result.status == :error }
|
|
245
|
+
@err.puts("saboteur: #{errors} of #{executed.size} mutants errored, so the run tested almost nothing; " \
|
|
246
|
+
"fix the errors and run again")
|
|
247
|
+
true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Agent mode: the recorded cause is the payload's own verdict, so the
|
|
251
|
+
# JSON and the process exit classify the run the same way. An
|
|
252
|
+
# incomplete run that mostly errored keeps its distinct cause, so the
|
|
253
|
+
# harness rule means the same thing in both modes.
|
|
254
|
+
def agent_exit_cause(report, payload)
|
|
255
|
+
case payload[:verdict]
|
|
256
|
+
when "clean" then "ok"
|
|
257
|
+
when "survivors" then "survivors"
|
|
258
|
+
else report.majority_errored? ? "errorMajority" : "incomplete"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Why the run exits the way it does, recorded in the JSON report so an
|
|
263
|
+
# outer harness can tell "most mutants errored" from the other exit 3s.
|
|
264
|
+
def exit_cause(report, code)
|
|
265
|
+
case code
|
|
266
|
+
when EXIT_OK then "ok"
|
|
267
|
+
when EXIT_SURVIVORS then config["threshold"].nil? ? "survivors" : "belowThreshold"
|
|
268
|
+
else report.majority_errored? ? "errorMajority" : "incompleteUnderThreshold"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
154
272
|
# With --threshold the score decides; without it any unignored survivor does.
|
|
155
273
|
def failed_quality_gate?(report)
|
|
156
274
|
threshold = config["threshold"]
|
|
@@ -184,9 +302,16 @@ module Saboteur
|
|
|
184
302
|
# replace the configured include and exclude patterns. Each must exist,
|
|
185
303
|
# so a typo is an error and not a run over zero subjects.
|
|
186
304
|
def use_explicit_sources
|
|
187
|
-
|
|
188
|
-
|
|
305
|
+
if @argv.empty?
|
|
306
|
+
raise UsageError, "--test needs at least one source file" if options[:tests] && !config["since"]
|
|
307
|
+
|
|
308
|
+
# With --since the changed files are the sources, so --test may
|
|
309
|
+
# stand alone; the files must still exist.
|
|
310
|
+
options[:tests]&.each { |test| require_existing_test(test) }
|
|
311
|
+
return
|
|
312
|
+
end
|
|
189
313
|
|
|
314
|
+
@sources = @argv.dup
|
|
190
315
|
options[:include] = @argv.map { |source| source_pattern(source) }
|
|
191
316
|
options[:exclude] = []
|
|
192
317
|
options[:tests]&.each { |test| require_existing_test(test) }
|
|
@@ -227,7 +352,7 @@ module Saboteur
|
|
|
227
352
|
opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
|
|
228
353
|
set(:seed, v)
|
|
229
354
|
end
|
|
230
|
-
opts.on("--config PATH", "Config file (default
|
|
355
|
+
opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
|
|
231
356
|
opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
|
|
232
357
|
opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
|
|
233
358
|
opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
|
|
@@ -237,6 +362,9 @@ module Saboteur
|
|
|
237
362
|
def define_output_options(opts)
|
|
238
363
|
opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
|
|
239
364
|
opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
|
|
365
|
+
opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
|
|
366
|
+
"strict exit codes") { |v| @format = v }
|
|
367
|
+
opts.on("--agent", "Same as --format agent") { @format = "agent" }
|
|
240
368
|
end
|
|
241
369
|
|
|
242
370
|
def define_explicit_file_options(opts)
|
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",
|
|
@@ -23,7 +28,8 @@ module Saboteur
|
|
|
23
28
|
"max_processes" => 64,
|
|
24
29
|
"max_mutants_per_method" => 100,
|
|
25
30
|
"jobs" => 1,
|
|
26
|
-
"ignore_file" => ".
|
|
31
|
+
"ignore_file" => "config/saboteur_ignore.rb",
|
|
32
|
+
"custom_map" => "config/saboteur_custom_map.rb",
|
|
27
33
|
"sources" => ["builtin"],
|
|
28
34
|
"hooks" => [],
|
|
29
35
|
"rails" => false,
|
|
@@ -38,6 +44,7 @@ module Saboteur
|
|
|
38
44
|
"incremental" => false,
|
|
39
45
|
"seed" => 1,
|
|
40
46
|
"tests" => [],
|
|
47
|
+
"fingerprint_extra" => [],
|
|
41
48
|
"threshold" => nil
|
|
42
49
|
}.freeze
|
|
43
50
|
|
|
@@ -45,23 +52,26 @@ module Saboteur
|
|
|
45
52
|
FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
|
|
46
53
|
THRESHOLD_RANGE = (0.0..100.0)
|
|
47
54
|
|
|
48
|
-
LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
|
|
55
|
+
LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
|
|
56
|
+
fingerprint_extra].freeze
|
|
49
57
|
|
|
50
58
|
attr_reader :root
|
|
51
59
|
|
|
52
60
|
def self.load(root: Dir.pwd, path: nil, overrides: {})
|
|
53
61
|
path ||= File.join(root, FILE)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
raise ConfigError, "#{path}: #{e.message}"
|
|
73
|
+
from_file = File.file?(path) ? ConfigFile.load(path) : {}
|
|
74
|
+
new(from_file.merge(stringify(overrides)), root: root)
|
|
65
75
|
end
|
|
66
76
|
|
|
67
77
|
def self.stringify(hash)
|
|
@@ -165,3 +175,5 @@ module Saboteur
|
|
|
165
175
|
end
|
|
166
176
|
end
|
|
167
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
|
data/lib/saboteur/doctor.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Saboteur
|
|
|
17
17
|
check_runtime
|
|
18
18
|
check_coverage
|
|
19
19
|
check_boot_files
|
|
20
|
+
check_custom_map
|
|
20
21
|
hooks = Hooks.new
|
|
21
22
|
integration = boot_target(hooks)
|
|
22
23
|
@out.puts "Integration: #{@config['integration']} (loaded)"
|
|
@@ -57,11 +58,26 @@ module Saboteur
|
|
|
57
58
|
"disable SimpleCov and remove other Coverage.start calls"
|
|
58
59
|
end
|
|
59
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
|
+
|
|
60
76
|
def check_boot_files
|
|
61
77
|
%w[require hooks].each do |key|
|
|
62
78
|
@config[key].each do |file|
|
|
63
79
|
path = File.expand_path(file, @config.root)
|
|
64
|
-
raise ConfigError, "#{key} file missing: #{file}; fix
|
|
80
|
+
raise ConfigError, "#{key} file missing: #{file}; fix config/saboteur.rb" unless File.file?(path)
|
|
65
81
|
|
|
66
82
|
@out.puts "#{key}: #{file} (found)"
|
|
67
83
|
end
|