saboteur 0.7.0 → 0.8.1

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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +772 -0
  3. data/README.md +221 -1072
  4. data/lib/saboteur/attribute_finder.rb +113 -0
  5. data/lib/saboteur/cli/exit_status.rb +32 -1
  6. data/lib/saboteur/cli/help_options.rb +2 -1
  7. data/lib/saboteur/cli/history_command.rb +135 -0
  8. data/lib/saboteur/cli/presets.rb +47 -3
  9. data/lib/saboteur/cli/run_budget_options.rb +3 -1
  10. data/lib/saboteur/cli/selection_options.rb +31 -0
  11. data/lib/saboteur/cli/shard_options.rb +17 -0
  12. data/lib/saboteur/cli/source_set.rb +35 -0
  13. data/lib/saboteur/cli/survivors_command.rb +157 -0
  14. data/lib/saboteur/cli.rb +74 -55
  15. data/lib/saboteur/config.rb +102 -6
  16. data/lib/saboteur/config_file.rb +19 -7
  17. data/lib/saboteur/constant_reads.rb +182 -0
  18. data/lib/saboteur/constant_subject.rb +70 -0
  19. data/lib/saboteur/constant_subject_finder.rb +281 -0
  20. data/lib/saboteur/coverage_reads.rb +29 -0
  21. data/lib/saboteur/custom_map.rb +12 -1
  22. data/lib/saboteur/dirty_tree_warning.rb +130 -0
  23. data/lib/saboteur/doctor.rb +13 -0
  24. data/lib/saboteur/engine/baseline_guard.rb +135 -21
  25. data/lib/saboteur/engine/boot.rb +11 -3
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  27. data/lib/saboteur/engine/candidate_selection.rb +20 -0
  28. data/lib/saboteur/engine/candidates.rb +43 -3
  29. data/lib/saboteur/engine/coverage_store.rb +58 -14
  30. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  31. data/lib/saboteur/engine/diagnostics.rb +19 -1
  32. data/lib/saboteur/engine/distribution.rb +10 -0
  33. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  34. data/lib/saboteur/engine/gated_run.rb +37 -0
  35. data/lib/saboteur/engine/load_coverage.rb +63 -0
  36. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  37. data/lib/saboteur/engine/pipelines.rb +115 -0
  38. data/lib/saboteur/engine/result_cache.rb +65 -8
  39. data/lib/saboteur/engine/run_budget.rb +32 -2
  40. data/lib/saboteur/engine/sandbox_policy.rb +59 -3
  41. data/lib/saboteur/engine/scheduler.rb +131 -17
  42. data/lib/saboteur/engine/selection_announcement.rb +59 -0
  43. data/lib/saboteur/engine/shard.rb +69 -0
  44. data/lib/saboteur/engine/subject_selection.rb +380 -21
  45. data/lib/saboteur/engine/test_guess.rb +407 -27
  46. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  47. data/lib/saboteur/engine.rb +103 -129
  48. data/lib/saboteur/execution/external_runner.rb +489 -0
  49. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  50. data/lib/saboteur/execution/marker_sweep.rb +80 -0
  51. data/lib/saboteur/execution/peak_memory.rb +28 -0
  52. data/lib/saboteur/execution/process_table.rb +19 -3
  53. data/lib/saboteur/execution/process_tree.rb +65 -5
  54. data/lib/saboteur/execution/reevaluator.rb +75 -12
  55. data/lib/saboteur/execution/runner.rb +46 -7
  56. data/lib/saboteur/execution/sandbox.rb +25 -6
  57. data/lib/saboteur/execution/seatbelt.rb +233 -0
  58. data/lib/saboteur/execution/supervisor.rb +29 -11
  59. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  60. data/lib/saboteur/execution/worker_pool.rb +23 -1
  61. data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
  62. data/lib/saboteur/gates.rb +43 -2
  63. data/lib/saboteur/help.rb +60 -9
  64. data/lib/saboteur/ignore_list.rb +29 -6
  65. data/lib/saboteur/integration/rspec.rb +223 -5
  66. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  67. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  68. data/lib/saboteur/mutation_source/builtin.rb +28 -5
  69. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  70. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  71. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  72. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  73. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  74. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  75. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  76. data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
  77. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  79. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  80. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  81. data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
  82. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  83. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  84. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  86. data/lib/saboteur/preview.rb +125 -12
  87. data/lib/saboteur/refinement_activations.rb +60 -0
  88. data/lib/saboteur/reporter/agent.rb +9 -1
  89. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  90. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  91. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  92. data/lib/saboteur/reporter/findings.rb +5 -2
  93. data/lib/saboteur/reporter/history_policy.rb +11 -0
  94. data/lib/saboteur/reporter/history_reader.rb +47 -0
  95. data/lib/saboteur/reporter/html.rb +26 -4
  96. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  97. data/lib/saboteur/reporter/stryker.rb +27 -3
  98. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  99. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  100. data/lib/saboteur/reporter/terminal.rb +56 -8
  101. data/lib/saboteur/result.rb +19 -3
  102. data/lib/saboteur/selection/coverage_map.rb +120 -10
  103. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  104. data/lib/saboteur/selection/external_test_map.rb +27 -0
  105. data/lib/saboteur/selection/git_diff.rb +93 -11
  106. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  107. data/lib/saboteur/selection/recorder.rb +22 -4
  108. data/lib/saboteur/selection/source_scope.rb +22 -0
  109. data/lib/saboteur/subject.rb +42 -7
  110. data/lib/saboteur/subject_finder.rb +173 -29
  111. data/lib/saboteur/version.rb +1 -1
  112. data/lib/saboteur/working_tree.rb +38 -15
  113. data/lib/saboteur.rb +14 -0
  114. metadata +39 -16
data/lib/saboteur/cli.rb CHANGED
@@ -11,17 +11,27 @@ require_relative "baseline/command"
11
11
  require_relative "cli/exit_status"
12
12
  require_relative "preview"
13
13
  require_relative "cli/run_reporting"
14
+ require_relative "cli/survivors_command"
15
+ require_relative "cli/source_set"
14
16
  require_relative "cli/run_budget_options"
17
+ require_relative "cli/shard_options"
18
+ require_relative "cli/selection_options"
15
19
  require_relative "cli/presets"
16
20
  require_relative "cli/help_options"
21
+ require_relative "cli/history_command"
17
22
 
18
23
  module Saboteur
19
24
  class CLI
20
25
  include Baseline::Command
21
26
  include ExitStatus
22
27
  include RunReporting
28
+ include SurvivorsCommand
29
+ include SourceSet
23
30
  include RunBudgetOptions
31
+ include ShardOptions
32
+ include SelectionOptions
24
33
  include Presets
34
+ include HistoryCommand
25
35
  include HelpOptions
26
36
 
27
37
  USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--all] [--threshold PERCENT] [--since REF] " \
@@ -58,11 +68,13 @@ module Saboteur
58
68
  case command
59
69
  when "run" then run_command
60
70
  when "baseline" then baseline
61
- when "doctor" then help_requested? ? show_help("doctor") : doctor
71
+ when "survivors" then survivors_command
72
+ when "doctor" then doctor_command
73
+ when "badge", "trend" then history_entry(command)
62
74
  when "version", "--version" then @out.puts(VERSION) || EXIT_OK
63
75
  when "help", "--help", "-h" then show_help(@argv.shift)
64
76
  when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
65
- else PRESETS.key?(command) ? preset(command) : usage_error
77
+ else preset_or_usage_error(command)
66
78
  end
67
79
  rescue OptionParser::ParseError => e
68
80
  usage_error(e.message)
@@ -98,13 +110,13 @@ module Saboteur
98
110
 
99
111
  private
100
112
 
101
- def run_command
102
- help_requested? ? show_help("run") : run
103
- end
113
+ def run_command = help_requested? ? show_help("run") : run
104
114
 
105
- def help_requested?
106
- @argv.intersect?(%w[--help -h])
107
- end
115
+ def doctor_command = help_requested? ? show_help("doctor") : doctor
116
+
117
+ def history_entry(command) = help_requested? ? show_help(command) : history_command(command)
118
+
119
+ def help_requested? = @argv.intersect?(%w[--help -h])
108
120
 
109
121
  def show_help(command_name)
110
122
  help = Help.new(option_lines: option_lines)
@@ -121,9 +133,7 @@ module Saboteur
121
133
  agent_failure(message || USAGE, EXIT_USAGE)
122
134
  end
123
135
 
124
- def agent?
125
- @format == "agent"
126
- end
136
+ def agent? = @format == "agent"
127
137
 
128
138
  def agent_requested? = self.class.agent_requested?(@argv)
129
139
 
@@ -144,6 +154,7 @@ module Saboteur
144
154
 
145
155
  return preview if options[:list_operators] || options[:dry_run]
146
156
 
157
+ refuse_empty_source_set
147
158
  execute_run
148
159
  rescue ConfigError => e
149
160
  @err.puts("saboteur: #{e.message}")
@@ -155,8 +166,12 @@ module Saboteur
155
166
 
156
167
  # Mutants run your code, and a mutant of code that writes or deletes files
157
168
  # can change the tree. Say so up front when uncommitted work is at stake.
169
+ # The scope the warning names is the configured one: positional source
170
+ # operands narrow the run after the warning, so the include/exclude they
171
+ # imply stay out of its config (#309, #503 review).
158
172
  def warn_dirty_tree
159
- WorkingTree.new(config.root, ignored: config.output_paths).state.warning&.then { |text| @err.puts(text) }
173
+ warning = @sources ? Config.load(path: options[:config], overrides: overrides.except(:include, :exclude)) : config
174
+ DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
160
175
  end
161
176
 
162
177
  # Contradictory selection flags are a usage error before any file or
@@ -165,30 +180,46 @@ module Saboteur
165
180
  # --test wins there, so generated commands stay valid over any config.
166
181
  def reject_test_selection_conflicts
167
182
  raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
183
+
184
+ reject_test_command_conflicts
168
185
  raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
169
186
  return unless options[:guess_tests] && options[:tests]&.any?
170
187
 
171
188
  raise UsageError, "--guess-tests cannot be combined with --test"
172
189
  end
173
190
 
191
+ # --test-command replaces the whole in-process test machinery: test
192
+ # selection, coverage, parallelism and the sandbox do not apply.
193
+ def reject_test_command_conflicts
194
+ return unless options[:test_command]
195
+
196
+ # options[:tests] is nil or a non-empty list, so truthiness is the check.
197
+ raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
198
+ raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
199
+ end
200
+
174
201
  # Usage errors found before the target boots.
175
202
  def validate_run_options
176
- reject_threshold_with_agent_format
203
+ reject_format_conflicts
204
+ validate_shard_options
177
205
  Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
178
206
  end
179
207
 
180
- # Agent mode has one strict meaning for exit 0, so a score threshold cannot also decide it.
181
- def reject_threshold_with_agent_format
182
- return unless agent? && (config["threshold"] || gates.any?)
208
+ # Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
209
+ def reject_format_conflicts
210
+ if agent? && (config["threshold"] || gates.any?)
211
+ raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
212
+ "cannot be combined with --agent"
213
+ end
214
+ return unless @format == "json" && !options[:dry_run]
183
215
 
184
- raise UsageError, "#{config['threshold'] ? '--threshold' : '--max-* gates'} cannot be combined with --agent"
216
+ raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
185
217
  end
186
218
 
187
219
  # One line naming the reports written, paths relative to the root when
188
220
  # they sit under it (a blank line before it, as the old JSON line had).
189
221
  def announce_reports(reports)
190
- written = reports.select { |path, _label| path }
191
- return if written.empty?
222
+ return if (written = reports.select { |path, _label| path }).empty?
192
223
 
193
224
  root = File.join(File.realpath(config.root), "")
194
225
  names = written.map do |path, label|
@@ -233,10 +264,14 @@ module Saboteur
233
264
  subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
234
265
  sources: @sources || [],
235
266
  config_path: options[:config], digest: options[:id], cache: config["cache"],
236
- incremental: config["incremental"], settings: agent_settings
267
+ incremental: config["incremental"], settings: agent_settings, typed_settings: typed_settings
237
268
  )
238
269
  end
239
270
 
271
+ # The settings keys typed on the command line: a typed empty operator
272
+ # list may clear a config value, so the generated commands keep it.
273
+ def typed_settings = options.keys & Reporter::Agent::Commands::SETTING_FLAGS.keys
274
+
240
275
  # A whole-suite run records its scope as --all, so the generated rerun
241
276
  # and replay commands do not default back to inference (which can exit
242
277
  # 2 in a project with a spec-less source file).
@@ -271,17 +306,15 @@ module Saboteur
271
306
  # replace the configured include and exclude patterns. Each must exist,
272
307
  # so a typo is an error and not a run over zero subjects.
273
308
  def use_explicit_sources
274
- if @argv.empty?
275
- # --test may stand alone: the configured include (or the --since
276
- # diff) names the sources. The files must still exist.
277
- options[:tests]&.each { |test| require_existing_test(test) }
278
- return
279
- end
309
+ Selection::SourceScope.verify_no_specs!(@argv)
310
+ options[:tests]&.each { |test| require_existing_test(test) }
311
+ # --test may stand alone: the configured include (or the --since
312
+ # diff) names the sources. The files must still exist.
313
+ return if @argv.empty?
280
314
 
281
315
  @sources = @argv.dup
282
316
  options[:include] = @argv.map { |source| source_pattern(source) }
283
317
  options[:exclude] = []
284
- options[:tests]&.each { |test| require_existing_test(test) }
285
318
  end
286
319
 
287
320
  def source_pattern(source)
@@ -303,12 +336,10 @@ module Saboteur
303
336
  raise UsageError, "test file not found: #{test}" unless File.exist?(path)
304
337
  end
305
338
 
306
- def overrides
307
- options.except(:config, :id, :suite, :list_operators, :dry_run)
308
- end
339
+ def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
309
340
 
310
341
  def preview
311
- raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
342
+ raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
312
343
  raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
313
344
 
314
345
  if options[:list_operators]
@@ -316,24 +347,10 @@ module Saboteur
316
347
  return EXIT_OK
317
348
  end
318
349
 
319
- Preview.new(config, out: @out, err: @err).call
350
+ Preview.new(config, out: @out, err: @err, format: @format).call
320
351
  EXIT_OK
321
352
  end
322
353
 
323
- def define_selection_options(opts)
324
- opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
325
- set(:operators, v)
326
- end
327
- opts.on("--exclude-operators NAMES", Array, "Exclude operator names from the selected set") do |v|
328
- set(:exclude_operators, v)
329
- end
330
- opts.on("--list-operators", "List operator names and descriptions") { set(:list_operators, true) }
331
- opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
332
- opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
333
- opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
334
- opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
335
- end
336
-
337
354
  def define_execution_options(opts)
338
355
  opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
339
356
  opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
@@ -352,6 +369,10 @@ module Saboteur
352
369
  end
353
370
  opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
354
371
  "mutated files") { set(:all, true) }
372
+ opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
373
+ "For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
374
+ set(:test_command, v)
375
+ end
355
376
  opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
356
377
  "--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
357
378
  set(:guess_tests, v)
@@ -368,11 +389,13 @@ module Saboteur
368
389
  opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
369
390
  opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
370
391
  opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
371
- opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
372
- "strict exit codes") { |v| @format = v }
392
+ opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
373
393
  opts.on("--agent", "Same as --format agent") { @format = "agent" }
374
394
  opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
375
- "a crash prints its backtrace") { @verbose = true }
395
+ "a crash prints its backtrace") do
396
+ @verbose = true
397
+ set(:verbose, true)
398
+ end
376
399
  opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
377
400
  @color = v ? :always : :never
378
401
  end
@@ -388,12 +411,8 @@ module Saboteur
388
411
  Gates.define_options(opts) { |key, value| set(key, value) }
389
412
  end
390
413
 
391
- def add_subject(expr)
392
- (options[:subjects] ||= []) << expr
393
- end
414
+ def add_subject(expr) = (options[:subjects] ||= []) << expr
394
415
 
395
- def set(key, value)
396
- options[key] = value
397
- end
416
+ def set(key, value) = (options[key] = value)
398
417
  end
399
418
  end
@@ -22,13 +22,27 @@ module Saboteur
22
22
  "integration" => "rspec",
23
23
  "require" => [],
24
24
  "include" => ["lib/**/*.rb"],
25
- "exclude" => [],
25
+ # Spec and test files vendored under lib/ (capybara ships its driver
26
+ # suite at lib/capybara/spec/**) are never mutation subjects (#504).
27
+ # A config's own c.exclude replaces this list wholesale.
28
+ "exclude" => ["lib/**/spec/**/*", "lib/**/test/**/*", "lib/**/tests/**/*",
29
+ "lib/**/*_spec.rb", "lib/**/*_test.rb", "lib/**/test_*.rb"],
26
30
  "arid" => MutationSource::Builtin::DEFAULT_ARID,
27
31
  "whole_file" => [],
32
+ # [{ "pattern" => "app/payments/**/*", "min_score" => 95.0 }], from
33
+ # repeatable c.score_gate calls (#473). --threshold stays the global
34
+ # fallback.
35
+ "score_gates" => [],
28
36
  "hazard" => MutationSource::HazardGuard::DEFAULT_HAZARD,
29
37
  "allow_hazardous" => false,
30
- "sandbox" => true, # true, false, or "strict": strict refuses to run without full Landlock
38
+ # true, false, "strict" or "seatbelt": strict refuses to run without full Landlock;
39
+ # seatbelt opts into the macOS Seatbelt sandbox (on Linux it behaves like true).
40
+ "sandbox" => true,
31
41
  "sandbox_writable" => [],
42
+ # off, survivors, or decisive (#476): one reordered confirmation run
43
+ # per new survivor (and per fresh kill in decisive); a disagreement
44
+ # becomes unverified, outside the score.
45
+ "flaky_check" => "off",
32
46
  "timeout_factor" => 3.0,
33
47
  "timeout_min" => 5.0,
34
48
  "baseline_timeout" => 300.0,
@@ -56,6 +70,7 @@ module Saboteur
56
70
  "since" => nil,
57
71
  "cache" => true,
58
72
  "incremental" => false,
73
+ "shard" => nil,
59
74
  "allow_focus" => false,
60
75
  "fail_fast" => false,
61
76
  "guess_tests" => false,
@@ -67,10 +82,14 @@ module Saboteur
67
82
  "max_duration" => nil,
68
83
  "max_duration_ok" => false,
69
84
  "tests" => [],
85
+ "test_command" => nil,
70
86
  "fingerprint_extra" => [],
71
87
  "threshold" => nil,
72
88
  "max_timeouts" => nil,
73
- "max_errors" => nil
89
+ "max_errors" => nil,
90
+ # --verbose also lands here, so boot-time output filters (the rspec
91
+ # load-error report, #608) can keep their raw text behind the flag.
92
+ "verbose" => false
74
93
  }.freeze
75
94
 
76
95
  INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes].freeze
@@ -160,14 +179,30 @@ module Saboteur
160
179
  matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
161
180
  excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
162
181
  (matched - excluded).select { |file| file.end_with?(".rb") }
163
- .map { |file| File.realpath(File.expand_path(Text.utf8_identity(file), root)) }
182
+ .filter_map { |file| real_source_path(file) }
164
183
  .uniq.sort
165
184
  end
166
185
 
186
+ # A broken symlink matches Dir.glob but has no real path: dangling
187
+ # (ENOENT), a loop (ELOOP) or through a regular file (ENOTDIR). It is
188
+ # not mutation source, so it is skipped rather than crashing the run
189
+ # (#503). A regular file that cannot be resolved still raises.
190
+ def real_source_path(file)
191
+ path = File.expand_path(Text.utf8_identity(file), root)
192
+ File.realpath(path)
193
+ rescue SystemCallError
194
+ raise unless File.symlink?(path)
195
+ end
196
+
167
197
  # Patterns that match no file. A pattern goes stale when files move;
168
198
  # an exclude that silently stops excluding is a hazard, so say so.
169
199
  def unmatched_patterns
170
200
  %w[include exclude].flat_map do |key|
201
+ # The default exclude shields (#504) match nothing on a healthy
202
+ # project - that is their job - so only a configured exclude can go
203
+ # stale enough to warn about.
204
+ next [] if key == "exclude" && !explicit?("exclude")
205
+
171
206
  self[key].select { |pattern| Dir.glob(pattern, base: root).empty? }.map { |pattern| "#{key}: #{pattern}" }
172
207
  end
173
208
  end
@@ -237,6 +272,41 @@ module Saboteur
237
272
  validate_matrix!
238
273
  validate_test_selection!
239
274
  validate_gates!
275
+ validate_score_gates!
276
+ end
277
+
278
+ # Per-path gates (#473): normalize each entry to string pattern /
279
+ # float floor, and fail loud on a pattern no included file matches -
280
+ # a gate that matches nothing would pass vacuously forever.
281
+ def validate_score_gates!
282
+ gates = self["score_gates"]
283
+ return if gates.empty?
284
+
285
+ normalized = gates.map { |gate| normalize_score_gate(gate) }
286
+ prefix = File.join(File.realpath(root), "")
287
+ relative = included_files.map { |file| file.delete_prefix(prefix) }
288
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
289
+ normalized.each do |gate|
290
+ next if relative.any? { |file| File.fnmatch?(gate["pattern"], file, flags) }
291
+
292
+ raise ConfigError, "score_gate #{gate['pattern']} matches no included file"
293
+ end
294
+ @values["score_gates"] = normalized
295
+ end
296
+
297
+ def normalize_score_gate(gate)
298
+ pattern = gate["pattern"].to_s
299
+ begin
300
+ floor = Float(gate["min_score"])
301
+ rescue ArgumentError, TypeError
302
+ raise ConfigError, "score_gate min_score must be between 0 and 100, got #{gate['min_score'].inspect}"
303
+ end
304
+ unless floor.finite? && floor.between?(0, 100)
305
+ raise ConfigError, "score_gate min_score must be between 0 and 100, got #{gate['min_score'].inspect}"
306
+ end
307
+ raise ConfigError, "score_gate pattern must not be empty" if pattern.empty?
308
+
309
+ { "pattern" => pattern, "min_score" => floor }
240
310
  end
241
311
 
242
312
  def validate_gates!
@@ -270,6 +340,28 @@ module Saboteur
270
340
  raise ConfigError, "test_selection must be one of: auto, all"
271
341
  end
272
342
 
343
+ # --test-command replaces the whole in-process machinery, on the
344
+ # EFFECTIVE config: CLI flags and config-file entries alike (#305).
345
+ # The command runs unsandboxed, so "strict" cannot be honored.
346
+ def validate_test_command!
347
+ command = self["test_command"]
348
+ # nil alone means unset: false, arrays and numbers are all rejected,
349
+ # never coerced and never sent to String methods (#305 QA).
350
+ raise ConfigError, "test_command must be a string" unless command.nil? || command.is_a?(String)
351
+ raise ConfigError, "test_command must not be blank" if command&.strip&.empty?
352
+
353
+ return if command.to_s.empty?
354
+ raise ConfigError, "test_command cannot be combined with tests" unless self["tests"].empty?
355
+ raise ConfigError, "test_command cannot be combined with incremental" if self["incremental"]
356
+ unless self["flaky_check"] == "off"
357
+ raise ConfigError, "test_command cannot be combined with flaky_check: an external run has no " \
358
+ "in-process scheduler to rerun the decisive tests through"
359
+ end
360
+ return unless self["sandbox"] == "strict"
361
+
362
+ raise ConfigError, 'test_command runs the command unsandboxed, so sandbox "strict" cannot be honored'
363
+ end
364
+
273
365
  def validate_reeval!
274
366
  raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
275
367
  Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
@@ -277,8 +369,11 @@ module Saboteur
277
369
 
278
370
  def validate!
279
371
  raise ConfigError, "jobs must be >= 1" if self["jobs"] < 1
280
- unless [true, false, "strict"].include?(self["sandbox"])
281
- raise ConfigError, 'sandbox must be true, false, or "strict"'
372
+ unless [true, false, "strict", "seatbelt"].include?(self["sandbox"])
373
+ raise ConfigError, 'sandbox must be true, false, "strict", or "seatbelt"'
374
+ end
375
+ unless %w[off survivors decisive].include?(self["flaky_check"])
376
+ raise ConfigError, "flaky_check must be one of: off, survivors, decisive"
282
377
  end
283
378
 
284
379
  validate_mutant_limit!
@@ -292,6 +387,7 @@ module Saboteur
292
387
 
293
388
  validate_reeval!
294
389
  validate_operator_config!
390
+ validate_test_command!
295
391
  end
296
392
 
297
393
  public
@@ -9,23 +9,36 @@ module Saboteur
9
9
  # boots, so it cannot reference app constants - plain Ruby and ENV only.
10
10
  module ConfigFile
11
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.
12
+ # unknown setting.
14
13
  class Settings
15
14
  def initialize
16
15
  @values = {}
17
- @lines = {}
18
16
  end
19
17
 
20
- attr_reader :values, :lines
18
+ attr_reader :values
21
19
 
22
- Config::DEFAULTS.each_key do |key|
20
+ (Config::DEFAULTS.keys - ["integration"]).each do |key|
23
21
  define_method("#{key}=") do |value|
24
22
  @values[key] = value
25
- @lines[key] = caller_locations(1, 1).first.lineno
26
23
  end
27
24
  end
28
25
 
26
+ def integration=(value)
27
+ line = caller_locations(1, 1).fetch(0).lineno
28
+ unless value.is_a?(String)
29
+ raise ConfigError, "#{line}: integration must be a String, got #{value.inspect}; use \"rspec\""
30
+ end
31
+
32
+ @values["integration"] = value
33
+ end
34
+
35
+ # Repeatable per-path score gates (#473), the one setting that takes
36
+ # arguments: c.score_gate "app/payments/**/*", min_score: 95. Values
37
+ # are validated later, on the effective Config.
38
+ def score_gate(pattern, min_score:)
39
+ (@values["score_gates"] ||= []) << { "pattern" => pattern, "min_score" => min_score }
40
+ end
41
+
29
42
  Config::INTEGER_KEYS.each do |key|
30
43
  define_method("#{key}=") do |value|
31
44
  unless value.is_a?(Integer)
@@ -33,7 +46,6 @@ module Saboteur
33
46
  raise ConfigError, "#{line}: #{key} must be an Integer, got #{value.inspect}"
34
47
  end
35
48
  @values[key] = value
36
- @lines[key] = caller_locations(1, 1).first.lineno
37
49
  end
38
50
  end
39
51
  end