saboteur 0.4.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -0
  3. data/README.md +393 -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 +105 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +114 -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 +101 -121
  19. data/lib/saboteur/config.rb +76 -6
  20. data/lib/saboteur/doctor.rb +40 -8
  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 +59 -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 +6 -4
  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 +52 -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 +192 -18
  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/gates.rb +83 -0
  48. data/lib/saboteur/help.rb +31 -7
  49. data/lib/saboteur/ignore_list.rb +5 -0
  50. data/lib/saboteur/inline_suppression.rb +65 -0
  51. data/lib/saboteur/integration/base.rb +1 -0
  52. data/lib/saboteur/integration/focus_guard.rb +83 -0
  53. data/lib/saboteur/integration/rspec.rb +21 -1
  54. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  55. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  56. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  57. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  58. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  59. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  60. data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
  61. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  62. data/lib/saboteur/preview.rb +106 -0
  63. data/lib/saboteur/reporter/agent_commands.rb +7 -1
  64. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  65. data/lib/saboteur/reporter/agent_summary.rb +11 -5
  66. data/lib/saboteur/reporter/findings.rb +4 -19
  67. data/lib/saboteur/reporter/history_paths.rb +38 -0
  68. data/lib/saboteur/reporter/history_record.rb +132 -0
  69. data/lib/saboteur/reporter/html.rb +12 -1
  70. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  71. data/lib/saboteur/reporter/next_steps.rb +30 -0
  72. data/lib/saboteur/reporter/palette.rb +37 -0
  73. data/lib/saboteur/reporter/score_history.rb +123 -0
  74. data/lib/saboteur/reporter/score_table.rb +57 -0
  75. data/lib/saboteur/reporter/stryker.rb +15 -2
  76. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  77. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  78. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  79. data/lib/saboteur/reporter/terminal.rb +131 -79
  80. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  81. data/lib/saboteur/result.rb +41 -6
  82. data/lib/saboteur/selection/coverage_map.rb +119 -13
  83. data/lib/saboteur/selection/git_diff.rb +28 -1
  84. data/lib/saboteur/selection/recorder.rb +55 -13
  85. data/lib/saboteur/subject.rb +40 -4
  86. data/lib/saboteur/subject_finder.rb +62 -12
  87. data/lib/saboteur/version.rb +1 -1
  88. data/lib/saboteur/working_tree.rb +8 -0
  89. data/lib/saboteur.rb +6 -0
  90. metadata +43 -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,11 +205,12 @@ 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
215
  incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
206
216
  )
@@ -213,89 +223,21 @@ module Saboteur
213
223
  @sources&.any? ? "files" : "full"
214
224
  end
215
225
 
216
- def write_json(report, cause, announce: true)
226
+ # Writes the JSON report and returns its path, or nil when none is configured.
227
+ def write_json(report, cause)
217
228
  path = config.path("json_report")
218
229
  return unless path
219
230
 
220
231
  Reporter::Stryker.new(path).call(report, exit_cause: cause)
221
- @out.puts "\nJSON report: #{path}" if announce
232
+ path
222
233
  end
223
234
 
224
- def write_html(report, announce: true)
235
+ def write_html(report)
225
236
  path = config.path("html_report")
226
237
  return unless path
227
238
 
228
239
  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?
240
+ path
299
241
  end
300
242
 
301
243
  # Positional arguments name the files (or directories) to mutate. They
@@ -337,10 +279,25 @@ module Saboteur
337
279
  end
338
280
 
339
281
  def overrides
340
- 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
341
293
  end
342
294
 
343
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) }
344
301
  opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
345
302
  opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
346
303
  opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
@@ -356,15 +313,37 @@ module Saboteur
356
313
  opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
357
314
  opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
358
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) }
359
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
360
334
  end
361
335
 
362
336
  def define_output_options(opts)
337
+ opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
363
338
  opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
364
339
  opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
365
340
  opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
366
341
  "strict exit codes") { |v| @format = v }
367
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
368
347
  end
369
348
 
370
349
  def define_explicit_file_options(opts)
@@ -374,6 +353,7 @@ module Saboteur
374
353
  opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
375
354
  set(:threshold, v)
376
355
  end
356
+ Gates.define_options(opts) { |key, value| set(key, value) }
377
357
  end
378
358
 
379
359
  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,37 @@ 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" => [],
34
42
  "hooks" => [],
35
43
  "rails" => false,
36
44
  "reeval" => "nesting",
37
45
  "cache_dir" => ".saboteur",
38
46
  "json_report" => ".saboteur/report.json",
39
47
  "html_report" => ".saboteur/report.html",
48
+ "score_history" => nil,
40
49
  "rspec_args" => [],
41
50
  "subjects" => [],
42
51
  "since" => nil,
43
52
  "cache" => true,
44
53
  "incremental" => false,
54
+ "allow_focus" => false,
55
+ "fail_fast" => false,
56
+ "guess_tests" => false,
57
+ "matrix" => false,
45
58
  "seed" => 1,
46
59
  "tests" => [],
47
60
  "fingerprint_extra" => [],
48
- "threshold" => nil
61
+ "threshold" => nil,
62
+ "max_timeouts" => nil,
63
+ "max_errors" => nil
49
64
  }.freeze
50
65
 
51
66
  INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
52
67
  FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
53
68
  THRESHOLD_RANGE = (0.0..100.0)
54
69
 
55
- LIST_KEYS = %w[require include exclude arid sources hooks rspec_args subjects tests
56
- fingerprint_extra].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
57
72
 
58
73
  attr_reader :root
59
74
 
@@ -106,7 +121,7 @@ module Saboteur
106
121
  matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
107
122
  excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
108
123
  (matched - excluded).select { |file| file.end_with?(".rb") }
109
- .map { |file| File.realpath(File.join(root, file)) }.uniq.sort
124
+ .map { |file| File.realpath(File.expand_path(file, root)) }.uniq.sort
110
125
  end
111
126
 
112
127
  # Patterns that match no file. A pattern goes stale when files move;
@@ -122,6 +137,15 @@ module Saboteur
122
137
  value && File.expand_path(value, root)
123
138
  end
124
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
+
125
149
  private
126
150
 
127
151
  def coerce!
@@ -139,9 +163,39 @@ module Saboteur
139
163
 
140
164
  def validate_report_paths!
141
165
  json = path("json_report")
142
- 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
143
186
 
144
- raise ConfigError, "json_report and html_report both point to #{json}; give each report its own path"
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"]
197
+
198
+ raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
145
199
  end
146
200
 
147
201
  def validate_threshold!
@@ -151,6 +205,13 @@ module Saboteur
151
205
  raise ConfigError, "threshold must be between 0 and 100"
152
206
  end
153
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
+
154
215
  def validate_reeval!
155
216
  raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
156
217
  Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
@@ -158,9 +219,13 @@ module Saboteur
158
219
 
159
220
  def validate!
160
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
161
225
 
162
226
  validate_mutant_limit!
163
227
  validate_threshold!
228
+ validate_modes!
164
229
  raise ConfigError, "seed must be >= 0" if self["seed"].negative?
165
230
  raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
166
231
  raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
@@ -168,6 +233,11 @@ module Saboteur
168
233
  raise ConfigError, "timeout_min must be > 0" unless self["timeout_min"].positive?
169
234
 
170
235
  validate_reeval!
236
+ validate_operator_config!
237
+ end
238
+
239
+ def validate_operator_config!
240
+ MutationSource::OperatorRegistry.resolve(self["operators"])
171
241
 
172
242
  self["arid"].each { |pattern| Regexp.new(pattern) }
173
243
  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,31 @@ 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'}"
25
28
  @out.puts "SimpleCov: #{defined?(::SimpleCov) ? 'present; start suppressed for this process' : 'not loaded'}"
26
29
  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."
30
+ check_suite(integration, hooks)
30
31
  true
31
32
  end
32
33
 
33
34
  private
34
35
 
36
+ def check_suite(integration, hooks)
37
+ unless @suite
38
+ @out.puts "Baseline: not run. Pass --suite to run the unchanged suite twice " \
39
+ "(add --test PATH to limit it to some specs)."
40
+ return
41
+ end
42
+
43
+ tests = BaselineHealth.new(@config, integration: integration, hooks: hooks).call
44
+ @out.puts "Baseline: #{tests.size} tests passed twice in the same order with seed #{@config['seed']}"
45
+ @out.puts "Healthy in these two runs. This does not prove the suite is free of flaky tests."
46
+ end
47
+
35
48
  def boot_target(hooks)
36
49
  Engine::Boot.new(@config, hooks).load_target
37
50
  rescue LoadError, SyntaxError, SystemExit, StandardError => e
@@ -52,10 +65,29 @@ module Saboteur
52
65
  def check_coverage
53
66
  state = Coverage.state
54
67
  @out.puts "Coverage: #{state}"
55
- return if Coverage.supported?(:lines) && state == :suspended
68
+ return if Coverage.supported?(:lines) && Coverage.supported?(:methods) && state == :suspended
69
+
70
+ raise Error, "Coverage must be suspended with line and method support before boot; use the saboteur " \
71
+ "executable, disable SimpleCov and remove other Coverage.start calls"
72
+ end
56
73
 
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"
74
+ # Match against all included source definitions, not the current diff or
75
+ # selected tests. A limited run must not make a valid ignore look stale.
76
+ def check_ignore_subjects
77
+ ignores = IgnoreList.load(@config.path("ignore_file"))
78
+ return if ignores.entries.empty?
79
+
80
+ finder = SubjectFinder.new
81
+ parsed = @config.included_files.map { |file| finder.call(file) }
82
+ if parsed.any? { |result| result.errors.any? }
83
+ @out.puts "Warning: ignore subject check incomplete: an included file does not parse"
84
+ return
85
+ end
86
+ subjects = parsed.flat_map(&:subjects).to_set(&:id)
87
+ ignores.entries.map(&:subject).uniq.reject { |subject| subjects.include?(subject) }.each do |subject|
88
+ @out.puts "Warning: ignore subject #{subject} matches no discovered subject in included files; " \
89
+ "check include/exclude settings and the exact Class#method or Class.method form"
90
+ end
59
91
  end
60
92
 
61
93
  def check_custom_map