saboteur 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +16 -3
- data/lib/saboteur/baseline/command.rb +6 -1
- data/lib/saboteur/baseline/comparison.rb +8 -0
- data/lib/saboteur/baseline/completeness.rb +14 -4
- data/lib/saboteur/baseline/evidence.rb +8 -1
- data/lib/saboteur/baseline/run_evidence.rb +10 -1
- data/lib/saboteur/baseline/workflow.rb +17 -5
- data/lib/saboteur/cli/color_option.rb +26 -0
- data/lib/saboteur/cli/doctor_command.rb +1 -0
- data/lib/saboteur/cli/exit_status.rb +12 -7
- data/lib/saboteur/cli/explain_command.rb +2 -1
- data/lib/saboteur/cli/failure_output.rb +13 -2
- data/lib/saboteur/cli/help_command.rb +5 -0
- data/lib/saboteur/cli/history_command.rb +2 -0
- data/lib/saboteur/cli/output_options.rb +1 -5
- data/lib/saboteur/cli/run_command.rb +1 -1
- data/lib/saboteur/cli/run_reporting.rb +2 -10
- data/lib/saboteur/cli/run_validation.rb +0 -1
- data/lib/saboteur/cli/selection_options.rb +3 -2
- data/lib/saboteur/cli/survivors_command.rb +81 -24
- data/lib/saboteur/cli/whole_suite_map_lookup.rb +33 -0
- data/lib/saboteur/cli.rb +6 -2
- data/lib/saboteur/engine/coverage_store.rb +7 -4
- data/lib/saboteur/engine/diagnostics.rb +27 -5
- data/lib/saboteur/engine/pipelines.rb +9 -2
- data/lib/saboteur/engine/survivor_confirmation.rb +75 -11
- data/lib/saboteur/engine/test_guess.rb +2 -1
- data/lib/saboteur/engine.rb +37 -18
- data/lib/saboteur/execution/runner.rb +3 -0
- data/lib/saboteur/gates.rb +1 -1
- data/lib/saboteur/help.rb +5 -5
- data/lib/saboteur/integration/rspec.rb +30 -0
- data/lib/saboteur/reporter/default_findings.rb +1 -1
- data/lib/saboteur/reporter/findings.rb +10 -1
- data/lib/saboteur/reporter/gap_kinds.rb +6 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/next_steps.rb +3 -1
- data/lib/saboteur/reporter/score_table.rb +6 -11
- data/lib/saboteur/reporter/stryker.rb +7 -2
- data/lib/saboteur/reporter/survivor_classification.rb +15 -0
- data/lib/saboteur/reporter/survivor_list.rb +6 -6
- data/lib/saboteur/reporter/terminal.rb +6 -3
- data/lib/saboteur/reporter/unconfirmed_section.rb +44 -0
- data/lib/saboteur/result.rb +39 -2
- data/lib/saboteur/selection/coverage_map.rb +11 -1
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +4 -0
- metadata +4 -1
data/lib/saboteur/engine.rb
CHANGED
|
@@ -31,11 +31,6 @@ module Saboteur
|
|
|
31
31
|
include GatedRun
|
|
32
32
|
include SelectionAnnouncement
|
|
33
33
|
|
|
34
|
-
# Confirmation only guards a partial coverage map, so it is silent unless
|
|
35
|
-
# the user opted into partial_coverage = use (#1228).
|
|
36
|
-
CONFIRMATION_SKIPPED = "survivor confirmation skipped: partial_coverage = use applies only to a cached " \
|
|
37
|
-
"whole-suite run (no --test, one coverage part)"
|
|
38
|
-
|
|
39
34
|
# Everything besides sources and tests that can change a verdict.
|
|
40
35
|
VERDICT_INPUTS = %w[integration require rspec_args rails reeval sources operators exclude_operators arid
|
|
41
36
|
timeout_factor timeout_min
|
|
@@ -46,10 +41,11 @@ module Saboteur
|
|
|
46
41
|
attr_reader :guessed_tests
|
|
47
42
|
|
|
48
43
|
def initialize(config, err: $stderr, digest: nil, capture_evidence: false, evidence_files: [], verbose: false,
|
|
49
|
-
palette: nil, quiet_start: true)
|
|
44
|
+
palette: nil, quiet_start: true, quiet: false)
|
|
50
45
|
@config = config
|
|
51
46
|
@hooks = Hooks.new
|
|
52
|
-
@diagnostics = Diagnostics.new(err, verbose: verbose, palette: palette, quiet_start: quiet_start
|
|
47
|
+
@diagnostics = Diagnostics.new(err, verbose: verbose, palette: palette, quiet_start: quiet_start,
|
|
48
|
+
quiet: quiet)
|
|
53
49
|
@completed_baseline_passes = 0
|
|
54
50
|
@baseline_elapsed = 0.0
|
|
55
51
|
@root = File.realpath(config.root)
|
|
@@ -129,6 +125,12 @@ module Saboteur
|
|
|
129
125
|
@coverage_store&.whole_suite_map
|
|
130
126
|
end
|
|
131
127
|
|
|
128
|
+
# True when this run did not build a whole-suite map: a diff, named
|
|
129
|
+
# subjects, an explicit --test or inferred specs (#1286).
|
|
130
|
+
def scoped_selection?
|
|
131
|
+
!whole_suite_run?
|
|
132
|
+
end
|
|
133
|
+
|
|
132
134
|
# False when caching is off, where no coverage build can ever leave a
|
|
133
135
|
# whole-suite map to consult, so advising one would be wrong (review C1).
|
|
134
136
|
def whole_suite_map_lookup?
|
|
@@ -291,15 +293,21 @@ module Saboteur
|
|
|
291
293
|
confirm_survivors(run_candidates(subjects, lists, scheduler, cache, ignore_list), scheduler, cache, ignore_list)
|
|
292
294
|
end
|
|
293
295
|
|
|
296
|
+
# Confirm each live survivor against the whole-suite coverage map (#1286):
|
|
297
|
+
# the covering specs the run's own selection never loaded get one budgeted
|
|
298
|
+
# run. Scoped runs (--since, --subject, pr) are the point, so nothing here
|
|
299
|
+
# depends on partial_coverage or on the run being unscoped. Without a
|
|
300
|
+
# whole-suite map there is nothing to confirm against; the run says so
|
|
301
|
+
# elsewhere (survivors unconfirmed: no whole-suite coverage map).
|
|
294
302
|
def confirm_survivors(results, scheduler, cache, ignore_list)
|
|
295
|
-
|
|
296
|
-
live = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
297
|
-
@diagnostics.inform(CONFIRMATION_SKIPPED) if live && config["partial_coverage"] == "use"
|
|
298
|
-
return results
|
|
299
|
-
end
|
|
303
|
+
return results if results.none? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
300
304
|
|
|
301
|
-
|
|
302
|
-
|
|
305
|
+
whole = whole_suite_coverage_map
|
|
306
|
+
return results if whole.nil?
|
|
307
|
+
|
|
308
|
+
SurvivorConfirmation.new(config: config, map: whole, scheduler: scheduler, diagnostics: @diagnostics,
|
|
309
|
+
budget_exceeded: method(:budget_exceeded?), cache: cache,
|
|
310
|
+
load_specs: ->(files) { scheduler.runner.integration.load_spec_files(files) })
|
|
303
311
|
.call(results, ignore_list: ignore_list, early_stop: @early_stop)
|
|
304
312
|
end
|
|
305
313
|
|
|
@@ -349,10 +357,19 @@ module Saboteur
|
|
|
349
357
|
# baseline's own chatter (demoted to --verbose) would start.
|
|
350
358
|
def announce_quiet_start(store, files)
|
|
351
359
|
@map_cached = store.reusable?(files)
|
|
352
|
-
@diagnostics.quiet_start_header(scope:
|
|
360
|
+
@diagnostics.quiet_start_header(scope: quiet_scope,
|
|
353
361
|
seed: config["seed"], operators: config.operator_policy["effective"].size)
|
|
354
362
|
end
|
|
355
363
|
|
|
364
|
+
# Names what the run covers: the diff, the --subject filter, or the
|
|
365
|
+
# whole project (P6) - a --subject run is not "whole project".
|
|
366
|
+
def quiet_scope
|
|
367
|
+
parts = []
|
|
368
|
+
parts << "--since #{config['since']}" if config["since"]
|
|
369
|
+
parts << "--subject #{config['subjects'].join(', ')}" if config["subjects"].any?
|
|
370
|
+
parts.empty? ? "whole project" : parts.join(", ")
|
|
371
|
+
end
|
|
372
|
+
|
|
356
373
|
def inform_about_run(subjects, map)
|
|
357
374
|
if @diagnostics.quiet_start && !@diagnostics.verbose
|
|
358
375
|
@diagnostics.quiet_baseline(passes: @completed_baseline_passes, elapsed: @baseline_elapsed,
|
|
@@ -366,11 +383,13 @@ module Saboteur
|
|
|
366
383
|
end
|
|
367
384
|
|
|
368
385
|
# This run's map is the whole-suite map only when nothing scoped it:
|
|
369
|
-
# no --since, no subjects, and
|
|
370
|
-
#
|
|
386
|
+
# no --since, no subjects, and every spec file loaded (--all or
|
|
387
|
+
# c.test_selection = :all). Inferred specs are a selection, never the
|
|
388
|
+
# whole suite: a spec with no matching source file is not in the map
|
|
389
|
+
# (#1286).
|
|
371
390
|
def whole_suite_run?
|
|
372
391
|
config["since"].nil? && config["subjects"].empty? &&
|
|
373
|
-
|
|
392
|
+
config["tests"].empty? && @guessed_tests.nil?
|
|
374
393
|
end
|
|
375
394
|
|
|
376
395
|
def coverage_store(integration, load_lines, load_methods = {})
|
|
@@ -74,6 +74,9 @@ module Saboteur
|
|
|
74
74
|
# sandbox: when given, a callable run first in every child (Landlock
|
|
75
75
|
# makes the project tree read-only there, #152). It raises rather than
|
|
76
76
|
# run half-restricted, which the child reports as an error.
|
|
77
|
+
# Survivor confirmation loads the spec files a scoped run left out (#1286).
|
|
78
|
+
attr_reader :integration
|
|
79
|
+
|
|
77
80
|
def initialize(integration:, reevaluator:, log_dir:, hooks: Hooks.new, supervisor: Supervisor.new, seed: 1,
|
|
78
81
|
sandbox: nil, canary: nil, crash_log: nil)
|
|
79
82
|
@integration = integration
|
data/lib/saboteur/gates.rb
CHANGED
|
@@ -205,7 +205,7 @@ module Saboteur
|
|
|
205
205
|
|
|
206
206
|
# A subject fails a gate when at least one of its mutants survived.
|
|
207
207
|
def failing_subjects(scored)
|
|
208
|
-
scored.select
|
|
208
|
+
scored.select(&:survived?).map(&:subject).uniq.size
|
|
209
209
|
end
|
|
210
210
|
|
|
211
211
|
def over?(count, limit, executed)
|
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
|
),
|
|
@@ -124,6 +124,36 @@ module Saboteur
|
|
|
124
124
|
world.example_groups.map { |group| File.expand_path(group.metadata[:file_path]) }.uniq
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# Loads spec files the run's own selection left out, in the parent, so a
|
|
128
|
+
# forked confirmation run can execute their examples (#1286). Files
|
|
129
|
+
# already loaded are skipped; a file that defines no group is an error,
|
|
130
|
+
# since its examples could never run.
|
|
131
|
+
def load_spec_files(paths)
|
|
132
|
+
loaded = test_files
|
|
133
|
+
absolute = paths.map { |path| File.expand_path(path, config.root) }.uniq
|
|
134
|
+
(absolute - loaded).each do |path|
|
|
135
|
+
require_inside_root(path)
|
|
136
|
+
before = world.example_groups.size
|
|
137
|
+
from_directory(config.root) { load(path) }
|
|
138
|
+
raise RefusedSpecPath, "#{path} defines no example group" if world.example_groups.size == before
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The ids come from a cached coverage map, which is untrusted input
|
|
143
|
+
# (#427): only a spec file that really lives under the project root,
|
|
144
|
+
# symlinks resolved, may be loaded.
|
|
145
|
+
def require_inside_root(path)
|
|
146
|
+
raise RefusedSpecPath, "refusing to load #{path}: not a *_spec.rb file" unless path.end_with?("_spec.rb")
|
|
147
|
+
|
|
148
|
+
root = File.realpath(config.root)
|
|
149
|
+
real = File.realpath(path)
|
|
150
|
+
return if real.start_with?("#{root}#{File::SEPARATOR}") && real.end_with?("_spec.rb")
|
|
151
|
+
|
|
152
|
+
raise RefusedSpecPath, "refusing to load #{path}: it resolves outside the project root"
|
|
153
|
+
rescue SystemCallError
|
|
154
|
+
raise RefusedSpecPath, "refusing to load #{path}: it does not exist under the project root"
|
|
155
|
+
end
|
|
156
|
+
|
|
127
157
|
# Runs the tests in the order given: groups by their earliest example,
|
|
128
158
|
# examples by position.
|
|
129
159
|
def run(ids)
|
|
@@ -57,7 +57,7 @@ module Saboteur
|
|
|
57
57
|
def list_lines(report, live)
|
|
58
58
|
groups(report, live).map do |(path, line, subject_id), results|
|
|
59
59
|
first, *rest = results
|
|
60
|
-
label = SurvivorClassification.
|
|
60
|
+
label = SurvivorClassification.group_label(results)
|
|
61
61
|
text = "#{path}:#{line} #{subject_id}#{" [#{label}]" if label} - #{first.digest}"
|
|
62
62
|
rest.any? ? "#{text} (+#{rest.size} more)" : text
|
|
63
63
|
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
|
|
@@ -58,6 +58,12 @@ module Saboteur
|
|
|
58
58
|
|
|
59
59
|
FALLBACK = "result unchecked"
|
|
60
60
|
|
|
61
|
+
# Which kind names a statement when its surviving mutants disagree
|
|
62
|
+
# (#P4): the first one listed that any of them carries. The broadest
|
|
63
|
+
# unchecked thing wins, so a branch condition is not called a
|
|
64
|
+
# boundary because one of its mutants happened to flip a comparison.
|
|
65
|
+
PRECEDENCE = ["result unchecked", "error path", "branch", "boundary", "effect", "number"].freeze
|
|
66
|
+
|
|
61
67
|
# The deterministic per-kind reason printed next to a survivor's rank.
|
|
62
68
|
REASONS = {
|
|
63
69
|
"boundary" => "the covering specs pass without pinning this boundary",
|
|
@@ -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
|
|
@@ -20,7 +20,9 @@ module Saboteur
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def call(report)
|
|
23
|
-
survivor
|
|
23
|
+
# The replay example is the #1 survivor of the ranked list, not
|
|
24
|
+
# whichever one the results hold first.
|
|
25
|
+
survivor = SurvivorRanking.ordered(report, report.unignored_survivors).first
|
|
24
26
|
return unless survivor
|
|
25
27
|
|
|
26
28
|
@out.puts "\n#{@palette.paint(:head, 'Next steps:')}"
|
|
@@ -15,7 +15,6 @@ module Saboteur
|
|
|
15
15
|
# it. Untested subjects are never dropped silently, even at 100 %.
|
|
16
16
|
class ScoreTable
|
|
17
17
|
MAX_ROWS = 15
|
|
18
|
-
MAX_UNTESTED_NAMES = 3
|
|
19
18
|
# The skip reason CandidateSelection gives a subject no test covers.
|
|
20
19
|
NO_COVERAGE = "no coverage"
|
|
21
20
|
|
|
@@ -63,7 +62,7 @@ module Saboteur
|
|
|
63
62
|
detected: breakdown.detected, tested: breakdown.tested, untested: untested }
|
|
64
63
|
end
|
|
65
64
|
|
|
66
|
-
# "cart.rb 100.0% (2/2) · n/a - 2 subjects - no test executes them
|
|
65
|
+
# "cart.rb 100.0% (2/2) · n/a - 2 subjects - no test executes them": the
|
|
67
66
|
# tested score and the untested count stand next to each other; a
|
|
68
67
|
# file with nothing tested gets no percentage at all.
|
|
69
68
|
def line(row, width)
|
|
@@ -75,18 +74,14 @@ module Saboteur
|
|
|
75
74
|
note ? "#{base} #{@palette.glyph(:middot)} #{note}" : base
|
|
76
75
|
end
|
|
77
76
|
|
|
78
|
-
# "n/a - 2 subjects - no test executes them:
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
# they just never execute it.
|
|
77
|
+
# "n/a - 2 subjects - no test executes them": only the count. The
|
|
78
|
+
# Not scored block below names every subject once (P6), so the table
|
|
79
|
+
# does not repeat the names. Wording owns the claim (#1145): a
|
|
80
|
+
# stubbed-away subject has tests, they just never execute it.
|
|
83
81
|
def untested_note(untested)
|
|
84
82
|
return if untested.empty?
|
|
85
83
|
|
|
86
|
-
|
|
87
|
-
shown = names.first(MAX_UNTESTED_NAMES)
|
|
88
|
-
shown << "+#{names.size - MAX_UNTESTED_NAMES} more" if names.size > MAX_UNTESTED_NAMES
|
|
89
|
-
@palette.paint(:warn, "n/a - #{Wording.untested_note(untested.size)}: #{shown.join(', ')}")
|
|
84
|
+
@palette.paint(:warn, "n/a - #{Wording.untested_note(untested.size)}")
|
|
90
85
|
end
|
|
91
86
|
end
|
|
92
87
|
end
|
|
@@ -98,7 +98,7 @@ module Saboteur
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def self.test_index(results)
|
|
101
|
-
names = results.flat_map { |result| result.covered_by + result.killed_by + result.executed.to_a }.uniq
|
|
101
|
+
names = results.flat_map { |result| result.covered_by + result.killed_by + result.executed.to_a }.uniq.sort
|
|
102
102
|
names.each_with_index.to_h { |name, index| [name, index.to_s] }
|
|
103
103
|
end
|
|
104
104
|
|
|
@@ -139,6 +139,10 @@ module Saboteur
|
|
|
139
139
|
# Why the survivor is worth a look, as a label only: scores and
|
|
140
140
|
# the exit code never change (#192).
|
|
141
141
|
classification: SurvivorClassification.label(result),
|
|
142
|
+
# Additive (#1286): confirmed, killed_by_confirmation or unconfirmed.
|
|
143
|
+
# An unconfirmed survivor reports Ignored: outside the score.
|
|
144
|
+
confirmation: result.confirmation_state,
|
|
145
|
+
creditedTest: result.credited_test&.then { |name| test_ids.fetch(name) },
|
|
142
146
|
killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
143
147
|
coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
144
148
|
# Which of the covering tests actually ran against the mutant; absent
|
|
@@ -151,7 +155,7 @@ module Saboteur
|
|
|
151
155
|
def self.status(result)
|
|
152
156
|
case result.status
|
|
153
157
|
when :killed then "Killed"
|
|
154
|
-
when :survived then "Survived"
|
|
158
|
+
when :survived then result.unconfirmed? ? "Ignored" : "Survived"
|
|
155
159
|
when :timeout then "Timeout"
|
|
156
160
|
when :error, :resource_limit then "RuntimeError"
|
|
157
161
|
when :skipped then result.reason&.start_with?("no coverage") ? "NoCoverage" : "Ignored"
|
|
@@ -169,6 +173,7 @@ module Saboteur
|
|
|
169
173
|
# ignore, and only wont_fix adds a suffix after the reason.
|
|
170
174
|
return "ignored: #{result.ignored} (wont_fix)" if result.triage_state == "wont_fix"
|
|
171
175
|
return "ignored: #{result.ignored}" if result.ignored
|
|
176
|
+
return "unconfirmed: #{result.unconfirmed_reason}" if result.unconfirmed?
|
|
172
177
|
return "resource limit: #{result.reason}" if result.status == :resource_limit
|
|
173
178
|
return result.crash_kill? ? "crash: #{result.kill_cause}" : result.kill_cause if result.kill_cause
|
|
174
179
|
|
|
@@ -55,6 +55,21 @@ module Saboteur
|
|
|
55
55
|
nil
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# The label of one statement's surviving mutants as a whole, the
|
|
59
|
+
# same whichever mutant sorts first. Any gap kind decides, by
|
|
60
|
+
# GapKinds::PRECEDENCE, so one equivalent, logging or input_gap
|
|
61
|
+
# sibling never hides a real survivor. With no gap kind, the first
|
|
62
|
+
# label present stands: likely_equivalent, arid or tool_limit then
|
|
63
|
+
# describe the statement when all its mutants carry it.
|
|
64
|
+
#
|
|
65
|
+
# Among gap kinds, result unchecked wins because it is the strictly
|
|
66
|
+
# larger gap: nothing pins the statement's value at all, which
|
|
67
|
+
# subsumes a missing branch, boundary or effect check on it.
|
|
68
|
+
def group_label(results)
|
|
69
|
+
labels = results.map { |result| label(result) }.compact
|
|
70
|
+
GapKinds::PRECEDENCE.find { |kind| labels.include?(kind) } || labels.first
|
|
71
|
+
end
|
|
72
|
+
|
|
58
73
|
private
|
|
59
74
|
|
|
60
75
|
# The mutant's own def text, compiled side by side with the
|
|
@@ -77,8 +77,8 @@ module Saboteur
|
|
|
77
77
|
line = MutationText.line(first)
|
|
78
78
|
path = relative(first.subject.file, report.root)
|
|
79
79
|
@out.puts "\n#{@palette.paint(:head, "#{rank}. #{path}:#{line}")} #{first.subject.id} " \
|
|
80
|
-
"#{@palette.paint(:dim, label_suffix(
|
|
81
|
-
reason = SurvivorHints.label_reason(@classifier.
|
|
80
|
+
"#{@palette.paint(:dim, label_suffix(group, rank, total))}".rstrip
|
|
81
|
+
reason = SurvivorHints.label_reason(@classifier.group_label(group))
|
|
82
82
|
@out.puts " #{@palette.paint(:dim, reason)}" if reason
|
|
83
83
|
fence(first, line)
|
|
84
84
|
@out.puts " #{@palette.paint(:dim, "why it matters: #{why(first.mutation.operator)}")}"
|
|
@@ -105,10 +105,10 @@ module Saboteur
|
|
|
105
105
|
# siblings read "#3 of 14 blocks", never blocks against mutants (#1147).
|
|
106
106
|
# Label and rank stay on the header line; the reason moves to its own
|
|
107
107
|
# dim line so the fixed text cannot push the header past 80 columns.
|
|
108
|
-
def label_suffix(
|
|
109
|
-
label = @classifier.
|
|
108
|
+
def label_suffix(group, rank, total)
|
|
109
|
+
label = @classifier.group_label(group)
|
|
110
110
|
unit = total == 1 ? "block" : "blocks"
|
|
111
|
-
parts = [("coarse" if
|
|
111
|
+
parts = [("coarse" if group.first.mutation.coarse), label, "##{rank} of #{total} #{unit}"].compact
|
|
112
112
|
"[#{parts.join(' · ')}]"
|
|
113
113
|
end
|
|
114
114
|
|
|
@@ -226,7 +226,7 @@ module Saboteur
|
|
|
226
226
|
|
|
227
227
|
whole = @whole_suite_map&.tests_for(group.first.subject)
|
|
228
228
|
if whole.nil?
|
|
229
|
-
@out.puts @palette.paint(:dim, " covering
|
|
229
|
+
@out.puts @palette.paint(:dim, " covering list is this run's selection only; run `saboteur run --all`")
|
|
230
230
|
return
|
|
231
231
|
end
|
|
232
232
|
|
|
@@ -6,6 +6,7 @@ require_relative "wording"
|
|
|
6
6
|
require_relative "baseline_tail"
|
|
7
7
|
require_relative "next_steps"
|
|
8
8
|
require_relative "kill_causes"
|
|
9
|
+
require_relative "unconfirmed_section"
|
|
9
10
|
require_relative "survivor_ranking"
|
|
10
11
|
require_relative "palette"
|
|
11
12
|
require_relative "notice"
|
|
@@ -62,6 +63,7 @@ module Saboteur
|
|
|
62
63
|
return quiet_call(report) if @quiet
|
|
63
64
|
|
|
64
65
|
findings(report)
|
|
66
|
+
UnconfirmedSection.new(@out, @palette).call(report)
|
|
65
67
|
errors(report)
|
|
66
68
|
skipped_subjects(report) if @verbose
|
|
67
69
|
test_command_gaps(report)
|
|
@@ -94,7 +96,7 @@ module Saboteur
|
|
|
94
96
|
|
|
95
97
|
def quiet_survivor_line(path, line, subject_id, results)
|
|
96
98
|
first, *rest = results
|
|
97
|
-
label = SurvivorClassification.
|
|
99
|
+
label = SurvivorClassification.group_label(results)
|
|
98
100
|
text = "#{path}:#{line} #{subject_id}#{" [#{label}]" if label} - #{first.digest}"
|
|
99
101
|
rest.any? ? "#{text} (+#{rest.size} more)" : text
|
|
100
102
|
end
|
|
@@ -332,7 +334,8 @@ module Saboteur
|
|
|
332
334
|
parts = state_parts(report)
|
|
333
335
|
parts << "no mutants" if parts.empty?
|
|
334
336
|
line = "saboteur: #{parts.join(', ')} in #{duration(report.wall_time)}"
|
|
335
|
-
|
|
337
|
+
# The headline withholds the score on an error-dominated run (#557); so does this line (#1271).
|
|
338
|
+
line += " - score #{painted_score(report.majority_errored? ? nil : report.score)}"
|
|
336
339
|
line += " (threshold #{format('%g', @threshold)})" if @threshold
|
|
337
340
|
@out.puts line
|
|
338
341
|
end
|
|
@@ -345,7 +348,7 @@ module Saboteur
|
|
|
345
348
|
untested = untested_subjects(report).size
|
|
346
349
|
[counted(counts[:killed], "killed", plural: false, color: :pass),
|
|
347
350
|
counted(live, "survived", plural: false, color: :fail),
|
|
348
|
-
counted(report.survivors.size - live, "accepted", plural: false),
|
|
351
|
+
counted(report.survivors.size - live, "accepted", plural: false), UnconfirmedSection.part(report),
|
|
349
352
|
counted(counts[:timeout], "timeout", color: :warn), counted(counts[:resource_limit], "resource limit"),
|
|
350
353
|
error_part(report, counts[:error]), counted(counts[:unverified], "unverified", plural: false),
|
|
351
354
|
("#{untested} subject#{'s' unless untested == 1} untested" if untested.positive?)].compact
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "mutation_text"
|
|
5
|
+
require_relative "survivor_ranking"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module Reporter
|
|
9
|
+
# Survivors whose covering tests were not all run (#1286): outside the
|
|
10
|
+
# score and the exit code, so they get their own short list instead of
|
|
11
|
+
# a place among the findings.
|
|
12
|
+
class UnconfirmedSection
|
|
13
|
+
LIMIT = 10
|
|
14
|
+
|
|
15
|
+
# The count for the one-line (--quiet) tally, nil when there is none.
|
|
16
|
+
def self.part(report)
|
|
17
|
+
count = report.unconfirmed_survivors.size
|
|
18
|
+
"#{count} unconfirmed" if count.positive?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(out, palette)
|
|
22
|
+
@out = out
|
|
23
|
+
@palette = palette
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(report)
|
|
27
|
+
pending = report.unconfirmed_survivors
|
|
28
|
+
return if pending.empty?
|
|
29
|
+
|
|
30
|
+
@out.puts @palette.paint(:head, "unconfirmed (covering tests not run) - #{pending.size}, not in the score")
|
|
31
|
+
SurvivorRanking.ordered(report, pending).first(LIMIT).each { |result| @out.puts line(report, result) }
|
|
32
|
+
@out.puts " ... #{pending.size - LIMIT} more" if pending.size > LIMIT
|
|
33
|
+
@out.puts
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def line(report, result)
|
|
39
|
+
path = result.subject.file.delete_prefix("#{report.root}/")
|
|
40
|
+
" #{path}:#{MutationText.line(result)} #{result.subject.id} - #{result.digest} (#{result.unconfirmed_reason})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/saboteur/result.rb
CHANGED
|
@@ -112,8 +112,37 @@ module Saboteur
|
|
|
112
112
|
@digest ||= mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# A survivor whose confirmation run could not happen (budget spent, or the
|
|
116
|
+
# run failed) is outside the score and the exit code until it is
|
|
117
|
+
# confirmed (#1286). A survivor the whole-suite map cannot speak for
|
|
118
|
+
# ("unconfirmed_map(...)") stays counted: a stale map never lowers the gate.
|
|
115
119
|
def survived?
|
|
116
|
-
status == :survived
|
|
120
|
+
status == :survived && !unconfirmed?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def unconfirmed?
|
|
124
|
+
status == :survived && @confirmation.to_s.start_with?("unconfirmed(")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The additive report field: confirmed, killed_by_confirmation or
|
|
128
|
+
# unconfirmed; nil when confirmation never applied (#1286).
|
|
129
|
+
def confirmation_state
|
|
130
|
+
return nil if @confirmation.nil?
|
|
131
|
+
|
|
132
|
+
label = @confirmation.to_s
|
|
133
|
+
label.start_with?("unconfirmed") ? "unconfirmed" : label
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The test a confirmation kill is credited to.
|
|
137
|
+
def credited_test
|
|
138
|
+
killed_by.first if confirmation_state == "killed_by_confirmation"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Why the survivor stayed unconfirmed: the text inside unconfirmed(...).
|
|
142
|
+
def unconfirmed_reason
|
|
143
|
+
return unless unconfirmed?
|
|
144
|
+
|
|
145
|
+
@confirmation.delete_prefix("unconfirmed(").delete_suffix(")")
|
|
117
146
|
end
|
|
118
147
|
|
|
119
148
|
# Killed because a test's error, not its check, fired: NoMethodError and
|
|
@@ -280,7 +309,9 @@ module Saboteur
|
|
|
280
309
|
end
|
|
281
310
|
|
|
282
311
|
def counts
|
|
283
|
-
STATUSES.to_h
|
|
312
|
+
STATUSES.to_h do |status|
|
|
313
|
+
[status, results.count { |result| status == :survived ? result.survived? : result.status == status }]
|
|
314
|
+
end
|
|
284
315
|
end
|
|
285
316
|
|
|
286
317
|
# (killed + timeout) / (killed + survived + timeout); nil with nothing tested.
|
|
@@ -308,6 +339,12 @@ module Saboteur
|
|
|
308
339
|
results.select(&:survived?)
|
|
309
340
|
end
|
|
310
341
|
|
|
342
|
+
# Survivors whose covering tests were not all run: outside the score and
|
|
343
|
+
# the exit code, listed on their own (#1286).
|
|
344
|
+
def unconfirmed_survivors
|
|
345
|
+
results.select(&:unconfirmed?)
|
|
346
|
+
end
|
|
347
|
+
|
|
311
348
|
def unignored_survivors
|
|
312
349
|
results.select(&:unignored_survivor?)
|
|
313
350
|
end
|
|
@@ -443,9 +443,19 @@ module Saboteur
|
|
|
443
443
|
[prefix.count("\n") + 1, offset - ((prefix.rindex("\n") || -1) + 1)]
|
|
444
444
|
end
|
|
445
445
|
|
|
446
|
+
# Durations are compared to the millisecond. Two tests within a
|
|
447
|
+
# millisecond of each other swap places from one map build to the next,
|
|
448
|
+
# and with fail-fast the first failing test names a mutant's killer and
|
|
449
|
+
# kill cause, so sub-millisecond noise must not decide the order (#1274).
|
|
450
|
+
# Suite order breaks the tie instead.
|
|
446
451
|
def sort_test_ids(ids)
|
|
447
452
|
order = test_order
|
|
448
|
-
ids.sort_by { |id| [
|
|
453
|
+
ids.sort_by { |id| [duration_bucket(id), order.fetch(id, Float::INFINITY)] }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def duration_bucket(id)
|
|
457
|
+
seconds = durations[id]
|
|
458
|
+
seconds ? (seconds * 1000).ceil : Float::INFINITY
|
|
449
459
|
end
|
|
450
460
|
|
|
451
461
|
def test_order
|
data/lib/saboteur/version.rb
CHANGED
data/lib/saboteur.rb
CHANGED
|
@@ -6,6 +6,10 @@ require_relative "saboteur/version"
|
|
|
6
6
|
module Saboteur
|
|
7
7
|
class Error < StandardError; end
|
|
8
8
|
|
|
9
|
+
# A spec path from the cached coverage map that is not a spec file inside
|
|
10
|
+
# the project: the map is untrusted input (#427, #1286).
|
|
11
|
+
class RefusedSpecPath < Error; end
|
|
12
|
+
|
|
9
13
|
# A phase timeout that --max-duration shortened: budget exhaustion, not a
|
|
10
14
|
# broken suite. The engine turns it into the budget partial-report path
|
|
11
15
|
# instead of a phase error (#296).
|
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.
|
|
4
|
+
version: 0.12.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vamsi Pavan Mahesh Gunturu
|
|
@@ -63,6 +63,7 @@ files:
|
|
|
63
63
|
- lib/saboteur/cli.rb
|
|
64
64
|
- lib/saboteur/cli/agent_provenance.rb
|
|
65
65
|
- lib/saboteur/cli/baseline_gate.rb
|
|
66
|
+
- lib/saboteur/cli/color_option.rb
|
|
66
67
|
- lib/saboteur/cli/dirty_tree_notice.rb
|
|
67
68
|
- lib/saboteur/cli/doctor_command.rb
|
|
68
69
|
- lib/saboteur/cli/error_cause.rb
|
|
@@ -89,6 +90,7 @@ files:
|
|
|
89
90
|
- lib/saboteur/cli/shard_options.rb
|
|
90
91
|
- lib/saboteur/cli/source_set.rb
|
|
91
92
|
- lib/saboteur/cli/survivors_command.rb
|
|
93
|
+
- lib/saboteur/cli/whole_suite_map_lookup.rb
|
|
92
94
|
- lib/saboteur/config.rb
|
|
93
95
|
- lib/saboteur/config_file.rb
|
|
94
96
|
- lib/saboteur/constant_reads.rb
|
|
@@ -253,6 +255,7 @@ files:
|
|
|
253
255
|
- lib/saboteur/reporter/survivor_ranking.rb
|
|
254
256
|
- lib/saboteur/reporter/terminal.rb
|
|
255
257
|
- lib/saboteur/reporter/test_matrix.rb
|
|
258
|
+
- lib/saboteur/reporter/unconfirmed_section.rb
|
|
256
259
|
- lib/saboteur/reporter/wording.rb
|
|
257
260
|
- lib/saboteur/result.rb
|
|
258
261
|
- lib/saboteur/score.rb
|