saboteur 0.11.2 → 0.12.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45635354d2b8067e2de9a11025677af615e3eb54da14dfcd26f0afbdcdd00073
4
- data.tar.gz: 40a395d6909fb4bcec99596c6b34e4b40edf1b72c76b349ab25cb3ae44b48726
3
+ metadata.gz: e9537980fed0394bdadcbd64d8e637e55cfb42925b5673d2ee30e6f0b18bb13e
4
+ data.tar.gz: a628132a21155e240bc856c99707f46323f280929344048be98a746fa74d1c9b
5
5
  SHA512:
6
- metadata.gz: 3f3dd155c87c3c6a45d1fd66a66cc5ec21d8c56dd5113dab2568cd07cc957ee2ba34bfc1b773cf4610aeee822e354bf36cc747418bcee24902365563f5f2b5c2
7
- data.tar.gz: 23f785f27b3396a38195f44d6a418d819d6c7359fe34a8ec9f8422855206b53a5ebd634eb70d8c8d40cf622787a9795d99395950932a5870a2418d8651520aa9
6
+ metadata.gz: c96be31b305e173850739c426f5343fb6a24b416cb5f923b30cfea4a8764fb0c9dd3eeeb5dc8b00af132e16267e8e4135ea84d2d5669b38e35433323e94c072c
7
+ data.tar.gz: 9763cf728f40ad70a714665bc4cdf09ce0a9d516d7da9101bb137f60cdb378a5715d321d8b71d725ac635736b167a9ce6ec3b8c5e99632ef1f204cea49ad3e94
data/CHANGELOG.md CHANGED
@@ -6,6 +6,63 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.1] - 2026-10-22
10
+
11
+ ### Changed
12
+
13
+ - `--quiet` now stays quiet from startup: the start header, baseline
14
+ line, inference detail and progress bar no longer print. A `--since`
15
+ diff that selects no mutants exits 0 with a plain "nothing to gate"
16
+ note instead of failing a score threshold, and `--ignore-empty` is a
17
+ deprecated no-op. A diff with suppressed, omitted or undiscovered
18
+ mutants, or with an unverifiable scope, still gates (#1268, #1142,
19
+ #1149).
20
+ - `saboteur survivors` lists survivors ranked by default, matching
21
+ `run`, and owns its own command surface and help (#1269, #1150).
22
+
23
+ ### Fixed
24
+
25
+ - An error-dominated run no longer prints a bare score: the terminal
26
+ closing line and the HTML report headline and summary say `n/a`, like
27
+ the terminal headline already did (#1275, #1271, #1276, #1272).
28
+
29
+ ### Tests
30
+
31
+ - New end-to-end pins for per-path score gates, the `Score: n/a`
32
+ headline, the README get-started flow, parallel coverage parts and
33
+ the trace recorder, survivor triage labels, kills split by cause and
34
+ a docs-only `--since` diff under a threshold (#1270, #1273, #1278,
35
+ #1279, #1280, #1281, #1277, #1159, #1158).
36
+
37
+ ## [0.12.0] - 2026-10-22
38
+
39
+ ### Added
40
+
41
+ - The default report is short: a findings list, the top three expanded
42
+ survivor blocks and `--details` for the rest, with a per-phase
43
+ wall-time speed footer and the slowest mutant. Fixed template lines
44
+ fit 80 columns and the piped-progress margin is wider so the summary
45
+ fits 80-column logs (#1256, #1152).
46
+ - A run on a Rails app with no `config/saboteur.rb` now defaults
47
+ `include` to `app/**/*.rb` and says so in the missing-config notice,
48
+ instead of exiting 2. A written config, even an empty one, keeps the
49
+ plain defaults, and a CLI `--include` still wins (#1257, #1171).
50
+ - The terminal summary prints one dim line under Score when mutants sit
51
+ on lines no test runs, saying they are not in the score and that the
52
+ JSON report lists them as `NoCoverage` (#1259, #1190).
53
+
54
+ ### Changed
55
+
56
+ - Uncovered survivors are labeled `input_gap`, separate from assertion
57
+ gaps (#1254, #1157).
58
+
59
+ ### Tests
60
+
61
+ - End-to-end pins for `--shard K/N` and the score history, badge and
62
+ trend commands, and for a stub-only method reported as not executed
63
+ (#1260, #1261, #1158, #1159). The survivor-ranking regression
64
+ fixture is the real money gem `allocation.rb` (#1262, #1160).
65
+
9
66
  ## [0.11.2] - 2026-10-22
10
67
 
11
68
  ### Added
@@ -15,7 +15,7 @@ module Saboteur
15
15
  def exit_code(report)
16
16
  return EXIT_BUDGET if budget_exhausted?(report)
17
17
 
18
- return EXIT_OK if ignore_empty_diff?(report)
18
+ return EXIT_OK if empty_clean_diff?(report)
19
19
 
20
20
  return EXIT_TOOL if untested_partial?(report)
21
21
 
@@ -29,14 +29,18 @@ module Saboteur
29
29
  gates.failed?(report, @err, record_cause: !quality_failed) || quality_failed ? EXIT_SURVIVORS : EXIT_OK
30
30
  end
31
31
 
32
- # --ignore-empty: a --since diff that selected no mutants exits 0 with
33
- # a note; suppressed, omitted or undiscovered mutants still gate.
34
- def ignore_empty_diff?(report)
35
- return false unless config["ignore_empty"] && config["since"] && report.results.empty?
32
+ # A --since diff that selected no mutants exits 0 with a plain note,
33
+ # with or without --ignore-empty: a docs-only change has no score, so a
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.
37
+ def empty_clean_diff?(report)
38
+ return false unless config["since"] && report.results.empty?
39
+ return false if unverifiable_scope?(report)
36
40
  return false unless report.omissions.empty? && report.inline_suppressions.empty? &&
37
41
  report.discovery_failures.empty?
38
42
 
39
- @err.puts("saboteur: no mutants in the diff - nothing to gate (--ignore-empty)")
43
+ @err.puts("saboteur: no mutants in the diff - nothing to gate")
40
44
  true
41
45
  end
42
46
 
@@ -42,6 +42,10 @@ module Saboteur
42
42
  end
43
43
  opts.on("--all-mutants", "Text report: list every survived mutant;",
44
44
  "default: one finding per statement, with the label mix") { set(:all_mutants, true) }
45
+ # D1: the default whole-project report lists findings and expands the
46
+ # top three; --details restores a block for every finding.
47
+ opts.on("--details", "Text report: full blocks for every finding;",
48
+ "default: one-line list plus the top 3 blocks") { set(:details, true) }
45
49
  opts.on("--no-color", "Deprecated alias for --color=never") { @color = :never }
46
50
  end
47
51
  end
@@ -25,7 +25,13 @@ module Saboteur
25
25
  return if @format == "agent" || config.config_file_present
26
26
 
27
27
  path = options[:config] || Config::FILE
28
- notice = "saboteur: no #{path} - using defaults (Rails: bundle exec rails generate saboteur:install writes one)"
28
+ notice =
29
+ if config["include"] == Config::RAILS_INCLUDE && !config.explicit?("include")
30
+ "saboteur: no #{path} - Rails app detected, mutating app/**/*.rb " \
31
+ "(bundle exec rails generate saboteur:install writes a config)"
32
+ else
33
+ "saboteur: no #{path} - using defaults (Rails: bundle exec rails generate saboteur:install writes one)"
34
+ end
29
35
  @err.puts(palette.paint(:dim, notice))
30
36
  end
31
37
 
@@ -21,7 +21,7 @@ module Saboteur
21
21
  Reporter::Terminal.new(@out, verbose: @verbose || false, quiet: @quiet || false, color: @color || :auto,
22
22
  overrides: config["palette"], threshold: config["threshold"],
23
23
  replay_command: survivor_replay_command(report, engine),
24
- all_mutants: options[:all_mutants] || false,
24
+ all_mutants: options[:all_mutants] || false, details: options[:details] || false,
25
25
  exit_code: code, previous_score: previous_score, scope: scope_label,
26
26
  whole_suite_map: whole_suite_map_for(engine),
27
27
  run_map: engine&.run_coverage_map,
@@ -81,7 +81,7 @@ module Saboteur
81
81
 
82
82
  # Text mode only: the engine prints a per-subject marks line when
83
83
  # --verbose is on (#262); --agent output stays as it is.
84
- engine = Engine.new(config, **arguments, verbose: @verbose || false)
84
+ engine = Engine.new(config, **arguments, verbose: @verbose || false, quiet: @quiet || false)
85
85
  text_run(engine.call, engine)
86
86
  end
87
87
 
@@ -22,8 +22,9 @@ module Saboteur
22
22
  opts.on("--id DIGEST", "Replay exactly one current mutant by its full digest") { |v| set(:id, v) }
23
23
  opts.on("--shard K/N", "Run one of N deterministic candidate shards (for example 1/4)") { |v| set(:shard, v) }
24
24
  opts.on("--since REF", "Only mutate subjects changed since git REF") { |v| options[:since] = v }
25
- opts.on("--ignore-empty", "A --since diff that selects no mutants exits 0 with a note instead of " \
26
- "failing a score gate") { set(:ignore_empty, true) }
25
+ opts.on("--ignore-empty", "Deprecated no-op: the no-mutant diff case always exits 0 (#1142)") do
26
+ set(:ignore_empty, true)
27
+ end
27
28
  flaky_desc = "Confirm a fresh verdict (survivors, decisive) with one reordered rerun"
28
29
  opts.on("--flaky-check MODE", %w[off survivors decisive], flaky_desc) { |v| set(:flaky_check, v) }
29
30
  opts.on("--subject EXPR", "Klass#meth, Klass.meth or a namespace; repeatable") { |v| add_subject(v) }
@@ -2,8 +2,10 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
+ require "stringio"
5
6
 
6
7
  require_relative "../survivor_guidance"
8
+ require_relative "../reporter/palette"
7
9
  require_relative "../reporter/survivor_hints"
8
10
 
9
11
  module Saboteur
@@ -17,6 +19,11 @@ module Saboteur
17
19
  # Where `saboteur run` writes the JSON report unless configured otherwise.
18
20
  DEFAULT_REPORT = Config::DEFAULTS["json_report"]
19
21
 
22
+ # survivors owns its usage line (#1150): the report path is optional
23
+ # because the run always writes the same default.
24
+ SURVIVORS_USAGE = "Usage: saboteur survivors [options] [REPORT] - REPORT defaults to " \
25
+ ".saboteur/report.json"
26
+
20
27
  private
21
28
 
22
29
  def survivors_command
@@ -27,28 +34,50 @@ module Saboteur
27
34
  end
28
35
 
29
36
  def survivors_command_inner
30
- parser = OptionParser.new do |opts|
31
- opts.on("--ranked", "Review-priority order: the report's ranked findings first")
32
- opts.on("--file SUBSTR", "Only survivors whose path contains SUBSTR")
33
- opts.on("--operator NAME", "Only survivors of operator NAME")
34
- opts.on("--triage LABEL", "Only survivors classified LABEL (for example boundary)")
35
- opts.on("--show DIGEST", "Diff, replay command and ignore template for one survivor")
36
- end
37
37
  options = {}
38
- args = parser.parse!(@argv, into: options)
38
+ parser = survivors_parser(options)
39
+ args = parse_survivors_args(parser, options)
40
+ if options[:help]
41
+ @out.puts(parser)
42
+ return EXIT_OK
43
+ end
39
44
  path = args.shift || DEFAULT_REPORT
40
- raise UsageError, "unexpected arguments: #{args.join(' ')}" if args.any?
45
+ raise UsageError, "unexpected arguments: #{args.join(' ')}\n#{parser}" if args.any?
41
46
 
42
47
  report = load_survivor_report(path)
43
- browser = SurvivorsBrowser.new(report)
48
+ browser = SurvivorsBrowser.new(report, palette: Reporter::Palette.for(@out, mode: options.fetch(:color, :auto)))
44
49
  if options[:show]
45
50
  @out.puts(browser.show(options[:show]))
46
51
  else
47
- @out.puts(browser.list(**options.slice(:ranked, :file, :operator, :triage)))
52
+ @out.puts(browser.list(**options.slice(:file, :operator, :triage)))
48
53
  end
49
54
  EXIT_OK
50
55
  end
51
56
 
57
+ # The same colour flags as run (#1150), plus the browser filters.
58
+ def survivors_parser(options)
59
+ OptionParser.new do |opts|
60
+ opts.banner = SURVIVORS_USAGE
61
+ opts.on("--ranked", "Review-priority order (the default): the terminal report's ordering")
62
+ opts.on("--file SUBSTR", "Only survivors whose path contains SUBSTR")
63
+ opts.on("--operator NAME", "Only survivors of operator NAME")
64
+ opts.on("--triage LABEL", "Only survivors classified LABEL (for example boundary)")
65
+ opts.on("--show DIGEST", "Diff, replay command and ignore template for one survivor")
66
+ opts.on("--color [MODE]", %w[always never auto],
67
+ "Colour the output: always, never, or auto when MODE is omitted") do |mode|
68
+ options[:color] = (mode || :always).to_sym
69
+ end
70
+ opts.on("--no-color", "Alias for --color=never") { options[:color] = :never }
71
+ opts.on("--help", "Show this help") { options[:help] = true }
72
+ end
73
+ end
74
+
75
+ def parse_survivors_args(parser, options)
76
+ parser.parse!(@argv, into: options)
77
+ rescue OptionParser::ParseError => e
78
+ raise UsageError, "#{e.message}\n#{parser}"
79
+ end
80
+
52
81
  def load_survivor_report(path)
53
82
  unless File.file?(path)
54
83
  hint = path == DEFAULT_REPORT ? "; run saboteur first, or pass a report path" : ""
@@ -64,14 +93,21 @@ module Saboteur
64
93
  # Reads the report's files section (the complete mutant list), not the
65
94
  # capped findings, so survivors beyond the findings limit still list.
66
95
  class SurvivorsBrowser
67
- def initialize(report)
96
+ # Sibling digests named on a collapse line; the rest are a count - the
97
+ # same shape the terminal report's collapse uses (#1150).
98
+ SIBLING_DIGEST_CAP = 3
99
+
100
+ def initialize(report, palette: Reporter::Palette.for(StringIO.new))
68
101
  @report = report
69
102
  @findings = report.dig("saboteur", "findings") || {}
103
+ @palette = palette
70
104
  end
71
105
 
72
- def list(ranked: false, file: nil, operator: nil, triage: nil)
73
- survivors = filtered(file, operator, triage)
74
- survivors = ranked_order(survivors) if ranked
106
+ # One ordering everywhere (#1150): the terminal report's ranked order
107
+ # is the default, exactly as the findings carry it; --ranked stays
108
+ # accepted for compatibility.
109
+ def list(file: nil, operator: nil, triage: nil, **)
110
+ survivors = ranked_order(filtered(file, operator, triage))
75
111
  return "no survivors#{filters_note(file, operator, triage)}" if survivors.empty?
76
112
 
77
113
  [header(survivors.size, file, operator, triage), *rows(survivors)]
@@ -89,6 +125,7 @@ module Saboteur
89
125
  " #{SurvivorGuidance::REPLAY_CAVEAT}",
90
126
  "",
91
127
  SurvivorGuidance::IGNORE_INTRO,
128
+ " #{SurvivorGuidance::IGNORE_CONFIG}",
92
129
  " #{ignore_entry(survivor, subject)}"
93
130
  ].join("\n")
94
131
  end
@@ -159,19 +196,32 @@ module Saboteur
159
196
  def header(size, file, operator, triage)
160
197
  text = "#{size} survivor#{'s' unless size == 1}#{filters_note(file, operator, triage)}"
161
198
  truncated = @findings["survivorsTruncated"].to_i
162
- return text unless truncated.positive?
163
-
164
- "#{text} (the report's ranked findings name only the first #{@findings['limit'] || 10})"
199
+ if truncated.positive?
200
+ text = "#{text} (the report's ranked findings name only the first #{@findings['limit'] || 10})"
201
+ end
202
+ @palette.paint(:head, text)
165
203
  end
166
204
 
205
+ # The terminal collapse: sibling mutants on one statement fold into the
206
+ # first row, which names up to SIBLING_DIGEST_CAP of their digests.
167
207
  def rows(survivors)
168
- survivors.each_with_index.map do |s, i|
169
- format("%3<i>d. %<path>s:%<line>d %-28<operator>s %<digest>s %<classification>s",
170
- i: i + 1, path: s[:path], line: s[:line], operator: s[:operator], digest: s[:digest],
171
- classification: s[:classification] || "-")
208
+ survivors.group_by { |s| [s[:path], s[:line]] }.values.each_with_index.map do |group, i|
209
+ first, *siblings = group
210
+ format("%3<i>d. %<path>s:%<line>d %-28<operator>s %<digest>s %<classification>s%<suffix>s",
211
+ i: i + 1, path: first[:path], line: first[:line], operator: first[:operator],
212
+ digest: first[:digest], classification: first[:classification] || "-",
213
+ suffix: siblings_suffix(siblings))
172
214
  end
173
215
  end
174
216
 
217
+ def siblings_suffix(siblings)
218
+ return "" if siblings.empty?
219
+
220
+ named = siblings.first(SIBLING_DIGEST_CAP).map { |s| s[:digest] }.join(", ")
221
+ rest = siblings.size - SIBLING_DIGEST_CAP
222
+ " (+#{siblings.size} here: #{named}#{", +#{rest} more" if rest.positive?})"
223
+ end
224
+
175
225
  def ignore_entry(survivor, subject)
176
226
  block = if subject
177
227
  SurvivorGuidance.ignore_block(survivor[:digest], subject)
@@ -191,12 +241,17 @@ module Saboteur
191
241
  subject: m["subject"], covered: m["coveredBy"] || [], source: entry["source"] }
192
242
  end
193
243
  end
194
- survivors.sort_by { |s| [s[:path], s[:line], s[:digest]] }
244
+ survivors
195
245
  end
196
246
  end
197
247
 
248
+ # Ranked order straight from the report: the uncapped findings "order"
249
+ # list first, with the capped findings as the fallback for older
250
+ # reports; anything unknown trails in file order.
198
251
  def ranked_order(survivors)
199
- rank = @findings.fetch("survivors", []).each_with_index.to_h { |f, i| [f["digest"], i] }
252
+ order = @findings["order"] ||
253
+ (@findings.fetch("survivors", []) + @findings.fetch("accepted", [])).map { |f| f["digest"] }
254
+ rank = order.each_with_index.to_h
200
255
  survivors.sort_by { |s| [rank[s[:digest]] || Float::INFINITY, -s[:covered].size, s[:path], s[:line], s[:digest]] }
201
256
  end
202
257
 
data/lib/saboteur/cli.rb CHANGED
@@ -139,7 +139,7 @@ module Saboteur
139
139
 
140
140
  def agent_requested? = self.class.agent_requested?(@argv)
141
141
 
142
- def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run, :all_mutants, :quiet)
142
+ def overrides = options.except(:config, :id, :suite, :list_operators, :dry_run, :all_mutants, :quiet, :details)
143
143
 
144
144
  def add_subject(expr) = (options[:subjects] ||= []) << expr
145
145
 
@@ -18,6 +18,11 @@ module Saboteur
18
18
  LEGACY_IGNORE_FILE = ".saboteur-ignore.yml"
19
19
  IGNORE_FILE = "config/saboteur_ignore.rb"
20
20
 
21
+ # A Rails app (config/application.rb + app/) without a config file gets
22
+ # the generator's include instead of the gem-shaped default, so a first
23
+ # run mutates app/ rather than refusing over an empty source set (#1171).
24
+ RAILS_INCLUDE = ["app/**/*.rb"].freeze
25
+
21
26
  # Settings that say where saboteur writes, never what it mutates: left
22
27
  # out of the run fingerprint, baseline evidence and score-history scope.
23
28
  OUTPUT_KEYS = %w[cache_dir json_report html_report score_history resume].freeze
@@ -145,9 +150,21 @@ module Saboteur
145
150
  "saboteur no longer reads #{LEGACY_IGNORE_FILE}; move its entries to #{IGNORE_FILE} (see README)."
146
151
  end
147
152
 
148
- from_file = File.file?(path) ? ConfigFile.load(path) : {}
149
- new(from_file.merge(stringify(overrides)), root: root, config_file_present: File.file?(path))
153
+ file_present = File.file?(path)
154
+ from_file = file_present ? ConfigFile.load(path) : {}
155
+ # Only a missing file triggers detection: a written config, even an
156
+ # empty one, means the defaults were chosen. The detected include is
157
+ # not an explicit key - the user set nothing.
158
+ detected = !file_present && rails_app?(root) ? { "include" => RAILS_INCLUDE } : {}
159
+ cli = stringify(overrides)
160
+ merged = detected.merge(from_file).merge(cli)
161
+ new(merged, root: root, explicit: from_file.keys | cli.keys, config_file_present: file_present)
162
+ end
163
+
164
+ def self.rails_app?(root)
165
+ File.file?(File.join(root, "config", "application.rb")) && File.directory?(File.join(root, "app"))
150
166
  end
167
+ private_class_method :rails_app?
151
168
 
152
169
  def self.stringify(hash)
153
170
  hash.to_h { |key, value| [key.to_s, value] }.compact
@@ -17,6 +17,12 @@ module Saboteur
17
17
  # be watched: the heartbeat then adds the count, the percentage and an
18
18
  # estimate of the time left to its elapsed-time line.
19
19
  class ProgressHeartbeat
20
+ # --quiet substitute: responds to the supervisor's calls, prints nothing.
21
+ NULL = Object.new.tap do |noop|
22
+ def noop.watch(*) = self
23
+ def noop.tick = nil
24
+ end
25
+
20
26
  COUNT = %r{(\d+)/([1-9]\d*) examples finished}
21
27
  TAIL_BYTES = 4096
22
28
 
@@ -77,9 +83,10 @@ module Saboteur
77
83
  # progress (see start_progress); the letter is legend-only.
78
84
  LEGEND = ".:killed S:survived T:timeout E:error s:skipped L:resource limit"
79
85
 
80
- # Chunk sizing: leave room for the " (n/total ...)" suffix, never
81
- # below the floor.
82
- CHUNK_MARGIN = 10
86
+ # Chunk sizing: leave room for the full " n/total (100%) ETA 123m45s"
87
+ # summary suffix (32 columns), never below the floor, so a piped line
88
+ # fits an 80-column CI log instead of wrapping mid-summary.
89
+ CHUNK_MARGIN = 32
83
90
  CHUNK_FLOOR = 40
84
91
  DEFAULT_WIDTH = 80
85
92
 
@@ -90,9 +97,10 @@ module Saboteur
90
97
  attr_reader :warnings, :live_announced, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
91
98
  :hazard_suppressions, :inline_suppressions, :verbose, :quiet_start
92
99
 
93
- def initialize(err, verbose: false, palette: nil, quiet_start: true)
100
+ def initialize(err, verbose: false, palette: nil, quiet_start: true, quiet: false)
94
101
  @err = err
95
102
  @verbose = verbose
103
+ @quiet = quiet
96
104
  @quiet_start = quiet_start
97
105
  @palette = palette || Reporter::Palette.for(err)
98
106
  @warnings = []
@@ -182,7 +190,7 @@ module Saboteur
182
190
  # Chatter the default view folds into the quiet-start lines;
183
191
  # --verbose prints it byte-for-byte as before.
184
192
  def detail(message)
185
- inform(message) if @verbose || !@quiet_start
193
+ inform(message) if !@quiet && (@verbose || !@quiet_start)
186
194
  end
187
195
 
188
196
  # A selection explanation the default view carries as one dim
@@ -198,7 +206,7 @@ module Saboteur
198
206
  # The quiet start: three lines, then progress. --verbose keeps
199
207
  # today's chatter instead and never prints these.
200
208
  def quiet_start_header(scope:, seed:, operators:)
201
- return if @verbose || !@quiet_start
209
+ return if @quiet || @verbose || !@quiet_start
202
210
 
203
211
  dot = @palette.glyph(:middot)
204
212
  line = "saboteur #{Saboteur::VERSION} #{dot} #{scope} #{dot} seed #{seed} #{dot} " \
@@ -207,7 +215,7 @@ module Saboteur
207
215
  end
208
216
 
209
217
  def quiet_baseline(passes:, elapsed:, tests:, cached:)
210
- return if @verbose || !@quiet_start
218
+ return if @quiet || @verbose || !@quiet_start
211
219
 
212
220
  check = @palette.paint(:pass, @palette.glyph(:check))
213
221
  suffix = cached ? " (cached)" : ""
@@ -218,7 +226,7 @@ module Saboteur
218
226
  end
219
227
 
220
228
  def quiet_inference(spec_count)
221
- return if spec_count.nil? || @verbose || !@quiet_start
229
+ return if spec_count.nil? || @quiet || @verbose || !@quiet_start
222
230
 
223
231
  files = spec_count == 1 ? "spec file" : "spec files"
224
232
  @err.puts(@palette.paint(:dim, "spec inference: #{spec_count} #{files} (details: --verbose)"))
@@ -251,6 +259,10 @@ module Saboteur
251
259
  # fork time. Prints "<label>: N s elapsed" about every interval
252
260
  # seconds, so a long phase does not look stuck.
253
261
  def progress_heartbeat(label, interval: 30)
262
+ # --quiet: the contract is findings, score and verdict - no elapsed
263
+ # chatter; the heartbeat's ticks become no-ops.
264
+ return ProgressHeartbeat::NULL if @quiet
265
+
254
266
  ProgressHeartbeat.new(self, label, interval: interval)
255
267
  end
256
268
 
@@ -260,6 +272,8 @@ module Saboteur
260
272
  # The count lives in one file the workers inherit, so every worker adds
261
273
  # to the same number.
262
274
  def start_progress(total)
275
+ return if @quiet
276
+
263
277
  @progress_total = total
264
278
  @progress_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
265
279
  @progress_file = Tempfile.new("saboteur-progress")
@@ -270,6 +284,8 @@ module Saboteur
270
284
  end
271
285
 
272
286
  def progress(result)
287
+ return if @quiet
288
+
273
289
  mark = MARKS.fetch(result.status, "?")
274
290
  return plain_progress(painted_mark(mark)) unless @progress_file
275
291
 
@@ -46,10 +46,11 @@ module Saboteur
46
46
  attr_reader :guessed_tests
47
47
 
48
48
  def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [], verbose: false,
49
- palette: nil, quiet_start: true)
49
+ palette: nil, quiet_start: true, quiet: false)
50
50
  @config = config
51
51
  @hooks = Hooks.new
52
- @diagnostics = Diagnostics.new(err, verbose: verbose, palette: palette, quiet_start: quiet_start)
52
+ @diagnostics = Diagnostics.new(err, verbose: verbose, palette: palette, quiet_start: quiet_start,
53
+ quiet: quiet)
53
54
  @completed_baseline_passes = 0
54
55
  @baseline_elapsed = 0.0
55
56
  @root = File.realpath(config.root)
@@ -199,7 +200,8 @@ module Saboteur
199
200
  matrix: config["matrix"], inline_suppressions: @diagnostics.inline_suppressions,
200
201
  operator_policy: config.operator_policy, discovery_failures: @diagnostics.discovery_failures,
201
202
  test_command: config["test_command"], reuse_label: reuse_label, since: config["since"],
202
- shard: @shard, memory: @memory_report, referenced: @guessed_referenced || {})
203
+ shard: @shard, memory: @memory_report, referenced: @guessed_referenced || {},
204
+ phase_timings: @phase_timings)
203
205
  end
204
206
 
205
207
  def complete_evidence(results, subjects)
@@ -226,14 +228,13 @@ module Saboteur
226
228
 
227
229
  def mutate(subjects, files, integration, scope, ignore_list, load_lines: {}, load_methods: {})
228
230
  store = coverage_store(integration, load_lines, load_methods)
229
- full_passes = config["baseline_passes"] + (store.reusable?(files) ? 0 : 1)
230
- warn_unscoped(integration, full_passes)
231
- announce_quiet_start(store, files)
232
- check_baseline(integration, full_passes)
231
+ map_reusable = store.reusable?(files)
232
+ full_passes = config["baseline_passes"] + (map_reusable ? 0 : 1)
233
+ baseline_preflight(store, integration, files, full_passes)
233
234
  refuse_unaffordable_coverage_build(store, files)
234
235
  return [] if abandon_for_budget?(subjects)
235
236
 
236
- map = store.fetch(files)
237
+ map, map_seconds = timed_map_fetch(store, files)
237
238
  inform_about_run(subjects, map)
238
239
  cache = result_cache(map, files)
239
240
  scheduler = scheduler_for(map, integration, cache)
@@ -246,13 +247,41 @@ module Saboteur
246
247
  # mutant when serial. Pack the heap now that the map and candidates
247
248
  # are retained, to reduce later copying of shared pages.
248
249
  Execution::ForkReadiness.prepare_heap
249
- results = run_and_confirm(runnable_subjects, runnable_lists, scheduler, cache, ignore_list)
250
+ results = run_and_confirm_timed(runnable_subjects, runnable_lists, scheduler, cache, ignore_list,
251
+ map_cached: map_reusable, map_seconds:)
250
252
  cache&.save
251
253
  results
252
254
  rescue BudgetPhaseTimeout => e
253
255
  budget_phase_stop(e, subjects)
254
256
  end
255
257
 
258
+ # The map phase's monotonic split, for the speed footer.
259
+ def timed_map_fetch(store, files)
260
+ started = monotonic
261
+ [store.fetch(files), monotonic - started]
262
+ end
263
+
264
+ # Baseline phase: announce, then verify the unchanged tree passes.
265
+ def baseline_preflight(store, integration, files, full_passes)
266
+ warn_unscoped(integration, full_passes)
267
+ announce_quiet_start(store, files)
268
+ check_baseline(integration, full_passes)
269
+ end
270
+
271
+ # The mutant phase plus the run's three disjoint timing intervals
272
+ # (baseline inside check_baseline, map build, mutants) and the worker
273
+ # count, recorded for the terminal's speed footer.
274
+ def run_and_confirm_timed(subjects, lists, scheduler, cache, ignore_list, map_cached:, map_seconds:)
275
+ started = monotonic
276
+ results = run_and_confirm(subjects, lists, scheduler, cache, ignore_list)
277
+ # @baseline_elapsed is a Float from initialize on, so no fallback is needed.
278
+ @phase_timings = PhaseTimings.new(baseline_seconds: @baseline_elapsed,
279
+ map_seconds:, map_cached:,
280
+ mutants_seconds: monotonic - started,
281
+ jobs: config["jobs"])
282
+ results
283
+ end
284
+
256
285
  # The candidate run plus the opt-in post-run confirmation pass (#1063):
257
286
  # confirmation runs after the last result, before the verdict cache is
258
287
  # written, so a confirmed kill overwrites the survivor verdict the run
data/lib/saboteur/help.rb CHANGED
@@ -140,15 +140,15 @@ module Saboteur
140
140
  name: "survivors",
141
141
  summary: "Browse a report's survivors in the terminal, read-only",
142
142
  description: "Reads the Stryker JSON report without booting the project or rerunning tests. " \
143
- "Lists every survivor with rank, file, line, operator, digest and triage label; " \
144
- "filters with --file, --operator and --triage; --ranked uses the report's ranked " \
145
- "findings order. --show DIGEST prints the diff, the exact replay command and an " \
146
- "ignore-file entry template for that mutant. It never edits the ignore file.",
143
+ "Lists every survivor with rank, file, line, operator, digest and triage label, " \
144
+ "in the report's ranked findings order; filters with --file, --operator and " \
145
+ "--triage; --ranked is kept for compatibility. --show DIGEST prints the diff, " \
146
+ "the exact replay command and an ignore-file entry template for that mutant. " \
147
+ "It never edits the ignore file.",
147
148
  takes_options: false,
148
149
  exit_codes: { 0 => "Survivors listed or shown (including none).",
149
150
  2 => "Missing, malformed or unreadable report; bad arguments." },
150
151
  examples: [["List survivors from the last run", "saboteur survivors .saboteur/report.json"],
151
- ["Review-priority order", "saboteur survivors .saboteur/report.json --ranked"],
152
152
  ["Diff, replay command and ignore template for one mutant",
153
153
  "saboteur survivors .saboteur/report.json --show 1a2b3c4d5e6f7a8b"]]
154
154
  ),
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "mutation_text"
5
+ require_relative "survivor_classification"
6
+ require_relative "survivor_list"
7
+
8
+ module Saboteur
9
+ module Reporter
10
+ # Default whole-project findings (D1): the one-line-per-finding list the
11
+ # --quiet report popularised, then full blocks for the top
12
+ # SurvivorRanking picks only, then a dim closer naming the rest. A
13
+ # --since run or --details keeps the full block list.
14
+ class DefaultFindings
15
+ # Blocks a default run expands; the closer counts what is left out.
16
+ TOP_BLOCKS = 3
17
+
18
+ # Two lines: the fixed text alone is 87 columns at rest; keep it inside 80.
19
+ CLOSER = "... %<more>d more %<findings>s - saboteur survivors .saboteur/report.json,\nor re-run with --details"
20
+
21
+ def initialize(out, palette, all_mutants: false, details: false,
22
+ whole_suite_map: SurvivorList::NO_WHOLE_SUITE_LOOKUP, run_map: nil)
23
+ @out = out
24
+ @palette = palette
25
+ @all_mutants = all_mutants
26
+ @details = details
27
+ @whole_suite_map = whole_suite_map
28
+ @run_map = run_map
29
+ end
30
+
31
+ def call(report, ranked)
32
+ live = ranked.select(&:unignored_survivor?)
33
+ return full(report, ranked) if @details || report.diff? || live.empty?
34
+
35
+ @out.puts(list_lines(report, live))
36
+ @out.puts
37
+ SurvivorList.new(@out, @palette, all_mutants: @all_mutants, limit: TOP_BLOCKS,
38
+ whole_suite_map: @whole_suite_map, run_map: @run_map).call(report, ranked)
39
+ more = finding_count(report, live) - TOP_BLOCKS
40
+ findings = "finding#{'s' unless more == 1}"
41
+ @out.puts @palette.paint(:dim, format(CLOSER, more:, findings:)) if more.positive?
42
+ @out.puts
43
+ end
44
+
45
+ private
46
+
47
+ # --details and --since runs: every finding expanded, exactly the
48
+ # pre-D1 default.
49
+ def full(report, ranked)
50
+ SurvivorList.new(@out, @palette, all_mutants: @all_mutants,
51
+ whole_suite_map: @whole_suite_map, run_map: @run_map).call(report, ranked)
52
+ @out.puts unless report.survivors.empty?
53
+ end
54
+
55
+ # The quiet list's line shape: path:line subject, label and digest,
56
+ # sibling mutants on the statement collapsed to a count (#1126/#1133).
57
+ def list_lines(report, live)
58
+ groups(report, live).map do |(path, line, subject_id), results|
59
+ first, *rest = results
60
+ label = SurvivorClassification.label(first)
61
+ text = "#{path}:#{line} #{subject_id}#{" [#{label}]" if label} - #{first.digest}"
62
+ rest.any? ? "#{text} (+#{rest.size} more)" : text
63
+ end
64
+ end
65
+
66
+ # --all-mutants gives every survivor its own block, so the closer
67
+ # counts mutants rather than statements.
68
+ def finding_count(report, live)
69
+ @all_mutants ? live.size : groups(report, live).size
70
+ end
71
+
72
+ def groups(report, live)
73
+ live.group_by do |result|
74
+ [relative(result.subject.file, report.root), MutationText.line(result), result.subject.id]
75
+ end
76
+ end
77
+
78
+ def relative(file, root)
79
+ file.delete_prefix("#{root}/")
80
+ end
81
+ end
82
+ end
83
+ end
@@ -12,6 +12,8 @@ module Saboteur
12
12
  # Survivors accepted on the ignore list are listed apart, with their
13
13
  # reason. The full, unbounded mutant data stays in the report's files
14
14
  # section; survivorsTruncated says how many more findings live only there.
15
+ # The uncapped ranked digest list lets offline readers (the survivors
16
+ # command) print the terminal's ordering without recomputing it (#1150).
15
17
  module Findings
16
18
  VERSION = 1
17
19
 
@@ -30,7 +32,8 @@ module Saboteur
30
32
  survivors: listed(report, live),
31
33
  survivorsTruncated: truncated(live),
32
34
  accepted: listed(report, accepted),
33
- acceptedTruncated: truncated(accepted)
35
+ acceptedTruncated: truncated(accepted),
36
+ order: full_order(report, live, accepted)
34
37
  }
35
38
  end
36
39
 
@@ -44,6 +47,12 @@ module Saboteur
44
47
  [results.size - LIMIT, 0].max
45
48
  end
46
49
 
50
+ # Every survivor digest in terminal order: live ranked first, then
51
+ # accepted - uncapped, unlike the findings lists themselves.
52
+ def full_order(report, live, accepted)
53
+ (ordered(report, live) + ordered(report, accepted)).map(&:digest)
54
+ end
55
+
47
56
  def ordered(report, results)
48
57
  SurvivorRanking.ordered(report, results)
49
58
  end
@@ -151,6 +151,8 @@ module Saboteur
151
151
  # the same contract as the terminal headline.
152
152
  def headline(report)
153
153
  breakdown = report.score_breakdown
154
+ return error_dominated_headline(report) if report.majority_errored?
155
+
154
156
  text = "<p><strong>Covered mutation score: #{score_text(report.score)}</strong>"
155
157
  if breakdown.tested.positive?
156
158
  text << " (#{breakdown.detected}/#{breakdown.tested} #{Wording.detected_word(breakdown)}, tested subjects)"
@@ -195,12 +197,21 @@ module Saboteur
195
197
  "#{Text.counted(report.subjects.size, 'subject')}, #{Text.counted(report.results.size, 'mutant')}"
196
198
  end
197
199
 
200
+ # An error-dominated run shows no bare score, like the terminal headline
201
+ # (#557, #1272): the share covers only the few mutants that were scored.
202
+ def error_dominated_headline(report)
203
+ breakdown = report.score_breakdown
204
+ "<p><strong>Covered mutation score: n/a</strong> (#{breakdown.tested} of #{report.executed.size} " \
205
+ "mutants scored, #{score_text(breakdown.value)} of scored) - most executed mutants errored, " \
206
+ "so the run tested almost nothing</p>"
207
+ end
208
+
198
209
  def summary(report, data)
199
210
  seed = data.dig(:config, :seed)
200
211
  seed_note = seed ? ", seed #{seed}" : ""
201
212
  counts = data[:files].values.flat_map { |entry| entry[:mutants] }.map { |mutant| mutant[:status] }.tally
202
213
  <<~HTML
203
- <p><strong>Covered mutation score: #{score_text(report.score)}</strong>
214
+ <p><strong>Covered mutation score: #{score_text(report.majority_errored? ? nil : report.score)}</strong>
204
215
  (#{counts_note(report)}, #{format('%.1f', report.wall_time)}s#{seed_note})</p>
205
216
  <p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
206
217
  mutants are not counted. Survivors accepted on the ignore list stay in the denominator. It covers only the subjects of this run, not the whole project. A tool that reads the
@@ -33,6 +33,7 @@ module Saboteur
33
33
  @out.puts " #{SurvivorGuidance::REPLAY_CAVEAT}"
34
34
  end
35
35
  @out.puts " #{SurvivorGuidance::IGNORE_INTRO}"
36
+ @out.puts " #{SurvivorGuidance::IGNORE_CONFIG}"
36
37
  @out.puts " Saboteur.ignore do |i|"
37
38
  @out.puts " #{SurvivorGuidance.ignore_entry(survivor.digest, survivor.subject.id)}"
38
39
  @out.puts " end"
@@ -30,7 +30,9 @@ module Saboteur
30
30
  GAP_HINT = "A test reaches this line and still passes with the change. " \
31
31
  "Add an assertion that pins this behaviour."
32
32
  TOOL_LIMIT_HINT = "Saboteur could not observe this mutant faithfully. Check it by hand."
33
- HINTS = GapKinds::KINDS.to_h { |label| [label, GAP_HINT] }.merge("tool_limit" => TOOL_LIMIT_HINT).freeze
33
+ HINTS = GapKinds::KINDS.to_h { |label| [label, GAP_HINT] }
34
+ .merge("tool_limit" => TOOL_LIMIT_HINT,
35
+ "input_gap" => "No test reaches this line. Exercise it with a varied input.").freeze
34
36
  DEFAULT_HINT = "No test fails with this change. Check whether a test should pin it."
35
37
 
36
38
  Plan = Struct.new(:post, :delete, :summary, keyword_init: true)
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ module Reporter
6
+ # PR16's speed footer: the run's wall time split by phase, so
7
+ # "saboteur is slow" gets a number with an owner (Stryker and
8
+ # cargo-mutants both print phase timings). A diagnostic, not part of
9
+ # the clean screen: default runs print it only when an overhead phase
10
+ # (baseline or map - never the mutant phase, which IS the work) ate
11
+ # more than OVERHEAD_SHARE of the wall past OVERHEAD_FLOOR seconds;
12
+ # --verbose always prints it.
13
+ class SpeedFooter
14
+ OVERHEAD_SHARE = 0.25
15
+ OVERHEAD_FLOOR = 2.0
16
+ # A mutant slower than SLOW_MUTANT_FACTOR times the median mutant
17
+ # past SLOW_MUTANT_FLOOR seconds is an outlier worth naming.
18
+ SLOW_MUTANT_FACTOR = 5
19
+ SLOW_MUTANT_FLOOR = 1.0
20
+
21
+ def initialize(out, palette, verbose: false)
22
+ @out = out
23
+ @palette = palette
24
+ @verbose = verbose
25
+ end
26
+
27
+ def call(report)
28
+ timings = report.phase_timings
29
+ return unless timings
30
+ return unless @verbose || overhead_exceeded?(timings, report.wall_time)
31
+
32
+ @out.puts @palette.paint(:dim, line(timings, report.wall_time))
33
+ outlier = slowest_outlier(report.results)
34
+ return unless outlier
35
+
36
+ @out.puts @palette.paint(:dim, " slowest: #{outlier.subject.id} " \
37
+ "(#{outlier.mutation.operator}, #{format('%.1f', outlier.runtime)}s)")
38
+ end
39
+
40
+ private
41
+
42
+ def overhead_exceeded?(timings, wall_time)
43
+ [timings.baseline_seconds, timings.map_seconds].any? do |seconds|
44
+ seconds > OVERHEAD_FLOOR && seconds > wall_time * OVERHEAD_SHARE
45
+ end
46
+ end
47
+
48
+ def line(timings, wall_time)
49
+ map = timings.map_cached ? "0.0s (cached)" : "#{format('%.1f', timings.map_seconds)}s"
50
+ "speed footer: done in #{format('%.1f', wall_time)}s - " \
51
+ "baseline #{format('%.1f', timings.baseline_seconds)}s #{middot} map #{map} #{middot} " \
52
+ "mutants #{format('%.1f', timings.mutants_seconds)}s (jobs=#{timings.jobs})"
53
+ end
54
+
55
+ def slowest_outlier(results)
56
+ timed = results.select(&:runtime)
57
+ return if timed.empty?
58
+
59
+ slowest = timed.max_by(&:runtime)
60
+ return unless slowest.runtime > SLOW_MUTANT_FLOOR && slowest.runtime > median(timed) * SLOW_MUTANT_FACTOR
61
+
62
+ slowest
63
+ end
64
+
65
+ def median(results)
66
+ runtimes = results.map(&:runtime).sort
67
+ middle = runtimes.size / 2
68
+ runtimes.size.odd? ? runtimes[middle] : (runtimes[middle - 1] + runtimes[middle]) / 2.0
69
+ end
70
+
71
+ def middot = @palette.glyph(:middot)
72
+ end
73
+ end
74
+ end
@@ -28,8 +28,12 @@ module Saboteur
28
28
  # behaviour; the kind names WHAT sort of check is missing, from the
29
29
  # operator and the edit shape only. The covering list is only the
30
30
  # PLANNED coverage: whole-body (coarse) mutants carry method-level
31
- # guesses, so they never get a gap kind, and neither does anything
32
- # without real coverage.
31
+ # guesses, so they never get a gap kind.
32
+ # input_gap - no real test covered the mutant at all, so the input
33
+ # never varied (D6). Coarse mutants stay unlabeled: their planned
34
+ # coverage is a method-level guess, not an observation. The
35
+ # deterministic version (the runner records the mutated value
36
+ # actually differing) is #1064 follow-up.
33
37
  # tool_limit - survivors saboteur could not observe faithfully: an
34
38
  # external-runner run records only the pseudo-test id, never real
35
39
  # coverage (#1071). A re-eval refusal records no signal on the
@@ -37,7 +41,7 @@ module Saboteur
37
41
  #
38
42
  # A survivor with no matching signal stays unlabeled.
39
43
  module SurvivorClassification
40
- LABELS = %w[likely_equivalent arid tool_limit].concat(GapKinds::KINDS).freeze
44
+ LABELS = %w[likely_equivalent arid tool_limit input_gap].concat(GapKinds::KINDS).freeze
41
45
 
42
46
  class << self
43
47
  def label(result, arid: MutationSource::Builtin::DEFAULT_ARID)
@@ -46,6 +50,7 @@ module Saboteur
46
50
  return "arid" if arid?(result, arid)
47
51
  return "tool_limit" if tool_limited?(result)
48
52
  return gap_kind(result) if possible_test_gap?(result)
53
+ return "input_gap" unless result.mutation.coarse
49
54
 
50
55
  nil
51
56
  end
@@ -96,7 +96,8 @@ module Saboteur
96
96
  LABEL_REASONS = {
97
97
  "likely_equivalent" => "compiles to the same instructions as the original",
98
98
  "arid" => "every edited line matches an arid pattern",
99
- "tool_limit" => "the tool could not observe this mutant faithfully"
99
+ "tool_limit" => "the tool could not observe this mutant faithfully",
100
+ "input_gap" => "no test reached this mutant, so its input never varied"
100
101
  }.merge(GapKinds::REASONS).freeze
101
102
 
102
103
  module_function
@@ -34,26 +34,31 @@ module Saboteur
34
34
  SIBLING_DIGEST_CAP = 3
35
35
 
36
36
  def initialize(out, palette, all_mutants: false, classifier: SurvivorClassification,
37
- whole_suite_map: NO_WHOLE_SUITE_LOOKUP, run_map: nil)
37
+ whole_suite_map: NO_WHOLE_SUITE_LOOKUP, run_map: nil, limit: nil)
38
38
  @out = out
39
39
  @palette = palette
40
40
  @all_mutants = all_mutants
41
41
  @classifier = classifier
42
42
  @whole_suite_map = whole_suite_map
43
43
  @run_map = run_map
44
+ @limit = limit
44
45
  end
45
46
 
46
47
  # Survivors in report (ranked) order; accepted ones trail as dim lines.
48
+ # A limit renders only the first groups as blocks - the caller names
49
+ # the rest itself - while the header still counts every live survivor.
47
50
  def call(report, survivors)
48
51
  return if survivors.empty?
49
52
 
50
53
  live, accepted = survivors.partition(&:unignored_survivor?)
51
54
  @out.puts @palette.paint(:head, "Survivors (#{live.size}) - changes your tests did not catch")
52
55
  block_list = groups(live)
53
- block_list.each_with_index { |group, index| block(report, group, index + 1, block_list.size) }
56
+ total = block_list.size
57
+ shown = @limit ? block_list.first(@limit) : block_list
58
+ shown.each_with_index { |group, index| block(report, group, index + 1, total) }
54
59
  accepted_section(report, accepted)
55
- hinted = live.select { |result| hint_for(result) }
56
- @out.puts "\n#{@palette.paint(:dim, SurvivorHints::CAVEAT)}" if hinted.any?
60
+ hinted = @limit ? shown.flatten.select { |result| hint_for(result) } : live.select { |result| hint_for(result) }
61
+ @out.puts "\n#{@palette.paint(:dim, wrap(SurvivorHints::CAVEAT))}" if hinted.any?
57
62
  end
58
63
 
59
64
  private
@@ -73,6 +78,8 @@ module Saboteur
73
78
  path = relative(first.subject.file, report.root)
74
79
  @out.puts "\n#{@palette.paint(:head, "#{rank}. #{path}:#{line}")} #{first.subject.id} " \
75
80
  "#{@palette.paint(:dim, label_suffix(first, rank, total))}".rstrip
81
+ reason = SurvivorHints.label_reason(@classifier.label(first))
82
+ @out.puts " #{@palette.paint(:dim, reason)}" if reason
76
83
  fence(first, line)
77
84
  @out.puts " #{@palette.paint(:dim, "why it matters: #{why(first.mutation.operator)}")}"
78
85
  changed_lines(report, path, first)
@@ -81,14 +88,28 @@ module Saboteur
81
88
  siblings(report, group)
82
89
  end
83
90
 
91
+ # Fixed furniture fits an 80-column log: word-wrap the caveat. The
92
+ # JSON agent format keeps SurvivorHints::CAVEAT as one line; only the
93
+ # terminal rendering wraps. Interpolated lines are exempt.
94
+ def wrap(text, width = 80)
95
+ text.split.each_with_object([+""]) do |word, lines|
96
+ if !lines.last.empty? && lines.last.length + word.length + 1 > width
97
+ lines << +word
98
+ else
99
+ lines.last << (lines.last.empty? ? word : " #{word}")
100
+ end
101
+ end.join("\n")
102
+ end
103
+
84
104
  # The count names blocks, the unit the leading ordinal uses: grouped
85
105
  # siblings read "#3 of 14 blocks", never blocks against mutants (#1147).
106
+ # Label and rank stay on the header line; the reason moves to its own
107
+ # dim line so the fixed text cannot push the header past 80 columns.
86
108
  def label_suffix(result, rank, total)
87
109
  label = @classifier.label(result)
88
110
  unit = total == 1 ? "block" : "blocks"
89
111
  parts = [("coarse" if result.mutation.coarse), label, "##{rank} of #{total} #{unit}"].compact
90
- reason = SurvivorHints.label_reason(label)
91
- "[#{parts.join(" \u00B7 ")}#{": #{reason}" if reason}]"
112
+ "[#{parts.join(' · ')}]"
92
113
  end
93
114
 
94
115
  # The enclosing def with the changed line as a -/+ pair; longer
@@ -10,6 +10,8 @@ require_relative "survivor_ranking"
10
10
  require_relative "palette"
11
11
  require_relative "notice"
12
12
  require_relative "score_table"
13
+ require_relative "speed_footer"
14
+ require_relative "default_findings"
13
15
  require_relative "survivor_list"
14
16
  require_relative "survivor_classification"
15
17
  require_relative "test_matrix"
@@ -26,12 +28,13 @@ module Saboteur
26
28
  MAX_PASSING_TESTS = 5
27
29
 
28
30
  def initialize(out, verbose: false, quiet: false, color: :auto, overrides: nil,
29
- replay_command: nil, all_mutants: false, exit_code: nil,
31
+ replay_command: nil, all_mutants: false, details: false, exit_code: nil,
30
32
  threshold: nil, previous_score: nil, baseline_gate: nil,
31
33
  scope: "whole project", baseline: nil, coverage: nil, mutant_phase: nil,
32
34
  whole_suite_map: SurvivorList::NO_WHOLE_SUITE_LOOKUP, run_map: nil)
33
35
  @out = SafeOut.new(out)
34
36
  @all_mutants = all_mutants
37
+ @details = details
35
38
  @verbose = verbose
36
39
  @quiet = quiet
37
40
  # Clean-screen context: scope from the CLI; PR4a plumbs the phase stats.
@@ -58,7 +61,7 @@ module Saboteur
58
61
  def call(report)
59
62
  return quiet_call(report) if @quiet
60
63
 
61
- survivors(report)
64
+ findings(report)
62
65
  errors(report)
63
66
  skipped_subjects(report) if @verbose
64
67
  test_command_gaps(report)
@@ -69,6 +72,7 @@ module Saboteur
69
72
  test_matrix(report)
70
73
  operator_policy(report)
71
74
  summary(report)
75
+ SpeedFooter.new(@out, @palette, verbose: @verbose).call(report)
72
76
  end
73
77
 
74
78
  private
@@ -161,6 +165,7 @@ module Saboteur
161
165
  @out.puts @palette.paint(:dim, tally)
162
166
  @out.puts report.shard.line if report.shard
163
167
  score_headline(report)
168
+ no_coverage_note(report)
164
169
  details(report)
165
170
  @score_table.call(report)
166
171
  not_scored(report)
@@ -210,9 +215,7 @@ module Saboteur
210
215
  @out.puts "#{dots} #{suffix}"
211
216
  end
212
217
 
213
- def win_tally(report)
214
- "#{report.counts[:killed]}/#{report.executed.size} mutants killed"
215
- end
218
+ def win_tally(report) = "#{report.counts[:killed]}/#{report.executed.size} mutants killed"
216
219
 
217
220
  def hazard_suppressions(report)
218
221
  return unless report.hazard_suppressions.positive?
@@ -254,6 +257,20 @@ module Saboteur
254
257
  @out.puts line
255
258
  end
256
259
 
260
+ # NoCoverage mutants ride in the mutant count but outside the score
261
+ # (#1190): say how many sit on lines no test runs, and how to list
262
+ # them. Stryker.status maps this skip-reason prefix to NoCoverage.
263
+ def no_coverage_note(report)
264
+ count = report.results.count do |result|
265
+ result.status == :skipped && result.reason&.start_with?("no coverage")
266
+ end
267
+ return if count.zero?
268
+
269
+ counted = Text.counted(count, "mutant")
270
+ note = "#{counted} on lines no test runs (not in the score): the JSON report lists them as NoCoverage"
271
+ @out.puts " #{@palette.paint(:dim, note)}"
272
+ end
273
+
257
274
  # The share of tested mutants the run detected: "killed" alone would
258
275
  # count timeouts too, so Wording names both when one is in the number.
259
276
  def score_parenthetical(breakdown)
@@ -315,7 +332,8 @@ module Saboteur
315
332
  parts = state_parts(report)
316
333
  parts << "no mutants" if parts.empty?
317
334
  line = "saboteur: #{parts.join(', ')} in #{duration(report.wall_time)}"
318
- line += " - score #{painted_score(report.score)}"
335
+ # The headline withholds the score on an error-dominated run (#557); so does this line (#1271).
336
+ line += " - score #{painted_score(report.majority_errored? ? nil : report.score)}"
319
337
  line += " (threshold #{format('%g', @threshold)})" if @threshold
320
338
  @out.puts line
321
339
  end
@@ -592,11 +610,12 @@ module Saboteur
592
610
  @out.puts " ... #{errored.size - MAX_ERRORS} more" if errored.size > MAX_ERRORS
593
611
  end
594
612
 
595
- def survivors(report)
596
- SurvivorList.new(@out, @palette, all_mutants: @all_mutants,
597
- whole_suite_map: @whole_suite_map, run_map: @run_map)
598
- .call(report, ranked_survivors(report))
599
- @out.puts unless report.survivors.empty?
613
+ # Default whole-project runs keep the first screen short (D1): the
614
+ # findings list plus the top blocks; --details or --since expands all.
615
+ def findings(report)
616
+ DefaultFindings.new(@out, @palette, all_mutants: @all_mutants, details: @details,
617
+ whole_suite_map: @whole_suite_map,
618
+ run_map: @run_map).call(report, ranked_survivors(report))
600
619
  end
601
620
 
602
621
  # Live survivors precede accepted ones; each group follows SurvivorRanking.
@@ -162,6 +162,18 @@ module Saboteur
162
162
  end
163
163
  end
164
164
 
165
+ # Per-phase wall time of one run: baseline check, coverage map build,
166
+ # mutant execution, and the worker count (PR16). Recorded from monotonic
167
+ # clocks at the phase boundaries; a reused map reports 0.0 s (cached), so
168
+ # the three phases never double count the cached build and their sum stays
169
+ # inside the wall time.
170
+ PhaseTimings = Data.define(:baseline_seconds, :map_seconds, :map_cached, :mutants_seconds, :jobs) do
171
+ def initialize(baseline_seconds:, map_seconds:, map_cached:, mutants_seconds:, jobs:)
172
+ super(baseline_seconds:, map_seconds: map_cached ? 0.0 : map_seconds, map_cached:,
173
+ mutants_seconds:, jobs:)
174
+ end
175
+ end
176
+
165
177
  # Everything one run produced.
166
178
  class Report
167
179
  # More than half of the executed mutants errored: the run tested
@@ -171,7 +183,8 @@ module Saboteur
171
183
  attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :live_announced, :root, :seed,
172
184
  :skipped_subjects, :since,
173
185
  :generation_events, :omissions, :run_evidence, :hazard_suppressions, :early_stop, :inline_suppressions,
174
- :operator_policy, :discovery_failures, :memory, :shard, :test_command, :referenced, :reuse_label
186
+ :operator_policy, :discovery_failures, :memory, :shard, :test_command, :referenced, :reuse_label,
187
+ :phase_timings
175
188
 
176
189
  # REVIEW: diff mode; the terminal then shows one survivor per line.
177
190
  # seed: the random seed every test run started from (nil when unknown).
@@ -180,7 +193,7 @@ module Saboteur
180
193
  seed: nil, skipped_subjects: {}, diff_scope: nil, generation_events: [], omissions: [],
181
194
  run_evidence: nil, hazard_suppressions: 0, early_stop: nil, matrix: false, inline_suppressions: [],
182
195
  operator_policy: nil, discovery_failures: [], memory: nil, shard: nil,
183
- test_command: nil, referenced: {}, reuse_label: nil, since: nil)
196
+ test_command: nil, referenced: {}, reuse_label: nil, since: nil, phase_timings: nil)
184
197
  @results = results
185
198
  @subjects = subjects
186
199
  @wall_time = wall_time
@@ -213,6 +226,9 @@ module Saboteur
213
226
  # or require instead of by name (zero-setup selection).
214
227
  @referenced = referenced
215
228
  @reuse_label = reuse_label
229
+ # nil for pipelines without the three phases (--test-command runs):
230
+ # the terminal prints no speed footer without a full split.
231
+ @phase_timings = phase_timings
216
232
  end
217
233
 
218
234
  # A --matrix run: every covering test ran per mutant.
@@ -8,8 +8,8 @@ module Saboteur
8
8
  # surfaces never drift (#1167).
9
9
  module SurvivorGuidance
10
10
  REPLAY_CAVEAT = "(add this run's own --config, --since, --subject, --test and source options)"
11
- IGNORE_INTRO = "accept an equivalent one: add to your ignore file " \
12
- "(c.ignore_file, default #{Config::IGNORE_FILE}):".freeze
11
+ IGNORE_INTRO = "accept an equivalent one: add to your ignore file"
12
+ IGNORE_CONFIG = "(c.ignore_file, default #{Config::IGNORE_FILE}):".freeze
13
13
  REASON_PLACEHOLDER = "why it is equivalent"
14
14
 
15
15
  module_function
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.11.2"
5
+ VERSION = "0.12.1"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saboteur
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.2
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu
@@ -225,6 +225,7 @@ files:
225
225
  - lib/saboteur/reporter/assets/mutation_report.LICENSE
226
226
  - lib/saboteur/reporter/assets/mutation_report.js
227
227
  - lib/saboteur/reporter/baseline_tail.rb
228
+ - lib/saboteur/reporter/default_findings.rb
228
229
  - lib/saboteur/reporter/diff.rb
229
230
  - lib/saboteur/reporter/findings.rb
230
231
  - lib/saboteur/reporter/gap_kinds.rb
@@ -244,6 +245,7 @@ files:
244
245
  - lib/saboteur/reporter/safe_out.rb
245
246
  - lib/saboteur/reporter/score_history.rb
246
247
  - lib/saboteur/reporter/score_table.rb
248
+ - lib/saboteur/reporter/speed_footer.rb
247
249
  - lib/saboteur/reporter/stryker.rb
248
250
  - lib/saboteur/reporter/survivor_classification.rb
249
251
  - lib/saboteur/reporter/survivor_hints.rb