saboteur 0.12.0 → 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: 7e61c3755190680c12faf62b3a0bcad5f74750da7ff3e3716736e8b9aa733c46
4
- data.tar.gz: 85dc6bbddcc174034da44607b55c5c438c883bc5e0c5954a68d8115abb4b5984
3
+ metadata.gz: e9537980fed0394bdadcbd64d8e637e55cfb42925b5673d2ee30e6f0b18bb13e
4
+ data.tar.gz: a628132a21155e240bc856c99707f46323f280929344048be98a746fa74d1c9b
5
5
  SHA512:
6
- metadata.gz: eceece31e29ceb03758b67aaa4ecfddcf1f6ca1f864358706fabcb8c9fed1f725da5548cd4f810684ac85250651f234b182452dd0525309711b616b997bf8026
7
- data.tar.gz: f62d86ac983b24a550ba5696372fee7f9883be0103e823997f5fdd64c7150b42a8d88aae38881cd65ecd3b2141b39c22c9b7513552506a6499dddceb30128a69
6
+ metadata.gz: c96be31b305e173850739c426f5343fb6a24b416cb5f923b30cfea4a8764fb0c9dd3eeeb5dc8b00af132e16267e8e4135ea84d2d5669b38e35433323e94c072c
7
+ data.tar.gz: 9763cf728f40ad70a714665bc4cdf09ce0a9d516d7da9101bb137f60cdb378a5715d321d8b71d725ac635736b167a9ce6ec3b8c5e99632ef1f204cea49ad3e94
data/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ 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
+
9
37
  ## [0.12.0] - 2026-10-22
10
38
 
11
39
  ### 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
 
@@ -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)]
@@ -160,19 +196,32 @@ module Saboteur
160
196
  def header(size, file, operator, triage)
161
197
  text = "#{size} survivor#{'s' unless size == 1}#{filters_note(file, operator, triage)}"
162
198
  truncated = @findings["survivorsTruncated"].to_i
163
- return text unless truncated.positive?
164
-
165
- "#{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)
166
203
  end
167
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.
168
207
  def rows(survivors)
169
- survivors.each_with_index.map do |s, i|
170
- format("%3<i>d. %<path>s:%<line>d %-28<operator>s %<digest>s %<classification>s",
171
- i: i + 1, path: s[:path], line: s[:line], operator: s[:operator], digest: s[:digest],
172
- 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))
173
214
  end
174
215
  end
175
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
+
176
225
  def ignore_entry(survivor, subject)
177
226
  block = if subject
178
227
  SurvivorGuidance.ignore_block(survivor[:digest], subject)
@@ -192,12 +241,17 @@ module Saboteur
192
241
  subject: m["subject"], covered: m["coveredBy"] || [], source: entry["source"] }
193
242
  end
194
243
  end
195
- survivors.sort_by { |s| [s[:path], s[:line], s[:digest]] }
244
+ survivors
196
245
  end
197
246
  end
198
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.
199
251
  def ranked_order(survivors)
200
- 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
201
255
  survivors.sort_by { |s| [rank[s[:digest]] || Float::INFINITY, -s[:covered].size, s[:path], s[:line], s[:digest]] }
202
256
  end
203
257
 
@@ -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
 
@@ -91,9 +97,10 @@ module Saboteur
91
97
  attr_reader :warnings, :live_announced, :invalid_mutations, :generation_events, :omissions, :discovery_failures,
92
98
  :hazard_suppressions, :inline_suppressions, :verbose, :quiet_start
93
99
 
94
- def initialize(err, verbose: false, palette: nil, quiet_start: true)
100
+ def initialize(err, verbose: false, palette: nil, quiet_start: true, quiet: false)
95
101
  @err = err
96
102
  @verbose = verbose
103
+ @quiet = quiet
97
104
  @quiet_start = quiet_start
98
105
  @palette = palette || Reporter::Palette.for(err)
99
106
  @warnings = []
@@ -183,7 +190,7 @@ module Saboteur
183
190
  # Chatter the default view folds into the quiet-start lines;
184
191
  # --verbose prints it byte-for-byte as before.
185
192
  def detail(message)
186
- inform(message) if @verbose || !@quiet_start
193
+ inform(message) if !@quiet && (@verbose || !@quiet_start)
187
194
  end
188
195
 
189
196
  # A selection explanation the default view carries as one dim
@@ -199,7 +206,7 @@ module Saboteur
199
206
  # The quiet start: three lines, then progress. --verbose keeps
200
207
  # today's chatter instead and never prints these.
201
208
  def quiet_start_header(scope:, seed:, operators:)
202
- return if @verbose || !@quiet_start
209
+ return if @quiet || @verbose || !@quiet_start
203
210
 
204
211
  dot = @palette.glyph(:middot)
205
212
  line = "saboteur #{Saboteur::VERSION} #{dot} #{scope} #{dot} seed #{seed} #{dot} " \
@@ -208,7 +215,7 @@ module Saboteur
208
215
  end
209
216
 
210
217
  def quiet_baseline(passes:, elapsed:, tests:, cached:)
211
- return if @verbose || !@quiet_start
218
+ return if @quiet || @verbose || !@quiet_start
212
219
 
213
220
  check = @palette.paint(:pass, @palette.glyph(:check))
214
221
  suffix = cached ? " (cached)" : ""
@@ -219,7 +226,7 @@ module Saboteur
219
226
  end
220
227
 
221
228
  def quiet_inference(spec_count)
222
- return if spec_count.nil? || @verbose || !@quiet_start
229
+ return if spec_count.nil? || @quiet || @verbose || !@quiet_start
223
230
 
224
231
  files = spec_count == 1 ? "spec file" : "spec files"
225
232
  @err.puts(@palette.paint(:dim, "spec inference: #{spec_count} #{files} (details: --verbose)"))
@@ -252,6 +259,10 @@ module Saboteur
252
259
  # fork time. Prints "<label>: N s elapsed" about every interval
253
260
  # seconds, so a long phase does not look stuck.
254
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
+
255
266
  ProgressHeartbeat.new(self, label, interval: interval)
256
267
  end
257
268
 
@@ -261,6 +272,8 @@ module Saboteur
261
272
  # The count lives in one file the workers inherit, so every worker adds
262
273
  # to the same number.
263
274
  def start_progress(total)
275
+ return if @quiet
276
+
264
277
  @progress_total = total
265
278
  @progress_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
266
279
  @progress_file = Tempfile.new("saboteur-progress")
@@ -271,6 +284,8 @@ module Saboteur
271
284
  end
272
285
 
273
286
  def progress(result)
287
+ return if @quiet
288
+
274
289
  mark = MARKS.fetch(result.status, "?")
275
290
  return plain_progress(painted_mark(mark)) unless @progress_file
276
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)
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
  ),
@@ -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
@@ -332,7 +332,8 @@ module Saboteur
332
332
  parts = state_parts(report)
333
333
  parts << "no mutants" if parts.empty?
334
334
  line = "saboteur: #{parts.join(', ')} in #{duration(report.wall_time)}"
335
- 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)}"
336
337
  line += " (threshold #{format('%g', @threshold)})" if @threshold
337
338
  @out.puts line
338
339
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Saboteur
5
- VERSION = "0.12.0"
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.12.0
4
+ version: 0.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vamsi Pavan Mahesh Gunturu