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
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # Execution, cache and hazard flags for `saboteur run` (#334).
7
+ module ExecutionOptions
8
+ private
9
+
10
+ def define_execution_options(opts)
11
+ opts.on("--timeout-factor N", Float, "Multiple of baseline runtime (default 3)") { |v| set(:timeout_factor, v) }
12
+ opts.on("--jobs N", Integer, "Concurrent workers (default 1)") { |v| set(:jobs, v) }
13
+ add_run_budget_options(opts)
14
+ opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |v| set(:config, v) }
15
+ opts.on("--integration NAME", "Test adapter (rspec)") { |v| set(:integration, v) }
16
+ opts.on("--reeval STRATEGY", Execution::Reevaluator::STRATEGIES, "class_eval or nesting") { |v| set(:reeval, v) }
17
+ opts.on("--no-cache", "Rebuild the coverage map") { set(:cache, false) }
18
+ opts.on("--allow-hazardous",
19
+ "Also run mutants inside destructive calls (rm_rf, system, ...)") { set(:allow_hazardous, true) }
20
+ opts.on("--fail-fast",
21
+ "Stop after the first subject with a survivor (needs --jobs 1)") { set(:fail_fast, true) }
22
+ add_reuse_options(opts)
23
+ opts.on("--allow-focus", "Run even when a focus filter (fit, fdescribe, :focus) narrows the suite") do
24
+ set(:allow_focus, true)
25
+ end
26
+ opts.on("--all", "Run the whole configured test suite instead of inferring the specs named after the " \
27
+ "mutated files") { set(:all, true) }
28
+ opts.on("--test-command CMD", "Run CMD per mutant in the project root; exit 0 means the mutant survived. " \
29
+ "For apps that cannot load in this Ruby; much slower, serial, no coverage") do |v|
30
+ set(:test_command, v)
31
+ end
32
+ opts.on("--[no-]guess-tests", "Deprecated alias: inferred specs are already the default; " \
33
+ "--no-guess-tests warns and is ignored - use --all for the whole suite") do |v|
34
+ set(:guess_tests, v)
35
+ end
36
+ end
37
+
38
+ def add_reuse_options(opts)
39
+ opts.on("--incremental", "Reuse earlier verdicts whose inputs did not change") { set(:incremental, true) }
40
+ opts.on("--resume DIR", "Keep verdicts in DIR and reuse them in a later run with the same inputs") do |v|
41
+ set(:resume, v)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -14,11 +14,9 @@ module Saboteur
14
14
  end
15
15
 
16
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.
17
+ # nil cache_dir) must not swallow the crash report itself: no log.
19
18
  def crash_log_path
20
- dir = config.path("cache_dir")
21
- dir && File.join(dir, CrashReport::LOG_NAME)
19
+ config.path("cache_dir")&.then { |dir| File.join(dir, CrashReport::LOG_NAME) }
22
20
  rescue StandardError
23
21
  nil
24
22
  end
@@ -34,7 +32,8 @@ module Saboteur
34
32
 
35
33
  return EXIT_TOOL if incomplete_under_threshold?(report)
36
34
 
37
- failed_quality_gate?(report) || gates.failed?(report, @err) ? EXIT_SURVIVORS : EXIT_OK
35
+ quality_failed = failed_quality_gate?(report)
36
+ gates.failed?(report, @err, record_cause: !quality_failed) || quality_failed ? EXIT_SURVIVORS : EXIT_OK
38
37
  end
39
38
 
40
39
  # A spent --max-duration budget exits 4 on its own, before the gates:
@@ -62,7 +61,7 @@ module Saboteur
62
61
  def unverifiable_scope?(report)
63
62
  if unverifiable_review?(report)
64
63
  report.skipped_subjects.each do |subject, reason|
65
- @err.puts("saboteur: #{subject.id} was skipped (#{reason}), so the change under test was not verified")
64
+ @err.puts(subject.skipped_gate_sentence(reason, report.root))
66
65
  end
67
66
  return true
68
67
  end
@@ -86,10 +85,9 @@ module Saboteur
86
85
  def mostly_errored?(report)
87
86
  return false unless report.majority_errored?
88
87
 
89
- executed = report.executed
90
- errors = executed.count { |result| result.status == :error }
91
- @err.puts("saboteur: #{errors} of #{executed.size} mutants errored, so the run tested almost nothing; " \
92
- "fix the errors and run again")
88
+ errored = report.executed.select { |result| result.status == :error }
89
+ @err.puts("saboteur: fix the errors and run again\n #{errored.size} of #{report.executed.size} " \
90
+ "mutants errored, so the run tested almost nothing#{ErrorCause.line(errored)}")
93
91
  true
94
92
  end
95
93
 
@@ -159,10 +157,10 @@ module Saboteur
159
157
  def incomplete_under_threshold?(report)
160
158
  return false if config["threshold"].nil?
161
159
 
162
- count = report.counts.values_at(*(gates.judges_errors? ? %i[resource_limit] : %i[error resource_limit])).sum
163
- @err.puts("saboteur: #{count} mutant(s) errored or hit a resource limit, so the score is incomplete") if
164
- count.positive?
165
- count.positive?
160
+ statuses = gates.judges_errors? ? %i[resource_limit] : %i[error resource_limit]
161
+ problem = report.results.select { |result| statuses.include?(result.status) }
162
+ @err.puts(ErrorCause.incomplete(problem)) if problem.any?
163
+ problem.any?
166
164
  end
167
165
  end
168
166
  end
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # Explicit test selection and quality-gate flags (#334).
7
+ module ExplicitFileOptions
8
+ private
9
+
10
+ def define_explicit_file_options(opts)
11
+ opts.on("--test PATH", "Spec file or directory to run; repeatable. Replaces the configured spec paths") do |v|
12
+ (options[:tests] ||= []) << v
13
+ end
14
+ opts.on("--threshold PERCENT", Float, "Exit 1 when the score is below PERCENT (0-100)") do |v|
15
+ set(:threshold, v)
16
+ end
17
+ Gates.define_options(opts) { |key, value| set(key, value) }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The source and test operands of `saboteur run`, checked against the
7
+ # file system before anything boots (#334). Extracted from CLI.
8
+ module ExplicitSources
9
+ private
10
+
11
+ # Positional arguments name the files (or directories) to mutate. They
12
+ # replace the configured include and exclude patterns. Each must exist,
13
+ # so a typo is an error and not a run over zero subjects.
14
+ def use_explicit_sources
15
+ Selection::SourceScope.verify_no_specs!(@argv)
16
+ options[:tests]&.each { |test| require_existing_test(test) }
17
+ # --test may stand alone: the configured include (or the --since
18
+ # diff) names the sources. The files must still exist.
19
+ return if @argv.empty?
20
+
21
+ @sources = @argv.dup
22
+ options[:include] = @argv.map { |source| source_pattern(source) }
23
+ options[:exclude] = []
24
+ end
25
+
26
+ def source_pattern(source)
27
+ return "#{glob_escape(source.chomp('/'))}/**/*.rb" if File.directory?(source)
28
+ raise UsageError, "source is not a Ruby file: #{source}" if File.file?(source) && !source.end_with?(".rb")
29
+ raise UsageError, "source file not found: #{source}" unless File.file?(source)
30
+
31
+ glob_escape(source)
32
+ end
33
+
34
+ # include is a list of glob patterns; a named file must match only itself.
35
+ def glob_escape(path)
36
+ path.gsub(/[\\*?\[\]{}]/) { |character| "\\#{character}" }
37
+ end
38
+
39
+ # A test may carry an example selector: spec/a_spec.rb:12 or spec/a_spec.rb[1:2].
40
+ def require_existing_test(test)
41
+ path = test.sub(/(:\d+|\[[\d:,]+\])+\z/, "")
42
+ raise UsageError, "test file not found: #{test}" unless File.exist?(path)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # Where a failed command speaks: stderr for people, and in agent mode the
7
+ # same failure as JSON on stdout (#334). Extracted from CLI.
8
+ module FailureOutput
9
+ private
10
+
11
+ def usage_error(message = nil)
12
+ @err.puts(message) if message
13
+ @err.puts(USAGE)
14
+ agent_failure(message || USAGE, EXIT_USAGE)
15
+ end
16
+
17
+ def agent? = @format == "agent"
18
+
19
+ # In agent mode the failure is also on stdout, where the agent reads its answer.
20
+ def agent_failure(message, exit_code)
21
+ @out.print(Reporter::Agent.render(Reporter::Agent.failure(message, exit_code))) if agent?
22
+ exit_code
23
+ end
24
+
25
+ # Error messages interpolate repository-controlled names, so each line is
26
+ # made safe before it is printed (a newline then "::error::" in a file
27
+ # name must not become a workflow command in the CI log).
28
+ def report_failure(error, exit_code)
29
+ message = Text.display_safe_lines(error.message)
30
+ @err.puts("saboteur: #{message}")
31
+ agent_failure(message, exit_code)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # `saboteur help` and the `--help` of every command (#334). Extracted
7
+ # from CLI by command.
8
+ module HelpCommand
9
+ private
10
+
11
+ def help_requested? = @argv.intersect?(%w[--help -h])
12
+
13
+ def show_help(command_name)
14
+ help = Help.new(option_lines: option_lines)
15
+ text = command_name ? help.command(command_name) : help.overview
16
+ return @out.puts(text) || EXIT_OK if text
17
+
18
+ @err.puts("unknown command: #{command_name}. Commands: #{help.command_names.join(', ')}")
19
+ EXIT_USAGE
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The --matrix flag for `saboteur run` (#334).
7
+ module MatrixOption
8
+ private
9
+
10
+ def define_matrix_option(opts)
11
+ opts.on("--matrix", "Run every covering test per mutant and list blind and redundant tests") do
12
+ set(:matrix, true)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # Report destinations and text output flags for `saboteur run` (#334).
7
+ module OutputOptions
8
+ private
9
+
10
+ def define_output_options(opts)
11
+ opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
12
+ opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
13
+ opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
14
+ opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
15
+ opts.on("--agent", "Same as --format agent") { @format = "agent" }
16
+ opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
17
+ "a crash prints its backtrace") do
18
+ @verbose = true
19
+ set(:verbose, true)
20
+ end
21
+ opts.on("--[no-]color", "Colour the text report (default: on for a terminal unless NO_COLOR is set)") do |v|
22
+ @color = v ? :always : :never
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -14,6 +14,10 @@ module Saboteur
14
14
  --html .saboteur/audit-report.html]
15
15
  }.freeze
16
16
 
17
+ # Preset flags that take a separate value token: the pairs a user
18
+ # flag can override in the banner (#666).
19
+ VALUE_FLAGS = %w[--baseline-passes --max-duration --since --operators --json --html].freeze
20
+
17
21
  private
18
22
 
19
23
  def preset_or_usage_error(command)
@@ -31,10 +35,46 @@ module Saboteur
31
35
  # deprecated inference alias (#245).
32
36
  explicit_scope = @argv.any? { |arg| arg == "--test" || arg.start_with?("--test=") || arg == "--guess-tests" }
33
37
  flags.delete("--all") if explicit_scope
34
- @err.puts("saboteur: preset '#{name}': #{flags.join(' ')} (explicit flags override)")
38
+ @err.puts("saboteur: preset '#{name}': #{preset_banner(flags).join(' ')} (explicit flags override)")
35
39
  @argv = flags + @argv
36
40
  run_command
37
41
  end
42
+
43
+ # The banner is the reader's only record of the effective settings,
44
+ # so a preset flag the user also passed shows the user's spelling -
45
+ # the later occurrence wins (#666). Flags the user did not override
46
+ # print as the preset declares them.
47
+ def preset_banner(flags)
48
+ overrides = user_flag_overrides
49
+ banner = []
50
+ index = 0
51
+ while index < flags.size
52
+ if VALUE_FLAGS.include?(flags[index])
53
+ banner.concat(overrides.fetch(flags[index], [flags[index], flags[index + 1]]))
54
+ index += 2
55
+ else
56
+ banner << flags[index]
57
+ index += 1
58
+ end
59
+ end
60
+ banner
61
+ end
62
+
63
+ # Each long flag the user passed, mapped to its effective spelling:
64
+ # the last occurrence, verbatim, with its separate value token when
65
+ # the flag takes one.
66
+ def user_flag_overrides
67
+ overrides = {}
68
+ @argv.each_with_index do |arg, index|
69
+ name = arg[/\A--[^=]+/] or next
70
+ if arg.include?("=")
71
+ overrides[name] = [arg]
72
+ elsif VALUE_FLAGS.include?(name) && @argv[index + 1]
73
+ overrides[name] = [name, @argv[index + 1]]
74
+ end
75
+ end
76
+ overrides
77
+ end
38
78
  end
39
79
  end
40
80
  end
@@ -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,8 +13,13 @@ 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
- opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent") do |v|
20
+ opts.on("--max-duration DURATION", "Wall-clock budget for the whole run, e.g. 10m; exit 4 when spent - " \
21
+ "the clock starts at engine entry, so boot, baselines and the " \
22
+ "coverage map spend it too") do |v|
18
23
  set(:max_duration, parse_duration(v))
19
24
  end
20
25
  opts.on("--[no-]max-duration-ok", "An exhausted --max-duration budget does not fail the run") do |v|
@@ -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
@@ -18,24 +18,6 @@ module Saboteur
18
18
  raise UsageError, empty_source_set_message
19
19
  end
20
20
 
21
- # A --since ref whose resolved range diffs empty yields 0 subjects
22
- # and a green run over nothing: `saboteur pr <merge_sha>` on a
23
- # checkout AT the merge diffs the merge against itself (#643).
24
- # Refuse like a typo'd ref: exit 2, name the base and the range.
25
- def refuse_empty_since_range
26
- ref = config["since"]
27
- return unless ref
28
-
29
- paths = Selection::GitDiff.changed_paths(ref, root: config.root)
30
- return unless paths.values.all?(&:empty?)
31
-
32
- base = Selection::GitDiff.merge_base(ref, root: config.root)
33
- raise UsageError, "--since #{ref}: the resolved range #{base}..the working tree has no changes - " \
34
- "the run would pass with 0 subjects. A checkout AT the ref (the CI merge-sha " \
35
- "checkout) diffs it against itself: fetch the merge's parent, or point --since " \
36
- "at the base of the change under test"
37
- end
38
-
39
21
  def empty_source_set_message
40
22
  include_patterns = config["include"]
41
23
  matched = include_patterns.flat_map { |pattern| Dir.glob(pattern, base: config.root) }