saboteur 0.12.1 → 0.12.3

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +18 -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 +10 -6
  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/result_cache.rb +8 -5
  28. data/lib/saboteur/engine/scheduler.rb +3 -2
  29. data/lib/saboteur/engine/survivor_confirmation.rb +75 -11
  30. data/lib/saboteur/engine/test_guess.rb +2 -1
  31. data/lib/saboteur/engine.rb +34 -16
  32. data/lib/saboteur/execution/runner.rb +14 -4
  33. data/lib/saboteur/execution/site_probe.rb +161 -0
  34. data/lib/saboteur/gates.rb +1 -1
  35. data/lib/saboteur/integration/rspec.rb +30 -0
  36. data/lib/saboteur/reporter/default_findings.rb +1 -1
  37. data/lib/saboteur/reporter/findings.rb +2 -1
  38. data/lib/saboteur/reporter/gap_kinds.rb +18 -0
  39. data/lib/saboteur/reporter/next_steps.rb +3 -1
  40. data/lib/saboteur/reporter/review_comments.rb +4 -1
  41. data/lib/saboteur/reporter/score_table.rb +6 -11
  42. data/lib/saboteur/reporter/stryker.rb +12 -2
  43. data/lib/saboteur/reporter/survivor_classification.rb +53 -6
  44. data/lib/saboteur/reporter/survivor_hints.rb +13 -9
  45. data/lib/saboteur/reporter/survivor_list.rb +6 -6
  46. data/lib/saboteur/reporter/terminal.rb +4 -2
  47. data/lib/saboteur/reporter/unconfirmed_section.rb +44 -0
  48. data/lib/saboteur/result.rb +46 -4
  49. data/lib/saboteur/selection/coverage_map.rb +11 -1
  50. data/lib/saboteur/version.rb +1 -1
  51. data/lib/saboteur.rb +5 -0
  52. metadata +5 -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: 19bac537a82f6aa32ade9a7a1955cd793793d5a95b026dc56274c7c97560cfa6
4
+ data.tar.gz: 40086d139cbb82e418c9b7de5aa31b2aedd19eeb61ae238ba01f80c0e3f0d5b4
5
5
  SHA512:
6
- metadata.gz: c96be31b305e173850739c426f5343fb6a24b416cb5f923b30cfea4a8764fb0c9dd3eeeb5dc8b00af132e16267e8e4135ea84d2d5669b38e35433323e94c072c
7
- data.tar.gz: 9763cf728f40ad70a714665bc4cdf09ce0a9d516d7da9101bb137f60cdb378a5715d321d8b71d725ac635736b167a9ce6ec3b8c5e99632ef1f204cea49ad3e94
6
+ metadata.gz: 1dda55357cc1866e39f052b3b69424e6dde81ec551b695822e35e58a5ec65fe00b0e795b650338c12e2a2869e4a046f3a6e0839742e86bb3bd7ae52db809e124
7
+ data.tar.gz: f094725207c45c6b5c73d848790f2a6d8a91e849dcd281eabf616c6de414fbf6f9f9f589c7a15c0d6e15c72d1e21971be4a2edc19802691bc2c508b965fe99e9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,92 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.3] - 2026-10-22
10
+
11
+ ### Added
12
+
13
+ - Survivors are labeled `input_gap` or `assertion_gap` from the value the
14
+ mutated expression actually produced (side-effect-free sites only).
15
+ `input_gap` means the tests never gave the expression an input where its
16
+ value differs, or no test reached it. `assertion_gap` means the value
17
+ changed and the tests still passed. The JSON report gains `valueDiffed`
18
+ and `gapKind` fields (#1290, #1305, #1306, #1307).
19
+
20
+ ### Changed
21
+
22
+ - The cache stores the observed value with each verdict (cache format 2),
23
+ so cached results from before this release are rebuilt once on the next
24
+ run (#1290, #1307).
25
+
26
+ ## [0.12.2] - 2026-10-22
27
+
28
+ ### Added
29
+
30
+ - A scoped run (`--since`, `--subject`, or the default spec inference)
31
+ confirms its survivors against the whole-suite coverage map when one
32
+ exists. Each survivor gets one budgeted confirmation run of the covering
33
+ specs the run did not load. A mutant those specs kill becomes killed,
34
+ credited to the killing test in the JSON report, and counts in the score
35
+ and exit code. A survivor whose covering specs could not be run is listed
36
+ as unconfirmed, left out of the score and exit code, and ranked below
37
+ confirmed survivors. A map that is older than the tree and says nothing
38
+ about a survivor's lines keeps that survivor counted, with one line
39
+ naming how many. A covering spec outside the project root is never
40
+ loaded (#1286, #1304).
41
+ - `--color` and `--no-color` work on every text command, and a usage error
42
+ names the command that failed instead of showing `saboteur run` usage
43
+ (#1288, #1301).
44
+
45
+ ### Changed
46
+
47
+ - Only `saboteur run --all` records the whole-suite coverage map. A run
48
+ built from inferred specs no longer claims to be whole-suite. Without a
49
+ whole-suite map a scoped run prints one line saying survivors are
50
+ unconfirmed and how to build the map, and each survivor block says its
51
+ covering list is this run's selection only (#1286, #1300).
52
+ - A statement's gap label comes from all of its surviving mutants with a
53
+ fixed precedence: result unchecked, error path, branch, boundary,
54
+ effect, number, instead of from the first mutant (#1289, #1297).
55
+ - `baseline propose` records a mutant on a line no test runs as a gap
56
+ instead of an unexecuted blocker, so a tested method with one untested
57
+ line can still be proposed (#1287, #1299).
58
+ - A config-less Rails app's default `app/**/*.rb` include counts as a
59
+ full run, so the stale ignore entry warning fires there (#1255, #1296).
60
+ - `--quiet` no longer prints the missing-config notice, the coverage cache
61
+ miss or the map-build lines (#1264, #1293).
62
+ - Output polish: the run header names the `--subject` scope, the
63
+ no-spec note reads in the singular, untested subjects are named once
64
+ under "Not scored", and the replay example uses the top-ranked
65
+ survivor (#1291, #1298).
66
+ - Report test ids in `killedBy`, `coveredBy` and `testFiles` are numbered
67
+ by sorted test name, so the same tests keep the same ids across runs
68
+ (#1282, #1295). Covering tests are ordered by millisecond buckets, so
69
+ kill attribution is stable (#1274, #1283).
70
+
71
+ ### Fixed
72
+
73
+ - An empty `--since` diff whose mutants all failed generation no longer
74
+ exits 0 under `--threshold`, and `--ignore-empty` no longer fails a run
75
+ without `--since` (#1265, #1266, #1284).
76
+ - `saboteur survivors` no longer drops an unknown keyword silently
77
+ (#1267, #1285).
78
+
79
+ ### Corrected
80
+
81
+ - 0.12.0 said uncovered survivors are labeled `input_gap`, "separate from
82
+ assertion gaps" (#1254, #1157). Only `input_gap` exists, meaning no test
83
+ ran the mutant. There is no assertion-gap label yet (#1064).
84
+ - 0.11.2 said survivor blocks consult the whole-suite coverage map,
85
+ "ending false survivors from diff-scoped runs" (#1252). That change
86
+ only affects which covering tests a block lists. A survivor whose real
87
+ test sits in a spec file saboteur did not select can still be reported
88
+ as a survivor; see "Know the limits" in the README.
89
+
90
+ ### Tests
91
+
92
+ - The worker pool and supervisor specs assert concurrency with a barrier
93
+ instead of a wall-clock bound (#1302, #1303).
94
+
9
95
  ## [0.12.1] - 2026-10-22
10
96
 
11
97
  ### Changed
data/README.md CHANGED
@@ -80,15 +80,21 @@ 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` (the tests
84
+ never gave the mutated expression an input where its value differs, or no
85
+ test reached it), `assertion_gap` (the value changed and the tests still
86
+ passed), or a gap kind) and their
84
87
  covering tests - split into the tests that ran against the mutant and
85
88
  the ones that never did. `saboteur survivors` browses them read-only:
86
89
  one digest shows the full diff and the exact replay command.
87
90
  - 🧪 **Flakiness contained.** `--flaky-check survivors` re-runs each new
88
91
  survivor in a reordered confirmation pass; a verdict the rerun disagrees
89
92
  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.
93
+ `partial_coverage = "use"`, open
94
+ survivors get one budgeted confirmation run against the covering specs
95
+ that never ran them when the map is built from every spec (`--all`, no
96
+ `--test`, one coverage part, cache on). Default inferred-spec runs, `pr`
97
+ included, skip it.
92
98
  - 📊 **Reports for people and tools.** Terminal, HTML, a Stryker-compatible
93
99
  JSON report, and a stable agent JSON API with a deprecation policy. The
94
100
  GitHub Action posts survivors as review comments on your PR diff lines.
@@ -127,6 +133,15 @@ tree refuses with exit 2 and points at the generator. Engine gem with
127
133
  - A survivor is review evidence, not proof of a bug in your tests. It can
128
134
  be a missing test, an equivalent change, a flaky test, or a limit of
129
135
  the tool. Read it before you write a test for it.
136
+ - A survivor can be false when the test that kills it lives in a spec
137
+ file saboteur did not select. Runs without `--test` pick specs by file
138
+ name and by the constants they read, so a test in a differently named
139
+ spec (say `spec/integration/flow_spec.rb` for `lib/ledger/router.rb`)
140
+ may never run against the mutant, and the "covered by ... ran, still
141
+ passed" lines list the specs that ran, plus any others marked "not run
142
+ here". If you know
143
+ another spec exercises the code, run again with `--test` naming it
144
+ before you trust the survivor.
130
145
  - `--since` and `--incremental` are iteration aids. Run the whole project
131
146
  on main and before a release.
132
147
  - 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
 
@@ -135,7 +138,7 @@ module Saboteur
135
138
  def detail_lines(survivor, finding)
136
139
  [
137
140
  "#{survivor[:path]}:#{survivor[:line]} #{survivor[:operator]} #{survivor[:digest]}",
138
- *classification_line(survivor[:classification]),
141
+ *classification_line(survivor[:classification], survivor[:gap_kind]),
139
142
  "",
140
143
  "- #{finding['original'] || original_line(survivor)}",
141
144
  "+ #{finding['replacement'] || survivor[:replacement]}",
@@ -146,10 +149,10 @@ module Saboteur
146
149
  end
147
150
 
148
151
  # The same wording the terminal survivor block uses (#1183).
149
- def classification_line(label)
152
+ def classification_line(label, kind = nil)
150
153
  return [] unless label
151
154
 
152
- reason = Reporter::SurvivorHints.label_reason(label)
155
+ reason = Reporter::SurvivorHints.label_reason(label, kind: kind)
153
156
  ["classification: #{label}#{" - #{reason}" if reason}"]
154
157
  end
155
158
 
@@ -238,7 +241,8 @@ module Saboteur
238
241
  entry.fetch("mutants", []).select { |m| m["status"] == "Survived" }.map do |m|
239
242
  { path: path, line: m.dig("location", "start", "line").to_i, operator: m["mutatorName"],
240
243
  digest: m["id"], replacement: m["replacement"], classification: m["classification"],
241
- subject: m["subject"], covered: m["coveredBy"] || [], source: entry["source"] }
244
+ gap_kind: m["gapKind"], subject: m["subject"], covered: m["coveredBy"] || [],
245
+ source: entry["source"] }
242
246
  end
243
247
  end
244
248
  survivors
@@ -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