saboteur 0.8.0 → 0.9.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +44 -6
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/attribute_finder.rb +116 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +2 -1
  7. data/lib/saboteur/cli/agent_provenance.rb +59 -0
  8. data/lib/saboteur/cli/dirty_tree_notice.rb +28 -0
  9. data/lib/saboteur/cli/doctor_command.rb +41 -0
  10. data/lib/saboteur/cli/error_cause.rb +40 -0
  11. data/lib/saboteur/cli/execution_options.rb +46 -0
  12. data/lib/saboteur/cli/exit_status.rb +12 -14
  13. data/lib/saboteur/cli/explicit_file_options.rb +21 -0
  14. data/lib/saboteur/cli/explicit_sources.rb +46 -0
  15. data/lib/saboteur/cli/failure_output.rb +35 -0
  16. data/lib/saboteur/cli/help_command.rb +23 -0
  17. data/lib/saboteur/cli/matrix_option.rb +17 -0
  18. data/lib/saboteur/cli/output_options.rb +27 -0
  19. data/lib/saboteur/cli/presets.rb +41 -1
  20. data/lib/saboteur/cli/preview_command.rb +26 -0
  21. data/lib/saboteur/cli/run_budget_options.rb +6 -1
  22. data/lib/saboteur/cli/run_command.rb +33 -0
  23. data/lib/saboteur/cli/run_output.rb +38 -0
  24. data/lib/saboteur/cli/run_reporting.rb +6 -5
  25. data/lib/saboteur/cli/run_validation.rb +54 -0
  26. data/lib/saboteur/cli/source_set.rb +0 -18
  27. data/lib/saboteur/cli.rb +29 -305
  28. data/lib/saboteur/config.rb +76 -7
  29. data/lib/saboteur/config_file.rb +12 -8
  30. data/lib/saboteur/constant_subject_finder.rb +3 -0
  31. data/lib/saboteur/custom_map.rb +24 -5
  32. data/lib/saboteur/deep_source.rb +49 -0
  33. data/lib/saboteur/dirty_tree_warning.rb +6 -4
  34. data/lib/saboteur/doctor.rb +10 -0
  35. data/lib/saboteur/engine/baseline_guard.rb +118 -15
  36. data/lib/saboteur/engine/baseline_parts.rb +51 -0
  37. data/lib/saboteur/engine/boot.rb +11 -3
  38. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  39. data/lib/saboteur/engine/candidate_selection.rb +16 -5
  40. data/lib/saboteur/engine/candidates.rb +4 -1
  41. data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
  42. data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
  43. data/lib/saboteur/engine/coverage_partial.rb +48 -0
  44. data/lib/saboteur/engine/coverage_parts.rb +50 -0
  45. data/lib/saboteur/engine/coverage_shadow.rb +92 -0
  46. data/lib/saboteur/engine/coverage_store.rb +296 -21
  47. data/lib/saboteur/engine/diagnostics.rb +24 -7
  48. data/lib/saboteur/engine/distribution.rb +2 -2
  49. data/lib/saboteur/engine/gated_run.rb +4 -1
  50. data/lib/saboteur/engine/load_coverage.rb +72 -8
  51. data/lib/saboteur/engine/partial_invalidation.rb +115 -0
  52. data/lib/saboteur/engine/partial_merge.rb +40 -0
  53. data/lib/saboteur/engine/pipelines.rb +11 -3
  54. data/lib/saboteur/engine/reference_specs.rb +71 -0
  55. data/lib/saboteur/engine/result_cache.rb +68 -5
  56. data/lib/saboteur/engine/resume_manifest.rb +110 -0
  57. data/lib/saboteur/engine/run_budget.rb +10 -7
  58. data/lib/saboteur/engine/run_fingerprint.rb +2 -1
  59. data/lib/saboteur/engine/sandbox_policy.rb +26 -5
  60. data/lib/saboteur/engine/scheduler.rb +33 -25
  61. data/lib/saboteur/engine/selection_announcement.rb +9 -3
  62. data/lib/saboteur/engine/shadow_comparison.rb +121 -0
  63. data/lib/saboteur/engine/shadow_run.rb +38 -0
  64. data/lib/saboteur/engine/subject_selection.rb +144 -64
  65. data/lib/saboteur/engine/suite_partition.rb +22 -0
  66. data/lib/saboteur/engine/test_guess.rb +186 -32
  67. data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
  68. data/lib/saboteur/engine.rb +44 -11
  69. data/lib/saboteur/environment_hints.rb +55 -0
  70. data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
  71. data/lib/saboteur/execution/external_runner.rb +123 -1
  72. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  73. data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
  74. data/lib/saboteur/execution/marker_sweep.rb +2 -1
  75. data/lib/saboteur/execution/process_table.rb +7 -3
  76. data/lib/saboteur/execution/reevaluator.rb +7 -0
  77. data/lib/saboteur/execution/sandbox.rb +39 -20
  78. data/lib/saboteur/execution/seatbelt.rb +10 -3
  79. data/lib/saboteur/execution/supervisor.rb +42 -8
  80. data/lib/saboteur/execution/supervisor_batch.rb +105 -0
  81. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  82. data/lib/saboteur/execution/worker_pool.rb +48 -4
  83. data/lib/saboteur/frameworks/rails.rb +2 -5
  84. data/lib/saboteur/frameworks/sqlite_memory_database.rb +32 -2
  85. data/lib/saboteur/gates.rb +121 -28
  86. data/lib/saboteur/install.rb +6 -1
  87. data/lib/saboteur/integration/base.rb +19 -2
  88. data/lib/saboteur/integration/rspec.rb +145 -18
  89. data/lib/saboteur/line_hits.rb +81 -0
  90. data/lib/saboteur/method_calls.rb +91 -0
  91. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  92. data/lib/saboteur/mutation_source/builtin.rb +33 -21
  93. data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
  94. data/lib/saboteur/mutation_source/hazard_guard.rb +94 -13
  95. data/lib/saboteur/mutation_source/jump_value.rb +59 -0
  96. data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
  97. data/lib/saboteur/mutation_source/operator_examples.rb +6 -0
  98. data/lib/saboteur/mutation_source/operator_registry.rb +6 -0
  99. data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
  100. data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
  101. data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
  102. data/lib/saboteur/mutation_source/when_removal.rb +51 -0
  103. data/lib/saboteur/preview.rb +13 -0
  104. data/lib/saboteur/refinement_activations.rb +22 -10
  105. data/lib/saboteur/reporter/agent.rb +2 -2
  106. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  107. data/lib/saboteur/reporter/agent_summary.rb +16 -10
  108. data/lib/saboteur/reporter/review_comments.rb +151 -0
  109. data/lib/saboteur/reporter/safe_out.rb +32 -0
  110. data/lib/saboteur/reporter/stryker.rb +8 -6
  111. data/lib/saboteur/reporter/survivor_classification.rb +41 -3
  112. data/lib/saboteur/reporter/survivor_hints.rb +5 -0
  113. data/lib/saboteur/reporter/terminal.rb +45 -5
  114. data/lib/saboteur/result.rb +14 -4
  115. data/lib/saboteur/selection/coverage_map.rb +150 -9
  116. data/lib/saboteur/selection/external_test_map.rb +4 -0
  117. data/lib/saboteur/selection/git_diff.rb +24 -2
  118. data/lib/saboteur/selection/layout_only_lines.rb +45 -1
  119. data/lib/saboteur/selection/recorder.rb +52 -22
  120. data/lib/saboteur/selection/trace_recorder.rb +83 -0
  121. data/lib/saboteur/selection/untracked_changes.rb +1 -2
  122. data/lib/saboteur/subject.rb +16 -0
  123. data/lib/saboteur/subject_finder.rb +91 -6
  124. data/lib/saboteur/text.rb +31 -0
  125. data/lib/saboteur/version.rb +1 -1
  126. data/lib/saboteur/working_tree.rb +7 -2
  127. data/lib/saboteur.rb +9 -0
  128. metadata +48 -16
data/lib/saboteur/cli.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "doctor"
8
8
  require_relative "help"
9
9
  require_relative "reporter/replay"
10
10
  require_relative "baseline/command"
11
+ require_relative "cli/error_cause"
11
12
  require_relative "cli/exit_status"
12
13
  require_relative "preview"
13
14
  require_relative "cli/run_reporting"
@@ -19,6 +20,20 @@ require_relative "cli/selection_options"
19
20
  require_relative "cli/presets"
20
21
  require_relative "cli/help_options"
21
22
  require_relative "cli/history_command"
23
+ require_relative "cli/execution_options"
24
+ require_relative "cli/matrix_option"
25
+ require_relative "cli/output_options"
26
+ require_relative "cli/explicit_file_options"
27
+ require_relative "cli/doctor_command"
28
+ require_relative "cli/agent_provenance"
29
+ require_relative "cli/preview_command"
30
+ require_relative "cli/help_command"
31
+ require_relative "cli/run_validation"
32
+ require_relative "cli/explicit_sources"
33
+ require_relative "cli/run_output"
34
+ require_relative "cli/dirty_tree_notice"
35
+ require_relative "cli/run_command"
36
+ require_relative "cli/failure_output"
22
37
 
23
38
  module Saboteur
24
39
  class CLI
@@ -32,7 +47,21 @@ module Saboteur
32
47
  include SelectionOptions
33
48
  include Presets
34
49
  include HistoryCommand
50
+ include ExecutionOptions
51
+ include MatrixOption
52
+ include OutputOptions
53
+ include ExplicitFileOptions
54
+ include DoctorCommand
55
+ include AgentProvenance
35
56
  include HelpOptions
57
+ include PreviewCommand
58
+ include HelpCommand
59
+ include RunValidation
60
+ include ExplicitSources
61
+ include RunOutput
62
+ include DirtyTreeNotice
63
+ include RunCommand
64
+ include FailureOutput
36
65
 
37
66
  USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--all] [--threshold PERCENT] [--since REF] " \
38
67
  "[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
@@ -96,319 +125,14 @@ module Saboteur
96
125
  end
97
126
  end
98
127
 
99
- def doctor_parser
100
- OptionParser.new do |opts|
101
- opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
102
- opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
103
- opts.on("--seed N", Integer, "Seed for the two baseline runs (default 1)") { |value| set(:seed, value) }
104
- opts.on("--suite", "Also run the unchanged suite twice (slow on a large suite)") { set(:suite, true) }
105
- opts.on("--test PATH", "Spec file or directory for the suite runs; repeatable. Implies --suite") do |value|
106
- (options[:tests] ||= []) << value
107
- end
108
- end
109
- end
110
-
111
128
  private
112
129
 
113
- def run_command = help_requested? ? show_help("run") : run
114
-
115
- def doctor_command = help_requested? ? show_help("doctor") : doctor
116
-
117
130
  def history_entry(command) = help_requested? ? show_help(command) : history_command(command)
118
131
 
119
- def help_requested? = @argv.intersect?(%w[--help -h])
120
-
121
- def show_help(command_name)
122
- help = Help.new(option_lines: option_lines)
123
- text = command_name ? help.command(command_name) : help.overview
124
- return @out.puts(text) || EXIT_OK if text
125
-
126
- @err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
127
- EXIT_USAGE
128
- end
129
-
130
- def usage_error(message = nil)
131
- @err.puts(message) if message
132
- @err.puts(USAGE)
133
- agent_failure(message || USAGE, EXIT_USAGE)
134
- end
135
-
136
- def agent? = @format == "agent"
137
-
138
132
  def agent_requested? = self.class.agent_requested?(@argv)
139
133
 
140
- # In agent mode the failure is also on stdout, where the agent reads its answer.
141
- def agent_failure(message, exit_code)
142
- @out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
143
- exit_code
144
- end
145
-
146
- def run
147
- parser.parse!(@argv)
148
- reject_test_selection_conflicts
149
- use_explicit_sources
150
- validate_run_options
151
- warn_dirty_tree
152
-
153
- config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
154
-
155
- return preview if options[:list_operators] || options[:dry_run]
156
-
157
- refuse_empty_source_set
158
- refuse_empty_since_range
159
- execute_run
160
- rescue ConfigError => e
161
- @err.puts("saboteur: #{e.message}")
162
- agent_failure(e.message, EXIT_USAGE)
163
- rescue Error => e
164
- @err.puts("saboteur: #{e.message}")
165
- agent_failure(e.message, EXIT_TOOL)
166
- end
167
-
168
- # Mutants run your code, and a mutant of code that writes or deletes files
169
- # can change the tree. Say so up front when uncommitted work is at stake.
170
- # The scope the warning names is the configured one: positional source
171
- # operands narrow the run after the warning, so the include/exclude they
172
- # imply stay out of its config (#309, #503 review).
173
- def warn_dirty_tree
174
- warning = @sources ? Config.load(path: options[:config], overrides: overrides.except(:include, :exclude)) : config
175
- DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
176
- end
177
-
178
- # Contradictory selection flags are a usage error before any file or
179
- # repository check: --test wins over inference by precedence, but only
180
- # one of them may be named. Config-file combinations are not errors:
181
- # --test wins there, so generated commands stay valid over any config.
182
- def reject_test_selection_conflicts
183
- raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
184
-
185
- reject_test_command_conflicts
186
- raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
187
- return unless options[:guess_tests] && options[:tests]&.any?
188
-
189
- raise UsageError, "--guess-tests cannot be combined with --test"
190
- end
191
-
192
- # --test-command replaces the whole in-process test machinery: test
193
- # selection, coverage, parallelism and the sandbox do not apply.
194
- def reject_test_command_conflicts
195
- return unless options[:test_command]
196
-
197
- # options[:tests] is nil or a non-empty list, so truthiness is the check.
198
- raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
199
- raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
200
- end
201
-
202
- # Usage errors found before the target boots.
203
- def validate_run_options
204
- reject_format_conflicts
205
- validate_shard_options
206
- Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
207
- end
208
-
209
- # Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
210
- def reject_format_conflicts
211
- if agent? && (config["threshold"] || gates.any?)
212
- raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
213
- "cannot be combined with --agent"
214
- end
215
- return unless @format == "json" && !options[:dry_run]
216
-
217
- raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
218
- end
219
-
220
- # One line naming the reports written, paths relative to the root when
221
- # they sit under it (a blank line before it, as the old JSON line had).
222
- def announce_reports(reports)
223
- return if (written = reports.select { |path, _label| path }).empty?
224
-
225
- root = File.join(File.realpath(config.root), "")
226
- names = written.map do |path, label|
227
- shown = File.expand_path(path).delete_prefix(root)
228
- "#{shown} (#{label})"
229
- end
230
- @out.puts "\nReports: #{names.join(', ')}"
231
- end
232
-
233
- def doctor
234
- doctor_parser.parse!(@argv)
235
- raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
236
-
237
- options[:tests]&.each { |test| require_existing_test(test) }
238
- Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
239
- EXIT_OK
240
- rescue ConfigError => e
241
- @err.puts("saboteur: #{e.message}")
242
- EXIT_USAGE
243
- rescue Error => e
244
- @err.puts("saboteur: #{e.message}")
245
- EXIT_TOOL
246
- end
247
-
248
- # Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
249
- # files, hooks, spec loading, child processes) goes to stderr while the engine runs.
250
- # Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
251
- # children cannot write into the JSON on stdout.
252
- def stdout_on_stderr
253
- saved = $stdout.dup
254
- $stdout.reopen($stderr)
255
- yield
256
- ensure
257
- $stdout.reopen(saved) if saved
258
- saved&.close
259
- end
260
-
261
- def agent_provenance(report, tree, engine)
262
- Reporter::Agent::Provenance.new(
263
- ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
264
- changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
265
- subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
266
- sources: @sources || [],
267
- config_path: options[:config], digest: options[:id], cache: config["cache"],
268
- incremental: config["incremental"], settings: agent_settings, typed_settings: typed_settings
269
- )
270
- end
271
-
272
- # The settings keys typed on the command line: a typed empty operator
273
- # list may clear a config value, so the generated commands keep it.
274
- def typed_settings = options.keys & Reporter::Agent::Commands::SETTING_FLAGS.keys
275
-
276
- # A whole-suite run records its scope as --all, so the generated rerun
277
- # and replay commands do not default back to inference (which can exit
278
- # 2 in a project with a spec-less source file).
279
- def agent_settings
280
- settings = options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
281
- .merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
282
- # The effective mode: named tests win over a configured whole-suite
283
- # selection by precedence, so all is recorded only when no tests
284
- # resolved - otherwise the follow-up commands would carry both --all
285
- # and --test, which the CLI rejects.
286
- settings[:all] = true if (config["all"] || config["test_selection"] == "all") && config["tests"].empty?
287
- # CLI-only switches that change candidate availability or boot, so a
288
- # printed rerun/replay must keep them (config-file settings like
289
- # arid/hazard patterns, whole-file, require, rspec_args and rails ride
290
- # along through --config or the default config path).
291
- settings[:allow_hazardous] = true if options[:allow_hazardous]
292
- settings[:allow_focus] = true if options[:allow_focus]
293
- # An explicit false (--no-max-duration-ok over a config true) is a
294
- # setting too: dropping it would silently re-enable the config value.
295
- settings[:max_duration_ok] = options[:max_duration_ok] unless options[:max_duration_ok].nil?
296
- settings
297
- end
298
-
299
- def agent_mode
300
- return "replay" if options[:id]
301
- return "diff" if config["since"]
302
-
303
- @sources&.any? ? "files" : "full"
304
- end
305
-
306
- # Positional arguments name the files (or directories) to mutate. They
307
- # replace the configured include and exclude patterns. Each must exist,
308
- # so a typo is an error and not a run over zero subjects.
309
- def use_explicit_sources
310
- Selection::SourceScope.verify_no_specs!(@argv)
311
- options[:tests]&.each { |test| require_existing_test(test) }
312
- # --test may stand alone: the configured include (or the --since
313
- # diff) names the sources. The files must still exist.
314
- return if @argv.empty?
315
-
316
- @sources = @argv.dup
317
- options[:include] = @argv.map { |source| source_pattern(source) }
318
- options[:exclude] = []
319
- end
320
-
321
- def source_pattern(source)
322
- return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
323
- raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
324
- raise UsageError, "source file not found: #{source}" unless File.file?(source)
325
-
326
- glob_escape(source)
327
- end
328
-
329
- # include is a list of glob patterns; a named file must match only itself.
330
- def glob_escape(path)
331
- path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
332
- end
333
-
334
- # A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
335
- def require_existing_test(test)
336
- path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
337
- raise UsageError, "test file not found: #{test}" unless File.exist?(path)
338
- end
339
-
340
134
  def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
341
135
 
342
- def preview
343
- raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
344
- raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
345
-
346
- if options[:list_operators]
347
- MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
348
- return EXIT_OK
349
- end
350
-
351
- Preview.new(config, out: @out, err: @err, format: @format).call
352
- EXIT_OK
353
- end
354
-
355
- def define_execution_options(opts)
356
- opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
357
- opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
358
- add_run_budget_options(opts)
359
- opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
360
- opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
361
- opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
362
- opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
363
- opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
364
- set(:allow_hazardous, true)
365
- end
366
- opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
367
- opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
368
- opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
369
- set(:allow_focus, true)
370
- end
371
- opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
372
- "mutated files") { set(:all, true) }
373
- opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
374
- "For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
375
- set(:test_command, v)
376
- end
377
- opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
378
- "--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
379
- set(:guess_tests, v)
380
- end
381
- end
382
-
383
- def define_matrix_option(opts)
384
- opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
385
- set(:matrix, true)
386
- end
387
- end
388
-
389
- def define_output_options(opts)
390
- opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
391
- opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
392
- opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
393
- opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
394
- opts.on("--agent", "Same as --format agent") { @format = "agent" }
395
- opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
396
- "a crash prints its backtrace") { @verbose = true }
397
- opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
398
- @color = v ? :always : :never
399
- end
400
- end
401
-
402
- def define_explicit_file_options(opts)
403
- opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
404
- (options[:tests] ||= []) << v
405
- end
406
- opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
407
- set(:threshold, v)
408
- end
409
- Gates.define_options(opts) { |key, value| set(key, value) }
410
- end
411
-
412
136
  def add_subject(expr) = (options[:subjects] ||= []) << expr
413
137
 
414
138
  def set(key, value) = (options[key] = value)
@@ -18,6 +18,10 @@ module Saboteur
18
18
  LEGACY_IGNORE_FILE = ".saboteur-ignore.yml"
19
19
  IGNORE_FILE = "config/saboteur_ignore.rb"
20
20
 
21
+ # Settings that say where saboteur writes, never what it mutates: left
22
+ # out of the run fingerprint, baseline evidence and score-history scope.
23
+ OUTPUT_KEYS = %w[cache_dir json_report html_report score_history resume].freeze
24
+
21
25
  DEFAULTS = {
22
26
  "integration" => "rspec",
23
27
  "require" => [],
@@ -69,7 +73,15 @@ module Saboteur
69
73
  "subjects" => [],
70
74
  "since" => nil,
71
75
  "cache" => true,
76
+ # Experimental (#46): "shadow" also builds a partial coverage map after a
77
+ # cache miss and compares it with the full map, which the run still uses.
78
+ # "use" uses the partial map when it is safe and builds the full map otherwise.
79
+ "partial_coverage" => "off",
80
+ # coverage (default) reads Coverage around every test; trace gets lines and
81
+ # methods from a TracePoint and reads branches once per spec file (#725).
82
+ "coverage_recorder" => "coverage",
72
83
  "incremental" => false,
84
+ "resume" => nil,
73
85
  "shard" => nil,
74
86
  "allow_focus" => false,
75
87
  "fail_fast" => false,
@@ -79,6 +91,8 @@ module Saboteur
79
91
  "matrix" => false,
80
92
  "seed" => 1,
81
93
  "baseline_passes" => 2,
94
+ # Parallel children for the coverage-map build (#741); 1 keeps the single child.
95
+ "coverage_parts" => 1,
82
96
  "max_duration" => nil,
83
97
  "max_duration_ok" => false,
84
98
  "tests" => [],
@@ -86,11 +100,16 @@ module Saboteur
86
100
  "fingerprint_extra" => [],
87
101
  "threshold" => nil,
88
102
  "max_timeouts" => nil,
89
- "max_errors" => nil
103
+ "max_errors" => nil,
104
+ # --verbose also lands here, so boot-time output filters (the rspec
105
+ # load-error report, #608) can keep their raw text behind the flag.
106
+ "verbose" => false
90
107
  }.freeze
91
108
 
92
- INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes].freeze
109
+ INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes
110
+ coverage_parts].freeze
93
111
  FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
112
+ STRING_KEYS = %w[test_selection partial_coverage coverage_recorder].freeze
94
113
  NILABLE_FLOAT_KEYS = %w[threshold max_duration].freeze
95
114
  THRESHOLD_RANGE = (0.0..100.0)
96
115
 
@@ -212,10 +231,29 @@ module Saboteur
212
231
  # Paths of saboteur's own output, relative to the root, that do not count as source changes.
213
232
  def output_paths
214
233
  prefix = File.join(File.realpath(root), "")
215
- %w[cache_dir json_report html_report score_history].filter_map { |key| path(key) }
216
- .map { |file| File.expand_path(file) }
217
- .select { |file| file.start_with?(prefix) }
218
- .map { |file| file.delete_prefix(prefix) }
234
+ OUTPUT_KEYS.filter_map { |key| path(key) }
235
+ .select { |file| file.start_with?(prefix) }
236
+ .map { |file| file.delete_prefix(prefix) }
237
+ end
238
+
239
+ # Tool-owned paths, relative to the root, that never count as
240
+ # working-tree changes (#638): the run's output, plus an in-repo
241
+ # BUNDLE_GEMFILE sidecar and its lock - the documented trial setup
242
+ # should not read as a dirty tree. Path-exact, never a glob, so a
243
+ # real untracked Gemfile.lock still counts.
244
+ def ignored_tree_paths(gemfile: ENV.fetch("BUNDLE_GEMFILE", nil))
245
+ prefix = File.join(File.realpath(root), "")
246
+ sidecar = gemfile && File.expand_path(gemfile, prefix)
247
+ begin
248
+ sidecar &&= File.join(File.realpath(File.dirname(sidecar)), File.basename(sidecar))
249
+ rescue SystemCallError
250
+ # The sidecar's directory does not exist; keep the expanded spelling.
251
+ end
252
+ paths = output_paths
253
+ if sidecar&.start_with?(prefix) && sidecar != File.join(prefix, "Gemfile")
254
+ paths.concat([sidecar, DirtyTreeWarning.lockfile_for(sidecar)].map { |file| file.delete_prefix(prefix) })
255
+ end
256
+ paths
219
257
  end
220
258
 
221
259
  private
@@ -236,11 +274,28 @@ module Saboteur
236
274
  FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
237
275
  LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
238
276
  NILABLE_FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) unless @values[key].nil? }
239
- @values["test_selection"] = @values["test_selection"].to_s
277
+ STRING_KEYS.each { |key| @values[key] = @values[key].to_s }
240
278
  rescue ArgumentError, TypeError => e
241
279
  raise ConfigError, "invalid config value: #{e.message}"
242
280
  end
243
281
 
282
+ def validate_partial_coverage!
283
+ return if %w[off shadow use].include?(self["partial_coverage"])
284
+
285
+ raise ConfigError, "partial_coverage must be one of: off, shadow, use"
286
+ end
287
+
288
+ def validate_coverage_settings!
289
+ validate_partial_coverage!
290
+ validate_coverage_recorder!
291
+ end
292
+
293
+ def validate_coverage_recorder!
294
+ return if %w[coverage trace].include?(self["coverage_recorder"])
295
+
296
+ raise ConfigError, "coverage_recorder must be one of: coverage, trace"
297
+ end
298
+
244
299
  def validate_mutant_limit!
245
300
  raise ConfigError, "max_mutants_per_method must be > 0" unless self["max_mutants_per_method"].positive?
246
301
  end
@@ -249,6 +304,7 @@ module Saboteur
249
304
  def validate_baseline!
250
305
  raise ConfigError, "seed must be >= 0" if self["seed"].negative?
251
306
  raise ConfigError, "baseline_passes must be >= 1" if self["baseline_passes"] < 1
307
+ raise ConfigError, "coverage_parts must be >= 1" if self["coverage_parts"] < 1
252
308
  raise ConfigError, "max_duration must be > 0" if self["max_duration"] && !self["max_duration"].positive?
253
309
  end
254
310
 
@@ -267,6 +323,7 @@ module Saboteur
267
323
  def validate_modes!
268
324
  validate_fail_fast!
269
325
  validate_matrix!
326
+ validate_resume!
270
327
  validate_test_selection!
271
328
  validate_gates!
272
329
  validate_score_gates!
@@ -324,6 +381,17 @@ module Saboteur
324
381
  raise ConfigError, "matrix cannot be combined with incremental: reused verdicts list only the first killer"
325
382
  end
326
383
 
384
+ # --resume DIR keeps verdicts in DIR for a later run (#471); it reuses
385
+ # them the way --incremental does, so the same limits apply.
386
+ def validate_resume!
387
+ resume = self["resume"]
388
+ return if resume.nil?
389
+ raise ConfigError, "resume must be a non-blank directory path" unless resume.is_a?(String) && !resume.strip.empty?
390
+ return unless self["matrix"] || !self["test_command"].to_s.empty?
391
+
392
+ raise ConfigError, "resume cannot be combined with matrix or test_command: reused verdicts are not valid there"
393
+ end
394
+
327
395
  def validate_threshold!
328
396
  threshold = self["threshold"]
329
397
  return if threshold.nil? || THRESHOLD_RANGE.cover?(threshold)
@@ -373,6 +441,7 @@ module Saboteur
373
441
  raise ConfigError, "flaky_check must be one of: off, survivors, decisive"
374
442
  end
375
443
 
444
+ validate_coverage_settings!
376
445
  validate_mutant_limit!
377
446
  validate_threshold!
378
447
  validate_modes!
@@ -9,29 +9,34 @@ 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
+
29
35
  # Repeatable per-path score gates (#473), the one setting that takes
30
36
  # arguments: c.score_gate "app/payments/**/*", min_score: 95. Values
31
37
  # are validated later, on the effective Config.
32
38
  def score_gate(pattern, min_score:)
33
39
  (@values["score_gates"] ||= []) << { "pattern" => pattern, "min_score" => min_score }
34
- @lines["score_gates"] = caller_locations(1, 1).first.lineno
35
40
  end
36
41
 
37
42
  Config::INTEGER_KEYS.each do |key|
@@ -41,7 +46,6 @@ module Saboteur
41
46
  raise ConfigError, "#{line}: #{key} must be an Integer, got #{value.inspect}"
42
47
  end
43
48
  @values[key] = value
44
- @lines[key] = caller_locations(1, 1).first.lineno
45
49
  end
46
50
  end
47
51
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "prism"
5
5
  require_relative "subject"
6
+ require_relative "deep_source"
6
7
  require_relative "constant_subject"
7
8
 
8
9
  module Saboteur
@@ -47,6 +48,8 @@ module Saboteur
47
48
  result = Prism.parse(source)
48
49
  return Parsed.new(subjects: [], unsupported: [], errors: result.errors.map(&:message)) if result.failure?
49
50
 
51
+ DeepSource.check!(result.value, file)
52
+
50
53
  ctx = { file: file, source: source, frozen: frozen_literals?(result) }
51
54
  out = { subjects: [], unsupported: [], ambiguous: [] }
52
55
  walk(result.value, [], out, **ctx)
@@ -64,15 +64,27 @@ module Saboteur
64
64
  unless spec.is_a?(String)
65
65
  raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; spec paths must be Strings"
66
66
  end
67
- return if key.is_a?(Regexp) && spec.match?(/\\\d/) # backreference filled in at match time
68
67
 
68
+ numbered = key.is_a?(Regexp) && spec.match?(/\\\d/)
69
+ named = key.is_a?(Regexp) && spec.match?(/%[-0-9]*[<{][^>}]+[>}]|\\k</)
70
+ # Existence is deliberately not checked here: a stale or mistyped
71
+ # entry warns at guess time and the naming convention takes over,
72
+ # instead of the whole run dying at boot on one bad line (#640).
73
+ # The root check comes first: no template, backreference or named,
74
+ # may skip it (#712).
69
75
  expanded = File.expand_path(spec, root)
70
76
  unless expanded.start_with?("#{root}/")
71
77
  raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which is outside the project root"
72
78
  end
73
- return if Dir.glob(expanded).any?
79
+ if numbered && named
80
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; " \
81
+ 'regex map specs support \1 backreferences only'
82
+ end
83
+ return if numbered # backreference filled in at match time
84
+ return unless named
74
85
 
75
- raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which matches no file"
86
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; " \
87
+ 'regex map specs support \1 backreferences only'
76
88
  end
77
89
 
78
90
  private_class_method :validate_entry, :validate_spec
@@ -149,14 +161,14 @@ module Saboteur
149
161
  return :excluded if matches.all? { |entry, _| entry.specs.empty? }
150
162
 
151
163
  matches.reject { |entry, _| entry.specs.empty? }
152
- .flat_map { |entry, match| resolve(entry, match) }
164
+ .flat_map { |entry, match| resolve(entry, match, file) }
153
165
  end
154
166
 
155
167
  def specificity(key)
156
168
  key.count("^*?[]{}\\")
157
169
  end
158
170
 
159
- def resolve(entry, match)
171
+ def resolve(entry, match, file = nil)
160
172
  return :excluded if entry.specs.empty?
161
173
 
162
174
  specs = entry.specs
@@ -165,6 +177,13 @@ module Saboteur
165
177
  # that is never a real spec path, so drop it here rather than letting
166
178
  # a junk entry reach the run.
167
179
  specs = specs.map { |spec| spec.gsub(/\\(\d+)/) { match[::Regexp.last_match(1).to_i].to_s } }.reject(&:empty?)
180
+ # A capture can carry "../" segments the template check never saw:
181
+ # refuse a filled spec that escapes the root (#712).
182
+ escaped = specs.reject { |spec| File.expand_path(spec, @root).start_with?("#{@root}/") }
183
+ if escaped.any?
184
+ raise ConfigError,
185
+ "#{entry.key.inspect} maps #{file} to #{escaped.first.inspect}, which is outside the project root"
186
+ end
168
187
  end
169
188
  specs
170
189
  end