saboteur 0.12.1 → 0.12.2

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +16 -3
  4. data/lib/saboteur/baseline/command.rb +6 -1
  5. data/lib/saboteur/baseline/comparison.rb +8 -0
  6. data/lib/saboteur/baseline/completeness.rb +14 -4
  7. data/lib/saboteur/baseline/evidence.rb +8 -1
  8. data/lib/saboteur/baseline/run_evidence.rb +10 -1
  9. data/lib/saboteur/baseline/workflow.rb +17 -5
  10. data/lib/saboteur/cli/color_option.rb +26 -0
  11. data/lib/saboteur/cli/doctor_command.rb +1 -0
  12. data/lib/saboteur/cli/exit_status.rb +4 -3
  13. data/lib/saboteur/cli/explain_command.rb +2 -1
  14. data/lib/saboteur/cli/failure_output.rb +13 -2
  15. data/lib/saboteur/cli/help_command.rb +5 -0
  16. data/lib/saboteur/cli/history_command.rb +2 -0
  17. data/lib/saboteur/cli/output_options.rb +1 -5
  18. data/lib/saboteur/cli/run_command.rb +1 -1
  19. data/lib/saboteur/cli/run_reporting.rb +1 -9
  20. data/lib/saboteur/cli/run_validation.rb +0 -1
  21. data/lib/saboteur/cli/survivors_command.rb +5 -2
  22. data/lib/saboteur/cli/whole_suite_map_lookup.rb +33 -0
  23. data/lib/saboteur/cli.rb +6 -2
  24. data/lib/saboteur/engine/coverage_store.rb +7 -4
  25. data/lib/saboteur/engine/diagnostics.rb +7 -0
  26. data/lib/saboteur/engine/pipelines.rb +9 -2
  27. data/lib/saboteur/engine/survivor_confirmation.rb +75 -11
  28. data/lib/saboteur/engine/test_guess.rb +2 -1
  29. data/lib/saboteur/engine.rb +34 -16
  30. data/lib/saboteur/execution/runner.rb +3 -0
  31. data/lib/saboteur/gates.rb +1 -1
  32. data/lib/saboteur/integration/rspec.rb +30 -0
  33. data/lib/saboteur/reporter/default_findings.rb +1 -1
  34. data/lib/saboteur/reporter/gap_kinds.rb +6 -0
  35. data/lib/saboteur/reporter/next_steps.rb +3 -1
  36. data/lib/saboteur/reporter/score_table.rb +6 -11
  37. data/lib/saboteur/reporter/stryker.rb +7 -2
  38. data/lib/saboteur/reporter/survivor_classification.rb +15 -0
  39. data/lib/saboteur/reporter/survivor_list.rb +6 -6
  40. data/lib/saboteur/reporter/terminal.rb +4 -2
  41. data/lib/saboteur/reporter/unconfirmed_section.rb +44 -0
  42. data/lib/saboteur/result.rb +39 -2
  43. data/lib/saboteur/selection/coverage_map.rb +11 -1
  44. data/lib/saboteur/version.rb +1 -1
  45. data/lib/saboteur.rb +4 -0
  46. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9537980fed0394bdadcbd64d8e637e55cfb42925b5673d2ee30e6f0b18bb13e
4
- data.tar.gz: a628132a21155e240bc856c99707f46323f280929344048be98a746fa74d1c9b
3
+ metadata.gz: ce718289117e022594f50dfb13bfaf3feacf03f99dc1a5b73c7c2463581d24f2
4
+ data.tar.gz: f99866dbeacb50607ac66ee9f15582b49b1596b318cba1598afdb7bac89a9133
5
5
  SHA512:
6
- metadata.gz: c96be31b305e173850739c426f5343fb6a24b416cb5f923b30cfea4a8764fb0c9dd3eeeb5dc8b00af132e16267e8e4135ea84d2d5669b38e35433323e94c072c
7
- data.tar.gz: 9763cf728f40ad70a714665bc4cdf09ce0a9d516d7da9101bb137f60cdb378a5715d321d8b71d725ac635736b167a9ce6ec3b8c5e99632ef1f204cea49ad3e94
6
+ metadata.gz: 2ef5f5dc4d8d441d35558569bd5649bc4d7bbece07bde9172bd6d660a8d54e0274cc02b36f2968f4e129080cc3ccc3c5bdb3a091c0fbe55341a67d53bf967540
7
+ data.tar.gz: 9f6d1c79c63abc5d314f79f699bb9ccfa9de5bdb2a8c7d4240d311854774c4eb4c3c40950234f206bb62872008b5525181bd20b13325b080f680ebc4227ad6e5
data/CHANGELOG.md CHANGED
@@ -6,6 +6,75 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.2] - 2026-10-22
10
+
11
+ ### Added
12
+
13
+ - A scoped run (`--since`, `--subject`, or the default spec inference)
14
+ confirms its survivors against the whole-suite coverage map when one
15
+ exists. Each survivor gets one budgeted confirmation run of the covering
16
+ specs the run did not load. A mutant those specs kill becomes killed,
17
+ credited to the killing test in the JSON report, and counts in the score
18
+ and exit code. A survivor whose covering specs could not be run is listed
19
+ as unconfirmed, left out of the score and exit code, and ranked below
20
+ confirmed survivors. A map that is older than the tree and says nothing
21
+ about a survivor's lines keeps that survivor counted, with one line
22
+ naming how many. A covering spec outside the project root is never
23
+ loaded (#1286, #1304).
24
+ - `--color` and `--no-color` work on every text command, and a usage error
25
+ names the command that failed instead of showing `saboteur run` usage
26
+ (#1288, #1301).
27
+
28
+ ### Changed
29
+
30
+ - Only `saboteur run --all` records the whole-suite coverage map. A run
31
+ built from inferred specs no longer claims to be whole-suite. Without a
32
+ whole-suite map a scoped run prints one line saying survivors are
33
+ unconfirmed and how to build the map, and each survivor block says its
34
+ covering list is this run's selection only (#1286, #1300).
35
+ - A statement's gap label comes from all of its surviving mutants with a
36
+ fixed precedence: result unchecked, error path, branch, boundary,
37
+ effect, number, instead of from the first mutant (#1289, #1297).
38
+ - `baseline propose` records a mutant on a line no test runs as a gap
39
+ instead of an unexecuted blocker, so a tested method with one untested
40
+ line can still be proposed (#1287, #1299).
41
+ - A config-less Rails app's default `app/**/*.rb` include counts as a
42
+ full run, so the stale ignore entry warning fires there (#1255, #1296).
43
+ - `--quiet` no longer prints the missing-config notice, the coverage cache
44
+ miss or the map-build lines (#1264, #1293).
45
+ - Output polish: the run header names the `--subject` scope, the
46
+ no-spec note reads in the singular, untested subjects are named once
47
+ under "Not scored", and the replay example uses the top-ranked
48
+ survivor (#1291, #1298).
49
+ - Report test ids in `killedBy`, `coveredBy` and `testFiles` are numbered
50
+ by sorted test name, so the same tests keep the same ids across runs
51
+ (#1282, #1295). Covering tests are ordered by millisecond buckets, so
52
+ kill attribution is stable (#1274, #1283).
53
+
54
+ ### Fixed
55
+
56
+ - An empty `--since` diff whose mutants all failed generation no longer
57
+ exits 0 under `--threshold`, and `--ignore-empty` no longer fails a run
58
+ without `--since` (#1265, #1266, #1284).
59
+ - `saboteur survivors` no longer drops an unknown keyword silently
60
+ (#1267, #1285).
61
+
62
+ ### Corrected
63
+
64
+ - 0.12.0 said uncovered survivors are labeled `input_gap`, "separate from
65
+ assertion gaps" (#1254, #1157). Only `input_gap` exists, meaning no test
66
+ ran the mutant. There is no assertion-gap label yet (#1064).
67
+ - 0.11.2 said survivor blocks consult the whole-suite coverage map,
68
+ "ending false survivors from diff-scoped runs" (#1252). That change
69
+ only affects which covering tests a block lists. A survivor whose real
70
+ test sits in a spec file saboteur did not select can still be reported
71
+ as a survivor; see "Know the limits" in the README.
72
+
73
+ ### Tests
74
+
75
+ - The worker pool and supervisor specs assert concurrency with a barrier
76
+ instead of a wall-clock bound (#1302, #1303).
77
+
9
78
  ## [0.12.1] - 2026-10-22
10
79
 
11
80
  ### Changed
data/README.md CHANGED
@@ -80,15 +80,19 @@ tree refuses with exit 2 and points at the generator. Engine gem with
80
80
  assertion, 1 by a crash`), and an error-dominated run prints
81
81
  `Score: n/a` instead of a number you would misread.
82
82
  - 🔍 **Survivors you can act on.** Survivors group per statement with a
83
- triage label (`likely_equivalent`, `arid`, `tool_limit`, or a gap kind) and their
83
+ triage label (`likely_equivalent`, `arid`, `tool_limit`, `input_gap` when no test
84
+ ran the mutant, or a gap kind) and their
84
85
  covering tests - split into the tests that ran against the mutant and
85
86
  the ones that never did. `saboteur survivors` browses them read-only:
86
87
  one digest shows the full diff and the exact replay command.
87
88
  - 🧪 **Flakiness contained.** `--flaky-check survivors` re-runs each new
88
89
  survivor in a reordered confirmation pass; a verdict the rerun disagrees
89
90
  with becomes `unverified` and stays out of the score. With
90
- `partial_coverage = "use"`, open survivors get one budgeted confirmation
91
- run against the covering specs that never ran them.
91
+ `partial_coverage = "use"`, open
92
+ survivors get one budgeted confirmation run against the covering specs
93
+ that never ran them when the map is built from every spec (`--all`, no
94
+ `--test`, one coverage part, cache on). Default inferred-spec runs, `pr`
95
+ included, skip it.
92
96
  - 📊 **Reports for people and tools.** Terminal, HTML, a Stryker-compatible
93
97
  JSON report, and a stable agent JSON API with a deprecation policy. The
94
98
  GitHub Action posts survivors as review comments on your PR diff lines.
@@ -127,6 +131,15 @@ tree refuses with exit 2 and points at the generator. Engine gem with
127
131
  - A survivor is review evidence, not proof of a bug in your tests. It can
128
132
  be a missing test, an equivalent change, a flaky test, or a limit of
129
133
  the tool. Read it before you write a test for it.
134
+ - A survivor can be false when the test that kills it lives in a spec
135
+ file saboteur did not select. Runs without `--test` pick specs by file
136
+ name and by the constants they read, so a test in a differently named
137
+ spec (say `spec/integration/flow_spec.rb` for `lib/ledger/router.rb`)
138
+ may never run against the mutant, and the "covered by ... ran, still
139
+ passed" lines list the specs that ran, plus any others marked "not run
140
+ here". If you know
141
+ another spec exercises the code, run again with `--test` naming it
142
+ before you trust the survivor.
130
143
  - `--since` and `--incremental` are iteration aids. Run the whole project
131
144
  on main and before a release.
132
145
  - Sandboxing varies by platform and network access is never restricted.
@@ -21,6 +21,7 @@ module Saboteur
21
21
  opts.on("--subject EXPR", "Whole subject filter; repeatable") { |value| add_subject(value) }
22
22
  opts.on("--seed N", Integer, "Repeatable run seed") { |value| set(:seed, value) }
23
23
  opts.on("--format FORMAT", "propose: terminal (default) or json") { |value| @format = value }
24
+ define_color_options(opts)
24
25
  end
25
26
  end
26
27
 
@@ -86,7 +87,11 @@ module Saboteur
86
87
  survivors = payload.fetch("survivors")
87
88
  @out.puts(" matched mutants: #{payload.fetch('candidates')} " \
88
89
  "(#{plural(survivors, 'survivor')} to reason about)")
89
- gaps = payload.fetch("gaps", [])
90
+ uncovered, gaps = payload.fetch("gaps", []).partition { |gap| gap.start_with?("no_coverage:") }
91
+ unless uncovered.empty?
92
+ @out.puts(" proposed with #{plural(uncovered.size, 'mutant')} on lines no test runs - " \
93
+ "the baseline cannot speak for them")
94
+ end
90
95
  unless gaps.empty?
91
96
  names = gaps.map { |gap| gap.sub(/\A(subject_skipped|omitted):/, "") }
92
97
  @out.puts(" proposed with #{plural(names.size, 'untested subject')}: #{names.join(', ')} - " \
@@ -38,7 +38,10 @@ module Saboteur
38
38
  def classify(baseline, current)
39
39
  categories = CATEGORY_NAMES.to_h { |name| [name, []] }
40
40
  previous = baseline.fetch("accepted").to_h { |entry| [entry.fetch("id"), entry] }
41
+ uncovered = uncovered_ids(current)
41
42
  current.fetch("candidates").each do |entry|
43
+ next if uncovered.include?(entry.fetch("id"))
44
+
42
45
  accepted = previous.delete(entry.fetch("id"))
43
46
  name = category(entry, accepted)
44
47
  categories.fetch(name) << entry if name
@@ -48,6 +51,11 @@ module Saboteur
48
51
  categories
49
52
  end
50
53
 
54
+ # Mutants on lines no test runs are listed as gaps, never as errors.
55
+ def uncovered_ids(current)
56
+ Completeness.uncovered_ids(current.fetch("gaps", []))
57
+ end
58
+
51
59
  def category(entry, accepted)
52
60
  case entry.fetch("status")
53
61
  when "survived"
@@ -7,17 +7,24 @@ module Saboteur
7
7
  class Completeness
8
8
  EXECUTED = %w[killed survived timeout].freeze
9
9
  # Soft gaps limit what the baseline can speak for; they never refuse a
10
- # proposal. Everything else is a hard blocker.
11
- SOFT_PREFIXES = %w[subject_skipped: omitted:].freeze
10
+ # proposal. Everything else is a hard blocker. no_coverage: is a mutant
11
+ # on a line no test runs inside a tested subject (#1287).
12
+ SOFT_PREFIXES = %w[subject_skipped: omitted: no_coverage:].freeze
12
13
  attr_reader :issues
13
14
 
15
+ # The mutant ids named by no_coverage: gaps.
16
+ def self.uncovered_ids(gaps)
17
+ gaps.filter_map { |gap| gap.delete_prefix("no_coverage:") if gap.start_with?("no_coverage:") }
18
+ end
19
+
14
20
  def self.soft_gap?(issue)
15
21
  SOFT_PREFIXES.any? { |prefix| issue.start_with?(prefix) }
16
22
  end
17
23
 
18
24
  def initialize(planned:, results:, subjects:, stable_suite:, stable_inputs:, skipped_subjects:, omitted:,
19
- generation_issues:)
25
+ generation_issues:, uncovered: [])
20
26
  @issues = []
27
+ @uncovered = uncovered
21
28
  issues << "unchanged_suite_incomplete" unless stable_suite
22
29
  issues << "inputs_unverified" unless stable_inputs
23
30
  validate_inventory(planned, subjects)
@@ -66,7 +73,10 @@ module Saboteur
66
73
  def validate_results(results)
67
74
  results.each do |entry|
68
75
  status = entry.fetch("status")
69
- issues << "unexecuted:#{entry.fetch('id')}:#{status}" unless EXECUTED.include?(status)
76
+ next if EXECUTED.include?(status)
77
+
78
+ id = entry.fetch("id")
79
+ issues << (@uncovered.include?(id) ? "no_coverage:#{id}" : "unexecuted:#{id}:#{status}")
70
80
  end
71
81
  end
72
82
  end
@@ -47,11 +47,18 @@ module Saboteur
47
47
  entry.slice("id", "context").merge("reason" => reasons)
48
48
  end
49
49
  { "format" => Manifest::FORMAT, "identity_version" => Identity::VERSION,
50
- "metadata" => metadata, "candidates" => document.fetch("candidates"), "accepted" => accepted }
50
+ "metadata" => metadata, "candidates" => executed_candidates, "accepted" => accepted }
51
51
  end
52
52
 
53
53
  private
54
54
 
55
+ # A mutant on a line no test runs is a gap in the proposal's issues,
56
+ # not a candidate: the baseline has no verdict to compare it against.
57
+ def executed_candidates
58
+ uncovered = Completeness.uncovered_ids(document.fetch("gaps"))
59
+ document.fetch("candidates").reject { |entry| uncovered.include?(entry.fetch("id")) }
60
+ end
61
+
55
62
  def array!(value, name)
56
63
  raise ConfigError, "baseline #{name}" unless value.is_a?(Array)
57
64
  end
@@ -43,6 +43,7 @@ module Saboteur
43
43
  errors = generation_issues(generation, results) + context_issues(observed) + discovery_issues(discovery)
44
44
  suppressions = suppression_scope(inline_suppressions)
45
45
  errors += suppression_issues(suppressions)
46
+ @uncovered = uncovered_ids(results)
46
47
  completeness = assess(observed, stable_suite, skipped_subjects, omissions, errors)
47
48
  document = { "compatibility" => compatibility, "snapshot" => @snapshot.to_h, "subjects" => @subjects,
48
49
  "candidates" => observed, "planned" => @planned,
@@ -83,13 +84,21 @@ module Saboteur
83
84
  end
84
85
  end
85
86
 
87
+ # Mutants skipped only because no test runs their line.
88
+ def uncovered_ids(results)
89
+ uncovered = results.select { |result| result.reason&.start_with?("no coverage") }
90
+ uncovered.map do |result|
91
+ Identity.new(subject: result.subject, mutation: result.mutation, root: @root).entry.fetch("id")
92
+ end
93
+ end
94
+
86
95
  def assess(observed, stable_suite, skipped_subjects, omissions, errors)
87
96
  skipped = skipped_subjects.map { |subject, reason| "#{subject.id}:#{reason}" }
88
97
  Completeness.new(planned: @planned.map { |entry| entry.fetch("id") }, results: observed,
89
98
  subjects: @subjects, stable_suite: stable_suite,
90
99
  stable_inputs: @fingerprint.verify(@snapshot).unchanged, skipped_subjects: skipped,
91
100
  omitted: omissions.map { |entry| "#{entry.subject}:#{entry.count}" },
92
- generation_issues: errors)
101
+ generation_issues: errors, uncovered: @uncovered)
93
102
  end
94
103
 
95
104
  def compatibility
@@ -13,6 +13,8 @@ module Saboteur
13
13
  # Opt-in orchestration: every check/proposal runs fresh; acceptance only
14
14
  # edits reasons after verifying private execution evidence and current inputs.
15
15
  class Workflow
16
+ UNEXECUTED = /\Aunexecuted:([0-9a-f]{64}):(\w+)\z/
17
+
16
18
  def initialize(config:, err: $stderr)
17
19
  @config = config
18
20
  @err = err
@@ -50,20 +52,30 @@ module Saboteur
50
52
  [Identity.new(subject: result.subject, mutation: result.mutation, root: @root).entry.fetch("id"), result]
51
53
  end
52
54
  blockers.each { |issue| @err.puts("saboteur: baseline proposal blocker: #{describe_blocker(issue, by_id)}") }
53
- if blockers.any? { |issue| issue.start_with?("unexecuted:") }
54
- @err.puts("saboteur: cover the skipped code with specs or narrow include, then propose again")
55
- end
55
+ advise_unexecuted(blockers, by_id)
56
56
  raise Error, "baseline proposal needs a proposable run"
57
57
  end
58
58
 
59
+ # Narrowing include cannot add line coverage, so a blocker whose only
60
+ # cause is no coverage gets the specs advice alone.
61
+ def advise_unexecuted(blockers, by_id)
62
+ unexecuted = blockers.grep(/\Aunexecuted:/)
63
+ return if unexecuted.empty?
64
+
65
+ results = unexecuted.map { |issue| by_id[UNEXECUTED.match(issue)&.[](1)] }
66
+ coverage_only = results.all? { |result| result&.reason&.start_with?("no coverage") }
67
+ advice = coverage_only ? "specs" : "specs or narrow include"
68
+ @err.puts("saboteur: cover the skipped code with #{advice}, then propose again")
69
+ end
70
+
59
71
  def describe_blocker(issue, by_id)
60
- match = /\Aunexecuted:([0-9a-f]{64}):(\w+)\z/.match(issue)
72
+ match = UNEXECUTED.match(issue)
61
73
  result = match && by_id[match[1]]
62
74
  return issue unless result
63
75
 
64
76
  detail = "#{result.subject.id} #{result.mutation.operator}"
65
77
  detail += " - #{result.reason}" if result.reason
66
- "#{issue} (#{detail})"
78
+ "unexecuted:#{result.digest}:#{match[2]} (#{detail})"
67
79
  end
68
80
 
69
81
  def propose(proposal:, destination:, previous: nil)
@@ -0,0 +1,26 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The colour switches every command that prints text accepts (#1288).
7
+ # --no-color stays as a deprecated alias for --color=never.
8
+ module ColorOption
9
+ private
10
+
11
+ def define_color_options(opts)
12
+ opts.on("--color [MODE]", %w[always never auto],
13
+ "Colour the text report: always, never or auto (default: auto)") do |value|
14
+ @color = (value || :always).to_sym
15
+ end
16
+ opts.on("--no-color", "Deprecated alias for --color=never") { @color = :never }
17
+ end
18
+
19
+ # Consume leading colour switches for commands that otherwise take
20
+ # positional arguments only (help, explain).
21
+ def consume_color_options
22
+ OptionParser.new { |opts| define_color_options(opts) }.order!(@argv)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -15,6 +15,7 @@ module Saboteur
15
15
  opts.on("--test PATH", "Spec file or directory for the suite runs; repeatable. Implies --suite") do |value|
16
16
  (options[:tests] ||= []) << value
17
17
  end
18
+ define_color_options(opts)
18
19
  end
19
20
  end
20
21
 
@@ -32,13 +32,14 @@ module Saboteur
32
32
  # A --since diff that selected no mutants exits 0 with a plain note,
33
33
  # with or without --ignore-empty: a docs-only change has no score, so a
34
34
  # threshold must not fail it (#1142). Suppressed, omitted or
35
- # undiscovered mutants still gate - the selection must be honestly
36
- # empty, not just empty-handed.
35
+ # undiscovered mutants still gate, and so do mutants dropped because they
36
+ # did not parse (#1265) - the selection must be honestly empty, not just
37
+ # empty-handed.
37
38
  def empty_clean_diff?(report)
38
39
  return false unless config["since"] && report.results.empty?
39
40
  return false if unverifiable_scope?(report)
40
41
  return false unless report.omissions.empty? && report.inline_suppressions.empty? &&
41
- report.discovery_failures.empty?
42
+ report.discovery_failures.empty? && report.invalid_mutations.zero?
42
43
 
43
44
  @err.puts("saboteur: no mutants in the diff - nothing to gate")
44
45
  true
@@ -15,6 +15,7 @@ module Saboteur
15
15
  private
16
16
 
17
17
  def explain_command
18
+ consume_color_options
18
19
  name = @argv.shift
19
20
  raise UsageError, "usage: saboteur explain <operator>" if name.nil?
20
21
 
@@ -27,7 +28,7 @@ module Saboteur
27
28
  return EXIT_USAGE
28
29
  end
29
30
 
30
- @out.puts(render_explain(entry, palette: Reporter::Palette.for(@out, mode: :auto)))
31
+ @out.puts(render_explain(entry, palette: Reporter::Palette.for(@out, mode: @color || :auto)))
31
32
  EXIT_OK
32
33
  rescue UsageError => e
33
34
  @err.puts("saboteur: #{e.message}")
@@ -26,8 +26,19 @@ module Saboteur
26
26
 
27
27
  def usage_error(message = nil)
28
28
  @err.puts(Reporter::Palette.for(@err, mode: @color || :auto).paint(:fail, message)) if message
29
- @err.puts(USAGE)
30
- agent_failure(message || USAGE, EXIT_USAGE)
29
+ usage = command_usage
30
+ @err.puts(usage)
31
+ agent_failure(message || usage, EXIT_USAGE)
32
+ end
33
+
34
+ # The usage line of the command that failed, never run's for another
35
+ # command (#1288).
36
+ def command_usage
37
+ banner = { "doctor" => :doctor_parser, "baseline" => :baseline_parser, "badge" => :badge_parser,
38
+ "trend" => :trend_parser }[@command]
39
+ return USAGE unless banner
40
+
41
+ "#{send(banner).banner}\nRun 'saboteur help #{@command}' for every option."
31
42
  end
32
43
 
33
44
  def agent? = @format == "agent"
@@ -8,6 +8,11 @@ module Saboteur
8
8
  module HelpCommand
9
9
  private
10
10
 
11
+ def help_command
12
+ consume_color_options
13
+ show_help(@argv.shift)
14
+ end
15
+
11
16
  def help_requested? = @argv.intersect?(%w[--help -h])
12
17
 
13
18
  def show_help(command_name)
@@ -20,6 +20,7 @@ module Saboteur
20
20
  opts.on("--mark-partial", "Badge an incomplete run, labeled as partial") { @mark_partial = true }
21
21
  opts.on("--high PERCENT", Float, "brightgreen at or above (default #{DEFAULT_HIGH.to_i})") { |v| @high = v }
22
22
  opts.on("--low PERCENT", Float, "yellow at or above (default #{DEFAULT_LOW.to_i})") { |v| @low = v }
23
+ define_color_options(opts)
23
24
  end
24
25
  end
25
26
 
@@ -27,6 +28,7 @@ module Saboteur
27
28
  OptionParser.new do |opts|
28
29
  opts.banner = "Usage: saboteur trend [--history PATH]"
29
30
  opts.on("--history PATH", "Score history JSONL (default: c.score_history)") { |v| @history_path = v }
31
+ define_color_options(opts)
30
32
  end
31
33
  end
32
34
 
@@ -29,10 +29,7 @@ module Saboteur
29
29
  @verbose = true
30
30
  set(:verbose, true)
31
31
  end
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
32
+ define_color_options(opts)
36
33
 
37
34
  # PR6: CI and pre-push output - the findings and the verdict only.
38
35
  opts.on("--quiet", "Text report: print only the findings and the verdict;",
@@ -46,7 +43,6 @@ module Saboteur
46
43
  # top three; --details restores a block for every finding.
47
44
  opts.on("--details", "Text report: full blocks for every finding;",
48
45
  "default: one-line list plus the top 3 blocks") { set(:details, true) }
49
- opts.on("--no-color", "Deprecated alias for --color=never") { @color = :never }
50
46
  end
51
47
  end
52
48
  end
@@ -22,7 +22,7 @@ module Saboteur
22
22
  # there is no init command - the Rails generator writes the config.
23
23
  def announce_missing_config_file(palette)
24
24
  # @format, not agent_requested?: parse! has already eaten --format.
25
- return if @format == "agent" || config.config_file_present
25
+ return if @format == "agent" || options[:quiet] || config.config_file_present
26
26
 
27
27
  path = options[:config] || Config::FILE
28
28
  notice =
@@ -23,7 +23,7 @@ module Saboteur
23
23
  replay_command: survivor_replay_command(report, engine),
24
24
  all_mutants: options[:all_mutants] || false, details: options[:details] || false,
25
25
  exit_code: code, previous_score: previous_score, scope: scope_label,
26
- whole_suite_map: whole_suite_map_for(engine),
26
+ whole_suite_map: whole_suite_map_for(engine, report),
27
27
  run_map: engine&.run_coverage_map,
28
28
  baseline_gate: (baseline_gate(report) if config["baseline"])).call(report)
29
29
  Reporter::Replay.new(@out).call(report) if options[:id]
@@ -40,14 +40,6 @@ module Saboteur
40
40
  code
41
41
  end
42
42
 
43
- # No engine or no cache: no whole-suite map can exist, so the survivor
44
- # block keeps its legacy covered-by line over an impossible build (review C1).
45
- def whole_suite_map_for(engine)
46
- return engine.whole_suite_coverage_map if engine&.whole_suite_map_lookup?
47
-
48
- Reporter::SurvivorList::NO_WHOLE_SUITE_LOOKUP
49
- end
50
-
51
43
  def agent_run(arguments)
52
44
  tree = WorkingTree.new(config.root, ignored: config.ignored_tree_paths).state
53
45
  # The machine path is exempt from the noise diet: quiet_start: false
@@ -36,7 +36,6 @@ 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
39
 
41
40
  validate_baseline_options
42
41
 
@@ -49,7 +49,10 @@ module Saboteur
49
49
  if options[:show]
50
50
  @out.puts(browser.show(options[:show]))
51
51
  else
52
- @out.puts(browser.list(**options.slice(:file, :operator, :triage)))
52
+ # Explicit keywords, and list takes no catch-all (#1267): a new
53
+ # parser option that never reaches the browser raises here instead
54
+ # of being swallowed. --ranked is the documented compat no-op.
55
+ @out.puts(browser.list(file: options[:file], operator: options[:operator], triage: options[:triage]))
53
56
  end
54
57
  EXIT_OK
55
58
  end
@@ -106,7 +109,7 @@ module Saboteur
106
109
  # One ordering everywhere (#1150): the terminal report's ranked order
107
110
  # is the default, exactly as the findings carry it; --ranked stays
108
111
  # accepted for compatibility.
109
- def list(file: nil, operator: nil, triage: nil, **)
112
+ def list(file: nil, operator: nil, triage: nil)
110
113
  survivors = ranked_order(filtered(file, operator, triage))
111
114
  return "no survivors#{filters_note(file, operator, triage)}" if survivors.empty?
112
115
 
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ class CLI
6
+ # The whole-suite coverage map a finished run hands to survivor
7
+ # presentation, and the notice for a scoped run that has none.
8
+ module WholeSuiteMapLookup
9
+ private
10
+
11
+ # No engine or no cache: no whole-suite map can exist, so the survivor
12
+ # block keeps its legacy covered-by line over an impossible build (review C1).
13
+ def whole_suite_map_for(engine, report)
14
+ warn_survivors_unconfirmed(report, engine)
15
+ return engine.whole_suite_coverage_map if engine&.whole_suite_map_lookup?
16
+
17
+ Reporter::SurvivorList::NO_WHOLE_SUITE_LOOKUP
18
+ end
19
+
20
+ # A scoped run's survivors may be pinned by a spec it never loaded.
21
+ # With no whole-suite map to check, say so once, even under --quiet
22
+ # (#1286). Verdicts, score and exit code are unchanged.
23
+ def warn_survivors_unconfirmed(report, engine)
24
+ return if report.unignored_survivors.empty?
25
+ return unless engine&.whole_suite_map_lookup? && engine.scoped_selection?
26
+ return if engine.whole_suite_coverage_map
27
+
28
+ @err.puts "saboteur: survivors unconfirmed: no whole-suite coverage map; " \
29
+ "run `saboteur run --all` to build one"
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/saboteur/cli.rb CHANGED
@@ -13,7 +13,9 @@ require_relative "cli/error_cause"
13
13
  require_relative "cli/exit_status"
14
14
  require_relative "cli/baseline_gate"
15
15
  require_relative "preview"
16
+ require_relative "cli/color_option"
16
17
  require_relative "cli/run_reporting"
18
+ require_relative "cli/whole_suite_map_lookup"
17
19
  require_relative "cli/survivors_command"
18
20
  require_relative "cli/explain_command"
19
21
  require_relative "cli/source_set"
@@ -43,7 +45,9 @@ module Saboteur
43
45
  include Baseline::Command
44
46
  include ExitStatus
45
47
  include BaselineGate
48
+ include ColorOption
46
49
  include RunReporting
50
+ include WholeSuiteMapLookup
47
51
  include SurvivorsCommand
48
52
  include ExplainCommand
49
53
  include SourceSet
@@ -100,7 +104,7 @@ module Saboteur
100
104
  end
101
105
 
102
106
  def call
103
- command = @argv.shift
107
+ command = @command = @argv.shift
104
108
  case command
105
109
  when "run" then run_command
106
110
  when "baseline" then baseline
@@ -109,7 +113,7 @@ module Saboteur
109
113
  when "doctor" then doctor_command
110
114
  when "badge", "trend" then history_entry(command)
111
115
  when "version", "--version" then @out.puts(VERSION) || EXIT_OK
112
- when "help", "--help", "-h" then show_help(@argv.shift)
116
+ when "help", "--help", "-h" then help_command
113
117
  when "agent-skill" then @out.print(AgentSkill::TEXT) || EXIT_OK
114
118
  else preset_or_usage_error(command)
115
119
  end
@@ -18,7 +18,10 @@ module Saboteur
18
18
  CACHE_INPUTS = %w[integration require rspec_args rails tests coverage_recorder].freeze
19
19
  # Names the freshest whole-suite map build; read as an untrusted hint:
20
20
  # only a bare 64-hex key joins the cache dir (#427).
21
- WHOLE_SUITE_POINTER = "whole-suite-map"
21
+ # Renamed from "whole-suite-map" in 0.12.2: older versions also wrote
22
+ # the pointer for maps built from inferred specs, which are not whole
23
+ # suites, so their pointers are never read (#1286).
24
+ WHOLE_SUITE_POINTER = "whole-suite-map-v2"
22
25
  LOCKFILES = %w[Gemfile.lock gems.locked].freeze
23
26
  LOG = "coverage-build.log"
24
27
  # RSpec rewrites this on every run (example_status_persistence_file_path),
@@ -65,7 +68,7 @@ module Saboteur
65
68
  return serve_cached(path, cached, files, preloaded) if cached
66
69
 
67
70
  note = miss_note(files)
68
- @diagnostics.inform(note.message(scoped: !@config["tests"].empty?)) if @config["cache"]
71
+ @diagnostics.notice(note.message(scoped: !@config["tests"].empty?)) if @config["cache"]
69
72
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
73
  map = fresh_map(files, note, preloaded)
71
74
  build_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
@@ -237,7 +240,7 @@ module Saboteur
237
240
  return build_parts(files, parts) if parts.size > 1
238
241
 
239
242
  scope = @config["tests"].empty? ? "the whole suite" : "the selected specs"
240
- @diagnostics.inform("building coverage map (runs #{scope} once)")
243
+ @diagnostics.notice("building coverage map (runs #{scope} once)")
241
244
  # The suite child forked next inherits this heap; pack it first, to
242
245
  # reduce later copying of shared pages.
243
246
  Execution::ForkReadiness.prepare_heap
@@ -267,7 +270,7 @@ module Saboteur
267
270
  total, counts = usage_checked { check_parts_cover_suite(parts) }
268
271
  saved = saved_parts(files, parts.size, counts)
269
272
  todo = saved.each_index.select { |index| saved[index].nil? }
270
- @diagnostics.inform(parts_note(parts.size, todo.size))
273
+ @diagnostics.notice(parts_note(parts.size, todo.size))
271
274
  built = todo.empty? ? [] : run_parts(files, parts, todo)
272
275
  todo.zip(built) { |index, map| saved[index] = map }
273
276
  map = combine_saved(saved, total, files, parts.size)
@@ -187,6 +187,13 @@ module Saboteur
187
187
  @err.puts("saboteur: #{Text.display_safe_lines(message)}")
188
188
  end
189
189
 
190
+ # First-run explanations (#1264): the --quiet contract is findings,
191
+ # score and verdict, so these stay off stderr under it and print
192
+ # exactly as before otherwise.
193
+ def notice(message)
194
+ inform(message) unless @quiet
195
+ end
196
+
190
197
  # Chatter the default view folds into the quiet-start lines;
191
198
  # --verbose prints it byte-for-byte as before.
192
199
  def detail(message)