saboteur 0.11.0 → 0.11.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +2 -2
  4. data/lib/saboteur/baseline/command.rb +46 -2
  5. data/lib/saboteur/baseline/comparison.rb +6 -1
  6. data/lib/saboteur/baseline/completeness.rb +21 -0
  7. data/lib/saboteur/baseline/evidence.rb +11 -3
  8. data/lib/saboteur/baseline/loader.rb +18 -5
  9. data/lib/saboteur/baseline/manifest.rb +13 -4
  10. data/lib/saboteur/baseline/run_evidence.rb +3 -1
  11. data/lib/saboteur/baseline/workflow.rb +57 -9
  12. data/lib/saboteur/baseline_health.rb +4 -1
  13. data/lib/saboteur/cli/dirty_tree_notice.rb +7 -1
  14. data/lib/saboteur/cli/doctor_command.rb +10 -1
  15. data/lib/saboteur/cli/exit_status.rb +10 -5
  16. data/lib/saboteur/cli/explain_command.rb +51 -0
  17. data/lib/saboteur/cli/explicit_sources.rb +1 -1
  18. data/lib/saboteur/cli/failure_output.rb +2 -2
  19. data/lib/saboteur/cli/flaky_exit.rb +23 -0
  20. data/lib/saboteur/cli/presets.rb +11 -1
  21. data/lib/saboteur/cli/run_command.rb +17 -1
  22. data/lib/saboteur/cli/run_output.rb +10 -0
  23. data/lib/saboteur/cli/run_reporting.rb +13 -2
  24. data/lib/saboteur/cli/survivors_command.rb +82 -18
  25. data/lib/saboteur/cli.rb +3 -0
  26. data/lib/saboteur/config.rb +7 -2
  27. data/lib/saboteur/crash_report.rb +3 -2
  28. data/lib/saboteur/doctor.rb +11 -2
  29. data/lib/saboteur/engine/boot.rb +4 -2
  30. data/lib/saboteur/engine/coverage_store.rb +54 -3
  31. data/lib/saboteur/engine/distribution.rb +6 -2
  32. data/lib/saboteur/engine/pipelines.rb +14 -2
  33. data/lib/saboteur/engine/result_cache.rb +4 -2
  34. data/lib/saboteur/engine/scheduler.rb +23 -21
  35. data/lib/saboteur/engine/selection_announcement.rb +5 -4
  36. data/lib/saboteur/engine/subject_selection.rb +11 -1
  37. data/lib/saboteur/engine/survivor_confirmation.rb +1 -1
  38. data/lib/saboteur/engine/test_guess.rb +1 -1
  39. data/lib/saboteur/engine/thread_warnings.rb +9 -4
  40. data/lib/saboteur/engine.rb +57 -9
  41. data/lib/saboteur/execution/fiddle_loader.rb +29 -2
  42. data/lib/saboteur/help.rb +13 -1
  43. data/lib/saboteur/ignore_list.rb +36 -3
  44. data/lib/saboteur/install.rb +18 -3
  45. data/lib/saboteur/mutation_source/builtin.rb +1 -0
  46. data/lib/saboteur/mutation_source/operator_explain.rb +181 -0
  47. data/lib/saboteur/preview.rb +6 -1
  48. data/lib/saboteur/reporter/agent_commands.rb +29 -7
  49. data/lib/saboteur/reporter/agent_incomplete.rb +9 -2
  50. data/lib/saboteur/reporter/agent_summary.rb +2 -1
  51. data/lib/saboteur/reporter/baseline_tail.rb +9 -7
  52. data/lib/saboteur/reporter/gap_kinds.rb +80 -0
  53. data/lib/saboteur/reporter/html.rb +160 -10
  54. data/lib/saboteur/reporter/next_steps.rb +9 -7
  55. data/lib/saboteur/reporter/notice.rb +24 -0
  56. data/lib/saboteur/reporter/reuse_note.rb +37 -0
  57. data/lib/saboteur/reporter/review_comments.rb +17 -12
  58. data/lib/saboteur/reporter/score_table.rb +8 -7
  59. data/lib/saboteur/reporter/stryker.rb +11 -1
  60. data/lib/saboteur/reporter/survivor_classification.rb +26 -8
  61. data/lib/saboteur/reporter/survivor_hints.rb +3 -2
  62. data/lib/saboteur/reporter/survivor_list.rb +58 -4
  63. data/lib/saboteur/reporter/survivor_ranking.rb +13 -5
  64. data/lib/saboteur/reporter/terminal.rb +32 -33
  65. data/lib/saboteur/reporter/wording.rb +37 -0
  66. data/lib/saboteur/result.rb +39 -8
  67. data/lib/saboteur/selection/git_diff.rb +19 -1
  68. data/lib/saboteur/survivor_guidance.rb +33 -0
  69. data/lib/saboteur/text.rb +6 -0
  70. data/lib/saboteur/version.rb +1 -1
  71. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0dd9ced23a90f2f769ccf934e50949a8d9b04324fe86aeec72c8d0de896129ed
4
- data.tar.gz: 3c9f5628615aefcaecac88474d2badb925e57d32d17066fc0afeef642eced780
3
+ metadata.gz: 45635354d2b8067e2de9a11025677af615e3eb54da14dfcd26f0afbdcdd00073
4
+ data.tar.gz: 40a395d6909fb4bcec99596c6b34e4b40edf1b72c76b349ab25cb3ae44b48726
5
5
  SHA512:
6
- metadata.gz: 44eb555775d94aa27ff1bf1f6b8a425faf973157bc8da3c9cbf66a337551699c63cbeba11dae6e5d9860f80002125dc60a08569d381a8f48034537b99c622a7b
7
- data.tar.gz: bd5801edf6723c1442fed00d5c54cc82a278b23875f677427e9d3b3cc47d0c52a26bf0e7d0155a06e4de6c1eba71d25f3e724e1cfe22f180977efa09107e3256
6
+ metadata.gz: 3f3dd155c87c3c6a45d1fd66a66cc5ec21d8c56dd5113dab2568cd07cc957ee2ba34bfc1b773cf4610aeee822e354bf36cc747418bcee24902365563f5f2b5c2
7
+ data.tar.gz: 23f785f27b3396a38195f44d6a418d819d6c7359fe34a8ec9f8422855206b53a5ebd634eb70d8c8d40cf622787a9795d99395950932a5870a2418d8651520aa9
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.11.2] - 2026-10-22
10
+
11
+ ### Added
12
+
13
+ - A full run warns when an ignore entry's subject never appears, so a
14
+ renamed or removed method can no longer kill the entry silently
15
+ (#1250, #1206).
16
+ - Survivor gaps are labeled by kind instead of the blanket
17
+ `missing_test` label (#1246, #1153, #1064), and the README names
18
+ `tool_limit` and the gap kinds in the survivor triage labels (#1247).
19
+ - The `explain` command prints a human-readable rationale for any
20
+ registry operator (#1251).
21
+ - The HTML report lists survivors, timeouts and errors before killed
22
+ rows inside each file, keeping source order within each group
23
+ (#1253, #1233).
24
+ - Survivor blocks consult the whole-suite coverage map, ending false
25
+ survivors from diff-scoped runs (#1252).
26
+
27
+ ### Changed
28
+
29
+ - Output polish across the terminal, JSON and HTML reports: the flaky
30
+ check names why it exits 3, `doctor` prints its coverage line and a
31
+ run without a config says so (#1239); stubbed-test wording, the
32
+ timeout tally and the replay scope line are clearer (#1240);
33
+ selection messaging states nothing-to-test once and names the
34
+ `--since` scope on `--subject` misses (#1241); survivor blocks count
35
+ blocks, not mutants (#1242); the run opener and coverage-map counts
36
+ are pluralized (#1244); the clean screen labels its scope, hints at
37
+ the live reports and summarizes an empty diff (#1245).
38
+ - Docs: the ignore list is named as the one way to accept a survivor,
39
+ the pre-1.0 policy for test selection and operator defaults is
40
+ stated, and the CLI-docs rule points at `docs/` rather than the
41
+ short README (#1248).
42
+
43
+ ## [0.11.1] - 2026-10-22
44
+
45
+ ### Added
46
+
47
+ - Diff runs echo the real `--since` ref in hints and name the diff
48
+ scope in the reports (#1235, #1218, #1219), and `survivors` reads the
49
+ last report by default and refuses ambiguous digest prefixes (#1235,
50
+ #1189, #1188, #1192).
51
+ - `baseline propose` tolerates untested subjects: proposability is the
52
+ gate at propose time, while strict evidence completeness stays where
53
+ it is required (#1168, #1141). The baseline command names what it is
54
+ doing, the file it writes and the follow-up command at every step,
55
+ and blockers resolve to subject, operator and reason plus a
56
+ remediation line (#1217, #1195, #1196).
57
+ - The GitHub Action gains an opt-in `max-duration` input passed to
58
+ `--max-duration`, summary lines for exits 2/3/4/5/70, an annotation
59
+ on exit 4, and failure messages that each name the command or
60
+ setting that fixes them (#1220).
61
+ - Incremental runs report reuse feedback (#1221), and the HTML report
62
+ gains a copy button on survivor blocks, print rules and aria fixes
63
+ (#1231).
64
+
65
+ ### Changed
66
+
67
+ - Warning notices, the baseline tail and the CLI rescue paths render
68
+ through the colour palette (monochrome output is byte-identical), and
69
+ first runs are quieter: the Ruby 3.4 fiddle stdlib notice is dropped
70
+ during the guarded require, Rails mode no longer warns about the
71
+ harmless AR Pool Reaper thread, and the no-spec stop ends with a
72
+ clearer custom-map pointer (#1236, #1226, #1229, #1230).
73
+ - Survivors rank by per-statement density first (the statement with
74
+ the most surviving mutants leads), then by thin coverage; ranking is
75
+ review priority only, with no score or exit-code effect (#1163).
76
+ - `survivors --show` prints the complete detail - diff, replay command
77
+ with the run's seed, and a paste-correct ignore template (#1194,
78
+ #1217) - and unknown-command and doctor include errors are clearer
79
+ (#1194).
80
+ - First-run DX: the footer ignore template pastes into `.saboteur.rb`
81
+ unchanged, install prints the next step, and lifecycle warnings are
82
+ quieter (#1217, #1221). The survivor-confirmation skip notice names
83
+ the real reason (#1231, #1184, #1177).
84
+ - The HTML report is printable and collapsible, with clearer baseline
85
+ errors (#1179).
86
+
87
+ ### Fixed
88
+
89
+ - Survivor confirmation no longer crashes the run (exit 70): a
90
+ confirmation failure degrades to a warning (#1164, #1140).
91
+ - README hero output shows the right subject count (#1162), and the
92
+ exit-code docs match the gates (#1231, #1222).
93
+
94
+
9
95
  ## [0.11.0] - 2026-10-21
10
96
 
11
97
  ### Added
data/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  ```text
19
19
  $ bundle exec saboteur pr
20
- saboteur 0.11.0: 1 subjects, 10 mutants, wall 0.70 s, seed 1 (repeat with --seed 1)
20
+ saboteur 0.11.0: 2 subjects, 10 mutants, wall 0.70 s, seed 1 (repeat with --seed 1)
21
21
  Score: 80.0% (8/10 killed, tested subjects)
22
22
  saboteur: 8 killed, 2 survived in 1s - score 80.0%
23
23
  ```
@@ -80,7 +80,7 @@ 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`, `missing_test`) and their
83
+ triage label (`likely_equivalent`, `arid`, `tool_limit`, or a gap kind) and their
84
84
  covering tests - split into the tests that ran against the mutant and
85
85
  the ones that never did. `saboteur survivors` browses them read-only:
86
86
  one digest shows the full diff and the exact replay command.
@@ -20,6 +20,7 @@ module Saboteur
20
20
  opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
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
+ opts.on("--format FORMAT", "propose: terminal (default) or json") { |value| @format = value }
23
24
  end
24
25
  end
25
26
 
@@ -34,7 +35,7 @@ module Saboteur
34
35
  workflow = Baseline::Workflow.new(config: config, err: @err)
35
36
  baseline_action(workflow, action)
36
37
  end
37
- @out.puts(JSON.generate(payload))
38
+ action == "propose" && @format != "json" ? print_proposal(payload) : @out.puts(JSON.generate(payload))
38
39
  payload.fetch("exit_code", CLI::EXIT_OK)
39
40
  rescue ConfigError, SystemCallError => e
40
41
  @err.puts("saboteur: #{e.message}")
@@ -44,13 +45,17 @@ module Saboteur
44
45
  CLI::EXIT_TOOL
45
46
  end
46
47
 
48
+ def baseline_path_required(action)
49
+ "--baseline PATH is required (for example: saboteur baseline #{action} --baseline baseline.json)"
50
+ end
51
+
47
52
  def parse_baseline(action)
48
53
  raise CLI::UsageError, "baseline expects check, propose or accept" unless %w[check propose
49
54
  accept].include?(action)
50
55
 
51
56
  baseline_parser.parse!(@argv)
52
57
  raise CLI::UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
53
- raise CLI::UsageError, "--baseline PATH is required" unless @baseline_path
58
+ raise CLI::UsageError, baseline_path_required(action) unless @baseline_path
54
59
  raise CLI::UsageError, "--proposal PATH is required" if action != "check" && !@proposal_path
55
60
  if action == "check" && (@proposal_path || @previous)
56
61
  raise CLI::UsageError,
@@ -58,6 +63,15 @@ module Saboteur
58
63
  end
59
64
  validate_epsilon(action)
60
65
  raise CLI::UsageError, "--previous needs a SHA-256" if @previous && !@previous.match?(/\A[0-9a-f]{64}\z/)
66
+
67
+ validate_format(action)
68
+ end
69
+
70
+ def validate_format(action)
71
+ unless @format.nil? || %w[terminal json].include?(@format)
72
+ raise CLI::UsageError, "--format must be terminal or json"
73
+ end
74
+ raise CLI::UsageError, "--format applies to propose only" if @format && action != "propose"
61
75
  end
62
76
 
63
77
  def validate_epsilon(action)
@@ -67,6 +81,36 @@ module Saboteur
67
81
  raise CLI::UsageError, "--epsilon must be between 0 and 100" unless (0.0..100.0).cover?(@epsilon)
68
82
  end
69
83
 
84
+ def print_proposal(payload)
85
+ @out.puts("baseline proposal written: #{payload.fetch('proposal')}")
86
+ survivors = payload.fetch("survivors")
87
+ @out.puts(" matched mutants: #{payload.fetch('candidates')} " \
88
+ "(#{plural(survivors, 'survivor')} to reason about)")
89
+ gaps = payload.fetch("gaps", [])
90
+ unless gaps.empty?
91
+ names = gaps.map { |gap| gap.sub(/\A(subject_skipped|omitted):/, "") }
92
+ @out.puts(" proposed with #{plural(names.size, 'untested subject')}: #{names.join(', ')} - " \
93
+ "the baseline cannot speak for them")
94
+ end
95
+ @out.puts(" next step: give each accepted survivor a reason in the proposal, then run: " \
96
+ "saboteur baseline accept#{scope_flags} " \
97
+ "--baseline #{payload.fetch('destination')} --proposal #{payload.fetch('proposal')}")
98
+ end
99
+
100
+ def plural(count, noun)
101
+ count == 1 ? "#{count} #{noun}" : "#{count} #{noun}s"
102
+ end
103
+
104
+ # accept re-verifies the recorded run settings, so the printed command
105
+ # repeats the scope flags this propose ran with.
106
+ def scope_flags
107
+ flags = +""
108
+ Array(options[:subjects]).each { |expr| flags << " --subject #{expr}" }
109
+ flags << " --seed #{options[:seed]}" if options[:seed]
110
+ flags << " --config #{options[:config]}" if options[:config]
111
+ flags
112
+ end
113
+
70
114
  def baseline_action(workflow, action)
71
115
  case action
72
116
  when "check" then workflow.check(path: @baseline_path, epsilon: @epsilon)
@@ -62,7 +62,12 @@ module Saboteur
62
62
  end
63
63
 
64
64
  def outcome(current)
65
- return "incomplete" unless current.fetch("complete") && issues.empty? && !current.fetch("candidates").empty?
65
+ # The proposability tier is the comparability gate: a run over soft
66
+ # gaps still speaks for the subjects it executed. Legacy documents
67
+ # without the split fall back to the strict flag and full issue list.
68
+ proposable = current.fetch("proposable") { current.fetch("complete") }
69
+ blockers = current.fetch("blockers") { current.fetch("issues") }
70
+ return "incomplete" unless proposable && blockers.empty? && !current.fetch("candidates").empty?
66
71
  return "incomplete" unless categories.fetch("errors").empty?
67
72
  return "review_required" if %w[new changed unmatched].any? { |name| !categories.fetch(name).empty? }
68
73
  return "review_required" if score&.fetch("exceeded")
@@ -6,8 +6,15 @@ module Saboteur
6
6
  # Exact planned/result reconciliation, independent of survivor acceptance.
7
7
  class Completeness
8
8
  EXECUTED = %w[killed survived timeout].freeze
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
9
12
  attr_reader :issues
10
13
 
14
+ def self.soft_gap?(issue)
15
+ SOFT_PREFIXES.any? { |prefix| issue.start_with?(prefix) }
16
+ end
17
+
11
18
  def initialize(planned:, results:, subjects:, stable_suite:, stable_inputs:, skipped_subjects:, omitted:,
12
19
  generation_issues:)
13
20
  @issues = []
@@ -25,6 +32,20 @@ module Saboteur
25
32
  issues.empty?
26
33
  end
27
34
 
35
+ def blockers
36
+ issues.reject { |issue| self.class.soft_gap?(issue) }
37
+ end
38
+
39
+ def gaps
40
+ issues.select { |issue| self.class.soft_gap?(issue) }
41
+ end
42
+
43
+ # A proposal may be written over soft gaps (they are listed in it);
44
+ # only hard blockers refuse.
45
+ def proposable?
46
+ blockers.empty?
47
+ end
48
+
28
49
  private
29
50
 
30
51
  def validate_inventory(planned, subjects)
@@ -10,8 +10,8 @@ module Saboteur
10
10
  # Validates the producer document before it can enter the acceptance path.
11
11
  # A claimed complete flag alone is never evidence of complete execution.
12
12
  class Evidence
13
- FIELDS = %w[compatibility snapshot subjects candidates planned complete issues generation_events
14
- omissions discovery_failures inline_suppressions].freeze
13
+ FIELDS = %w[compatibility snapshot subjects candidates planned complete proposable issues blockers gaps
14
+ generation_events omissions discovery_failures inline_suppressions].freeze
15
15
  COMPATIBILITY = %w[generator runtime config scope].freeze
16
16
  PLAN_FIELDS = %w[id identity context].freeze
17
17
 
@@ -27,6 +27,13 @@ module Saboteur
27
27
  validate_compatibility
28
28
  validate_generation
29
29
  validate_plan
30
+ blockers = @document.fetch("blockers")
31
+ gaps = @document.fetch("gaps")
32
+ unless @document.fetch("proposable") == blockers.empty? &&
33
+ (blockers + gaps).sort == @document.fetch("issues")
34
+ raise ConfigError, "baseline evidence: completeness tiers disagree"
35
+ end
36
+
30
37
  # Manifest owns exact candidate, identity, subject and acceptance shapes.
31
38
  Manifest.new(manifest(reasons: "validation"))
32
39
  rescue ArgumentError, KeyError, TypeError, NoMethodError => e
@@ -34,7 +41,8 @@ module Saboteur
34
41
  end
35
42
 
36
43
  def manifest(reasons: "")
37
- metadata = document.fetch("compatibility").merge(document.slice("snapshot", "subjects", "complete", "issues"))
44
+ metadata = document.fetch("compatibility").merge(document.slice("snapshot", "subjects", "complete",
45
+ "proposable", "issues"))
38
46
  accepted = document.fetch("candidates").select { |entry| entry.fetch("status") == "survived" }.map do |entry|
39
47
  entry.slice("id", "context").merge("reason" => reasons)
40
48
  end
@@ -24,17 +24,30 @@ module Saboteur
24
24
  bytes = File.read(path)
25
25
  digest = Digest::SHA256.hexdigest(bytes)
26
26
  manifest = Manifest.new(JSON.parse(Text.utf8_strict(bytes)))
27
+ Evidence.new(restored_evidence(manifest))
28
+ [manifest, digest]
29
+ rescue Errno::ENOENT
30
+ raise ConfigError, "baseline file #{path} does not exist; " \
31
+ "create it with saboteur baseline propose --baseline #{path}"
32
+ rescue SystemCallError, JSON::ParserError, ArgumentError => e
33
+ raise ConfigError, "baseline file: #{e.message}"
34
+ end
35
+
36
+ # The evidence snapshot a manifest stands for: its metadata, candidates
37
+ # and plan, with no generation events (nothing was generated).
38
+ def restored_evidence(manifest)
27
39
  metadata = manifest.to_h.fetch("metadata")
28
40
  candidates = manifest.candidates.values
29
- restored = metadata.slice("snapshot", "subjects", "complete", "issues").merge(
41
+ issues = metadata.fetch("issues")
42
+ metadata.slice("snapshot", "subjects", "complete", "issues").merge(
43
+ "proposable" => metadata.fetch("proposable", metadata["complete"]),
44
+ "blockers" => issues.reject { |issue| Completeness.soft_gap?(issue) },
45
+ "gaps" => issues.select { |issue| Completeness.soft_gap?(issue) }
46
+ ).merge(
30
47
  "compatibility" => manifest.compatibility, "candidates" => candidates,
31
48
  "planned" => candidates.map { |entry| entry.slice(*Evidence::PLAN_FIELDS) },
32
49
  "generation_events" => [], "omissions" => [], "discovery_failures" => [], "inline_suppressions" => []
33
50
  )
34
- Evidence.new(restored)
35
- [manifest, digest]
36
- rescue SystemCallError, JSON::ParserError, ArgumentError => e
37
- raise ConfigError, "baseline file: #{e.message}"
38
51
  end
39
52
  end
40
53
  end
@@ -4,12 +4,17 @@
4
4
  require "digest"
5
5
  require "json"
6
6
 
7
+ require_relative "completeness"
8
+
7
9
  module Saboteur
8
10
  module Baseline
9
11
  # Validated, immutable human acceptance and the complete run it describes.
10
12
  class Manifest
11
13
  FORMAT = 1
12
- METADATA = %w[generator runtime config scope snapshot subjects complete issues].freeze
14
+ METADATA = %w[generator runtime config scope snapshot subjects complete proposable issues].freeze
15
+ # Baselines written before the two-tier completeness split carry no
16
+ # proposable key; their strict complete flag is the proposability gate.
17
+ LEGACY_METADATA = (METADATA - %w[proposable]).freeze
13
18
  STATUSES = %w[killed survived timeout].freeze
14
19
 
15
20
  attr_reader :candidates, :accepted
@@ -48,8 +53,12 @@ module Saboteur
48
53
 
49
54
  def validate_metadata
50
55
  metadata = @document["metadata"]
51
- fields!(metadata, METADATA, "metadata")
52
- fail!("incomplete evidence") unless metadata["complete"] == true && metadata["issues"] == []
56
+ fail!("metadata fields") unless metadata.is_a?(Hash)
57
+ keys = metadata.keys.sort
58
+ fail!("metadata fields") unless [METADATA.sort, LEGACY_METADATA.sort].include?(keys)
59
+ proposable = metadata.fetch("proposable", metadata["complete"])
60
+ hard = metadata.fetch("issues").reject { |issue| Completeness.soft_gap?(issue) }
61
+ fail!("incomplete evidence") unless proposable == true && hard.empty?
53
62
  %w[generator runtime config scope snapshot].each do |name|
54
63
  fail!("metadata #{name}") unless metadata[name].is_a?(Hash) && !metadata[name].empty?
55
64
  end
@@ -121,7 +130,7 @@ module Saboteur
121
130
  current = candidates[entry["id"]]
122
131
  fail!("acceptance candidate") unless current && current["status"] == "survived"
123
132
  fail!("acceptance context") unless current["context"] == entry["context"]
124
- text!(entry["reason"], "reason")
133
+ text!(entry["reason"], "reason on acceptance #{entry['id'][0, 16]} (write a non-blank reason)")
125
134
  end
126
135
 
127
136
  def fields!(value, fields, name)
@@ -46,7 +46,9 @@ module Saboteur
46
46
  completeness = assess(observed, stable_suite, skipped_subjects, omissions, errors)
47
47
  document = { "compatibility" => compatibility, "snapshot" => @snapshot.to_h, "subjects" => @subjects,
48
48
  "candidates" => observed, "planned" => @planned,
49
- "complete" => completeness.complete?, "issues" => completeness.issues,
49
+ "complete" => completeness.complete?, "proposable" => completeness.proposable?,
50
+ "issues" => completeness.issues, "blockers" => completeness.blockers,
51
+ "gaps" => completeness.gaps,
50
52
  "generation_events" => generation, "omissions" => omissions.map(&:to_h),
51
53
  "discovery_failures" => discovery, "inline_suppressions" => suppressions }
52
54
  Engine::RunFingerprint.canonical_value(document)
@@ -3,6 +3,7 @@
3
3
 
4
4
  require_relative "acceptance"
5
5
  require_relative "comparison"
6
+ require_relative "identity"
6
7
  require_relative "loader"
7
8
  require_relative "../engine"
8
9
  require_relative "../text"
@@ -41,21 +42,48 @@ module Saboteur
41
42
  payload
42
43
  end
43
44
 
45
+ # Blockers carry only a digest and a status. Name the subject, operator
46
+ # and the engine's recorded reason, and say how to unblock the proposal.
47
+ def refuse_proposal(evidence, report)
48
+ blockers = evidence.fetch("blockers")
49
+ by_id = report.results.to_h do |result|
50
+ [Identity.new(subject: result.subject, mutation: result.mutation, root: @root).entry.fetch("id"), result]
51
+ end
52
+ 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
56
+ raise Error, "baseline proposal needs a proposable run"
57
+ end
58
+
59
+ def describe_blocker(issue, by_id)
60
+ match = /\Aunexecuted:([0-9a-f]{64}):(\w+)\z/.match(issue)
61
+ result = match && by_id[match[1]]
62
+ return issue unless result
63
+
64
+ detail = "#{result.subject.id} #{result.mutation.operator}"
65
+ detail += " - #{result.reason}" if result.reason
66
+ "#{issue} (#{detail})"
67
+ end
68
+
44
69
  def propose(proposal:, destination:, previous: nil)
45
70
  guard_artifacts(proposal, destination)
71
+ preflight_outputs(proposal, destination, previous)
46
72
  report = Engine.new(@config, err: @err, capture_evidence: true).call
47
- unless report.run_evidence["complete"] == true
48
- raise Error,
49
- "baseline proposal requires complete execution evidence"
50
- end
73
+ evidence = report.run_evidence
74
+ refuse_proposal(evidence, report) unless evidence["proposable"] == true
51
75
 
52
- captured = Evidence.new(report.run_evidence)
76
+ captured = Evidence.new(evidence)
53
77
  store(captured.document.fetch("snapshot")).propose(document: captured.manifest,
54
78
  evidence: captured.document, proposal: proposal,
55
79
  destination: destination, previous: previous)
56
- { "state" => "proposal", "proposal" => File.expand_path(proposal),
57
- "destination" => File.expand_path(destination),
58
- "survivors" => captured.manifest.fetch("accepted").size }
80
+ payload = { "state" => "proposal", "proposal" => File.expand_path(proposal),
81
+ "destination" => File.expand_path(destination),
82
+ "survivors" => captured.manifest.fetch("accepted").size,
83
+ "candidates" => captured.manifest.fetch("candidates").size }
84
+ gaps = evidence.fetch("gaps")
85
+ payload["gaps"] = gaps unless gaps.empty?
86
+ payload
59
87
  end
60
88
 
61
89
  def accept(proposal:, destination:, previous: nil)
@@ -80,12 +108,20 @@ module Saboteur
80
108
  extras = saved.inventory.select { |entry| entry.kind == "extra" }.map do |entry|
81
109
  { path: input_path(entry.path), required: entry.required }
82
110
  end
83
- Engine::RunFingerprint.new(config: @config, extra_files: extras).verify(saved).unchanged
111
+ raise_unless_unchanged(Engine::RunFingerprint.new(config: @config, extra_files: extras).verify(saved))
84
112
  end
85
113
  Acceptance.new(directory: File.join(Identity.canonical_path(@config.path("cache_dir")), "baseline-evidence"),
86
114
  protected_paths: protected, verify: verifier, require_evidence: true)
87
115
  end
88
116
 
117
+ def raise_unless_unchanged(result)
118
+ return true if result.unchanged
119
+
120
+ detail = result.changes.map { |change| "#{change.path} #{change.reason}" }.join(", ")
121
+ detail = "verification incomplete" if detail.empty?
122
+ raise ConfigError, "baseline inputs changed or unverified: #{detail}"
123
+ end
124
+
89
125
  def input_paths(snapshot)
90
126
  snapshot.inventory.map { |entry| input_path(entry.path) }
91
127
  end
@@ -94,6 +130,18 @@ module Saboteur
94
130
  path.start_with?("external:") ? path.delete_prefix("external:") : File.join(@root, path)
95
131
  end
96
132
 
133
+ # Cheap pre-checks before the full run; Acceptance re-verifies exclusively after it.
134
+ def preflight_outputs(proposal, destination, previous)
135
+ raise ConfigError, "baseline output exists: #{Identity.canonical_path(proposal)}" if File.exist?(proposal)
136
+
137
+ # Mirrors Acceptance#verify_previous so a doomed proposal fails before the run.
138
+ if previous.nil?
139
+ raise ConfigError, "baseline destination exists" if File.exist?(destination)
140
+ elsif !File.file?(destination) || Digest::SHA256.file(destination).hexdigest != previous
141
+ raise ConfigError, "baseline previous destination changed"
142
+ end
143
+ end
144
+
97
145
  def guard_artifacts(*paths)
98
146
  paths.each do |path|
99
147
  expanded = Identity.canonical_path(path)
@@ -35,7 +35,10 @@ module Saboteur
35
35
 
36
36
  def call
37
37
  expected = @integration.all_tests.map(&:id)
38
- raise Error, "baseline has no tests; check rspec_args and spec paths" if expected.empty?
38
+ if expected.empty?
39
+ raise Error, "baseline has no tests; check rspec_args and the suite filters, " \
40
+ "then run saboteur doctor --suite again"
41
+ end
39
42
 
40
43
  first = check_run(1, expected)
41
44
  second = check_run(2, expected)
@@ -1,6 +1,9 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "../reporter/palette"
5
+ require_relative "../reporter/notice"
6
+
4
7
  module Saboteur
5
8
  class CLI
6
9
  # The uncommitted-work warning printed before a run (#334). Extracted
@@ -21,7 +24,10 @@ module Saboteur
21
24
  else
22
25
  config
23
26
  end
24
- DirtyTreeWarning.for(warning, config_path: options[:config])&.then { |text| @err.puts(text) }
27
+ DirtyTreeWarning.for(warning, config_path: options[:config])&.then do |text|
28
+ palette = Reporter::Palette.for(@err, mode: @color || :auto, overrides: warning["palette"])
29
+ @err.puts(Reporter::Notice.paint(palette, text))
30
+ end
25
31
  end
26
32
  end
27
33
  end
@@ -22,13 +22,22 @@ module Saboteur
22
22
 
23
23
  def doctor_command = help_requested? ? show_help("doctor") : doctor
24
24
 
25
+ # The environment is healthy only if a run would have something to
26
+ # mutate: the same empty source set `run` refuses (exit 2).
27
+ def doctor_verdict
28
+ return EXIT_OK unless config.included_files.empty?
29
+
30
+ @err.puts("saboteur: #{empty_source_set_message}")
31
+ EXIT_USAGE
32
+ end
33
+
25
34
  def doctor
26
35
  doctor_parser.parse!(@argv)
27
36
  raise UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
28
37
 
29
38
  options[:tests]&.each { |test| require_existing_test(test) }
30
39
  Doctor.new(config, out: @out, suite: options[:suite] || options.key?(:tests)).call
31
- EXIT_OK
40
+ doctor_verdict
32
41
  rescue ConfigError => e
33
42
  @err.puts("saboteur: #{Text.display_safe_lines(e.message)}")
34
43
  EXIT_USAGE
@@ -1,11 +1,15 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "flaky_exit"
5
+
4
6
  module Saboteur
5
7
  class CLI
6
8
  # How a finished run maps to an exit code and a recorded exit cause. Mixed
7
9
  # into the CLI, which supplies `config` and the `@err` stream.
8
10
  module ExitStatus
11
+ include FlakyExit
12
+
9
13
  private
10
14
 
11
15
  def exit_code(report)
@@ -15,7 +19,7 @@ module Saboteur
15
19
 
16
20
  return EXIT_TOOL if untested_partial?(report)
17
21
 
18
- return EXIT_TOOL if unverifiable_scope?(report)
22
+ return EXIT_TOOL if unverifiable_scope?(report) || flaky_disagreement?(report)
19
23
 
20
24
  return EXIT_TOOL if mostly_errored?(report)
21
25
 
@@ -60,9 +64,10 @@ module Saboteur
60
64
  # must not exit 0 with score n/a and green-light an untested change.
61
65
  def unverifiable_scope?(report)
62
66
  if unverifiable_review?(report)
63
- report.skipped_subjects.each do |subject, reason|
64
- @err.puts(subject.skipped_gate_sentence(reason, report.root))
65
- end
67
+ # A text run already printed each skip up front; json and agent
68
+ # runs read this sentence as the exit explanation (#1227).
69
+ skips = %w[json agent].include?(@format) ? report.skipped_subjects : report.unannounced_skips
70
+ skips.each { |subject, reason| @err.puts(subject.skipped_gate_sentence(reason, report.root)) }
66
71
  return true
67
72
  end
68
73
  return false unless unverifiable_unreachable?(report)
@@ -116,7 +121,7 @@ module Saboteur
116
121
  else
117
122
  if report.majority_errored?
118
123
  "errorMajority"
119
- elsif unverifiable_review?(report) || unverifiable_unreachable?(report) ||
124
+ elsif unverifiable_review?(report) || unverifiable_unreachable?(report) || report.results.any?(&:flaky?) ||
120
125
  (report.early_stop && report.score_breakdown.tested.zero?)
121
126
  "incomplete"
122
127
  else
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../help"
5
+ require_relative "../reporter/palette"
6
+ require_relative "../mutation_source/operator_explain"
7
+
8
+ module Saboteur
9
+ class CLI
10
+ # Shared rendering for `saboteur explain <operator>` (#1097): the
11
+ # curated definition, a before/after example and survive/kill guidance
12
+ # for one operator, so a survivor's operator can be understood and
13
+ # targeted without leaving the terminal.
14
+ module ExplainCommand
15
+ private
16
+
17
+ def explain_command
18
+ name = @argv.shift
19
+ raise UsageError, "usage: saboteur explain <operator>" if name.nil?
20
+
21
+ entry = MutationSource::OperatorExplain.for(name)
22
+ if entry.nil?
23
+ @err.puts("saboteur: unknown operator: #{name}")
24
+ suggestion = MutationSource::OperatorRegistry.suggestion(name)
25
+ @err.puts("saboteur: #{suggestion}") unless suggestion.empty?
26
+ @err.puts("saboteur: operators: #{MutationSource::OperatorRegistry::DESCRIPTIONS.keys.join(', ')}")
27
+ return EXIT_USAGE
28
+ end
29
+
30
+ @out.puts(render_explain(entry, palette: Reporter::Palette.for(@out, mode: :auto)))
31
+ EXIT_OK
32
+ rescue UsageError => e
33
+ @err.puts("saboteur: #{e.message}")
34
+ EXIT_USAGE
35
+ end
36
+
37
+ def render_explain(entry, palette: Reporter::Palette.new(enabled: false))
38
+ original, replacement = entry.example.split(" -> ")
39
+ [
40
+ "#{palette.paint(:head, 'Operator:')} #{entry.name}",
41
+ "#{palette.paint(:head, 'Definition:')} #{entry.definition}",
42
+ palette.paint(:head, "Example:"),
43
+ " #{palette.paint(:fail, "- #{original}")}",
44
+ " #{palette.paint(:pass, "+ #{replacement}")}",
45
+ "#{palette.paint(:warn, 'Survives when:')} #{entry.survives}",
46
+ "#{palette.paint(:info, 'Kill it with:')} #{entry.kill}"
47
+ ].join("\n")
48
+ end
49
+ end
50
+ end
51
+ end