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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/saboteur/cli/exit_status.rb +10 -6
- data/lib/saboteur/cli/run_reporting.rb +1 -1
- data/lib/saboteur/cli/selection_options.rb +3 -2
- data/lib/saboteur/cli/survivors_command.rb +78 -24
- data/lib/saboteur/engine/diagnostics.rb +20 -5
- data/lib/saboteur/engine.rb +3 -2
- data/lib/saboteur/help.rb +5 -5
- data/lib/saboteur/reporter/findings.rb +10 -1
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/terminal.rb +2 -1
- data/lib/saboteur/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9537980fed0394bdadcbd64d8e637e55cfb42925b5673d2ee30e6f0b18bb13e
|
|
4
|
+
data.tar.gz: a628132a21155e240bc856c99707f46323f280929344048be98a746fa74d1c9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
#
|
|
33
|
-
# a
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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", "
|
|
26
|
-
|
|
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
|
-
|
|
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(:
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 |
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/saboteur/engine.rb
CHANGED
|
@@ -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
|
|
145
|
-
"
|
|
146
|
-
"ignore-file entry template for that mutant.
|
|
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
|
-
|
|
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
|
data/lib/saboteur/version.rb
CHANGED