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 +4 -4
- data/CHANGELOG.md +57 -0
- data/lib/saboteur/cli/exit_status.rb +10 -6
- data/lib/saboteur/cli/output_options.rb +4 -0
- data/lib/saboteur/cli/run_command.rb +7 -1
- data/lib/saboteur/cli/run_reporting.rb +2 -2
- data/lib/saboteur/cli/selection_options.rb +3 -2
- data/lib/saboteur/cli/survivors_command.rb +79 -24
- data/lib/saboteur/cli.rb +1 -1
- data/lib/saboteur/config.rb +19 -2
- data/lib/saboteur/engine/diagnostics.rb +24 -8
- data/lib/saboteur/engine.rb +38 -9
- data/lib/saboteur/help.rb +5 -5
- data/lib/saboteur/reporter/default_findings.rb +83 -0
- data/lib/saboteur/reporter/findings.rb +10 -1
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/next_steps.rb +1 -0
- data/lib/saboteur/reporter/review_comments.rb +3 -1
- data/lib/saboteur/reporter/speed_footer.rb +74 -0
- data/lib/saboteur/reporter/survivor_classification.rb +8 -3
- data/lib/saboteur/reporter/survivor_hints.rb +2 -1
- data/lib/saboteur/reporter/survivor_list.rb +27 -6
- data/lib/saboteur/reporter/terminal.rb +30 -11
- data/lib/saboteur/result.rb +18 -2
- data/lib/saboteur/survivor_guidance.rb +2 -2
- data/lib/saboteur/version.rb +1 -1
- metadata +3 -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,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
|
|
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
|
|
|
@@ -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 =
|
|
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", "
|
|
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)]
|
|
@@ -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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 |
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/saboteur/config.rb
CHANGED
|
@@ -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
|
-
|
|
149
|
-
|
|
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 "
|
|
81
|
-
# below the floor
|
|
82
|
-
|
|
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
|
|
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)
|
|
@@ -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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
),
|
|
@@ -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] }
|
|
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
|
|
32
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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.
|
data/lib/saboteur/result.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/saboteur/version.rb
CHANGED
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.
|
|
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
|