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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +280 -1
  3. data/README.md +635 -77
  4. data/exe/saboteur +6 -2
  5. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  6. data/lib/saboteur/agent_skill.rb +46 -0
  7. data/lib/saboteur/baseline/acceptance.rb +218 -0
  8. data/lib/saboteur/baseline/command.rb +80 -0
  9. data/lib/saboteur/baseline/comparison.rb +105 -0
  10. data/lib/saboteur/baseline/completeness.rb +53 -0
  11. data/lib/saboteur/baseline/evidence.rb +114 -0
  12. data/lib/saboteur/baseline/identity.rb +78 -0
  13. data/lib/saboteur/baseline/manifest.rb +164 -0
  14. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  15. data/lib/saboteur/baseline/workflow.rb +121 -0
  16. data/lib/saboteur/baseline_health.rb +1 -1
  17. data/lib/saboteur/cli/exit_status.rb +93 -0
  18. data/lib/saboteur/cli/run_reporting.rb +75 -0
  19. data/lib/saboteur/cli.rb +171 -63
  20. data/lib/saboteur/config.rb +101 -19
  21. data/lib/saboteur/config_file.rb +97 -0
  22. data/lib/saboteur/custom_map.rb +170 -0
  23. data/lib/saboteur/doctor.rb +57 -9
  24. data/lib/saboteur/engine/baseline_guard.rb +44 -5
  25. data/lib/saboteur/engine/boot.rb +9 -0
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  27. data/lib/saboteur/engine/canary.rb +74 -0
  28. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  29. data/lib/saboteur/engine/candidates.rb +59 -7
  30. data/lib/saboteur/engine/coverage_store.rb +53 -6
  31. data/lib/saboteur/engine/data_inputs.rb +43 -0
  32. data/lib/saboteur/engine/diagnostics.rb +176 -2
  33. data/lib/saboteur/engine/diff_scope.rb +18 -0
  34. data/lib/saboteur/engine/distribution.rb +54 -13
  35. data/lib/saboteur/engine/result_cache.rb +10 -5
  36. data/lib/saboteur/engine/run_fingerprint.rb +40 -10
  37. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  38. data/lib/saboteur/engine/scheduler.rb +99 -36
  39. data/lib/saboteur/engine/subject_selection.rb +24 -1
  40. data/lib/saboteur/engine/suite_files.rb +8 -1
  41. data/lib/saboteur/engine/test_guess.rb +52 -0
  42. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  43. data/lib/saboteur/engine/verdict_check.rb +7 -6
  44. data/lib/saboteur/engine.rb +257 -22
  45. data/lib/saboteur/execution/protocol.rb +2 -1
  46. data/lib/saboteur/execution/reevaluator.rb +79 -14
  47. data/lib/saboteur/execution/runner.rb +120 -17
  48. data/lib/saboteur/execution/sandbox.rb +271 -0
  49. data/lib/saboteur/execution/stale_captures.rb +43 -0
  50. data/lib/saboteur/execution/supervisor.rb +113 -9
  51. data/lib/saboteur/execution/worker_pool.rb +20 -4
  52. data/lib/saboteur/execution/wrapper.rb +45 -0
  53. data/lib/saboteur/frameworks/rails.rb +329 -13
  54. data/lib/saboteur/gates.rb +83 -0
  55. data/lib/saboteur/help.rb +63 -23
  56. data/lib/saboteur/ignore_list.rb +76 -17
  57. data/lib/saboteur/inline_suppression.rb +65 -0
  58. data/lib/saboteur/install.rb +132 -0
  59. data/lib/saboteur/integration/base.rb +1 -0
  60. data/lib/saboteur/integration/focus_guard.rb +83 -0
  61. data/lib/saboteur/integration/rspec.rb +68 -4
  62. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  63. data/lib/saboteur/mutation_source/builtin.rb +30 -5
  64. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  65. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  67. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  68. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  69. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  70. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  71. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  72. data/lib/saboteur/preview.rb +106 -0
  73. data/lib/saboteur/replay.rb +1 -1
  74. data/lib/saboteur/reporter/agent.rb +87 -0
  75. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  76. data/lib/saboteur/reporter/agent_commands.rb +67 -0
  77. data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
  78. data/lib/saboteur/reporter/agent_summary.rb +137 -0
  79. data/lib/saboteur/reporter/findings.rb +12 -19
  80. data/lib/saboteur/reporter/history_paths.rb +38 -0
  81. data/lib/saboteur/reporter/history_record.rb +132 -0
  82. data/lib/saboteur/reporter/html.rb +61 -2
  83. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  84. data/lib/saboteur/reporter/next_steps.rb +30 -0
  85. data/lib/saboteur/reporter/palette.rb +37 -0
  86. data/lib/saboteur/reporter/score_history.rb +123 -0
  87. data/lib/saboteur/reporter/score_table.rb +57 -0
  88. data/lib/saboteur/reporter/stryker.rb +25 -5
  89. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  90. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  91. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  92. data/lib/saboteur/reporter/terminal.rb +157 -42
  93. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  94. data/lib/saboteur/result.rb +86 -10
  95. data/lib/saboteur/score.rb +89 -0
  96. data/lib/saboteur/selection/coverage_map.rb +119 -13
  97. data/lib/saboteur/selection/git_diff.rb +28 -1
  98. data/lib/saboteur/selection/recorder.rb +55 -13
  99. data/lib/saboteur/subject.rb +40 -4
  100. data/lib/saboteur/subject_finder.rb +62 -12
  101. data/lib/saboteur/version.rb +1 -1
  102. data/lib/saboteur/working_tree.rb +56 -0
  103. data/lib/saboteur.rb +10 -0
  104. metadata +58 -2
data/lib/saboteur/cli.rb CHANGED
@@ -6,9 +6,17 @@ require_relative "../saboteur"
6
6
  require_relative "doctor"
7
7
  require_relative "help"
8
8
  require_relative "reporter/replay"
9
+ require_relative "baseline/command"
10
+ require_relative "cli/exit_status"
11
+ require_relative "preview"
12
+ require_relative "cli/run_reporting"
9
13
 
10
14
  module Saboteur
11
15
  class CLI
16
+ include Baseline::Command
17
+ include ExitStatus
18
+ include RunReporting
19
+
12
20
  USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
13
21
  "[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
14
22
  " \
@@ -18,7 +26,6 @@ module Saboteur
18
26
  EXIT_SURVIVORS = 1
19
27
  EXIT_USAGE = 2
20
28
  EXIT_TOOL = 3
21
-
22
29
  class UsageError < ConfigError; end
23
30
 
24
31
  attr_reader :options
@@ -28,14 +35,17 @@ module Saboteur
28
35
  @out = out
29
36
  @err = err
30
37
  @options = {}
38
+ @format = "agent" if agent_requested?
31
39
  end
32
40
 
33
41
  def call
34
42
  case @argv.shift
35
- when "run" then help_requested? ? show_help("run") : run
43
+ when "run" then run_command
44
+ when "baseline" then baseline
36
45
  when "doctor" then help_requested? ? show_help("doctor") : doctor
37
46
  when "version", "--version" then @out.puts(VERSION) || EXIT_OK
38
47
  when "help" then show_help(@argv.shift)
48
+ when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
39
49
  when "--help", "-h" then show_help(nil)
40
50
  else usage_error
41
51
  end
@@ -53,25 +63,34 @@ module Saboteur
53
63
  define_selection_options(opts)
54
64
  define_explicit_file_options(opts)
55
65
  define_execution_options(opts)
66
+ define_matrix_option(opts)
56
67
  define_output_options(opts)
57
68
  end
58
69
  end
59
70
 
60
71
  # Each command's own option lines, as printed under OPTIONS in `saboteur help`.
61
72
  def option_lines
62
- { "run" => parser.summarize, "doctor" => doctor_parser.summarize }
73
+ { "run" => parser.summarize, "doctor" => doctor_parser.summarize, "baseline" => baseline_parser.summarize }
63
74
  end
64
75
 
65
76
  def doctor_parser
66
77
  OptionParser.new do |opts|
67
- opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
68
- opts.on("--config PATH", "Config file (default .saboteur.yml)") { |value| set(:config, value) }
78
+ opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
79
+ opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
69
80
  opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
81
+ opts.on("--suite", "Also run the unchanged suite twice (slow on a large suite)") { set(:suite, true) }
82
+ opts.on("--test PATH", "Spec file or directory for the suite runs; repeatable. Implies --suite") do |value|
83
+ (options[:tests] ||= []) << value
84
+ end
70
85
  end
71
86
  end
72
87
 
73
88
  private
74
89
 
90
+ def run_command
91
+ help_requested? ? show_help("run") : run
92
+ end
93
+
75
94
  def help_requested?
76
95
  @argv.intersect?(%w[--help -h])
77
96
  end
@@ -88,35 +107,82 @@ module Saboteur
88
107
  def usage_error(message = nil)
89
108
  @err.puts(message) if message
90
109
  @err.puts(USAGE)
91
- EXIT_USAGE
110
+ agent_failure(message || USAGE, EXIT_USAGE)
111
+ end
112
+
113
+ def agent?
114
+ @format == "agent"
115
+ end
116
+
117
+ # Found before parsing, so any error, even a bad option that comes first, still
118
+ # answers an agent with JSON.
119
+ def agent_requested?
120
+ @argv.include?("--agent") || @argv.include?("--format=agent") ||
121
+ @argv.each_cons(2).any? { |flag, value| flag == "--format" && value == "agent" }
122
+ end
123
+
124
+ # In agent mode the failure is also on stdout, where the agent reads its answer.
125
+ def agent_failure(message, exit_code)
126
+ @out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
127
+ exit_code
92
128
  end
93
129
 
94
130
  def run
95
131
  parser.parse!(@argv)
96
132
  use_explicit_sources
133
+ validate_run_options
134
+ warn_dirty_tree
135
+
136
+ return preview if options[:list_operators] || options[:dry_run]
97
137
 
98
- arguments = { err: @err }
99
- 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)
138
+ execute_run
107
139
  rescue ConfigError => e
108
140
  @err.puts("saboteur: #{e.message}")
109
- EXIT_USAGE
141
+ agent_failure(e.message, EXIT_USAGE)
110
142
  rescue Error => e
111
143
  @err.puts("saboteur: #{e.message}")
112
- EXIT_TOOL
144
+ agent_failure(e.message, EXIT_TOOL)
145
+ end
146
+
147
+ # Mutants run your code, and a mutant of code that writes or deletes files
148
+ # can change the tree. Say so up front when uncommitted work is at stake.
149
+ def warn_dirty_tree
150
+ WorkingTree.new(config.root, ignored: config.output_paths).state.warning&.then { |text| @err.puts(text) }
151
+ end
152
+
153
+ # Usage errors found before the target boots.
154
+ def validate_run_options
155
+ reject_threshold_with_agent_format
156
+ Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
157
+ end
158
+
159
+ # Agent mode has one strict meaning for exit 0, so a score threshold cannot also decide it.
160
+ def reject_threshold_with_agent_format
161
+ return unless agent? && (config["threshold"] || gates.any?)
162
+
163
+ raise UsageError, "#{config['threshold'] ? '--threshold' : '--max-* gates'} cannot be combined with --agent"
164
+ end
165
+
166
+ # One line naming the reports written, paths relative to the root when
167
+ # they sit under it (a blank line before it, as the old JSON line had).
168
+ def announce_reports(reports)
169
+ written = reports.select { |path, _label| path }
170
+ return if written.empty?
171
+
172
+ root = File.join(File.realpath(config.root), "")
173
+ names = written.map do |path, label|
174
+ shown = File.expand_path(path).delete_prefix(root)
175
+ "#{shown} (#{label})"
176
+ end
177
+ @out.puts "\nReports: #{names.join(', ')}"
113
178
  end
114
179
 
115
180
  def doctor
116
181
  doctor_parser.parse!(@argv)
117
182
  raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
118
183
 
119
- Doctor.new(config, out: @out).call
184
+ options[:tests]&.each { |test| require_existing_test(test) }
185
+ Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
120
186
  EXIT_OK
121
187
  rescue ConfigError => e
122
188
  @err.puts("saboteur: #{e.message}")
@@ -126,67 +192,68 @@ module Saboteur
126
192
  EXIT_TOOL
127
193
  end
128
194
 
129
- def write_json(report)
130
- path = config.path("json_report")
131
- return unless path
132
-
133
- Reporter::Stryker.new(path).call(report)
134
- @out.puts "\nJSON report: #{path}"
195
+ # Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
196
+ # files, hooks, spec loading, child processes) goes to stderr while the engine runs.
197
+ # Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
198
+ # children cannot write into the JSON on stdout.
199
+ def stdout_on_stderr
200
+ saved = $stdout.dup
201
+ $stdout.reopen($stderr)
202
+ yield
203
+ ensure
204
+ $stdout.reopen(saved) if saved
205
+ saved&.close
135
206
  end
136
207
 
137
- def write_html(report)
138
- path = config.path("html_report")
139
- return unless path
140
-
141
- Reporter::Html.new(path).call(report)
142
- @out.puts "HTML report: #{path}"
208
+ def agent_provenance(report, tree, engine)
209
+ Reporter::Agent::Provenance.new(
210
+ ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
211
+ changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
212
+ subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || options[:tests]),
213
+ sources: @sources || [],
214
+ config_path: options[:config], digest: options[:id], cache: config["cache"],
215
+ incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
216
+ )
143
217
  end
144
218
 
145
- def exit_code(report)
146
- executed = report.executed
147
- return EXIT_TOOL if executed.any? && executed.all? { |result| result.status == :error }
219
+ def agent_mode
220
+ return "replay" if options[:id]
221
+ return "diff" if config["since"]
148
222
 
149
- return EXIT_TOOL if incomplete_under_threshold?(report)
150
-
151
- failed_quality_gate?(report) ? EXIT_SURVIVORS : EXIT_OK
223
+ @sources&.any? ? "files" : "full"
152
224
  end
153
225
 
154
- # With --threshold the score decides; without it any unignored survivor does.
155
- def failed_quality_gate?(report)
156
- threshold = config["threshold"]
157
- return report.unignored_survivors.any? if threshold.nil?
158
-
159
- score = report.score
160
- return missing_score_reported? if score.nil?
161
-
162
- score * 100 < threshold
163
- end
226
+ # Writes the JSON report and returns its path, or nil when none is configured.
227
+ def write_json(report, cause)
228
+ path = config.path("json_report")
229
+ return unless path
164
230
 
165
- # A gate that cannot measure a score must not pass: CI would go green
166
- # on a run that tested nothing.
167
- def missing_score_reported?
168
- @err.puts("saboteur: no mutant was tested, so there is no score to compare with --threshold")
169
- true
231
+ Reporter::Stryker.new(path).call(report, exit_cause: cause)
232
+ path
170
233
  end
171
234
 
172
- # Errors and resource limits are outside the score. With a threshold they
173
- # make the run incomplete, so the score alone cannot pass it.
174
- def incomplete_under_threshold?(report)
175
- return false if config["threshold"].nil?
235
+ def write_html(report)
236
+ path = config.path("html_report")
237
+ return unless path
176
238
 
177
- count = report.counts.values_at(:error, :resource_limit).sum
178
- @err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
179
- count.positive?
180
- count.positive?
239
+ Reporter::Html.new(path).call(report)
240
+ path
181
241
  end
182
242
 
183
243
  # Positional arguments name the files (or directories) to mutate. They
184
244
  # replace the configured include and exclude patterns. Each must exist,
185
245
  # so a typo is an error and not a run over zero subjects.
186
246
  def use_explicit_sources
187
- raise UsageError, "--test needs at least one source file" if options[:tests] && @argv.empty?
188
- return if @argv.empty?
247
+ if @argv.empty?
248
+ raise UsageError, "--test needs at least one source file" if options[:tests] && !config["since"]
189
249
 
250
+ # With --since the changed files are the sources, so --test may
251
+ # stand alone; the files must still exist.
252
+ options[:tests]&.each { |test| require_existing_test(test) }
253
+ return
254
+ end
255
+
256
+ @sources = @argv.dup
190
257
  options[:include] = @argv.map { |source| source_pattern(source) }
191
258
  options[:exclude] = []
192
259
  options[:tests]&.each { |test| require_existing_test(test) }
@@ -212,10 +279,25 @@ module Saboteur
212
279
  end
213
280
 
214
281
  def overrides
215
- options.except(:config, :id)
282
+ options.except(:config, :id, :suite, :list_operators, :dry_run)
283
+ end
284
+
285
+ def preview
286
+ raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
287
+ raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
288
+
289
+ return MutationSource::OperatorRegistry.list(@out) || EXIT_OK if options[:list_operators]
290
+
291
+ Preview.new(config, out: @out, err: @err).call
292
+ EXIT_OK
216
293
  end
217
294
 
218
295
  def define_selection_options(opts)
296
+ opts.on("--operators NAMES", Array, "Choose operator names or tiers (core, extended, all)") do |v|
297
+ set(:operators, v)
298
+ end
299
+ opts.on("--list-operators", "List operator names, descriptions and tiers") { set(:list_operators, true) }
300
+ opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
219
301
  opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
220
302
  opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
221
303
  opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
@@ -227,16 +309,41 @@ module Saboteur
227
309
  opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
228
310
  set(:seed, v)
229
311
  end
230
- opts.on("--config PATH", "Config file (default .saboteur.yml)") { |v| set(:config, v) }
312
+ opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
231
313
  opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
232
314
  opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
233
315
  opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
316
+ opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
317
+ set(:allow_hazardous, true)
318
+ end
319
+ opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
234
320
  opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
321
+ opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
322
+ set(:allow_focus, true)
323
+ end
324
+ opts.on("--[no-]guess-tests", "With --since: run only the specs named after the changed files " \
325
+ "(lib/a.rb -> spec/a_spec.rb); the whole suite if any has none") do |v|
326
+ set(:guess_tests, v)
327
+ end
328
+ end
329
+
330
+ def define_matrix_option(opts)
331
+ opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
332
+ set(:matrix, true)
333
+ end
235
334
  end
236
335
 
237
336
  def define_output_options(opts)
337
+ opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
238
338
  opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
239
339
  opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
340
+ opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
341
+ "strict exit codes") { |v| @format = v }
342
+ opts.on("--agent", "Same as --format agent") { @format = "agent" }
343
+ opts.on("--verbose", "Text report: also print score split, timings and kill causes") { @verbose = true }
344
+ opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
345
+ @color = v ? :always : :never
346
+ end
240
347
  end
241
348
 
242
349
  def define_explicit_file_options(opts)
@@ -246,6 +353,7 @@ module Saboteur
246
353
  opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
247
354
  set(:threshold, v)
248
355
  end
356
+ Gates.define_options(opts) { |key, value| set(key, value) }
249
357
  end
250
358
 
251
359
  def add_subject(expr)
@@ -1,13 +1,20 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
- require "yaml"
5
4
  require_relative "mutation_source/builtin"
5
+ require_relative "mutation_source/operator_registry"
6
+ require_relative "mutation_source/hazard_guard"
6
7
 
7
8
  module Saboteur
8
- # Settings from .saboteur.yml, overridden by CLI flags.
9
+ # Settings from config/saboteur.rb, overridden by CLI flags.
9
10
  class Config
10
- FILE = ".saboteur.yml"
11
+ FILE = "config/saboteur.rb"
12
+
13
+ # Died with the YAML port: a leftover file means settings are
14
+ # silently ignored, which could turn off a threshold. Fail loud.
15
+ LEGACY_FILE = ".saboteur.yml"
16
+ LEGACY_IGNORE_FILE = ".saboteur-ignore.yml"
17
+ IGNORE_FILE = "config/saboteur_ignore.rb"
11
18
 
12
19
  DEFAULTS = {
13
20
  "integration" => "rspec",
@@ -15,6 +22,11 @@ module Saboteur
15
22
  "include" => ["lib/**/*.rb"],
16
23
  "exclude" => [],
17
24
  "arid" => MutationSource::Builtin::DEFAULT_ARID,
25
+ "whole_file" => [],
26
+ "hazard" => MutationSource::HazardGuard::DEFAULT_HAZARD,
27
+ "allow_hazardous" => false,
28
+ "sandbox" => true, # true, false, or "strict": strict refuses to run without full Landlock
29
+ "sandbox_writable" => [],
18
30
  "timeout_factor" => 3.0,
19
31
  "timeout_min" => 5.0,
20
32
  "baseline_timeout" => 300.0,
@@ -23,45 +35,58 @@ module Saboteur
23
35
  "max_processes" => 64,
24
36
  "max_mutants_per_method" => 100,
25
37
  "jobs" => 1,
26
- "ignore_file" => ".saboteur-ignore.yml",
38
+ "ignore_file" => "config/saboteur_ignore.rb",
39
+ "custom_map" => "config/saboteur_custom_map.rb",
27
40
  "sources" => ["builtin"],
41
+ "operators" => [],
28
42
  "hooks" => [],
29
43
  "rails" => false,
30
44
  "reeval" => "nesting",
31
45
  "cache_dir" => ".saboteur",
32
46
  "json_report" => ".saboteur/report.json",
33
47
  "html_report" => ".saboteur/report.html",
48
+ "score_history" => nil,
34
49
  "rspec_args" => [],
35
50
  "subjects" => [],
36
51
  "since" => nil,
37
52
  "cache" => true,
38
53
  "incremental" => false,
54
+ "allow_focus" => false,
55
+ "fail_fast" => false,
56
+ "guess_tests" => false,
57
+ "matrix" => false,
39
58
  "seed" => 1,
40
59
  "tests" => [],
41
- "threshold" => nil
60
+ "fingerprint_extra" => [],
61
+ "threshold" => nil,
62
+ "max_timeouts" => nil,
63
+ "max_errors" => nil
42
64
  }.freeze
43
65
 
44
66
  INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
45
67
  FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
46
68
  THRESHOLD_RANGE = (0.0..100.0)
47
69
 
48
- LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests].freeze
70
+ LIST_KEYS = %w[require include exclude arid hazard sources hooks rspec_args subjects tests
71
+ fingerprint_extra sandbox_writable operators whole_file].freeze
49
72
 
50
73
  attr_reader :root
51
74
 
52
75
  def self.load(root: Dir.pwd, path: nil, overrides: {})
53
76
  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)
77
+ legacy = File.join(root, LEGACY_FILE)
78
+ if File.file?(legacy)
79
+ raise ConfigError,
80
+ "saboteur no longer reads #{LEGACY_FILE}; move its settings to #{FILE} (see README)."
81
+ end
82
+ legacy_ignore = File.join(root, LEGACY_IGNORE_FILE)
83
+ if File.file?(legacy_ignore)
84
+ raise ConfigError,
85
+ "saboteur no longer reads #{LEGACY_IGNORE_FILE}; move its entries to #{IGNORE_FILE} (see README)."
86
+ end
61
87
 
62
- data
63
- rescue Psych::SyntaxError => e
64
- raise ConfigError, "#{path}: #{e.message}"
88
+ from_file = File.file?(path) ? ConfigFile.load(path) : {}
89
+ new(from_file.merge(stringify(overrides)), root: root)
65
90
  end
66
91
 
67
92
  def self.stringify(hash)
@@ -96,7 +121,7 @@ module Saboteur
96
121
  matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
97
122
  excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
98
123
  (matched - excluded).select { |file| file.end_with?(".rb") }
99
- .map { |file| File.realpath(File.join(root, file)) }.uniq.sort
124
+ .map { |file| File.realpath(File.expand_path(file, root)) }.uniq.sort
100
125
  end
101
126
 
102
127
  # Patterns that match no file. A pattern goes stale when files move;
@@ -112,6 +137,15 @@ module Saboteur
112
137
  value && File.expand_path(value, root)
113
138
  end
114
139
 
140
+ # Paths of saboteur's own output, relative to the root, that do not count as source changes.
141
+ def output_paths
142
+ prefix = File.join(File.realpath(root), "")
143
+ %w[cache_dir json_report html_report score_history].filter_map { |key| path(key) }
144
+ .map { |file| File.expand_path(file) }
145
+ .select { |file| file.start_with?(prefix) }
146
+ .map { |file| file.delete_prefix(prefix) }
147
+ end
148
+
115
149
  private
116
150
 
117
151
  def coerce!
@@ -129,9 +163,39 @@ module Saboteur
129
163
 
130
164
  def validate_report_paths!
131
165
  json = path("json_report")
132
- return unless json && json == path("html_report")
166
+ if json && json == path("html_report")
167
+ raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
168
+ end
169
+
170
+ paths = %w[json_report html_report score_history].filter_map { |key| path(key) }
171
+ return if paths.uniq.size == paths.size
172
+
173
+ raise ConfigError, "json_report, html_report and score_history must have distinct paths"
174
+ end
175
+
176
+ def validate_modes!
177
+ validate_fail_fast!
178
+ validate_matrix!
179
+ validate_guess_tests!
180
+ validate_gates!
181
+ end
182
+
183
+ def validate_gates!
184
+ Gates::SETTINGS.each_key { |key| Gates.limit(self[key], key) }
185
+ end
186
+
187
+ def validate_fail_fast!
188
+ return unless self["fail_fast"] && self["jobs"] > 1
189
+
190
+ raise ConfigError, "fail_fast needs jobs 1: parallel workers have already started the other subjects"
191
+ end
192
+
193
+ # A reused verdict was computed by a run that stopped at the first
194
+ # failing test, so it lists too few killers for a matrix.
195
+ def validate_matrix!
196
+ return unless self["matrix"] && self["incremental"]
133
197
 
134
- raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
198
+ raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
135
199
  end
136
200
 
137
201
  def validate_threshold!
@@ -141,6 +205,13 @@ module Saboteur
141
205
  raise ConfigError, "threshold must be between 0 and 100"
142
206
  end
143
207
 
208
+ def validate_guess_tests!
209
+ return unless self["guess_tests"]
210
+
211
+ raise ConfigError, "guess_tests needs since" unless self["since"]
212
+ raise ConfigError, "guess_tests cannot be combined with tests" if self["tests"].any?
213
+ end
214
+
144
215
  def validate_reeval!
145
216
  raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
146
217
  Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
@@ -148,9 +219,13 @@ module Saboteur
148
219
 
149
220
  def validate!
150
221
  raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
222
+ unless [true, false, "strict"].include?(self["sandbox"])
223
+ raise ConfigError, 'sandbox must be true, false, or "strict"'
224
+ end
151
225
 
152
226
  validate_mutant_limit!
153
227
  validate_threshold!
228
+ validate_modes!
154
229
  raise ConfigError, "seed must be >= 0" if self["seed"].negative?
155
230
  raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
156
231
  raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
@@ -158,6 +233,11 @@ module Saboteur
158
233
  raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
159
234
 
160
235
  validate_reeval!
236
+ validate_operator_config!
237
+ end
238
+
239
+ def validate_operator_config!
240
+ MutationSource::OperatorRegistry.resolve(self["operators"])
161
241
 
162
242
  self["arid"].each { |pattern| Regexp.new(pattern) }
163
243
  rescue RegexpError => e
@@ -165,3 +245,5 @@ module Saboteur
165
245
  end
166
246
  end
167
247
  end
248
+
249
+ 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