saboteur 0.6.0 → 0.7.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +166 -40
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +49 -4
  8. data/lib/saboteur/cli/help_options.rb +16 -0
  9. data/lib/saboteur/cli/presets.rb +36 -0
  10. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  11. data/lib/saboteur/cli/run_reporting.rb +74 -5
  12. data/lib/saboteur/cli.rb +67 -46
  13. data/lib/saboteur/config.rb +66 -11
  14. data/lib/saboteur/crash_report.rb +154 -0
  15. data/lib/saboteur/custom_map.rb +3 -1
  16. data/lib/saboteur/doctor.rb +40 -1
  17. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  18. data/lib/saboteur/engine/boot.rb +12 -1
  19. data/lib/saboteur/engine/canary.rb +41 -10
  20. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  21. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  22. data/lib/saboteur/engine/coverage_store.rb +48 -6
  23. data/lib/saboteur/engine/diagnostics.rb +23 -2
  24. data/lib/saboteur/engine/diff_scope.rb +24 -5
  25. data/lib/saboteur/engine/distribution.rb +124 -8
  26. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  27. data/lib/saboteur/engine/result_cache.rb +2 -1
  28. data/lib/saboteur/engine/run_budget.rb +63 -0
  29. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  30. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  31. data/lib/saboteur/engine/scheduler.rb +114 -18
  32. data/lib/saboteur/engine/subject_selection.rb +84 -5
  33. data/lib/saboteur/engine/suite_files.rb +3 -1
  34. data/lib/saboteur/engine/test_guess.rb +145 -22
  35. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  36. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  37. data/lib/saboteur/engine/verdict_check.rb +54 -18
  38. data/lib/saboteur/engine.rb +166 -95
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/runner.rb +179 -17
  41. data/lib/saboteur/execution/sandbox.rb +6 -1
  42. data/lib/saboteur/execution/supervisor.rb +18 -3
  43. data/lib/saboteur/execution/worker_pool.rb +87 -7
  44. data/lib/saboteur/help.rb +55 -11
  45. data/lib/saboteur/ignore_list.rb +11 -1
  46. data/lib/saboteur/install.rb +4 -3
  47. data/lib/saboteur/integration/base.rb +7 -2
  48. data/lib/saboteur/integration/rspec.rb +40 -4
  49. data/lib/saboteur/mutation_source/builtin.rb +20 -11
  50. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  51. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  52. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  53. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  54. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  55. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  56. data/lib/saboteur/mutation_source/operator_registry.rb +4 -1
  57. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  58. data/lib/saboteur/preview.rb +2 -1
  59. data/lib/saboteur/reporter/agent_commands.rb +38 -13
  60. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  61. data/lib/saboteur/reporter/next_steps.rb +14 -4
  62. data/lib/saboteur/reporter/score_history.rb +5 -1
  63. data/lib/saboteur/reporter/stryker.rb +13 -4
  64. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  65. data/lib/saboteur/reporter/terminal.rb +34 -10
  66. data/lib/saboteur/result.rb +22 -7
  67. data/lib/saboteur/selection/coverage_map.rb +2 -1
  68. data/lib/saboteur/selection/git_diff.rb +15 -5
  69. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  70. data/lib/saboteur/subject.rb +11 -0
  71. data/lib/saboteur/text.rb +42 -0
  72. data/lib/saboteur/version.rb +1 -1
  73. data/lib/saboteur.rb +16 -1
  74. metadata +17 -2
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The run's clock and repeatability switches: seed, baseline check count
7
+ # (#55) and the wall-clock budget (#245). Grouped so the option table in
8
+ # the CLI stays under the house size caps.
9
+ module RunBudgetOptions
10
+ private
11
+
12
+ def add_run_budget_options(opts)
13
+ opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
14
+ set(:seed, v)
15
+ end
16
+ opts.on("--baseline-passes N", Integer, "Unchanged-suite checks (default 2)") { |v| set(:baseline_passes, v) }
17
+ opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent") do |v|
18
+ set(:max_duration, parse_duration(v))
19
+ end
20
+ opts.on("--[no-]max-duration-ok", "An exhausted --max-duration budget does not fail the run") do |v|
21
+ set(:max_duration_ok, v)
22
+ end
23
+ end
24
+
25
+ # "10m" -> 600.0; bare numbers are seconds. A bad value is a usage error.
26
+ def parse_duration(value)
27
+ match = /\A(?<amount>\d+(?:\.\d+)?)(?<unit>[smh])?\z/.match(value.to_s.strip)
28
+ unless match
29
+ raise ConfigError,
30
+ "invalid --max-duration #{value.inspect}: use seconds or an s/m/h suffix, e.g. 10m"
31
+ end
32
+
33
+ match[:amount].to_f * { "s" => 1, "m" => 60, "h" => 3600, nil => 1 }.fetch(match[:unit])
34
+ end
35
+ end
36
+ end
37
+ end
@@ -10,9 +10,10 @@ module Saboteur
10
10
  module RunReporting
11
11
  private
12
12
 
13
- def text_run(report)
13
+ def text_run(report, engine)
14
14
  @err.puts
15
- Reporter::Terminal.new(@out, verbose: @verbose || false, color: @color || :auto).call(report)
15
+ Reporter::Terminal.new(@out, verbose: @verbose || false, color: @color || :auto,
16
+ replay_command: survivor_replay_command(report, engine)).call(report)
16
17
  Reporter::Replay.new(@out).call(report) if options[:id]
17
18
  code = exit_code(report)
18
19
  # The JSON carries the exit cause an outer harness trusts, so it is
@@ -50,7 +51,65 @@ module Saboteur
50
51
  validate_history
51
52
  arguments = { err: @err }
52
53
  arguments[:digest] = options[:id] if options[:id]
53
- agent? ? agent_run(arguments) : text_run(Engine.new(config, **arguments).call)
54
+ return agent_run(arguments) if agent?
55
+
56
+ # Text mode only: the engine prints a per-subject marks line when
57
+ # --verbose is on (#262); --agent output stays as it is.
58
+ engine = Engine.new(config, **arguments, verbose: @verbose || false)
59
+ text_run(engine.call, engine)
60
+ end
61
+
62
+ # The full replay command for a survivor digest: this run's own
63
+ # selection options, assembled by the same code that writes the agent
64
+ # follow-ups, so the user never reassembles them by hand (#261).
65
+ # Computed lazily: only a run with survivors asks for it.
66
+ def survivor_replay_command(report, engine)
67
+ lambda do |digest|
68
+ tree = WorkingTree.new(config.root, ignored: config.output_paths).state
69
+ Reporter::Agent::Commands.replay(agent_provenance(report, tree, engine), digest, capped: false)
70
+ end
71
+ end
72
+
73
+ # Writes the JSON report and returns its path, or nil when none is configured.
74
+ #
75
+ # An explicitly requested JSON report (--json, c.json_report) is the
76
+ # completion evidence an outer harness trusts: a failed write fails
77
+ # the run (exit 3), because a warning with exit 0 could leave a stale
78
+ # or partial file at the path and present old results as this run's.
79
+ # The default .saboteur/report.json is best-effort like the HTML
80
+ # report and the score history (#296).
81
+ def write_json(report, cause)
82
+ path = config.path("json_report")
83
+ return unless path
84
+
85
+ Reporter::Stryker.new(path).call(report, exit_cause: cause)
86
+ path
87
+ rescue SystemCallError, IOError => e
88
+ if config.explicit?("json_report")
89
+ raise Error, "could not write the JSON report to #{path}: #{e.message} - " \
90
+ "any file at that path may be stale or incomplete"
91
+ end
92
+
93
+ warn_report_write_failure("JSON report", path, e)
94
+ end
95
+
96
+ def write_html(report)
97
+ path = config.path("html_report")
98
+ return unless path
99
+
100
+ Reporter::Html.new(path).call(report)
101
+ path
102
+ rescue SystemCallError, IOError => e
103
+ warn_report_write_failure("HTML report", path, e)
104
+ end
105
+
106
+ # An auxiliary report artifact that cannot be written - the tree is
107
+ # read-only inside a sandboxed child, as in the dogfood's nested runs
108
+ # (#296) - is a warning, not a run failure: the exit code belongs to
109
+ # the results. The JSON report is not auxiliary; see write_json.
110
+ def warn_report_write_failure(what, path, error)
111
+ @err.puts "saboteur: could not write the #{what} to #{path} (#{error.message}); continuing without it"
112
+ nil
54
113
  end
55
114
 
56
115
  def validate_history
@@ -67,8 +126,18 @@ module Saboteur
67
126
 
68
127
  tree = WorkingTree.new(config.root, ignored: config.output_paths).state
69
128
  scope = config.selection_settings.except("json_report", "html_report", "score_history", "cache_dir")
70
- message = Reporter::ScoreHistory.new(path).append(report, head: tree.head, scope: scope)
71
- @err.puts("saboteur: #{message}")
129
+ # Recording is an auxiliary artifact: when the file cannot be
130
+ # written - read-only tree inside a sandboxed child (#296) - warn
131
+ # and let the run exit by its results instead of failing as a tool
132
+ # error. Only the write's IO failures are soft: validation,
133
+ # integrity, record-size and lock failures (Saboteur::Error) stay
134
+ # hard, because they say the history is unusable, not unwritable.
135
+ message = begin
136
+ Reporter::ScoreHistory.new(path).append(report, head: tree.head, scope: scope)
137
+ rescue SystemCallError, IOError => e
138
+ warn_report_write_failure("score history", path, e)
139
+ end
140
+ @err.puts("saboteur: #{message}") if message
72
141
  end
73
142
  end
74
143
  end
data/lib/saboteur/cli.rb CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "optparse"
5
5
  require_relative "../saboteur"
6
+ require_relative "crash_report"
6
7
  require_relative "doctor"
7
8
  require_relative "help"
8
9
  require_relative "reporter/replay"
@@ -10,14 +11,20 @@ require_relative "baseline/command"
10
11
  require_relative "cli/exit_status"
11
12
  require_relative "preview"
12
13
  require_relative "cli/run_reporting"
14
+ require_relative "cli/run_budget_options"
15
+ require_relative "cli/presets"
16
+ require_relative "cli/help_options"
13
17
 
14
18
  module Saboteur
15
19
  class CLI
16
20
  include Baseline::Command
17
21
  include ExitStatus
18
22
  include RunReporting
23
+ include RunBudgetOptions
24
+ include Presets
25
+ include HelpOptions
19
26
 
20
- USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--threshold PERCENT] [--since REF] " \
27
+ USAGE = "Usage: saboteur run [SOURCE...] [--test TEST...] [--all] [--threshold PERCENT] [--since REF] " \
21
28
  "[--subject 'Klass#meth'] [--timeout-factor N] [--jobs N] [--seed N] [--id DIGEST]
22
29
  " \
23
30
  "Run 'saboteur help' for every command, option, config key and exit code."
@@ -26,10 +33,18 @@ module Saboteur
26
33
  EXIT_SURVIVORS = 1
27
34
  EXIT_USAGE = 2
28
35
  EXIT_TOOL = 3
36
+ EXIT_BUDGET = 4
29
37
  class UsageError < ConfigError; end
30
38
 
31
39
  attr_reader :options
32
40
 
41
+ # Found before parsing, so any error, even a bad option that comes first, still
42
+ # answers an agent with JSON. Class-level: the executable's fallback reads raw arguments.
43
+ def self.agent_requested?(argv)
44
+ argv.include?("--agent") || argv.include?("--format=agent") ||
45
+ argv.each_cons(2).any? { |flag, value| flag == "--format" && value == "agent" }
46
+ end
47
+
33
48
  def initialize(argv, out: $stdout, err: $stderr)
34
49
  @argv = argv.dup
35
50
  @out = out
@@ -39,18 +54,19 @@ module Saboteur
39
54
  end
40
55
 
41
56
  def call
42
- case @argv.shift
57
+ command = @argv.shift
58
+ case command
43
59
  when "run" then run_command
44
60
  when "baseline" then baseline
45
61
  when "doctor" then help_requested? ? show_help("doctor") : doctor
46
62
  when "version", "--version" then @out.puts(VERSION) || EXIT_OK
47
- when "help" then show_help(@argv.shift)
63
+ when "help", "--help", "-h" then show_help(@argv.shift)
48
64
  when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
49
- when "--help", "-h" then show_help(nil)
50
- else usage_error
65
+ else PRESETS.key?(command) ? preset(command) : usage_error
51
66
  end
52
67
  rescue OptionParser::ParseError => e
53
68
  usage_error(e.message)
69
+ rescue StandardError => e then internal_error(e)
54
70
  end
55
71
 
56
72
  def config
@@ -68,11 +84,6 @@ module Saboteur
68
84
  end
69
85
  end
70
86
 
71
- # Each command's own option lines, as printed under OPTIONS in `saboteur help`.
72
- def option_lines
73
- { "run" => parser.summarize, "doctor" => doctor_parser.summarize, "baseline" => baseline_parser.summarize }
74
- end
75
-
76
87
  def doctor_parser
77
88
  OptionParser.new do |opts|
78
89
  opts.banner = "Usage: saboteur doctor [--config PATH] [--seed N] [--suite] [--test PATH]"
@@ -114,12 +125,7 @@ module Saboteur
114
125
  @format == "agent"
115
126
  end
116
127
 
117
- # Found before parsing, so any error, even a bad option that comes first, still
118
- # answers an agent with JSON.
119
- def agent_requested?
120
- @argv.include?("--agent") || @argv.include?("--format=agent") ||
121
- @argv.each_cons(2).any? { |flag, value| flag == "--format" && value == "agent" }
122
- end
128
+ def agent_requested? = self.class.agent_requested?(@argv)
123
129
 
124
130
  # In agent mode the failure is also on stdout, where the agent reads its answer.
125
131
  def agent_failure(message, exit_code)
@@ -129,10 +135,13 @@ module Saboteur
129
135
 
130
136
  def run
131
137
  parser.parse!(@argv)
138
+ reject_test_selection_conflicts
132
139
  use_explicit_sources
133
140
  validate_run_options
134
141
  warn_dirty_tree
135
142
 
143
+ config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
144
+
136
145
  return preview if options[:list_operators] || options[:dry_run]
137
146
 
138
147
  execute_run
@@ -150,6 +159,18 @@ module Saboteur
150
159
  WorkingTree.new(config.root, ignored: config.output_paths).state.warning&.then { |text| @err.puts(text) }
151
160
  end
152
161
 
162
+ # Contradictory selection flags are a usage error before any file or
163
+ # repository check: --test wins over inference by precedence, but only
164
+ # one of them may be named. Config-file combinations are not errors:
165
+ # --test wins there, so generated commands stay valid over any config.
166
+ def reject_test_selection_conflicts
167
+ raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
168
+ raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
169
+ return unless options[:guess_tests] && options[:tests]&.any?
170
+
171
+ raise UsageError, "--guess-tests cannot be combined with --test"
172
+ end
173
+
153
174
  # Usage errors found before the target boots.
154
175
  def validate_run_options
155
176
  reject_threshold_with_agent_format
@@ -209,16 +230,34 @@ module Saboteur
209
230
  Reporter::Agent::Provenance.new(
210
231
  ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
211
232
  changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
212
- subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || options[:tests]),
233
+ subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
213
234
  sources: @sources || [],
214
235
  config_path: options[:config], digest: options[:id], cache: config["cache"],
215
236
  incremental: config["incremental"], settings: agent_settings
216
237
  )
217
238
  end
218
239
 
240
+ # A whole-suite run records its scope as --all, so the generated rerun
241
+ # and replay commands do not default back to inference (which can exit
242
+ # 2 in a project with a spec-less source file).
219
243
  def agent_settings
220
- options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
221
- .merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
244
+ settings = options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
245
+ .merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
246
+ # The effective mode: named tests win over a configured whole-suite
247
+ # selection by precedence, so all is recorded only when no tests
248
+ # resolved - otherwise the follow-up commands would carry both --all
249
+ # and --test, which the CLI rejects.
250
+ settings[:all] = true if (config["all"] || config["test_selection"] == "all") && config["tests"].empty?
251
+ # CLI-only switches that change candidate availability or boot, so a
252
+ # printed rerun/replay must keep them (config-file settings like
253
+ # arid/hazard patterns, whole-file, require, rspec_args and rails ride
254
+ # along through --config or the default config path).
255
+ settings[:allow_hazardous] = true if options[:allow_hazardous]
256
+ settings[:allow_focus] = true if options[:allow_focus]
257
+ # An explicit false (--no-max-duration-ok over a config true) is a
258
+ # setting too: dropping it would silently re-enable the config value.
259
+ settings[:max_duration_ok] = options[:max_duration_ok] unless options[:max_duration_ok].nil?
260
+ settings
222
261
  end
223
262
 
224
263
  def agent_mode
@@ -228,32 +267,13 @@ module Saboteur
228
267
  @sources&.any? ? "files" : "full"
229
268
  end
230
269
 
231
- # Writes the JSON report and returns its path, or nil when none is configured.
232
- def write_json(report, cause)
233
- path = config.path("json_report")
234
- return unless path
235
-
236
- Reporter::Stryker.new(path).call(report, exit_cause: cause)
237
- path
238
- end
239
-
240
- def write_html(report)
241
- path = config.path("html_report")
242
- return unless path
243
-
244
- Reporter::Html.new(path).call(report)
245
- path
246
- end
247
-
248
270
  # Positional arguments name the files (or directories) to mutate. They
249
271
  # replace the configured include and exclude patterns. Each must exist,
250
272
  # so a typo is an error and not a run over zero subjects.
251
273
  def use_explicit_sources
252
274
  if @argv.empty?
253
- raise UsageError, "--test needs at least one source file" if options[:tests] && !config["since"]
254
-
255
- # With --since the changed files are the sources, so --test may
256
- # stand alone; the files must still exist.
275
+ # --test may stand alone: the configured include (or the --since
276
+ # diff) names the sources. The files must still exist.
257
277
  options[:tests]&.each { |test| require_existing_test(test) }
258
278
  return
259
279
  end
@@ -317,9 +337,7 @@ module Saboteur
317
337
  def define_execution_options(opts)
318
338
  opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
319
339
  opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
320
- opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
321
- set(:seed, v)
322
- end
340
+ add_run_budget_options(opts)
323
341
  opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
324
342
  opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
325
343
  opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
@@ -332,8 +350,10 @@ module Saboteur
332
350
  opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
333
351
  set(:allow_focus, true)
334
352
  end
335
- opts.on("--[no-]guess-tests", "With --since: run only the specs named after the changed files " \
336
- "(lib/a.rb -> spec/a_spec.rb); the whole suite if any has none") do |v|
353
+ opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
354
+ "mutated files") { set(:all, true) }
355
+ opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
356
+ "--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
337
357
  set(:guess_tests, v)
338
358
  end
339
359
  end
@@ -351,7 +371,8 @@ module Saboteur
351
371
  opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
352
372
  "strict exit codes") { |v| @format = v }
353
373
  opts.on("--agent", "Same as --format agent") { @format = "agent" }
354
- opts.on("--verbose", "Text report: also print score split, timings and kill causes") { @verbose = true }
374
+ opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
375
+ "a crash prints its backtrace") { @verbose = true }
355
376
  opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
356
377
  @color = v ? :always : :never
357
378
  end
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "text"
5
+
4
6
  require_relative "mutation_source/builtin"
5
7
  require_relative "mutation_source/operator_registry"
6
8
  require_relative "mutation_source/hazard_guard"
@@ -57,8 +59,13 @@ module Saboteur
57
59
  "allow_focus" => false,
58
60
  "fail_fast" => false,
59
61
  "guess_tests" => false,
62
+ "all" => false,
63
+ "test_selection" => "auto",
60
64
  "matrix" => false,
61
65
  "seed" => 1,
66
+ "baseline_passes" => 2,
67
+ "max_duration" => nil,
68
+ "max_duration_ok" => false,
62
69
  "tests" => [],
63
70
  "fingerprint_extra" => [],
64
71
  "threshold" => nil,
@@ -66,8 +73,9 @@ module Saboteur
66
73
  "max_errors" => nil
67
74
  }.freeze
68
75
 
69
- INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed].freeze
76
+ INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes].freeze
70
77
  FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
78
+ NILABLE_FLOAT_KEYS = %w[threshold max_duration].freeze
71
79
  THRESHOLD_RANGE = (0.0..100.0)
72
80
 
73
81
  LIST_KEYS = %w[require include exclude arid hazard sources hooks rspec_args subjects tests
@@ -96,11 +104,19 @@ module Saboteur
96
104
  hash.to_h { |key, value| [key.to_s, value] }.compact
97
105
  end
98
106
 
99
- def initialize(values = {}, root: Dir.pwd)
100
- @root = File.expand_path(root)
107
+ # Deprecations found while loading: printed by the CLI, never fatal.
108
+ attr_reader :deprecation_warnings
109
+
110
+ def initialize(values = {}, root: Dir.pwd, explicit: nil)
111
+ # Retag the root losslessly: under a non-UTF-8 locale it arrives
112
+ # locale-tagged, and every glob, join and expand below would mix
113
+ # encodings with the UTF-8 patterns from the config file (#231).
114
+ @root = Text.utf8_identity(File.expand_path(root))
101
115
  unknown = values.keys - DEFAULTS.keys
102
116
  raise ConfigError, "unknown config key(s): #{unknown.join(', ')}" if unknown.any?
103
117
 
118
+ @deprecation_warnings = deprecations(values)
119
+ @explicit_keys = (explicit || values.keys).map(&:to_s).freeze
104
120
  @values = DEFAULTS.merge(values)
105
121
  coerce!
106
122
  validate!
@@ -115,6 +131,26 @@ module Saboteur
115
131
  @values.fetch(key.to_s, default)
116
132
  end
117
133
 
134
+ # Keys the user set (config file or CLI flag) rather than relying on
135
+ # the default; carried through Engine's config clone so provenance
136
+ # survives (#296).
137
+ attr_reader :explicit_keys
138
+
139
+ # A copy with some values replaced or dropped, keeping which keys the
140
+ # user set explicitly: the replacements count as explicit, the dropped
141
+ # keys lose it (#296).
142
+ def with(values, except: [])
143
+ merged = to_h.except(*except).merge(values)
144
+ Config.new(merged, root: root, explicit: (explicit_keys - except.map(&:to_s)) | values.keys.map(&:to_s))
145
+ end
146
+
147
+ # Whether the user set a key (config file or CLI flag) rather than
148
+ # relying on the default. An explicitly requested report artifact is
149
+ # completion evidence, so its write failure is a hard error (#296).
150
+ def explicit?(key)
151
+ @explicit_keys.include?(key.to_s)
152
+ end
153
+
118
154
  def to_h
119
155
  @values.dup
120
156
  end
@@ -124,7 +160,8 @@ module Saboteur
124
160
  matched = self["include"].flat_map { |pattern| Dir.glob(pattern, base: root) }
125
161
  excluded = self["exclude"].flat_map { |pattern| Dir.glob(pattern, base: root) }
126
162
  (matched - excluded).select { |file| file.end_with?(".rb") }
127
- .map { |file| File.realpath(File.expand_path(file, root)) }.uniq.sort
163
+ .map { |file| File.realpath(File.expand_path(Text.utf8_identity(file), root)) }
164
+ .uniq.sort
128
165
  end
129
166
 
130
167
  # Patterns that match no file. A pattern goes stale when files move;
@@ -151,11 +188,23 @@ module Saboteur
151
188
 
152
189
  private
153
190
 
191
+ # An explicit guess_tests = false (config file or --no-guess-tests) used
192
+ # to keep the whole suite; inference is the default now, so the opt-out
193
+ # is --all / c.test_selection = :all. guess_tests = true is the default
194
+ # behavior spelled out, so it stays silent.
195
+ def deprecations(values)
196
+ return [] unless values["guess_tests"] == false
197
+
198
+ ["guess_tests = false is deprecated: inferred specs are the default and it no longer restores the " \
199
+ "whole suite; use --all or c.test_selection = :all to run every spec"]
200
+ end
201
+
154
202
  def coerce!
155
203
  INTEGER_KEYS.each { |key| @values[key] = Integer(@values[key]) }
156
204
  FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) }
157
205
  LIST_KEYS.each { |key| @values[key] = Array(@values[key]).map(&:to_s) }
158
- @values["threshold"] = Float(@values["threshold"]) unless @values["threshold"].nil?
206
+ NILABLE_FLOAT_KEYS.each { |key| @values[key] = Float(@values[key]) unless @values[key].nil? }
207
+ @values["test_selection"] = @values["test_selection"].to_s
159
208
  rescue ArgumentError, TypeError => e
160
209
  raise ConfigError, "invalid config value: #{e.message}"
161
210
  end
@@ -164,6 +213,13 @@ module Saboteur
164
213
  raise ConfigError, "max_mutants_per_method must be > 0" unless self["max_mutants_per_method"].positive?
165
214
  end
166
215
 
216
+ # The unchanged-suite checks: how many, and the whole run's clock (#55, #245).
217
+ def validate_baseline!
218
+ raise ConfigError, "seed must be >= 0" if self["seed"].negative?
219
+ raise ConfigError, "baseline_passes must be >= 1" if self["baseline_passes"] < 1
220
+ raise ConfigError, "max_duration must be > 0" if self["max_duration"] && !self["max_duration"].positive?
221
+ end
222
+
167
223
  def validate_report_paths!
168
224
  json = path("json_report")
169
225
  if json && json == path("html_report")
@@ -179,7 +235,7 @@ module Saboteur
179
235
  def validate_modes!
180
236
  validate_fail_fast!
181
237
  validate_matrix!
182
- validate_guess_tests!
238
+ validate_test_selection!
183
239
  validate_gates!
184
240
  end
185
241
 
@@ -208,11 +264,10 @@ module Saboteur
208
264
  raise ConfigError, "threshold must be between 0 and 100"
209
265
  end
210
266
 
211
- def validate_guess_tests!
212
- return unless self["guess_tests"]
267
+ def validate_test_selection!
268
+ return if %w[auto all].include?(self["test_selection"])
213
269
 
214
- raise ConfigError, "guess_tests needs since" unless self["since"]
215
- raise ConfigError, "guess_tests cannot be combined with tests" if self["tests"].any?
270
+ raise ConfigError, "test_selection must be one of: auto, all"
216
271
  end
217
272
 
218
273
  def validate_reeval!
@@ -229,7 +284,7 @@ module Saboteur
229
284
  validate_mutant_limit!
230
285
  validate_threshold!
231
286
  validate_modes!
232
- raise ConfigError, "seed must be >= 0" if self["seed"].negative?
287
+ validate_baseline!
233
288
  raise ConfigError, "max_memory_mb must be > 0" unless self["max_memory_mb"].positive?
234
289
  raise ConfigError, "max_processes must be > 0" unless self["max_processes"].positive?
235
290
  raise ConfigError, "timeout_factor must be > 0" unless self["timeout_factor"].positive?