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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +113 -1
  3. data/README.md +248 -63
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/cli.rb +150 -22
  7. data/lib/saboteur/config.rb +27 -15
  8. data/lib/saboteur/config_file.rb +97 -0
  9. data/lib/saboteur/custom_map.rb +170 -0
  10. data/lib/saboteur/doctor.rb +17 -1
  11. data/lib/saboteur/engine/baseline_guard.rb +20 -3
  12. data/lib/saboteur/engine/boot.rb +9 -0
  13. data/lib/saboteur/engine/coverage_store.rb +31 -4
  14. data/lib/saboteur/engine/data_inputs.rb +43 -0
  15. data/lib/saboteur/engine/diagnostics.rb +28 -0
  16. data/lib/saboteur/engine/diff_scope.rb +8 -0
  17. data/lib/saboteur/engine/run_fingerprint.rb +34 -6
  18. data/lib/saboteur/engine/scheduler.rb +7 -16
  19. data/lib/saboteur/engine/suite_files.rb +8 -1
  20. data/lib/saboteur/engine.rb +74 -13
  21. data/lib/saboteur/execution/reevaluator.rb +7 -4
  22. data/lib/saboteur/execution/supervisor.rb +23 -11
  23. data/lib/saboteur/frameworks/rails.rb +329 -13
  24. data/lib/saboteur/help.rb +33 -17
  25. data/lib/saboteur/ignore_list.rb +71 -17
  26. data/lib/saboteur/install.rb +132 -0
  27. data/lib/saboteur/integration/rspec.rb +47 -3
  28. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  29. data/lib/saboteur/mutation_source/builtin.rb +8 -2
  30. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  31. data/lib/saboteur/replay.rb +1 -1
  32. data/lib/saboteur/reporter/agent.rb +87 -0
  33. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  34. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  35. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  36. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  37. data/lib/saboteur/reporter/findings.rb +8 -0
  38. data/lib/saboteur/reporter/html.rb +49 -1
  39. data/lib/saboteur/reporter/stryker.rb +11 -4
  40. data/lib/saboteur/reporter/terminal.rb +65 -2
  41. data/lib/saboteur/result.rb +46 -5
  42. data/lib/saboteur/score.rb +89 -0
  43. data/lib/saboteur/version.rb +1 -1
  44. data/lib/saboteur/working_tree.rb +48 -0
  45. data/lib/saboteur.rb +4 -0
  46. 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 .saboteur.yml)") { |value| set(:config, value) }
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
- report = Engine.new(config, **arguments).call
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
- def write_json(report)
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
- executed = report.executed
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
- raise UsageError, "--test needs at least one source file" if options[:tests] && @argv.empty?
188
- return if @argv.empty?
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 .saboteur.yml)") { |v| set(:config, v) }
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)
@@ -1,13 +1,18 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "yaml"
5
4
  require_relative "mutation_source/builtin"
6
5
 
7
6
  module Saboteur
8
- # Settings from .saboteur.yml, overridden by CLI flags.
7
+ # Settings from config/saboteur.rb, overridden by CLI flags.
9
8
  class Config
10
- FILE = ".saboteur.yml"
9
+ FILE = "config/saboteur.rb"
10
+
11
+ # Died with the YAML port: a leftover file means settings are
12
+ # silently ignored, which could turn off a threshold. Fail loud.
13
+ LEGACY_FILE = ".saboteur.yml"
14
+ LEGACY_IGNORE_FILE = ".saboteur-ignore.yml"
15
+ IGNORE_FILE = "config/saboteur_ignore.rb"
11
16
 
12
17
  DEFAULTS = {
13
18
  "integration" => "rspec",
@@ -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" => ".saboteur-ignore.yml",
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].freeze
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
- from_file = File.file?(path) ? parse(path) : {}
55
- new(from_file.merge(stringify(overrides)), root: root)
56
- end
57
-
58
- def self.parse(path)
59
- data = YAML.safe_load_file(path) || {}
60
- raise ConfigError, "#{path}: expected a mapping at the top level" unless data.is_a?(Hash)
62
+ legacy = File.join(root, LEGACY_FILE)
63
+ if File.file?(legacy)
64
+ raise ConfigError,
65
+ "saboteur no longer reads #{LEGACY_FILE}; move its settings to #{FILE} (see README)."
66
+ end
67
+ legacy_ignore = File.join(root, LEGACY_IGNORE_FILE)
68
+ if File.file?(legacy_ignore)
69
+ raise ConfigError,
70
+ "saboteur no longer reads #{LEGACY_IGNORE_FILE}; move its entries to #{IGNORE_FILE} (see README)."
71
+ end
61
72
 
62
- data
63
- rescue Psych::SyntaxError => e
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
@@ -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 .saboteur.yml" unless File.file?(path)
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