saboteur 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -1
  3. data/README.md +526 -73
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/baseline_health.rb +116 -0
  7. data/lib/saboteur/cli.rb +305 -30
  8. data/lib/saboteur/config.rb +65 -19
  9. data/lib/saboteur/config_file.rb +97 -0
  10. data/lib/saboteur/custom_map.rb +170 -0
  11. data/lib/saboteur/doctor.rb +88 -0
  12. data/lib/saboteur/engine/baseline_guard.rb +116 -0
  13. data/lib/saboteur/engine/boot.rb +9 -0
  14. data/lib/saboteur/engine/candidate_selection.rb +35 -0
  15. data/lib/saboteur/engine/coverage_store.rb +40 -6
  16. data/lib/saboteur/engine/data_inputs.rb +43 -0
  17. data/lib/saboteur/engine/diagnostics.rb +28 -0
  18. data/lib/saboteur/engine/diff_scope.rb +8 -0
  19. data/lib/saboteur/engine/distribution.rb +4 -2
  20. data/lib/saboteur/engine/run_fingerprint.rb +344 -0
  21. data/lib/saboteur/engine/scheduler.rb +7 -16
  22. data/lib/saboteur/engine/suite_files.rb +8 -1
  23. data/lib/saboteur/engine/verdict_check.rb +4 -2
  24. data/lib/saboteur/engine.rb +125 -25
  25. data/lib/saboteur/execution/fork_readiness.rb +46 -0
  26. data/lib/saboteur/execution/reevaluator.rb +7 -4
  27. data/lib/saboteur/execution/supervisor.rb +23 -11
  28. data/lib/saboteur/frameworks/rails.rb +329 -13
  29. data/lib/saboteur/help.rb +210 -0
  30. data/lib/saboteur/ignore_list.rb +71 -17
  31. data/lib/saboteur/install.rb +132 -0
  32. data/lib/saboteur/integration/base.rb +8 -0
  33. data/lib/saboteur/integration/rspec.rb +69 -4
  34. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  35. data/lib/saboteur/mutation_source/builtin.rb +14 -3
  36. data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
  37. data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
  38. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  39. data/lib/saboteur/replay.rb +33 -0
  40. data/lib/saboteur/reporter/agent.rb +87 -0
  41. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  42. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  43. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  44. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  45. data/lib/saboteur/reporter/findings.rb +131 -0
  46. data/lib/saboteur/reporter/html.rb +213 -0
  47. data/lib/saboteur/reporter/replay.rb +23 -0
  48. data/lib/saboteur/reporter/stryker.rb +21 -4
  49. data/lib/saboteur/reporter/terminal.rb +76 -2
  50. data/lib/saboteur/result.rb +61 -9
  51. data/lib/saboteur/score.rb +89 -0
  52. data/lib/saboteur/version.rb +1 -1
  53. data/lib/saboteur/working_tree.rb +48 -0
  54. data/lib/saboteur.rb +7 -0
  55. metadata +30 -2
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "rails/generators"
5
+ require "saboteur/install"
6
+
7
+ module Saboteur
8
+ module Generators
9
+ # rails generate saboteur:install [--force]
10
+ class InstallGenerator < Rails::Generators::Base
11
+ desc "Writes config/saboteur.rb, bin/saboteur and a .gitignore entry for a Rails + RSpec app."
12
+
13
+ def install_saboteur
14
+ installer = Saboteur::Install.new(root: destination_root, force: options[:force])
15
+ installer.call.each { |result| say_status(result.status, result.path) }
16
+ hint = installer.gemfile_hint
17
+ say("\n#{hint}") if hint
18
+ end
19
+ end
20
+ end
21
+ end
@@ -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
@@ -0,0 +1,116 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "tempfile"
6
+
7
+ module Saboteur
8
+ # Two fresh supervised children check the full suite without changing methods
9
+ # or writing caches. This samples stability; it cannot prove absence of flakes.
10
+ class BaselineHealth
11
+ class TestSequence
12
+ attr_reader :executed
13
+
14
+ def initialize
15
+ @executed = []
16
+ end
17
+
18
+ def test_started(_id, _file); end
19
+
20
+ def test_finished(id)
21
+ @executed << id
22
+ end
23
+ end
24
+
25
+ def initialize(config, integration:, hooks: Hooks.new, supervisor: nil)
26
+ @config = config
27
+ @integration = integration
28
+ @hooks = hooks
29
+ limits = Execution::Supervisor::Limits.new(max_memory_mb: config["max_memory_mb"],
30
+ max_processes: config["max_processes"])
31
+ @supervisor = supervisor || Execution::Supervisor.new(limits: limits)
32
+ end
33
+
34
+ def call
35
+ expected = @integration.all_tests.map(&:id)
36
+ raise Error, "baseline has no tests; check rspec_args and spec paths" if expected.empty?
37
+
38
+ first = check_run(1, expected)
39
+ second = check_run(2, expected)
40
+ if first != second
41
+ raise Error,
42
+ "baseline repeated with different test order; check random ordering and suite setup"
43
+ end
44
+
45
+ first
46
+ end
47
+
48
+ private
49
+
50
+ def check_run(number, expected)
51
+ Tempfile.create("saboteur-baseline") do |log|
52
+ result = @supervisor.run(timeout: @config["baseline_timeout"]) { |writer| record(writer, log.path) }
53
+ data = read_result(result, number)
54
+ verify_execution(data, number, expected)
55
+ raise Error, "baseline run #{number} left processes running; fix suite cleanup" if result.leftover_pids.any?
56
+
57
+ data["executed"]
58
+ end
59
+ rescue JSON::ParserError
60
+ raise Error, "baseline run #{number} returned an invalid result"
61
+ rescue StandardError => e
62
+ raise e if e.is_a?(Error)
63
+
64
+ raise Error, "baseline check failed: #{e.class}: #{e.message}; check suite hooks and setup"
65
+ end
66
+
67
+ def read_result(result, number)
68
+ raise Error, "baseline run #{number} stopped: #{result.detail}" if result.stopped_by
69
+ raise Error, "baseline run #{number} exited without a result; check suite boot and exits" if result.payload.empty?
70
+ if result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
71
+ raise Error, "baseline run #{number} exited unsuccessfully; fix suite cleanup and exit handlers"
72
+ end
73
+
74
+ data = JSON.parse(result.payload)
75
+ raise Error, "baseline run #{number} returned an invalid result" unless data.is_a?(Hash)
76
+
77
+ data
78
+ end
79
+
80
+ def verify_execution(data, number, expected)
81
+ unless data["passed"] == true
82
+ raise Error, "baseline run #{number} failed: #{data['error'] || 'fix the unchanged suite'}"
83
+ end
84
+ return if data["executed"].is_a?(Array) && data["executed"].sort == expected.sort
85
+
86
+ raise Error, "baseline run #{number} did not execute every requested test; check filters and suite hooks"
87
+ end
88
+
89
+ def failure_detail
90
+ return nil unless defined?(::RSpec)
91
+
92
+ ::RSpec.world.all_examples.filter_map do |example|
93
+ exception = example.execution_result.exception
94
+ next unless exception
95
+
96
+ message = exception.message.to_s.lines.map(&:strip).find { |line| !line.empty? }.to_s[0, 200]
97
+ "#{example.id}: #{example.full_description.to_s[0, 200]}: #{message}"
98
+ end.first(5).join("; ")
99
+ end
100
+
101
+ def record(writer, log_path)
102
+ $stdout.reopen(log_path, "w")
103
+ $stderr.reopen($stdout)
104
+ @hooks.run(:after_fork)
105
+ Kernel.srand(@config["seed"])
106
+ ::RSpec.configuration.order = "rand:#{@config['seed']}" if defined?(::RSpec)
107
+ sequence = TestSequence.new
108
+ passed = @integration.run_all(sequence)
109
+ writer.write(JSON.generate(passed: passed, executed: sequence.executed, error: failure_detail))
110
+ rescue Exception => e # rubocop:disable Lint/RescueException
111
+ writer.write(JSON.generate(passed: false, error: "#{e.class}: #{e.message}"))
112
+ ensure
113
+ writer.close
114
+ end
115
+ end
116
+ end
data/lib/saboteur/cli.rb CHANGED
@@ -3,16 +3,21 @@
3
3
 
4
4
  require "optparse"
5
5
  require_relative "../saboteur"
6
+ require_relative "doctor"
7
+ require_relative "help"
8
+ require_relative "reporter/replay"
6
9
 
7
10
  module Saboteur
8
11
  class CLI
9
- USAGE = "Usage: saboteur run [--since REF] [--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N]"
12
+ USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
13
+ "[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
14
+ " \
15
+ "Run 'saboteur help' for every command, option, config key and exit code."
10
16
 
11
17
  EXIT_OK = 0
12
18
  EXIT_SURVIVORS = 1
13
19
  EXIT_USAGE = 2
14
20
  EXIT_TOOL = 3
15
-
16
21
  class UsageError < ConfigError; end
17
22
 
18
23
  attr_reader :options
@@ -22,12 +27,17 @@ module Saboteur
22
27
  @out = out
23
28
  @err = err
24
29
  @options = {}
30
+ @format = "agent" if agent_requested?
25
31
  end
26
32
 
27
33
  def call
28
34
  case @argv.shift
29
- when "run" then run
35
+ when "run" then help_requested? ? show_help("run") : run
36
+ when "doctor" then help_requested? ? show_help("doctor") : doctor
30
37
  when "version", "--version" then @out.puts(VERSION) || EXIT_OK
38
+ when "help" then show_help(@argv.shift)
39
+ when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
40
+ when "--help", "-h" then show_help(nil)
31
41
  else usage_error
32
42
  end
33
43
  rescue OptionParser::ParseError => e
@@ -38,23 +48,110 @@ module Saboteur
38
48
  @config ||= Config.load(root: Dir.pwd, path: options[:config], overrides: overrides)
39
49
  end
40
50
 
51
+ def parser
52
+ OptionParser.new do |opts|
53
+ opts.banner = USAGE
54
+ define_selection_options(opts)
55
+ define_explicit_file_options(opts)
56
+ define_execution_options(opts)
57
+ define_output_options(opts)
58
+ end
59
+ end
60
+
61
+ # Each command's own option lines, as printed under OPTIONS in `saboteur help`.
62
+ def option_lines
63
+ { "run" => parser.summarize, "doctor" => doctor_parser.summarize }
64
+ end
65
+
66
+ def doctor_parser
67
+ OptionParser.new do |opts|
68
+ opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
69
+ opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
70
+ opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
71
+ end
72
+ end
73
+
41
74
  private
42
75
 
76
+ def help_requested?
77
+ @argv.intersect?(%w[--help -h])
78
+ end
79
+
80
+ def show_help(command_name)
81
+ help = Help.new(option_lines: option_lines)
82
+ text = command_name ? help.command(command_name) : help.overview
83
+ return @out.puts(text) || EXIT_OK if text
84
+
85
+ @err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
86
+ EXIT_USAGE
87
+ end
88
+
43
89
  def usage_error(message = nil)
44
90
  @err.puts(message) if message
45
91
  @err.puts(USAGE)
46
- 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
47
110
  end
48
111
 
49
112
  def run
50
113
  parser.parse!(@argv)
51
- raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
114
+ use_explicit_sources
115
+ reject_threshold_with_agent_format
116
+
117
+ arguments = { err: @err }
118
+ arguments[:digest] = options[:id] if options[:id]
119
+ agent? ? agent_run(arguments) : text_run(Engine.new(config, **arguments).call)
120
+ rescue ConfigError => e
121
+ @err.puts("saboteur: #{e.message}")
122
+ agent_failure(e.message, EXIT_USAGE)
123
+ rescue Error => e
124
+ @err.puts("saboteur: #{e.message}")
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"]
52
131
 
53
- report = Engine.new(config, err: @err).call
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)
54
136
  @err.puts
55
137
  Reporter::Terminal.new(@out).call(report)
56
- write_json(report)
57
- exit_code(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
147
+ end
148
+
149
+ def doctor
150
+ doctor_parser.parse!(@argv)
151
+ raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
152
+
153
+ Doctor.new(config, out: @out).call
154
+ EXIT_OK
58
155
  rescue ConfigError => e
59
156
  @err.puts("saboteur: #{e.message}")
60
157
  EXIT_USAGE
@@ -63,41 +160,219 @@ module Saboteur
63
160
  EXIT_TOOL
64
161
  end
65
162
 
66
- 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)
67
217
  path = config.path("json_report")
68
218
  return unless path
69
219
 
70
- Reporter::Stryker.new(path).call(report)
71
- @out.puts "\nJSON report: #{path}"
220
+ Reporter::Stryker.new(path).call(report, exit_cause: cause)
221
+ @out.puts "\nJSON report: #{path}" if announce
222
+ end
223
+
224
+ def write_html(report, announce: true)
225
+ path = config.path("html_report")
226
+ return unless path
227
+
228
+ Reporter::Html.new(path).call(report)
229
+ @out.puts "HTML report: #{path}" if announce
72
230
  end
73
231
 
74
232
  def exit_code(report)
233
+ return EXIT_TOOL if mostly_errored?(report)
234
+
235
+ return EXIT_TOOL if incomplete_under_threshold?(report)
236
+
237
+ failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
238
+ end
239
+
240
+ def mostly_errored?(report)
241
+ return false unless report.majority_errored?
242
+
75
243
  executed = report.executed
76
- return EXIT_TOOL if executed.any? && executed.all? { |result| result.status == :error }
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
+
272
+ # With --threshold the score decides; without it any unignored survivor does.
273
+ def failed_quality_gate?(report)
274
+ threshold = config["threshold"]
275
+ return report.unignored_survivors.any? if threshold.nil?
276
+
277
+ score = report.score
278
+ return missing_score_reported? if score.nil?
279
+
280
+ score * 100 < threshold
281
+ end
282
+
283
+ # A gate that cannot measure a score must not pass: CI would go green
284
+ # on a run that tested nothing.
285
+ def missing_score_reported?
286
+ @err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
287
+ true
288
+ end
77
289
 
78
- report.unignored_survivors.any? ? EXIT_SURVIVORS : EXIT_OK
290
+ # Errors and resource limits are outside the score. With a threshold they
291
+ # make the run incomplete, so the score alone cannot pass it.
292
+ def incomplete_under_threshold?(report)
293
+ return false if config["threshold"].nil?
294
+
295
+ count = report.counts.values_at(:error, :resource_limit).sum
296
+ @err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
297
+ count.positive?
298
+ count.positive?
299
+ end
300
+
301
+ # Positional arguments name the files (or directories) to mutate. They
302
+ # replace the configured include and exclude patterns. Each must exist,
303
+ # so a typo is an error and not a run over zero subjects.
304
+ def use_explicit_sources
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
313
+
314
+ @sources = @argv.dup
315
+ options[:include] = @argv.map { |source| source_pattern(source) }
316
+ options[:exclude] = []
317
+ options[:tests]&.each { |test| require_existing_test(test) }
318
+ end
319
+
320
+ def source_pattern(source)
321
+ return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
322
+ raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
323
+ raise UsageError, "source file not found: #{source}" unless File.file?(source)
324
+
325
+ glob_escape(source)
326
+ end
327
+
328
+ # include is a list of glob patterns; a named file must match only itself.
329
+ def glob_escape(path)
330
+ path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
331
+ end
332
+
333
+ # A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
334
+ def require_existing_test(test)
335
+ path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
336
+ raise UsageError, "test file not found: #{test}" unless File.exist?(path)
79
337
  end
80
338
 
81
339
  def overrides
82
- options.except(:config)
340
+ options.except(:config, :id)
83
341
  end
84
342
 
85
- def parser
86
- OptionParser.new do |opts|
87
- opts.banner = USAGE
88
- opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
89
- opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
90
- opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
91
- opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
92
- opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
93
- set(:seed, v)
94
- end
95
- opts.on("--config PATH", "Config file (default .saboteur.yml)") { |v| set(:config, v) }
96
- opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
97
- opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
98
- opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
99
- opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
100
- opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
343
+ def define_selection_options(opts)
344
+ opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
345
+ opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
346
+ opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
347
+ end
348
+
349
+ def define_execution_options(opts)
350
+ opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
351
+ opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
352
+ opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
353
+ set(:seed, v)
354
+ end
355
+ opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
356
+ opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
357
+ opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
358
+ opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
359
+ opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
360
+ end
361
+
362
+ def define_output_options(opts)
363
+ opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
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" }
368
+ end
369
+
370
+ def define_explicit_file_options(opts)
371
+ opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
372
+ (options[:tests] ||= []) << v
373
+ end
374
+ opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
375
+ set(:threshold, v)
101
376
  end
102
377
  end
103
378