saboteur 0.5.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -0
  3. data/README.md +202 -42
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/comparison.rb +1 -0
  6. data/lib/saboteur/baseline/evidence.rb +11 -1
  7. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  8. data/lib/saboteur/baseline/workflow.rb +2 -1
  9. data/lib/saboteur/baseline_health.rb +40 -6
  10. data/lib/saboteur/cli/exit_status.rb +49 -4
  11. data/lib/saboteur/cli/help_options.rb +16 -0
  12. data/lib/saboteur/cli/presets.rb +36 -0
  13. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  14. data/lib/saboteur/cli/run_reporting.rb +75 -6
  15. data/lib/saboteur/cli.rb +80 -48
  16. data/lib/saboteur/config.rb +88 -13
  17. data/lib/saboteur/crash_report.rb +154 -0
  18. data/lib/saboteur/custom_map.rb +3 -1
  19. data/lib/saboteur/doctor.rb +64 -2
  20. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  21. data/lib/saboteur/engine/boot.rb +12 -1
  22. data/lib/saboteur/engine/canary.rb +41 -10
  23. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  24. data/lib/saboteur/engine/candidates.rb +4 -1
  25. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  26. data/lib/saboteur/engine/coverage_store.rb +48 -6
  27. data/lib/saboteur/engine/diagnostics.rb +23 -2
  28. data/lib/saboteur/engine/diff_scope.rb +24 -5
  29. data/lib/saboteur/engine/distribution.rb +124 -8
  30. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  31. data/lib/saboteur/engine/result_cache.rb +2 -1
  32. data/lib/saboteur/engine/run_budget.rb +63 -0
  33. data/lib/saboteur/engine/run_fingerprint.rb +5 -1
  34. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  35. data/lib/saboteur/engine/scheduler.rb +114 -18
  36. data/lib/saboteur/engine/subject_selection.rb +84 -5
  37. data/lib/saboteur/engine/suite_files.rb +3 -1
  38. data/lib/saboteur/engine/test_guess.rb +183 -23
  39. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  40. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  41. data/lib/saboteur/engine/verdict_check.rb +54 -18
  42. data/lib/saboteur/engine.rb +175 -96
  43. data/lib/saboteur/execution/protocol.rb +2 -1
  44. data/lib/saboteur/execution/runner.rb +179 -17
  45. data/lib/saboteur/execution/sandbox.rb +6 -1
  46. data/lib/saboteur/execution/supervisor.rb +18 -3
  47. data/lib/saboteur/execution/worker_pool.rb +87 -7
  48. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  49. data/lib/saboteur/frameworks/rails.rb +8 -10
  50. data/lib/saboteur/help.rb +55 -10
  51. data/lib/saboteur/ignore_list.rb +11 -1
  52. data/lib/saboteur/install.rb +4 -3
  53. data/lib/saboteur/integration/base.rb +7 -2
  54. data/lib/saboteur/integration/rspec.rb +40 -4
  55. data/lib/saboteur/mutation_source/builtin.rb +22 -13
  56. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  57. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  58. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  59. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  60. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  61. data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
  62. data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
  63. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  64. data/lib/saboteur/preview.rb +12 -2
  65. data/lib/saboteur/reporter/agent_commands.rb +49 -16
  66. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  67. data/lib/saboteur/reporter/agent_summary.rb +1 -1
  68. data/lib/saboteur/reporter/history_policy.rb +35 -0
  69. data/lib/saboteur/reporter/history_record.rb +4 -5
  70. data/lib/saboteur/reporter/next_steps.rb +14 -4
  71. data/lib/saboteur/reporter/score_history.rb +19 -3
  72. data/lib/saboteur/reporter/stryker.rb +19 -8
  73. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  74. data/lib/saboteur/reporter/terminal.rb +43 -10
  75. data/lib/saboteur/result.rb +25 -7
  76. data/lib/saboteur/selection/coverage_map.rb +2 -1
  77. data/lib/saboteur/selection/git_diff.rb +15 -5
  78. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  79. data/lib/saboteur/subject.rb +11 -0
  80. data/lib/saboteur/text.rb +42 -0
  81. data/lib/saboteur/version.rb +1 -1
  82. data/lib/saboteur.rb +16 -1
  83. metadata +20 -2
@@ -8,7 +8,26 @@ module Saboteur
8
8
  module ExitStatus
9
9
  private
10
10
 
11
+ # Last resort: a bug must not escape as a bare stack trace and an accidental exit 1.
12
+ def internal_error(error)
13
+ CrashReport.call(error, verbose: @verbose, agent: agent?, err: @err, out: @out, log_path: crash_log_path)
14
+ end
15
+
16
+ # The crash log lives with the other run state. A broken config (or a
17
+ # nil cache_dir) must not swallow the crash report itself, so fall
18
+ # back to no log.
19
+ def crash_log_path
20
+ dir = config.path("cache_dir")
21
+ dir && File.join(dir, CrashReport::LOG_NAME)
22
+ rescue StandardError
23
+ nil
24
+ end
25
+
11
26
  def exit_code(report)
27
+ return EXIT_BUDGET if budget_exhausted?(report)
28
+
29
+ return EXIT_TOOL if untested_partial?(report)
30
+
12
31
  return EXIT_TOOL if mostly_errored?(report)
13
32
 
14
33
  return EXIT_TOOL if incomplete_under_threshold?(report)
@@ -16,6 +35,24 @@ module Saboteur
16
35
  failed_quality_gate?(report) || gates.failed?(report, @err) ? EXIT_SURVIVORS : EXIT_OK
17
36
  end
18
37
 
38
+ # A spent --max-duration budget exits 4 on its own, before the gates:
39
+ # the run is partial by design, and --max-duration-ok opts back into
40
+ # the plain verdict.
41
+ def budget_exhausted?(report)
42
+ report.early_stop&.reason == "max_duration" && !config["max_duration_ok"]
43
+ end
44
+
45
+ # A run stopped before any mutant was tested has no verdict to fall
46
+ # back on: --max-duration-ok must not turn it green. Unverified
47
+ # results are not tested verdicts, so an unverified-only stop has no
48
+ # score either (#296). Exit 3, like the other incomplete shapes.
49
+ def untested_partial?(report)
50
+ return false unless report.early_stop && report.score_breakdown.tested.zero?
51
+
52
+ @err.puts("saboteur: the run stopped before any mutant was tested, so there is no score to judge")
53
+ true
54
+ end
55
+
19
56
  def gates = (@gates ||= Gates.new(config))
20
57
 
21
58
  def mostly_errored?(report)
@@ -33,9 +70,11 @@ module Saboteur
33
70
  # incomplete run that mostly errored keeps its distinct cause, so the
34
71
  # harness rule means the same thing in both modes.
35
72
  def agent_exit_cause(report, payload)
73
+ return "budgetExhausted" if budget_exhausted?(report)
74
+
36
75
  case payload[:verdict]
37
76
  when "clean" then "ok"
38
- when "survivors" then report.early_stop ? "failFast" : "survivors"
77
+ when "survivors" then report.early_stop&.reason == "fail_fast" ? "failFast" : "survivors"
39
78
  else report.majority_errored? ? "errorMajority" : "incomplete"
40
79
  end
41
80
  end
@@ -45,22 +84,28 @@ module Saboteur
45
84
  def exit_cause(report, code)
46
85
  case code
47
86
  when EXIT_OK then "ok"
87
+ when EXIT_BUDGET then "budgetExhausted"
48
88
  when EXIT_SURVIVORS then survivors_cause(report)
49
- else report.majority_errored? ? "errorMajority" : "incompleteUnderThreshold"
89
+ else
90
+ if report.majority_errored?
91
+ "errorMajority"
92
+ else
93
+ report.early_stop && report.score_breakdown.tested.zero? ? "incomplete" : "incompleteUnderThreshold"
94
+ end
50
95
  end
51
96
  end
52
97
 
53
98
  # A run stopped by --fail-fast always exits 1: it stopped on a survivor, and
54
99
  # its score covers only the subjects that ran, so no threshold can pass it.
55
100
  def survivors_cause(report)
56
- return "failFast" if report.early_stop
101
+ return "failFast" if report.early_stop&.reason == "fail_fast"
57
102
 
58
103
  gates.cause || (config["threshold"].nil? ? "survivors" : "belowThreshold")
59
104
  end
60
105
 
61
106
  # With --threshold the score decides; without it any unignored survivor does.
62
107
  def failed_quality_gate?(report)
63
- return true if report.early_stop
108
+ return true if report.early_stop&.reason == "fail_fast"
64
109
 
65
110
  threshold = config["threshold"]
66
111
  return report.unignored_survivors.any? if threshold.nil?
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The option map `saboteur help` prints per command. run, pr and audit
7
+ # share the run parser: the presets take every run option.
8
+ module HelpOptions
9
+ def option_lines
10
+ run = parser.summarize
11
+ { "run" => run, "pr" => run, "audit" => run, "doctor" => doctor_parser.summarize,
12
+ "baseline" => baseline_parser.summarize }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # Opinionated bundles (#244): pr is the fast changed-lines loop (one
7
+ # baseline check, a 10-minute budget that does not fail the build),
8
+ # audit is the whole project with both reports. Preset flags go first,
9
+ # so an explicit flag after them wins on the usual last-flag rule.
10
+ module Presets
11
+ PRESETS = {
12
+ "pr" => %w[--baseline-passes 1 --max-duration 10m --max-duration-ok],
13
+ "audit" => %w[--all --operators all --baseline-passes 2 --json .saboteur/audit-report.json
14
+ --html .saboteur/audit-report.html]
15
+ }.freeze
16
+
17
+ private
18
+
19
+ def preset(name)
20
+ flags = PRESETS.fetch(name).dup
21
+ if name == "pr"
22
+ ref = @argv.first.nil? || @argv.first.start_with?("-") ? "main" : @argv.shift
23
+ flags += ["--since", ref]
24
+ end
25
+ # An explicit scope beats the injected whole-suite --all, which
26
+ # would otherwise reject the combination: named tests, and the
27
+ # deprecated inference alias (#245).
28
+ explicit_scope = @argv.any? { |arg| arg == "--test" || arg.start_with?("--test=") || arg == "--guess-tests" }
29
+ flags.delete("--all") if explicit_scope
30
+ @err.puts("saboteur: preset '#{name}': #{flags.join(' ')} (explicit flags override)")
31
+ @argv = flags + @argv
32
+ run_command
33
+ end
34
+ end
35
+ end
36
+ end
@@ -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
@@ -66,9 +125,19 @@ module Saboteur
66
125
  return unless path
67
126
 
68
127
  tree = WorkingTree.new(config.root, ignored: config.output_paths).state
69
- scope = config.to_h.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}")
128
+ scope = config.selection_settings.except("json_report", "html_report", "score_history", "cache_dir")
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,13 +230,36 @@ 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
- incremental: config["incremental"], settings: options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
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).
243
+ def agent_settings
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
261
+ end
262
+
219
263
  def agent_mode
220
264
  return "replay" if options[:id]
221
265
  return "diff" if config["since"]
@@ -223,32 +267,13 @@ module Saboteur
223
267
  @sources&.any? ? "files" : "full"
224
268
  end
225
269
 
226
- # Writes the JSON report and returns its path, or nil when none is configured.
227
- def write_json(report, cause)
228
- path = config.path("json_report")
229
- return unless path
230
-
231
- Reporter::Stryker.new(path).call(report, exit_cause: cause)
232
- path
233
- end
234
-
235
- def write_html(report)
236
- path = config.path("html_report")
237
- return unless path
238
-
239
- Reporter::Html.new(path).call(report)
240
- path
241
- end
242
-
243
270
  # Positional arguments name the files (or directories) to mutate. They
244
271
  # replace the configured include and exclude patterns. Each must exist,
245
272
  # so a typo is an error and not a run over zero subjects.
246
273
  def use_explicit_sources
247
274
  if @argv.empty?
248
- raise UsageError, "--test needs at least one source file" if options[:tests] && !config["since"]
249
-
250
- # With --since the changed files are the sources, so --test may
251
- # 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.
252
277
  options[:tests]&.each { |test| require_existing_test(test) }
253
278
  return
254
279
  end
@@ -286,17 +311,23 @@ module Saboteur
286
311
  raise UsageError, "--dry-run and --list-operators print text, not --agent JSON" if agent?
287
312
  raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
288
313
 
289
- return MutationSource::OperatorRegistry.list(@out) || EXIT_OK if options[:list_operators]
314
+ if options[:list_operators]
315
+ MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
316
+ return EXIT_OK
317
+ end
290
318
 
291
319
  Preview.new(config, out: @out, err: @err).call
292
320
  EXIT_OK
293
321
  end
294
322
 
295
323
  def define_selection_options(opts)
296
- opts.on("--operators NAMES", Array, "Choose operator names or tiers (core, extended, all)") do |v|
324
+ opts.on("--operators NAMES", Array, "Choose operator names (default: all)") do |v|
297
325
  set(:operators, v)
298
326
  end
299
- opts.on("--list-operators", "List operator names, descriptions and tiers") { set(:list_operators, true) }
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) }
300
331
  opts.on("--dry-run", "Preview mutants without boot, tests, forks or report writes") { set(:dry_run, true) }
301
332
  opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
302
333
  opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
@@ -306,9 +337,7 @@ module Saboteur
306
337
  def define_execution_options(opts)
307
338
  opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
308
339
  opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
309
- opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
310
- set(:seed, v)
311
- end
340
+ add_run_budget_options(opts)
312
341
  opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
313
342
  opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
314
343
  opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
@@ -321,8 +350,10 @@ module Saboteur
321
350
  opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
322
351
  set(:allow_focus, true)
323
352
  end
324
- opts.on("--[no-]guess-tests", "With --since: run only the specs named after the changed files " \
325
- "(lib/a.rb -> spec/a_spec.rb); the whole suite if any has none") do |v|
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|
326
357
  set(:guess_tests, v)
327
358
  end
328
359
  end
@@ -340,7 +371,8 @@ module Saboteur
340
371
  opts.on("--format FORMAT", %w[text agent], "text (default) or agent: one bounded JSON summary on stdout, " \
341
372
  "strict exit codes") { |v| @format = v }
342
373
  opts.on("--agent", "Same as --format agent") { @format = "agent" }
343
- opts.on("--verbose", "Text report: also print score split, timings and kill causes") { @verbose = true }
374
+ opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
375
+ "a crash prints its backtrace") { @verbose = true }
344
376
  opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
345
377
  @color = v ? :always : :never
346
378
  end