saboteur 0.4.0 → 0.6.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. metadata +46 -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
  " \
@@ -32,7 +40,8 @@ module Saboteur
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)
@@ -54,25 +63,34 @@ module Saboteur
54
63
  define_selection_options(opts)
55
64
  define_explicit_file_options(opts)
56
65
  define_execution_options(opts)
66
+ define_matrix_option(opts)
57
67
  define_output_options(opts)
58
68
  end
59
69
  end
60
70
 
61
71
  # Each command's own option lines, as printed under OPTIONS in `saboteur help`.
62
72
  def option_lines
63
- { "run" => parser.summarize, "doctor" => doctor_parser.summarize }
73
+ { "run" => parser.summarize, "doctor" => doctor_parser.summarize, "baseline" => baseline_parser.summarize }
64
74
  end
65
75
 
66
76
  def doctor_parser
67
77
  OptionParser.new do |opts|
68
- opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N]"
78
+ opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
69
79
  opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
70
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
71
85
  end
72
86
  end
73
87
 
74
88
  private
75
89
 
90
+ def run_command
91
+ help_requested? ? show_help("run") : run
92
+ end
93
+
76
94
  def help_requested?
77
95
  @argv.intersect?(%w[--help -h])
78
96
  end
@@ -112,11 +130,12 @@ module Saboteur
112
130
  def run
113
131
  parser.parse!(@argv)
114
132
  use_explicit_sources
115
- reject_threshold_with_agent_format
133
+ validate_run_options
134
+ warn_dirty_tree
116
135
 
117
- arguments = { err: @err }
118
- arguments[:digest] = options[:id] if options[:id]
119
- agent? ? agent_run(arguments) : text_run(Engine.new(config, **arguments).call)
136
+ return preview if options[:list_operators] || options[:dry_run]
137
+
138
+ execute_run
120
139
  rescue ConfigError => e
121
140
  @err.puts("saboteur: #{e.message}")
122
141
  agent_failure(e.message, EXIT_USAGE)
@@ -125,32 +144,45 @@ module Saboteur
125
144
  agent_failure(e.message, EXIT_TOOL)
126
145
  end
127
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
+
128
159
  # Agent mode has one strict meaning for exit 0, so a score threshold cannot also decide it.
129
160
  def reject_threshold_with_agent_format
130
- return unless agent? && config["threshold"]
161
+ return unless agent? && (config["threshold"] || gates.any?)
131
162
 
132
- raise UsageError, "--threshold cannot be combined with --agent: agent mode exits 0 only for a complete, clean run"
163
+ raise UsageError, "#{config['threshold'] ? '--threshold' : '--max-* gates'} cannot be combined with --agent"
133
164
  end
134
165
 
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
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(', ')}"
147
178
  end
148
179
 
149
180
  def doctor
150
181
  doctor_parser.parse!(@argv)
151
182
  raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
152
183
 
153
- 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
154
186
  EXIT_OK
155
187
  rescue ConfigError => e
156
188
  @err.puts("saboteur: #{e.message}")
@@ -162,29 +194,6 @@ module Saboteur
162
194
 
163
195
  # Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
164
196
  # 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
197
  # Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
189
198
  # children cannot write into the JSON on stdout.
190
199
  def stdout_on_stderr
@@ -196,16 +205,22 @@ module Saboteur
196
205
  saved&.close
197
206
  end
198
207
 
199
- def agent_provenance(report, tree)
208
+ def agent_provenance(report, tree, engine)
200
209
  Reporter::Agent::Provenance.new(
201
210
  ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
202
211
  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 || [],
212
+ subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || options[:tests]),
213
+ sources: @sources || [],
204
214
  config_path: options[:config], digest: options[:id], cache: config["cache"],
205
- incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
215
+ incremental: config["incremental"], settings: agent_settings
206
216
  )
207
217
  end
208
218
 
219
+ def agent_settings
220
+ options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
221
+ .merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
222
+ end
223
+
209
224
  def agent_mode
210
225
  return "replay" if options[:id]
211
226
  return "diff" if config["since"]
@@ -213,89 +228,21 @@ module Saboteur
213
228
  @sources&.any? ? "files" : "full"
214
229
  end
215
230
 
216
- def write_json(report, cause, announce: true)
231
+ # Writes the JSON report and returns its path, or nil when none is configured.
232
+ def write_json(report, cause)
217
233
  path = config.path("json_report")
218
234
  return unless path
219
235
 
220
236
  Reporter::Stryker.new(path).call(report, exit_cause: cause)
221
- @out.puts "\nJSON report: #{path}" if announce
237
+ path
222
238
  end
223
239
 
224
- def write_html(report, announce: true)
240
+ def write_html(report)
225
241
  path = config.path("html_report")
226
242
  return unless path
227
243
 
228
244
  Reporter::Html.new(path).call(report)
229
- @out.puts "HTML report: #{path}" if announce
230
- end
231
-
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
-
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
-
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
289
-
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?
245
+ path
299
246
  end
300
247
 
301
248
  # Positional arguments name the files (or directories) to mutate. They
@@ -337,10 +284,31 @@ module Saboteur
337
284
  end
338
285
 
339
286
  def overrides
340
- options.except(:config, :id)
287
+ options.except(:config, :id, :suite, :list_operators, :dry_run)
288
+ end
289
+
290
+ def preview
291
+ raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
292
+ raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
293
+
294
+ if options[:list_operators]
295
+ MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
296
+ return EXIT_OK
297
+ end
298
+
299
+ Preview.new(config, out: @out, err: @err).call
300
+ EXIT_OK
341
301
  end
342
302
 
343
303
  def define_selection_options(opts)
304
+ opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
305
+ set(:operators, v)
306
+ end
307
+ opts.on("--exclude-operators NAMES", Array, "Exclude operator names from the selected set") do |v|
308
+ set(:exclude_operators, v)
309
+ end
310
+ opts.on("--list-operators", "List operator names and descriptions") { set(:list_operators, true) }
311
+ opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
344
312
  opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
345
313
  opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
346
314
  opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
@@ -356,15 +324,37 @@ module Saboteur
356
324
  opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
357
325
  opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
358
326
  opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
327
+ opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
328
+ set(:allow_hazardous, true)
329
+ end
330
+ opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
359
331
  opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
332
+ opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
333
+ set(:allow_focus, true)
334
+ end
335
+ opts.on("--[no-]guess-tests", "With --since: run only the specs named after the changed files " \
336
+ "(lib/a.rb -> spec/a_spec.rb); the whole suite if any has none") do |v|
337
+ set(:guess_tests, v)
338
+ end
339
+ end
340
+
341
+ def define_matrix_option(opts)
342
+ opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
343
+ set(:matrix, true)
344
+ end
360
345
  end
361
346
 
362
347
  def define_output_options(opts)
348
+ opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
363
349
  opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
364
350
  opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
365
351
  opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
366
352
  "strict exit codes") { |v| @format = v }
367
353
  opts.on("--agent", "Same as --format agent") { @format = "agent" }
354
+ opts.on("--verbose", "Text report: also print score split, timings and kill causes") { @verbose = true }
355
+ opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
356
+ @color = v ? :always : :never
357
+ end
368
358
  end
369
359
 
370
360
  def define_explicit_file_options(opts)
@@ -374,6 +364,7 @@ module Saboteur
374
364
  opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
375
365
  set(:threshold, v)
376
366
  end
367
+ Gates.define_options(opts) { |key, value| set(key, value) }
377
368
  end
378
369
 
379
370
  def add_subject(expr)
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "mutation_source/builtin"
5
+ require_relative "mutation_source/operator_registry"
6
+ require_relative "mutation_source/hazard_guard"
5
7
 
6
8
  module Saboteur
7
9
  # Settings from config/saboteur.rb, overridden by CLI flags.
@@ -20,6 +22,11 @@ module Saboteur
20
22
  "include" => ["lib/**/*.rb"],
21
23
  "exclude" => [],
22
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" => [],
23
30
  "timeout_factor" => 3.0,
24
31
  "timeout_min" => 5.0,
25
32
  "baseline_timeout" => 300.0,
@@ -31,29 +38,40 @@ module Saboteur
31
38
  "ignore_file" => "config/saboteur_ignore.rb",
32
39
  "custom_map" => "config/saboteur_custom_map.rb",
33
40
  "sources" => ["builtin"],
41
+ "operators" => [],
42
+ "exclude_operators" => [],
34
43
  "hooks" => [],
35
44
  "rails" => false,
45
+ # Hosts the user asserts are disposable test databases (issue #258).
46
+ "allow_db_hosts" => [],
36
47
  "reeval" => "nesting",
37
48
  "cache_dir" => ".saboteur",
38
49
  "json_report" => ".saboteur/report.json",
39
50
  "html_report" => ".saboteur/report.html",
51
+ "score_history" => nil,
40
52
  "rspec_args" => [],
41
53
  "subjects" => [],
42
54
  "since" => nil,
43
55
  "cache" => true,
44
56
  "incremental" => false,
57
+ "allow_focus" => false,
58
+ "fail_fast" => false,
59
+ "guess_tests" => false,
60
+ "matrix" => false,
45
61
  "seed" => 1,
46
62
  "tests" => [],
47
63
  "fingerprint_extra" => [],
48
- "threshold" => nil
64
+ "threshold" => nil,
65
+ "max_timeouts" => nil,
66
+ "max_errors" => nil
49
67
  }.freeze
50
68
 
51
69
  INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
52
70
  FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
53
71
  THRESHOLD_RANGE = (0.0..100.0)
54
72
 
55
- LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
56
- fingerprint_extra].freeze
73
+ LIST_KEYS = %w[require include exclude arid hazard sources hooks rspec_args subjects tests
74
+ fingerprint_extra sandbox_writable operators exclude_operators whole_file allow_db_hosts].freeze
57
75
 
58
76
  attr_reader :root
59
77
 
@@ -106,7 +124,7 @@ module Saboteur
106
124
  matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
107
125
  excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
108
126
  (matched - excluded).select { |file| file.end_with?(".rb") }
109
- .map { |file| File.realpath(File.join(root, file)) }.uniq.sort
127
+ .map { |file| File.realpath(File.expand_path(file, root)) }.uniq.sort
110
128
  end
111
129
 
112
130
  # Patterns that match no file. A pattern goes stale when files move;
@@ -122,6 +140,15 @@ module Saboteur
122
140
  value && File.expand_path(value, root)
123
141
  end
124
142
 
143
+ # Paths of saboteur's own output, relative to the root, that do not count as source changes.
144
+ def output_paths
145
+ prefix = File.join(File.realpath(root), "")
146
+ %w[cache_dir json_report html_report score_history].filter_map { |key| path(key) }
147
+ .map { |file| File.expand_path(file) }
148
+ .select { |file| file.start_with?(prefix) }
149
+ .map { |file| file.delete_prefix(prefix) }
150
+ end
151
+
125
152
  private
126
153
 
127
154
  def coerce!
@@ -139,9 +166,39 @@ module Saboteur
139
166
 
140
167
  def validate_report_paths!
141
168
  json = path("json_report")
142
- return unless json && json == path("html_report")
169
+ if json && json == path("html_report")
170
+ raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
171
+ end
172
+
173
+ paths = %w[json_report html_report score_history].filter_map { |key| path(key) }
174
+ return if paths.uniq.size == paths.size
175
+
176
+ raise ConfigError, "json_report, html_report and score_history must have distinct paths"
177
+ end
178
+
179
+ def validate_modes!
180
+ validate_fail_fast!
181
+ validate_matrix!
182
+ validate_guess_tests!
183
+ validate_gates!
184
+ end
143
185
 
144
- raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
186
+ def validate_gates!
187
+ Gates::SETTINGS.each_key { |key| Gates.limit(self[key], key) }
188
+ end
189
+
190
+ def validate_fail_fast!
191
+ return unless self["fail_fast"] && self["jobs"] > 1
192
+
193
+ raise ConfigError, "fail_fast needs jobs 1: parallel workers have already started the other subjects"
194
+ end
195
+
196
+ # A reused verdict was computed by a run that stopped at the first
197
+ # failing test, so it lists too few killers for a matrix.
198
+ def validate_matrix!
199
+ return unless self["matrix"] && self["incremental"]
200
+
201
+ raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
145
202
  end
146
203
 
147
204
  def validate_threshold!
@@ -151,6 +208,13 @@ module Saboteur
151
208
  raise ConfigError, "threshold must be between 0 and 100"
152
209
  end
153
210
 
211
+ def validate_guess_tests!
212
+ return unless self["guess_tests"]
213
+
214
+ raise ConfigError, "guess_tests needs since" unless self["since"]
215
+ raise ConfigError, "guess_tests cannot be combined with tests" if self["tests"].any?
216
+ end
217
+
154
218
  def validate_reeval!
155
219
  raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
156
220
  Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
@@ -158,9 +222,13 @@ module Saboteur
158
222
 
159
223
  def validate!
160
224
  raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
225
+ unless [true, false, "strict"].include?(self["sandbox"])
226
+ raise ConfigError, 'sandbox must be true, false, or "strict"'
227
+ end
161
228
 
162
229
  validate_mutant_limit!
163
230
  validate_threshold!
231
+ validate_modes!
164
232
  raise ConfigError, "seed must be >= 0" if self["seed"].negative?
165
233
  raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
166
234
  raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
@@ -168,6 +236,28 @@ module Saboteur
168
236
  raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
169
237
 
170
238
  validate_reeval!
239
+ validate_operator_config!
240
+ end
241
+
242
+ public
243
+
244
+ def operator_policy
245
+ MutationSource::OperatorRegistry.policy(self["operators"], self["exclude_operators"])
246
+ end
247
+
248
+ # Versioned policy travels with every identity, not with mutation digests.
249
+ def selection_settings
250
+ to_h.merge("operator_policy" => operator_policy)
251
+ end
252
+
253
+ private
254
+
255
+ def validate_operator_config!
256
+ effective = MutationSource::OperatorRegistry.select(self["operators"], self["exclude_operators"])
257
+ if effective.empty?
258
+ raise ConfigError,
259
+ "operator selection is empty: enable at least one operator or clear exclusions"
260
+ end
171
261
 
172
262
  self["arid"].each { |pattern| Regexp.new(pattern) }
173
263
  rescue RegexpError => e
@@ -5,11 +5,13 @@ require "coverage"
5
5
  require_relative "baseline_health"
6
6
 
7
7
  module Saboteur
8
- # Reports the current environment and runs baseline checks, never mutations.
8
+ # Reports the current environment, never mutations. The two unchanged suite
9
+ # passes are slow on a large suite, so they run only when asked for.
9
10
  class Doctor
10
- def initialize(config, out: $stdout)
11
+ def initialize(config, out: $stdout, suite: false)
11
12
  @config = config
12
13
  @out = out
14
+ @suite = suite
13
15
  end
14
16
 
15
17
  def call
@@ -18,20 +20,32 @@ module Saboteur
18
20
  check_coverage
19
21
  check_boot_files
20
22
  check_custom_map
23
+ check_ignore_subjects
21
24
  hooks = Hooks.new
22
25
  integration = boot_target(hooks)
23
26
  @out.puts "Integration: #{@config['integration']} (loaded)"
24
27
  @out.puts "Rails: #{@config['rails'] ? 'enabled' : 'disabled'}"
28
+ check_database_hosts
25
29
  @out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
26
30
  check_coverage
27
- tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
28
- @out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
29
- @out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
31
+ check_suite(integration, hooks)
30
32
  true
31
33
  end
32
34
 
33
35
  private
34
36
 
37
+ def check_suite(integration, hooks)
38
+ unless @suite
39
+ @out.puts "Baseline: not run. Pass --suite to run the unchanged suite twice " \
40
+ "(add --test PATH to limit it to some specs)."
41
+ return
42
+ end
43
+
44
+ tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
45
+ @out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
46
+ @out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
47
+ end
48
+
35
49
  def boot_target(hooks)
36
50
  Engine::Boot.new(@config, hooks).load_target
37
51
  rescue LoadError, SyntaxError, SystemExit, StandardError => e
@@ -52,10 +66,29 @@ module Saboteur
52
66
  def check_coverage
53
67
  state = Coverage.state
54
68
  @out.puts "Coverage: #{state}"
55
- return if Coverage.supported?(:lines) && state == :suspended
69
+ return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
56
70
 
57
- raise Error, "Coverage must be suspended with line support before boot; use the saboteur executable, " \
58
- "disable SimpleCov and remove other Coverage.start calls"
71
+ raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
72
+ "executable, disable SimpleCov and remove other Coverage.start calls"
73
+ end
74
+
75
+ # Match against all included source definitions, not the current diff or
76
+ # selected tests. A limited run must not make a valid ignore look stale.
77
+ def check_ignore_subjects
78
+ ignores = IgnoreList.load(@config.path("ignore_file"))
79
+ return if ignores.entries.empty?
80
+
81
+ finder = SubjectFinder.new
82
+ parsed = @config.included_files.map { |file| finder.call(file) }
83
+ if parsed.any? { |result| result.errors.any? }
84
+ @out.puts "Warning: ignore subject check incomplete: an included file does not parse"
85
+ return
86
+ end
87
+ subjects = parsed.flat_map(&:subjects).to_set(&:id)
88
+ ignores.entries.map(&:subject).uniq.reject { |subject| subjects.include?(subject) }.each do |subject|
89
+ @out.puts "Warning: ignore subject #{subject} matches no discovered subject in included files; " \
90
+ "check include/exclude settings and the exact Class#method or Class.method form"
91
+ end
59
92
  end
60
93
 
61
94
  def check_custom_map
@@ -73,11 +106,33 @@ module Saboteur
73
106
  @out.puts "Custom map: #{map.entries.size} entries (loaded)"
74
107
  end
75
108
 
109
+ # Prints the test database hosts the booted app reports, so a non-local
110
+ # refusal names the host here instead of only at target boot (issue #258).
111
+ def check_database_hosts
112
+ return unless @config["rails"]
113
+ return @out.puts("Databases: ActiveRecord did not load; host check skipped") unless defined?(::ActiveRecord::Base)
114
+
115
+ configs = ::ActiveRecord::Base.configurations.configs_for(env_name: "test")
116
+ configs.each do |db|
117
+ host = db.configuration_hash[:host]
118
+ @out.puts "Database: #{db.name} (host #{host.to_s.empty? ? 'local socket' : host})"
119
+ end
120
+ remote = Frameworks::Rails.remote_databases(configs, @config["allow_db_hosts"])
121
+ return if remote.empty?
122
+
123
+ raise ConfigError, "non-local test databases: #{remote.join(', ')}; if they are disposable test databases, " \
124
+ "allow them with c.allow_db_hosts in #{Config::FILE}"
125
+ end
126
+
76
127
  def check_boot_files
77
128
  %w[require hooks].each do |key|
78
129
  @config[key].each do |file|
79
130
  path = File.expand_path(file, @config.root)
80
- raise ConfigError, "#{key} file missing: #{file}; fix config/saboteur.rb" unless File.file?(path)
131
+ # Resolve the way Kernel#require does: the path as given, or with
132
+ # .rb appended (the Rails quickstart sets ./config/environment, issue #257).
133
+ unless File.file?(path) || File.file?("#{path}.rb")
134
+ raise ConfigError, "#{key} file missing: #{file}; fix config/saboteur.rb"
135
+ end
81
136
 
82
137
  @out.puts "#{key}: #{file} (found)"
83
138
  end