saboteur 0.8.1 → 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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/README.md +10 -0
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/attribute_finder.rb +3 -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/preview_command.rb +26 -0
  20. data/lib/saboteur/cli/run_budget_options.rb +3 -0
  21. data/lib/saboteur/cli/run_command.rb +33 -0
  22. data/lib/saboteur/cli/run_output.rb +38 -0
  23. data/lib/saboteur/cli/run_reporting.rb +6 -5
  24. data/lib/saboteur/cli/run_validation.rb +54 -0
  25. data/lib/saboteur/cli.rb +29 -307
  26. data/lib/saboteur/config.rb +72 -6
  27. data/lib/saboteur/constant_subject_finder.rb +3 -0
  28. data/lib/saboteur/custom_map.rb +20 -12
  29. data/lib/saboteur/deep_source.rb +49 -0
  30. data/lib/saboteur/dirty_tree_warning.rb +1 -1
  31. data/lib/saboteur/doctor.rb +6 -0
  32. data/lib/saboteur/engine/baseline_guard.rb +50 -13
  33. data/lib/saboteur/engine/baseline_parts.rb +51 -0
  34. data/lib/saboteur/engine/boot.rb +4 -0
  35. data/lib/saboteur/engine/candidate_selection.rb +15 -5
  36. data/lib/saboteur/engine/candidates.rb +4 -1
  37. data/lib/saboteur/engine/coverage_inputs_snapshot.rb +146 -0
  38. data/lib/saboteur/engine/coverage_miss_note.rb +86 -0
  39. data/lib/saboteur/engine/coverage_partial.rb +48 -0
  40. data/lib/saboteur/engine/coverage_parts.rb +50 -0
  41. data/lib/saboteur/engine/coverage_shadow.rb +92 -0
  42. data/lib/saboteur/engine/coverage_store.rb +275 -22
  43. data/lib/saboteur/engine/diagnostics.rb +23 -6
  44. data/lib/saboteur/engine/distribution.rb +2 -2
  45. data/lib/saboteur/engine/gated_run.rb +1 -0
  46. data/lib/saboteur/engine/load_coverage.rb +54 -8
  47. data/lib/saboteur/engine/partial_invalidation.rb +115 -0
  48. data/lib/saboteur/engine/partial_merge.rb +40 -0
  49. data/lib/saboteur/engine/pipelines.rb +7 -7
  50. data/lib/saboteur/engine/reference_specs.rb +71 -0
  51. data/lib/saboteur/engine/result_cache.rb +68 -5
  52. data/lib/saboteur/engine/resume_manifest.rb +110 -0
  53. data/lib/saboteur/engine/run_budget.rb +5 -5
  54. data/lib/saboteur/engine/run_fingerprint.rb +2 -1
  55. data/lib/saboteur/engine/sandbox_policy.rb +5 -2
  56. data/lib/saboteur/engine/scheduler.rb +1 -1
  57. data/lib/saboteur/engine/selection_announcement.rb +1 -0
  58. data/lib/saboteur/engine/shadow_comparison.rb +121 -0
  59. data/lib/saboteur/engine/shadow_run.rb +38 -0
  60. data/lib/saboteur/engine/subject_selection.rb +33 -37
  61. data/lib/saboteur/engine/suite_partition.rb +22 -0
  62. data/lib/saboteur/engine/test_guess.rb +82 -18
  63. data/lib/saboteur/engine/unchanged_syntax.rb +10 -2
  64. data/lib/saboteur/engine.rb +41 -9
  65. data/lib/saboteur/environment_hints.rb +55 -0
  66. data/lib/saboteur/execution/bundle_gemfile_pin.rb +72 -0
  67. data/lib/saboteur/execution/external_runner.rb +105 -1
  68. data/lib/saboteur/execution/gemfile_path_sources.rb +166 -0
  69. data/lib/saboteur/execution/sandbox.rb +30 -17
  70. data/lib/saboteur/execution/supervisor.rb +40 -7
  71. data/lib/saboteur/execution/supervisor_batch.rb +105 -0
  72. data/lib/saboteur/execution/worker_pool.rb +48 -4
  73. data/lib/saboteur/frameworks/rails.rb +2 -5
  74. data/lib/saboteur/gates.rb +121 -28
  75. data/lib/saboteur/install.rb +6 -1
  76. data/lib/saboteur/integration/base.rb +19 -2
  77. data/lib/saboteur/integration/rspec.rb +46 -7
  78. data/lib/saboteur/line_hits.rb +81 -0
  79. data/lib/saboteur/method_calls.rb +91 -0
  80. data/lib/saboteur/mutation_source/builtin.rb +32 -22
  81. data/lib/saboteur/mutation_source/condition_negation.rb +3 -12
  82. data/lib/saboteur/mutation_source/jump_value.rb +59 -0
  83. data/lib/saboteur/mutation_source/literal_replacement.rb +6 -23
  84. data/lib/saboteur/mutation_source/operator_examples.rb +5 -0
  85. data/lib/saboteur/mutation_source/operator_registry.rb +5 -0
  86. data/lib/saboteur/mutation_source/operator_visit.rb +7 -1
  87. data/lib/saboteur/mutation_source/rescue_removal.rb +69 -0
  88. data/lib/saboteur/mutation_source/unary_removal.rb +47 -0
  89. data/lib/saboteur/mutation_source/when_removal.rb +51 -0
  90. data/lib/saboteur/preview.rb +9 -1
  91. data/lib/saboteur/refinement_activations.rb +22 -10
  92. data/lib/saboteur/reporter/agent.rb +2 -2
  93. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  94. data/lib/saboteur/reporter/agent_summary.rb +16 -10
  95. data/lib/saboteur/reporter/review_comments.rb +151 -0
  96. data/lib/saboteur/reporter/safe_out.rb +32 -0
  97. data/lib/saboteur/reporter/stryker.rb +8 -6
  98. data/lib/saboteur/reporter/survivor_classification.rb +41 -3
  99. data/lib/saboteur/reporter/survivor_hints.rb +5 -0
  100. data/lib/saboteur/reporter/terminal.rb +29 -4
  101. data/lib/saboteur/result.rb +14 -4
  102. data/lib/saboteur/selection/coverage_map.rb +136 -10
  103. data/lib/saboteur/selection/external_test_map.rb +4 -0
  104. data/lib/saboteur/selection/git_diff.rb +24 -2
  105. data/lib/saboteur/selection/layout_only_lines.rb +45 -1
  106. data/lib/saboteur/selection/recorder.rb +52 -22
  107. data/lib/saboteur/selection/trace_recorder.rb +83 -0
  108. data/lib/saboteur/selection/untracked_changes.rb +1 -2
  109. data/lib/saboteur/subject.rb +16 -0
  110. data/lib/saboteur/subject_finder.rb +91 -6
  111. data/lib/saboteur/text.rb +31 -0
  112. data/lib/saboteur/version.rb +1 -1
  113. data/lib/saboteur/working_tree.rb +7 -2
  114. data/lib/saboteur.rb +8 -0
  115. metadata +44 -2
@@ -0,0 +1,26 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # `run --dry-run` and `run --list-operators`: plan output, no mutations
7
+ # (#334). Extracted from CLI by command.
8
+ module PreviewCommand
9
+ private
10
+
11
+ def preview
12
+ raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
13
+ raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
14
+
15
+ if options[:list_operators]
16
+ MutationSource::OperatorRegistry.list(@out, names: config["operators"],
17
+ excluded: config["exclude_operators"])
18
+ return EXIT_OK
19
+ end
20
+
21
+ Preview.new(config, out: @out, err: @err, format: @format).call
22
+ EXIT_OK
23
+ end
24
+ end
25
+ end
26
+ end
@@ -13,6 +13,9 @@ module Saboteur
13
13
  opts.on("--seed N", Integer, "Seed for Ruby's random numbers in every test run (default 1)") do |v|
14
14
  set(:seed, v)
15
15
  end
16
+ opts.on("--coverage-parts N", Integer, "Build the coverage map in N parallel parts (default 1)") do |v|
17
+ set(:coverage_parts, v)
18
+ end
16
19
  opts.on("--baseline-passes N", Integer, "Unchanged-suite checks (default 2)") { |v| set(:baseline_passes, v) }
17
20
  opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent - " \
18
21
  "the clock starts at engine entry, so boot, baselines and the " \
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # `saboteur run`: parse, validate, then preview or execute (#334).
7
+ # Extracted from CLI by command.
8
+ module RunCommand
9
+ private
10
+
11
+ def run_command = help_requested? ? show_help("run") : run
12
+
13
+ def run
14
+ parser.parse!(@argv)
15
+ reject_test_selection_conflicts
16
+ use_explicit_sources
17
+ validate_run_options
18
+ warn_dirty_tree
19
+
20
+ config.deprecation_warnings.each { |warning| @err.puts("saboteur: #{warning}") }
21
+
22
+ return preview if options[:list_operators] || options[:dry_run]
23
+
24
+ refuse_empty_source_set
25
+ execute_run
26
+ rescue ConfigError => e
27
+ report_failure(e, EXIT_USAGE)
28
+ rescue Error => e
29
+ report_failure(e, EXIT_TOOL)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # Where a run's own output goes: the report names it prints and the
7
+ # stdout redirect that keeps agent JSON clean (#334). Extracted from CLI.
8
+ module RunOutput
9
+ private
10
+
11
+ # One line naming the reports written, paths relative to the root when
12
+ # they sit under it (a blank line before it, as the old JSON line had).
13
+ def announce_reports(reports)
14
+ return if (written = reports.select { |path, _label| path }).empty?
15
+
16
+ root = File.join(File.realpath(config.root), "")
17
+ names = written.map do |path, label|
18
+ shown = File.expand_path(path).delete_prefix(root)
19
+ "#{shown} (#{label})"
20
+ end
21
+ @out.puts "\nReports: #{names.join(', ')}"
22
+ end
23
+
24
+ # Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
25
+ # files, hooks, spec loading, child processes) goes to stderr while the engine runs.
26
+ # Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
27
+ # children cannot write into the JSON on stdout.
28
+ def stdout_on_stderr
29
+ saved = $stdout.dup
30
+ $stdout.reopen($stderr)
31
+ yield
32
+ ensure
33
+ $stdout.reopen(saved) if saved
34
+ saved&.close
35
+ end
36
+ end
37
+ end
38
+ end
@@ -30,7 +30,7 @@ module Saboteur
30
30
  end
31
31
 
32
32
  def agent_run(arguments)
33
- tree = WorkingTree.new(config.root, ignored: config.output_paths).state
33
+ tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
34
34
  engine = Engine.new(config, **arguments)
35
35
  report = stdout_on_stderr { engine.call }
36
36
  payload = Reporter::Agent.call(report, agent_provenance(report, tree, engine))
@@ -65,7 +65,7 @@ module Saboteur
65
65
  # Computed lazily: only a run with survivors asks for it.
66
66
  def survivor_replay_command(report, engine)
67
67
  lambda do |digest|
68
- tree = WorkingTree.new(config.root, ignored: config.output_paths).state
68
+ tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
69
69
  Reporter::Agent::Commands.replay(agent_provenance(report, tree, engine), digest, capped: false)
70
70
  end
71
71
  end
@@ -82,7 +82,8 @@ module Saboteur
82
82
  path = config.path("json_report")
83
83
  return unless path
84
84
 
85
- Reporter::Stryker.new(path).call(report, exit_cause: cause)
85
+ Reporter::Stryker.new(path).call(report, exit_cause: cause,
86
+ score_gates: gates.score_gate_breakdowns(report))
86
87
  path
87
88
  rescue SystemCallError, IOError => e
88
89
  if config.explicit?("json_report")
@@ -124,8 +125,8 @@ module Saboteur
124
125
  path = config.path("score_history")
125
126
  return unless path
126
127
 
127
- tree = WorkingTree.new(config.root, ignored: config.output_paths).state
128
- scope = config.selection_settings.except("json_report", "html_report", "score_history", "cache_dir")
128
+ tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
129
+ scope = config.selection_settings.except(*Config::OUTPUT_KEYS)
129
130
  # Recording is an auxiliary artifact: when the file cannot be
130
131
  # written - read-only tree inside a sandboxed child (#296) - warn
131
132
  # and let the run exit by its results instead of failing as a tool
@@ -0,0 +1,54 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The usage errors `saboteur run` finds before the target boots: option
7
+ # combinations that contradict each other (#334). Extracted from CLI.
8
+ module RunValidation
9
+ private
10
+
11
+ # Contradictory selection flags are a usage error before any file or
12
+ # repository check: --test wins over inference by precedence, but only
13
+ # one of them may be named. Config-file combinations are not errors:
14
+ # --test wins there, so generated commands stay valid over any config.
15
+ def reject_test_selection_conflicts
16
+ raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
17
+
18
+ reject_test_command_conflicts
19
+ raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
20
+ return unless options[:guess_tests] && options[:tests]&.any?
21
+
22
+ raise UsageError, "--guess-tests cannot be combined with --test"
23
+ end
24
+
25
+ # --test-command replaces the whole in-process test machinery: test
26
+ # selection, coverage, parallelism and the sandbox do not apply.
27
+ def reject_test_command_conflicts
28
+ return unless options[:test_command]
29
+
30
+ # options[:tests] is nil or a non-empty list, so truthiness is the check.
31
+ raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
32
+ raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
33
+ end
34
+
35
+ # Usage errors found before the target boots.
36
+ def validate_run_options
37
+ reject_format_conflicts
38
+ validate_shard_options
39
+ Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
40
+ end
41
+
42
+ # Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
43
+ def reject_format_conflicts
44
+ if agent? && (config["threshold"] || gates.any?)
45
+ raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
46
+ "cannot be combined with --agent"
47
+ end
48
+ return unless @format == "json" && !options[:dry_run]
49
+
50
+ raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
51
+ end
52
+ end
53
+ end
54
+ end
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,321 +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
- execute_run
159
- rescue ConfigError => e
160
- @err.puts("saboteur: #{e.message}")
161
- agent_failure(e.message, EXIT_USAGE)
162
- rescue Error => e
163
- @err.puts("saboteur: #{e.message}")
164
- agent_failure(e.message, EXIT_TOOL)
165
- end
166
-
167
- # Mutants run your code, and a mutant of code that writes or deletes files
168
- # can change the tree. Say so up front when uncommitted work is at stake.
169
- # The scope the warning names is the configured one: positional source
170
- # operands narrow the run after the warning, so the include/exclude they
171
- # imply stay out of its config (#309, #503 review).
172
- def warn_dirty_tree
173
- warning = @sources ? Config.load(path: options[:config], overrides: overrides.except(:include, :exclude)) : config
174
- DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
175
- end
176
-
177
- # Contradictory selection flags are a usage error before any file or
178
- # repository check: --test wins over inference by precedence, but only
179
- # one of them may be named. Config-file combinations are not errors:
180
- # --test wins there, so generated commands stay valid over any config.
181
- def reject_test_selection_conflicts
182
- raise UsageError, "--all cannot be combined with --test" if options[:all] && options[:tests]&.any?
183
-
184
- reject_test_command_conflicts
185
- raise UsageError, "--all cannot be combined with --guess-tests" if options[:all] && options[:guess_tests]
186
- return unless options[:guess_tests] && options[:tests]&.any?
187
-
188
- raise UsageError, "--guess-tests cannot be combined with --test"
189
- end
190
-
191
- # --test-command replaces the whole in-process test machinery: test
192
- # selection, coverage, parallelism and the sandbox do not apply.
193
- def reject_test_command_conflicts
194
- return unless options[:test_command]
195
-
196
- # options[:tests] is nil or a non-empty list, so truthiness is the check.
197
- raise UsageError, "--test-command cannot be combined with --test" if options[:tests]
198
- raise UsageError, "--test-command cannot be combined with --incremental" if options[:incremental]
199
- end
200
-
201
- # Usage errors found before the target boots.
202
- def validate_run_options
203
- reject_format_conflicts
204
- validate_shard_options
205
- Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
206
- end
207
-
208
- # Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
209
- def reject_format_conflicts
210
- if agent? && (config["threshold"] || gates.any?)
211
- raise UsageError, "#{config['threshold'] ? '--threshold' : gates.agent_option_name} " \
212
- "cannot be combined with --agent"
213
- end
214
- return unless @format == "json" && !options[:dry_run]
215
-
216
- raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
217
- end
218
-
219
- # One line naming the reports written, paths relative to the root when
220
- # they sit under it (a blank line before it, as the old JSON line had).
221
- def announce_reports(reports)
222
- return if (written = reports.select { |path, _label| path }).empty?
223
-
224
- root = File.join(File.realpath(config.root), "")
225
- names = written.map do |path, label|
226
- shown = File.expand_path(path).delete_prefix(root)
227
- "#{shown} (#{label})"
228
- end
229
- @out.puts "\nReports: #{names.join(', ')}"
230
- end
231
-
232
- def doctor
233
- doctor_parser.parse!(@argv)
234
- raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
235
-
236
- options[:tests]&.each { |test| require_existing_test(test) }
237
- Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
238
- EXIT_OK
239
- rescue ConfigError => e
240
- @err.puts("saboteur: #{e.message}")
241
- EXIT_USAGE
242
- rescue Error => e
243
- @err.puts("saboteur: #{e.message}")
244
- EXIT_TOOL
245
- end
246
-
247
- # Agent mode: stdout carries exactly one JSON payload. The target's own output (boot
248
- # files, hooks, spec loading, child processes) goes to stderr while the engine runs.
249
- # Descriptor 1 points at stderr for the block, so Kernel#puts, system and forked
250
- # children cannot write into the JSON on stdout.
251
- def stdout_on_stderr
252
- saved = $stdout.dup
253
- $stdout.reopen($stderr)
254
- yield
255
- ensure
256
- $stdout.reopen(saved) if saved
257
- saved&.close
258
- end
259
-
260
- def agent_provenance(report, tree, engine)
261
- Reporter::Agent::Provenance.new(
262
- ref: config["since"], seed: report.seed, head: tree.head, working_tree: tree.label,
263
- changed_files: tree.changed_files, mode: agent_mode, json_report: config.path("json_report"),
264
- subjects: Array(config["subjects"]), tests: Array(engine.guessed_tests || config["tests"]),
265
- sources: @sources || [],
266
- config_path: options[:config], digest: options[:id], cache: config["cache"],
267
- incremental: config["incremental"], settings: agent_settings, typed_settings: typed_settings
268
- )
269
- end
270
-
271
- # The settings keys typed on the command line: a typed empty operator
272
- # list may clear a config value, so the generated commands keep it.
273
- def typed_settings = options.keys & Reporter::Agent::Commands::SETTING_FLAGS.keys
274
-
275
- # A whole-suite run records its scope as --all, so the generated rerun
276
- # and replay commands do not default back to inference (which can exit
277
- # 2 in a project with a spec-less source file).
278
- def agent_settings
279
- settings = options.slice(*Reporter::Agent::Commands::SETTING_FLAGS.keys)
280
- .merge(operators: config["operators"], exclude_operators: config["exclude_operators"])
281
- # The effective mode: named tests win over a configured whole-suite
282
- # selection by precedence, so all is recorded only when no tests
283
- # resolved - otherwise the follow-up commands would carry both --all
284
- # and --test, which the CLI rejects.
285
- settings[:all] = true if (config["all"] || config["test_selection"] == "all") && config["tests"].empty?
286
- # CLI-only switches that change candidate availability or boot, so a
287
- # printed rerun/replay must keep them (config-file settings like
288
- # arid/hazard patterns, whole-file, require, rspec_args and rails ride
289
- # along through --config or the default config path).
290
- settings[:allow_hazardous] = true if options[:allow_hazardous]
291
- settings[:allow_focus] = true if options[:allow_focus]
292
- # An explicit false (--no-max-duration-ok over a config true) is a
293
- # setting too: dropping it would silently re-enable the config value.
294
- settings[:max_duration_ok] = options[:max_duration_ok] unless options[:max_duration_ok].nil?
295
- settings
296
- end
297
-
298
- def agent_mode
299
- return "replay" if options[:id]
300
- return "diff" if config["since"]
301
-
302
- @sources&.any? ? "files" : "full"
303
- end
304
-
305
- # Positional arguments name the files (or directories) to mutate. They
306
- # replace the configured include and exclude patterns. Each must exist,
307
- # so a typo is an error and not a run over zero subjects.
308
- def use_explicit_sources
309
- Selection::SourceScope.verify_no_specs!(@argv)
310
- options[:tests]&.each { |test| require_existing_test(test) }
311
- # --test may stand alone: the configured include (or the --since
312
- # diff) names the sources. The files must still exist.
313
- return if @argv.empty?
314
-
315
- @sources = @argv.dup
316
- options[:include] = @argv.map { |source| source_pattern(source) }
317
- options[:exclude] = []
318
- end
319
-
320
- def source_pattern(source)
321
- return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
322
- raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
323
- raise UsageError, "source file not found: #{source}" unless File.file?(source)
324
-
325
- glob_escape(source)
326
- end
327
-
328
- # include is a list of glob patterns; a named file must match only itself.
329
- def glob_escape(path)
330
- path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
331
- end
332
-
333
- # A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
334
- def require_existing_test(test)
335
- path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
336
- raise UsageError, "test file not found: #{test}" unless File.exist?(path)
337
- end
338
-
339
134
  def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
340
135
 
341
- def preview
342
- raise UsageError, "--dry-run and --list-operators print text, not --agent JSON; use --format json" if agent?
343
- raise UsageError, "--id replays one mutant: not with --dry-run or --list-operators" if options[:id]
344
-
345
- if options[:list_operators]
346
- MutationSource::OperatorRegistry.list(@out, names: config["operators"], excluded: config["exclude_operators"])
347
- return EXIT_OK
348
- end
349
-
350
- Preview.new(config, out: @out, err: @err, format: @format).call
351
- EXIT_OK
352
- end
353
-
354
- def define_execution_options(opts)
355
- opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
356
- opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
357
- add_run_budget_options(opts)
358
- opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
359
- opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
360
- opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
361
- opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
362
- opts.on("--allow-hazardous", "Also run mutants inside destructive calls (rm_rf, system, ...)") do
363
- set(:allow_hazardous, true)
364
- end
365
- opts.on("--fail-fast", "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
366
- opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
367
- opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
368
- set(:allow_focus, true)
369
- end
370
- opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
371
- "mutated files") { set(:all, true) }
372
- opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
373
- "For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
374
- set(:test_command, v)
375
- end
376
- opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
377
- "--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
378
- set(:guess_tests, v)
379
- end
380
- end
381
-
382
- def define_matrix_option(opts)
383
- opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
384
- set(:matrix, true)
385
- end
386
- end
387
-
388
- def define_output_options(opts)
389
- opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
390
- opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
391
- opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
392
- opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
393
- opts.on("--agent", "Same as --format agent") { @format = "agent" }
394
- opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
395
- "a crash prints its backtrace") do
396
- @verbose = true
397
- set(:verbose, true)
398
- end
399
- opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
400
- @color = v ? :always : :never
401
- end
402
- end
403
-
404
- def define_explicit_file_options(opts)
405
- opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
406
- (options[:tests] ||= []) << v
407
- end
408
- opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
409
- set(:threshold, v)
410
- end
411
- Gates.define_options(opts) { |key, value| set(key, value) }
412
- end
413
-
414
136
  def add_subject(expr) = (options[:subjects] ||= []) << expr
415
137
 
416
138
  def set(key, value) = (options[key] = value)