saboteur 0.9.0 → 0.11.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +109 -247
  4. data/exe/saboteur +2 -2
  5. data/lib/saboteur/baseline/loader.rb +41 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  7. data/lib/saboteur/baseline/run_gate.rb +51 -0
  8. data/lib/saboteur/baseline/workflow.rb +2 -15
  9. data/lib/saboteur/cli/baseline_gate.rb +58 -0
  10. data/lib/saboteur/cli/error_cause.rb +16 -0
  11. data/lib/saboteur/cli/exit_status.rb +14 -13
  12. data/lib/saboteur/cli/failure_output.rb +23 -0
  13. data/lib/saboteur/cli/output_options.rb +24 -2
  14. data/lib/saboteur/cli/run_budget_options.rb +6 -0
  15. data/lib/saboteur/cli/run_command.rb +14 -1
  16. data/lib/saboteur/cli/run_reporting.rb +34 -6
  17. data/lib/saboteur/cli/run_validation.rb +36 -3
  18. data/lib/saboteur/cli/selection_options.rb +2 -0
  19. data/lib/saboteur/cli.rb +6 -1
  20. data/lib/saboteur/config.rb +23 -2
  21. data/lib/saboteur/crash_report.rb +16 -7
  22. data/lib/saboteur/engine/baseline_guard.rb +2 -2
  23. data/lib/saboteur/engine/coverage_partial.rb +8 -1
  24. data/lib/saboteur/engine/coverage_store.rb +4 -5
  25. data/lib/saboteur/engine/diagnostics.rb +120 -15
  26. data/lib/saboteur/engine/run_fingerprint.rb +1 -1
  27. data/lib/saboteur/engine/scheduler.rb +20 -0
  28. data/lib/saboteur/engine/selection_announcement.rb +8 -1
  29. data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
  30. data/lib/saboteur/engine.rb +43 -6
  31. data/lib/saboteur/help.rb +4 -1
  32. data/lib/saboteur/mutation_source/builtin.rb +9 -3
  33. data/lib/saboteur/mutation_source/copy_replacement.rb +72 -0
  34. data/lib/saboteur/mutation_source/ensure_removal.rb +49 -0
  35. data/lib/saboteur/mutation_source/operator_examples.rb +2 -0
  36. data/lib/saboteur/mutation_source/operator_registry.rb +3 -1
  37. data/lib/saboteur/reporter/agent_summary.rb +3 -1
  38. data/lib/saboteur/reporter/assets/mutation_report.LICENSE +201 -0
  39. data/lib/saboteur/reporter/assets/mutation_report.js +331 -0
  40. data/lib/saboteur/reporter/baseline_tail.rb +93 -0
  41. data/lib/saboteur/reporter/diff.rb +10 -0
  42. data/lib/saboteur/reporter/html.rb +69 -14
  43. data/lib/saboteur/reporter/kill_causes.rb +53 -0
  44. data/lib/saboteur/reporter/next_steps.rb +4 -3
  45. data/lib/saboteur/reporter/palette.rb +64 -13
  46. data/lib/saboteur/reporter/safe_out.rb +1 -1
  47. data/lib/saboteur/reporter/score_table.rb +53 -18
  48. data/lib/saboteur/reporter/stryker.rb +4 -1
  49. data/lib/saboteur/reporter/survivor_classification.rb +12 -3
  50. data/lib/saboteur/reporter/survivor_hints.rb +72 -0
  51. data/lib/saboteur/reporter/survivor_list.rb +207 -75
  52. data/lib/saboteur/reporter/terminal.rb +315 -69
  53. data/lib/saboteur/result.rb +28 -1
  54. data/lib/saboteur/score.rb +6 -0
  55. data/lib/saboteur/version.rb +1 -1
  56. metadata +12 -2
@@ -0,0 +1,58 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ module Saboteur
7
+ class CLI
8
+ # The `saboteur run --baseline PATH` gate: loading the accepted baseline
9
+ # (fail fast, before the suite boots) and mapping the comparison to the
10
+ # run's exit code. Mixed into the CLI, which supplies `config`.
11
+ module BaselineGate
12
+ private
13
+
14
+ # The --baseline comparison replaces the ordinary quality-gate verdict.
15
+ def exit_code(report) = config["baseline"] ? baseline_verdict(report) : super
16
+
17
+ # --baseline: the comparison against the accepted baseline is the
18
+ # verdict. Accepted legacy survivors no longer fail the run; new
19
+ # survivors or a score drop beyond --epsilon do (exit 1). An
20
+ # incompatible baseline is a usage error; an incomplete comparison
21
+ # means the run's own evidence cannot judge it (exit 3).
22
+ def baseline_verdict(report)
23
+ gate = baseline_gate(report)
24
+ raise ConfigError, gate.comparison.issues.join("; ") if gate.incompatible?
25
+ return EXIT_TOOL if gate.incomplete?
26
+
27
+ gate.failed? ? EXIT_SURVIVORS : EXIT_OK
28
+ end
29
+
30
+ def baseline_gate(report)
31
+ @baseline_gate ||= begin
32
+ path = config["baseline"]
33
+ if Digest::SHA256.file(path).hexdigest != @baseline_digest
34
+ raise ConfigError, "baseline changed during the run"
35
+ end
36
+
37
+ Baseline::RunGate.build(manifest: baseline_manifest, report: report, epsilon: config["epsilon"] || 0.0)
38
+ end
39
+ end
40
+
41
+ # The accepted baseline at config["baseline"], loaded once (and before
42
+ # the suite boots) so a missing or corrupt file fails fast as a usage
43
+ # error naming the fix.
44
+ def baseline_manifest
45
+ return @baseline_manifest if @baseline_manifest
46
+
47
+ path = config["baseline"]
48
+ raise UsageError, "--baseline file not found: #{path} - run `saboteur baseline propose` first" unless
49
+ File.exist?(path)
50
+
51
+ @baseline_manifest, @baseline_digest = Baseline::Loader.call(path)
52
+ @baseline_manifest
53
+ rescue ConfigError => e
54
+ raise UsageError, "#{e.message} - run `saboteur baseline propose` first"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -15,6 +15,22 @@ module Saboteur
15
15
 
16
16
  LIMIT = 200
17
17
 
18
+ # The machine cause and the one-line human reason for the two exits no
19
+ # report can describe, because the run never produced one. The verdict
20
+ # line names the reason for a nonzero exit; the report-driven codes get
21
+ # theirs from the terminal summary's verdict line.
22
+ # Keyed by exit code. Literals for EXIT_BASELINE_RED and EXIT_CRASH:
23
+ # this file loads before the CLI defines them; the cause pins spec
24
+ # asserts the keys are those two constants.
25
+ CAUSE_LINES = {
26
+ 5 => ["baselineRed", "baseline suite red: fix your tests first"],
27
+ 70 => ["crash", "internal crash (a saboteur bug, never your code)"]
28
+ }.freeze
29
+
30
+ def self.cause(code) = CAUSE_LINES.fetch(code).first
31
+
32
+ def self.verdict_line(code) = "exit #{code} - #{CAUSE_LINES.fetch(code).last}"
33
+
18
34
  # "" when no result carries a reason.
19
35
  def self.line(errored)
20
36
  reasons = errored.filter_map { |result| first_line(result.reason) }.reject(&:empty?)
@@ -8,22 +8,11 @@ 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: no log.
18
- def crash_log_path
19
- config.path("cache_dir")&.then { |dir| File.join(dir, CrashReport::LOG_NAME) }
20
- rescue StandardError
21
- nil
22
- end
23
-
24
11
  def exit_code(report)
25
12
  return EXIT_BUDGET if budget_exhausted?(report)
26
13
 
14
+ return EXIT_OK if ignore_empty_diff?(report)
15
+
27
16
  return EXIT_TOOL if untested_partial?(report)
28
17
 
29
18
  return EXIT_TOOL if unverifiable_scope?(report)
@@ -36,6 +25,17 @@ module Saboteur
36
25
  gates.failed?(report, @err, record_cause: !quality_failed) || quality_failed ? EXIT_SURVIVORS : EXIT_OK
37
26
  end
38
27
 
28
+ # --ignore-empty: a --since diff that selected no mutants exits 0 with
29
+ # a note; suppressed, omitted or undiscovered mutants still gate.
30
+ def ignore_empty_diff?(report)
31
+ return false unless config["ignore_empty"] && config["since"] && report.results.empty?
32
+ return false unless report.omissions.empty? && report.inline_suppressions.empty? &&
33
+ report.discovery_failures.empty?
34
+
35
+ @err.puts("saboteur: no mutants in the diff - nothing to gate (--ignore-empty)")
36
+ true
37
+ end
38
+
39
39
  # A spent --max-duration budget exits 4 on its own, before the gates:
40
40
  # the run is partial by design, and --max-duration-ok opts back into
41
41
  # the plain verdict.
@@ -112,6 +112,7 @@ module Saboteur
112
112
  when EXIT_OK then "ok"
113
113
  when EXIT_BUDGET then "budgetExhausted"
114
114
  when EXIT_SURVIVORS then survivors_cause(report)
115
+ when EXIT_BASELINE_RED, EXIT_CRASH then ErrorCause.cause(code)
115
116
  else
116
117
  if report.majority_errored?
117
118
  "errorMajority"
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "../reporter/palette"
5
+
4
6
  module Saboteur
5
7
  class CLI
6
8
  # Where a failed command speaks: stderr for people, and in agent mode the
@@ -8,6 +10,20 @@ module Saboteur
8
10
  module FailureOutput
9
11
  private
10
12
 
13
+ # Last resort: a bug must not escape as a bare stack trace and an accidental exit 1.
14
+ def internal_error(error)
15
+ CrashReport.call(error, verbose: @verbose, agent: agent?, err: @err, out: @out,
16
+ log_path: crash_log_path, color: @color || :auto)
17
+ end
18
+
19
+ # The crash log lives with the other run state. A broken config (or a
20
+ # nil cache_dir) must not swallow the crash report itself: no log.
21
+ def crash_log_path
22
+ config.path("cache_dir")&.then { |dir| File.join(dir, CrashReport::LOG_NAME) }
23
+ rescue StandardError
24
+ nil
25
+ end
26
+
11
27
  def usage_error(message = nil)
12
28
  @err.puts(message) if message
13
29
  @err.puts(USAGE)
@@ -28,8 +44,15 @@ module Saboteur
28
44
  def report_failure(error, exit_code)
29
45
  message = Text.display_safe_lines(error.message)
30
46
  @err.puts("saboteur: #{message}")
47
+ @err.puts(verdict_line(EXIT_BASELINE_RED)) if exit_code == EXIT_BASELINE_RED
31
48
  agent_failure(message, exit_code)
32
49
  end
50
+
51
+ # The terminal verdict names the reason for a nonzero exit. Painted
52
+ # red under the same colour rules as the report.
53
+ def verdict_line(code)
54
+ Reporter::Palette.for(@err, mode: @color || :auto).paint(:fail, ErrorCause.verdict_line(code))
55
+ end
33
56
  end
34
57
  end
35
58
  end
@@ -8,19 +8,41 @@ module Saboteur
8
8
  private
9
9
 
10
10
  def define_output_options(opts)
11
+ define_report_destination_options(opts)
12
+ define_text_report_options(opts)
13
+ end
14
+
15
+ # Report destinations and the format switch.
16
+ def define_report_destination_options(opts)
11
17
  opts.on("--score-history PATH", "Append score history JSONL (opt-in)") { |v| set(:score_history, v) }
12
18
  opts.on("--json PATH", "Stryker JSON report path") { |v| set(:json_report, v) }
13
19
  opts.on("--html PATH", "Self-contained HTML report path") { |v| set(:html_report, v) }
14
20
  opts.on("--format FORMAT", %w[text agent json], "text, agent or json (--dry-run inventory)") { |v| @format = v }
15
21
  opts.on("--agent", "Same as --format agent") { @format = "agent" }
22
+ end
23
+
24
+ # Flags that shape the text report. Kept under the per-method mutant cap
25
+ # by the destination/text split above.
26
+ def define_text_report_options(opts)
16
27
  opts.on("--verbose", "Text report: also print score split, timings and kill causes;",
17
28
  "a crash prints its backtrace") do
18
29
  @verbose = true
19
30
  set(:verbose, true)
20
31
  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
32
+ opts.on("--color [MODE]", %w[always never auto],
33
+ "Colour the text report: always, never or auto (default: auto)") do |v|
34
+ @color = (v || :always).to_sym
35
+ end
36
+
37
+ # PR6: CI and pre-push output - the findings and the verdict only.
38
+ opts.on("--quiet", "Text report: print only the findings and the verdict;",
39
+ "no effect with --format json/agent; ignores --all-mutants") do
40
+ @quiet = true
41
+ set(:quiet, true)
23
42
  end
43
+ opts.on("--all-mutants", "Text report: list every survived mutant;",
44
+ "default: one finding per statement, with the label mix") { set(:all_mutants, true) }
45
+ opts.on("--no-color", "Deprecated alias for --color=never") { @color = :never }
24
46
  end
25
47
  end
26
48
  end
@@ -22,6 +22,12 @@ module Saboteur
22
22
  "coverage map spend it too") do |v|
23
23
  set(:max_duration, parse_duration(v))
24
24
  end
25
+ opts.on("--baseline PATH", "Gate the run on an accepted baseline: only new survivors or a score " \
26
+ "drop beyond --epsilon fail (propose one with: saboteur baseline propose)") do |v|
27
+ set(:baseline, v)
28
+ end
29
+ opts.on("--epsilon POINTS", Float, "With --baseline: allowed score drop in percentage points " \
30
+ "(default 0: any drop fails)") { |v| set(:epsilon, v) }
25
31
  opts.on("--[no-]max-duration-ok", "An exhausted --max-duration budget does not fail the run") do |v|
26
32
  set(:max_duration_ok, v)
27
33
  end
@@ -6,8 +6,21 @@ module Saboteur
6
6
  # `saboteur run`: parse, validate, then preview or execute (#334).
7
7
  # Extracted from CLI by command.
8
8
  module RunCommand
9
+ # The baseline-red shapes the engine raises as Saboteur::Error when the
10
+ # unchanged suite itself fails: the user's tests are broken, so no
11
+ # mutant was ever judged. These route to EXIT_BASELINE_RED; every other
12
+ # deliberate raise keeps EXIT_TOOL.
13
+ BASELINE_RED_MESSAGE = /\A#{Regexp.union(
14
+ 'baseline check ', 'baseline has no tests', 'the unchanged suite failed',
15
+ 'the test command fails on the unchanged tree'
16
+ )}/
17
+
9
18
  private
10
19
 
20
+ def baseline_red?(error)
21
+ error.message.to_s.match?(BASELINE_RED_MESSAGE)
22
+ end
23
+
11
24
  def run_command = help_requested? ? show_help("run") : run
12
25
 
13
26
  def run
@@ -26,7 +39,7 @@ module Saboteur
26
39
  rescue ConfigError => e
27
40
  report_failure(e, EXIT_USAGE)
28
41
  rescue Error => e
29
- report_failure(e, EXIT_TOOL)
42
+ report_failure(e, baseline_red?(e) ? EXIT_BASELINE_RED : EXIT_TOOL)
30
43
  end
31
44
  end
32
45
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "../reporter/score_history"
5
+ require_relative "../reporter/history_reader"
5
6
  require_relative "../reporter/history_paths"
6
7
 
7
8
  module Saboteur
@@ -12,10 +13,18 @@ module Saboteur
12
13
 
13
14
  def text_run(report, engine)
14
15
  @err.puts
15
- Reporter::Terminal.new(@out, verbose: @verbose || false, color: @color || :auto,
16
- replay_command: survivor_replay_command(report, engine)).call(report)
17
- Reporter::Replay.new(@out).call(report) if options[:id]
16
+ # Classify before printing: the terminal's closing verdict line names
17
+ # the exit and its reason, so the code must be known up front. The
18
+ # gate and skip notes exit_code writes to @err now precede the
19
+ # report instead of following it.
18
20
  code = exit_code(report)
21
+ Reporter::Terminal.new(@out, verbose: @verbose || false, quiet: @quiet || false, color: @color || :auto,
22
+ overrides: config["palette"], threshold: config["threshold"],
23
+ replay_command: survivor_replay_command(report, engine),
24
+ all_mutants: options[:all_mutants] || false,
25
+ exit_code: code, previous_score: previous_score,
26
+ baseline_gate: (baseline_gate(report) if config["baseline"])).call(report)
27
+ Reporter::Replay.new(@out).call(report) if options[:id]
19
28
  # The JSON carries the exit cause an outer harness trusts, so it is
20
29
  # the completion evidence for the whole run: write it only after
21
30
  # every other fallible reporting stage, or a late failure would
@@ -31,7 +40,9 @@ module Saboteur
31
40
 
32
41
  def agent_run(arguments)
33
42
  tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
34
- engine = Engine.new(config, **arguments)
43
+ # The machine path is exempt from the noise diet: quiet_start: false
44
+ # keeps agent stderr byte-identical to the pre-diet default output.
45
+ engine = Engine.new(config, **arguments, quiet_start: false)
35
46
  report = stdout_on_stderr { engine.call }
36
47
  payload = Reporter::Agent.call(report, agent_provenance(report, tree, engine))
37
48
  rendered = Reporter::Agent.render(payload)
@@ -49,8 +60,13 @@ module Saboteur
49
60
 
50
61
  def execute_run
51
62
  validate_history
52
- arguments = { err: @err }
63
+ # One palette per stream: stdout and stderr detect colour on their
64
+ # own, so a forced terminal report still leaves piped progress plain.
65
+ arguments = { err: @err,
66
+ palette: Reporter::Palette.for(@err, mode: @color || :auto, overrides: config["palette"]) }
53
67
  arguments[:digest] = options[:id] if options[:id]
68
+ arguments[:capture_evidence] = true if config["baseline"]
69
+ arguments[:evidence_files] = [config["baseline"]] if config["baseline"]
54
70
  return agent_run(arguments) if agent?
55
71
 
56
72
  # Text mode only: the engine prints a per-subject marks line when
@@ -63,6 +79,18 @@ module Saboteur
63
79
  # selection options, assembled by the same code that writes the agent
64
80
  # follow-ups, so the user never reassembles them by hand (#261).
65
81
  # Computed lazily: only a run with survivors asks for it.
82
+ # The headline's delta: the covered score of the last history record.
83
+ # The history is auxiliary evidence (#296): anything unreadable simply
84
+ # means no delta, since validate_history reports real corruption.
85
+ def previous_score
86
+ path = config.path("score_history")
87
+ return unless path && File.exist?(path)
88
+
89
+ Reporter::HistoryReader.read(path).last&.fetch("coveredScore", nil)
90
+ rescue Error
91
+ nil
92
+ end
93
+
66
94
  def survivor_replay_command(report, engine)
67
95
  lambda do |digest|
68
96
  tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
@@ -126,7 +154,7 @@ module Saboteur
126
154
  return unless path
127
155
 
128
156
  tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
129
- scope = config.selection_settings.except(*Config::OUTPUT_KEYS)
157
+ scope = config.selection_settings.except(*Config::OUTPUT_KEYS, *Config::PRESENTATION_KEYS)
130
158
  # Recording is an auxiliary artifact: when the file cannot be
131
159
  # written - read-only tree inside a sandboxed child (#296) - warn
132
160
  # and let the run exit by its results instead of failing as a tool
@@ -36,19 +36,52 @@ module Saboteur
36
36
  def validate_run_options
37
37
  reject_format_conflicts
38
38
  validate_shard_options
39
+ raise UsageError, "--ignore-empty applies to --since runs" if config["ignore_empty"] && !config["since"]
40
+
41
+ validate_baseline_options
42
+
39
43
  Selection::GitDiff.verify_ref!(config["since"], root: config.root) if config["since"]
40
44
  end
41
45
 
46
+ # The run gate needs fresh whole-scope execution evidence, so it
47
+ # refuses every flag the evidence capture rejects, up front. A missing
48
+ # or corrupt baseline file is a usage error before the suite boots.
49
+ def validate_baseline_options
50
+ raise UsageError, "--epsilon applies with --baseline" if config["epsilon"] && !config["baseline"]
51
+ if config["epsilon"] && !(0.0..100.0).cover?(config["epsilon"])
52
+ raise UsageError, "--epsilon must be between 0 and 100"
53
+ end
54
+ return unless config["baseline"]
55
+
56
+ conflicts = %w[since incremental threshold resume shard].select { |key| config[key] }
57
+ conflicts << "id" if options[:id]
58
+ unless conflicts.empty?
59
+ flags = conflicts.map { |key| "--#{key}" }.join(", ")
60
+ raise UsageError, "--baseline cannot be combined with #{flags}"
61
+ end
62
+ baseline_manifest
63
+ end
64
+
42
65
  # Agent mode allows no score gates (one strict exit 0); the json inventory is --dry-run planning only.
43
66
  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"
67
+ # --quiet prints only the findings and the verdict; --verbose adds
68
+ # the debug statistics. One output cannot be both.
69
+ raise UsageError, "--quiet cannot be combined with --verbose" if options[:quiet] && options[:verbose]
70
+
71
+ if agent? && (config["threshold"] || config["baseline"] || gates.any?)
72
+ raise UsageError, "#{agent_gate_name} cannot be combined with --agent"
47
73
  end
48
74
  return unless @format == "json" && !options[:dry_run]
49
75
 
50
76
  raise UsageError, "--format json is the --dry-run mutant inventory; a run summary is --format agent"
51
77
  end
78
+
79
+ def agent_gate_name
80
+ return "--threshold" if config["threshold"]
81
+ return "--baseline" if config["baseline"]
82
+
83
+ gates.agent_option_name
84
+ end
52
85
  end
53
86
  end
54
87
  end
@@ -22,6 +22,8 @@ module Saboteur
22
22
  opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
23
23
  opts.on("--shard K/N", "Run one of N deterministic candidate shards (for example 1/4)") { |v| set(:shard, v) }
24
24
  opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
25
+ opts.on("--ignore-empty", "A --since diff that selects no mutants exits 0 with a note instead of " \
26
+ "failing a score gate") { set(:ignore_empty, true) }
25
27
  flaky_desc = "Confirm a fresh verdict (survivors, decisive) with one reordered rerun"
26
28
  opts.on("--flaky-check MODE", %w[off survivors decisive], flaky_desc) { |v| set(:flaky_check, v) }
27
29
  opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
data/lib/saboteur/cli.rb CHANGED
@@ -8,8 +8,10 @@ require_relative "doctor"
8
8
  require_relative "help"
9
9
  require_relative "reporter/replay"
10
10
  require_relative "baseline/command"
11
+ require_relative "baseline/run_gate"
11
12
  require_relative "cli/error_cause"
12
13
  require_relative "cli/exit_status"
14
+ require_relative "cli/baseline_gate"
13
15
  require_relative "preview"
14
16
  require_relative "cli/run_reporting"
15
17
  require_relative "cli/survivors_command"
@@ -39,6 +41,7 @@ module Saboteur
39
41
  class CLI
40
42
  include Baseline::Command
41
43
  include ExitStatus
44
+ include BaselineGate
42
45
  include RunReporting
43
46
  include SurvivorsCommand
44
47
  include SourceSet
@@ -73,6 +76,8 @@ module Saboteur
73
76
  EXIT_USAGE = 2
74
77
  EXIT_TOOL = 3
75
78
  EXIT_BUDGET = 4
79
+ EXIT_BASELINE_RED = 5
80
+ EXIT_CRASH = 70
76
81
  class UsageError < ConfigError; end
77
82
 
78
83
  attr_reader :options
@@ -131,7 +136,7 @@ module Saboteur
131
136
 
132
137
  def agent_requested? = self.class.agent_requested?(@argv)
133
138
 
134
- def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run)
139
+ def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run, :all_mutants, :quiet)
135
140
 
136
141
  def add_subject(expr) = (options[:subjects] ||= []) << expr
137
142
 
@@ -22,6 +22,11 @@ module Saboteur
22
22
  # out of the run fingerprint, baseline evidence and score-history scope.
23
23
  OUTPUT_KEYS = %w[cache_dir json_report html_report score_history resume].freeze
24
24
 
25
+ # Presentation-only settings: like OUTPUT_KEYS they never enter the run
26
+ # fingerprint, baseline evidence or score-history scope, but they are
27
+ # not paths.
28
+ PRESENTATION_KEYS = %w[palette].freeze
29
+
25
30
  DEFAULTS = {
26
31
  "integration" => "rspec",
27
32
  "require" => [],
@@ -77,6 +82,9 @@ module Saboteur
77
82
  # cache miss and compares it with the full map, which the run still uses.
78
83
  # "use" uses the partial map when it is safe and builds the full map otherwise.
79
84
  "partial_coverage" => "off",
85
+ # Total seconds survivor confirmation may spend per run when
86
+ # partial_coverage=use (#1063); the per-survivor spec cap is a constant.
87
+ "confirmation_budget" => 300.0,
80
88
  # coverage (default) reads Coverage around every test; trace gets lines and
81
89
  # methods from a TracePoint and reads branches once per spec file (#725).
82
90
  "coverage_recorder" => "coverage",
@@ -95,6 +103,9 @@ module Saboteur
95
103
  "coverage_parts" => 1,
96
104
  "max_duration" => nil,
97
105
  "max_duration_ok" => false,
106
+ "ignore_empty" => false,
107
+ "baseline" => nil,
108
+ "epsilon" => nil,
98
109
  "tests" => [],
99
110
  "test_command" => nil,
100
111
  "fingerprint_extra" => [],
@@ -103,12 +114,15 @@ module Saboteur
103
114
  "max_errors" => nil,
104
115
  # --verbose also lands here, so boot-time output filters (the rspec
105
116
  # load-error report, #608) can keep their raw text behind the flag.
106
- "verbose" => false
117
+ "verbose" => false,
118
+ # Text-report colour overrides: token -> ANSI colour name, validated
119
+ # against Reporter::Palette. Presentation only, never fingerprinted.
120
+ "palette" => {}
107
121
  }.freeze
108
122
 
109
123
  INTEGER_KEYS = %w[jobs max_memory_mb max_processes max_mutants_per_method seed baseline_passes
110
124
  coverage_parts].freeze
111
- FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout].freeze
125
+ FLOAT_KEYS = %w[timeout_factor timeout_min baseline_timeout coverage_timeout confirmation_budget].freeze
112
126
  STRING_KEYS = %w[test_selection partial_coverage coverage_recorder].freeze
113
127
  NILABLE_FLOAT_KEYS = %w[threshold max_duration].freeze
114
128
  THRESHOLD_RANGE = (0.0..100.0)
@@ -321,6 +335,7 @@ module Saboteur
321
335
  end
322
336
 
323
337
  def validate_modes!
338
+ validate_palette!
324
339
  validate_fail_fast!
325
340
  validate_matrix!
326
341
  validate_resume!
@@ -427,6 +442,12 @@ module Saboteur
427
442
  raise ConfigError, 'test_command runs the command unsandboxed, so sandbox "strict" cannot be honored'
428
443
  end
429
444
 
445
+ def validate_palette!
446
+ Reporter::Palette.validate_overrides!(self["palette"])
447
+ rescue ArgumentError => e
448
+ raise ConfigError, e.message
449
+ end
450
+
430
451
  def validate_reeval!
431
452
  raise ConfigError, "reeval must be one of: #{Execution::Reevaluator::STRATEGIES.join(', ')}" unless
432
453
  Execution::Reevaluator::STRATEGIES.include?(self["reeval"].to_s)
@@ -6,18 +6,26 @@ require "json"
6
6
  require "tmpdir"
7
7
  require "pathname"
8
8
 
9
+ require_relative "reporter/palette"
10
+
9
11
  module Saboteur
10
12
  # Last-resort crash reporting, shared by the CLI's StandardError rescue
11
13
  # and the exe-level wrapper: one bounded line on stderr, the full
12
14
  # backtrace in a log file the line names, the backtrace on stderr with
13
15
  # --verbose, and the same failure as agent JSON when an agent listens.
14
- # Always returns EXIT_TOOL.
16
+ # Always returns EXIT_CRASH (70): a crash is a saboteur bug, never a
17
+ # check the user can fix.
15
18
  module CrashReport
16
19
  ISSUES_URL = "https://github.com/vamsipavanmahesh/saboteur/issues"
17
20
 
18
- # Same value as CLI::EXIT_TOOL, kept local so the reporter still works
21
+ # Same value as CLI::EXIT_CRASH, kept local so the reporter still works
19
22
  # when the CLI itself never finished loading.
20
- EXIT_TOOL = 3
23
+ EXIT_CRASH = 70
24
+
25
+ # The crash cause's verdict line, kept in sync with
26
+ # CLI::ErrorCause::CAUSE_LINES["crash"] by the cause pins: the reporter
27
+ # cannot load the CLI's vocabulary when the CLI itself never loaded.
28
+ VERDICT_LINE = "exit 70 - internal crash (a saboteur bug, never your code)"
21
29
 
22
30
  # The crash log's file name under the run's cache dir; the runner reads
23
31
  # it back to explain a nested run's crash in the failure detail (#296).
@@ -25,15 +33,16 @@ module Saboteur
25
33
 
26
34
  module_function
27
35
 
28
- def call(error, verbose:, agent:, err:, out:, log_path: nil)
36
+ def call(error, verbose:, agent:, err:, out:, log_path: nil, color: :auto)
29
37
  summary = "internal error (#{error.class}: #{one_line(error.message)})"
30
38
  line = "saboteur: #{summary} - please report it: #{ISSUES_URL}"
31
39
  written = write_log(log_path, error)
32
40
  line = "#{line}; full backtrace: #{display_path(written)}" if written
33
41
  err.puts(line)
42
+ err.puts(Reporter::Palette.for(err, mode: color).paint(:fail, VERDICT_LINE))
34
43
  err.puts(full_text(error)) if verbose
35
44
  out.print(agent_json(summary)) if agent
36
- EXIT_TOOL
45
+ EXIT_CRASH
37
46
  end
38
47
 
39
48
  # A crash message can span lines or carry bytes that are invalid in
@@ -59,9 +68,9 @@ module Saboteur
59
68
  def agent_json(summary)
60
69
  payload =
61
70
  if defined?(Reporter::Agent)
62
- Reporter::Agent.failure(summary, EXIT_TOOL)
71
+ Reporter::Agent.failure(summary, EXIT_CRASH)
63
72
  else
64
- { version: 1, status: "error", verdict: "tool_failure", exitCode: EXIT_TOOL,
73
+ { version: 1, status: "error", verdict: "tool_failure", exitCode: EXIT_CRASH,
65
74
  message: summary, next: { help: "saboteur help run" } }
66
75
  end
67
76
  "#{JSON.pretty_generate(payload)}\n"
@@ -46,7 +46,7 @@ module Saboteur
46
46
  expected.empty?
47
47
 
48
48
  passes = @config["baseline_passes"]
49
- @diagnostics.inform(announce(passes))
49
+ @diagnostics.detail(announce(passes))
50
50
  previous = nil
51
51
  passes.times do |index|
52
52
  break if budget_spent?(index)
@@ -114,7 +114,7 @@ module Saboteur
114
114
  work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
115
115
  work << "the coverage map build" if coverage_pending
116
116
  total = remaining + (coverage_pending ? 1 : 0)
117
- @diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
117
+ @diagnostics.detail("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
118
118
  "#{work.join(' and ')} at that rate")
119
119
  end
120
120
 
@@ -12,12 +12,19 @@ module Saboteur
12
12
  # shadow helpers it shares (shadow_invalidation, shadow_rerun,
13
13
  # shadow_clean).
14
14
  module CoveragePartial
15
+ # The full opt-in predicate, factored out for survivor confirmation
16
+ # (#1063): partial coverage only applies to a cached, unscoped,
17
+ # single-part run.
18
+ def self.use?(config, parts)
19
+ config["partial_coverage"] == "use" && config["cache"] && config["tests"].empty? && parts == 1
20
+ end
21
+
15
22
  private
16
23
 
17
24
  # Same preconditions as the shadow run: a scoped or parted map is not
18
25
  # what a partial rebuild starts from.
19
26
  def partial_use?
20
- @config["partial_coverage"] == "use" && @config["cache"] && @config["tests"].empty? && @parts == 1
27
+ CoveragePartial.use?(@config, @parts)
21
28
  end
22
29
 
23
30
  def partial_map(files, note, preloaded)
@@ -393,11 +393,10 @@ module Saboteur
393
393
 
394
394
  def preloaded_message(files)
395
395
  names = Text.name_list(files.map { |file| file.delete_prefix("#{@root}/") })
396
- "#{names} loaded before saboteur started coverage (a require ran before the " \
397
- "executable's Coverage.start - commonly the gemspec requiring the library while bundler/setup " \
398
- "evaluates it, or RUBYOPT -r), so no test can ever cover #{files.size > 1 ? 'them' : 'it'}. " \
399
- "Let the test suite load these " \
400
- "files; a gemspec should require only its version file."
396
+ "#{names} can never be covered: loaded before saboteur started coverage\n " \
397
+ "a require ran before the executable's Coverage.start, commonly the gemspec requiring the " \
398
+ "library while bundler/setup evaluates it, or RUBYOPT -r\n " \
399
+ "Let the test suite load these files; a gemspec should require only its version file."
401
400
  end
402
401
 
403
402
  # Bootsnap is named only when it is loaded: blaming a library the app