saboteur 0.12.1 → 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 +69 -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 +4 -3
- 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 +1 -9
- data/lib/saboteur/cli/run_validation.rb +0 -1
- data/lib/saboteur/cli/survivors_command.rb +5 -2
- 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 +7 -0
- 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 +34 -16
- data/lib/saboteur/execution/runner.rb +3 -0
- data/lib/saboteur/gates.rb +1 -1
- data/lib/saboteur/integration/rspec.rb +30 -0
- data/lib/saboteur/reporter/default_findings.rb +1 -1
- data/lib/saboteur/reporter/gap_kinds.rb +6 -0
- 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 +4 -2
- 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
|
@@ -104,9 +104,16 @@ module Saboteur
|
|
|
104
104
|
# "Full run" for ignore staleness (#1206): no --since diff, no --subject
|
|
105
105
|
# filter, and no narrowed include (explicit file arguments replace the
|
|
106
106
|
# configured include) - only then was every in-scope subject examined, so
|
|
107
|
-
# an entry whose subject never appears really points at gone code.
|
|
107
|
+
# an entry whose subject never appears really points at gone code. The
|
|
108
|
+
# include nobody set counts as unscoped whether it is the generic
|
|
109
|
+
# default or the Rails one a config-less app gets (#1255).
|
|
108
110
|
def unscoped_full_run?
|
|
109
|
-
!config["since"] && config["subjects"].empty? &&
|
|
111
|
+
!config["since"] && config["subjects"].empty? && default_include?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def default_include?
|
|
115
|
+
config["include"] == Config::DEFAULTS["include"] ||
|
|
116
|
+
(config["include"] == Config::RAILS_INCLUDE && !config.explicit?("include"))
|
|
110
117
|
end
|
|
111
118
|
|
|
112
119
|
def external_command? = !config["test_command"].to_s.empty?
|
|
@@ -15,13 +15,14 @@ module Saboteur
|
|
|
15
15
|
# confirmation_budget caps the whole pass.
|
|
16
16
|
MAX_SPECS_PER_SURVIVOR = 8
|
|
17
17
|
|
|
18
|
-
def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil)
|
|
18
|
+
def initialize(config:, map:, scheduler:, diagnostics:, budget_exceeded:, cache: nil, load_specs: ->(_files) {})
|
|
19
19
|
@config = config
|
|
20
20
|
@map = map
|
|
21
21
|
@scheduler = scheduler
|
|
22
22
|
@diagnostics = diagnostics
|
|
23
23
|
@budget_exceeded = budget_exceeded
|
|
24
24
|
@cache = cache
|
|
25
|
+
@load_specs = load_specs
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def call(results, ignore_list:, early_stop: nil)
|
|
@@ -33,19 +34,60 @@ module Saboteur
|
|
|
33
34
|
"stopped early (#{early_stop.reason})")
|
|
34
35
|
end
|
|
35
36
|
@remaining = @config["confirmation_budget"]
|
|
37
|
+
@map_silent = 0
|
|
36
38
|
results.map { |result| survivors.include?(result) ? confirm(result) : result }
|
|
37
39
|
.tap { |confirmed| summarize(confirmed) }
|
|
40
|
+
.tap { warn_map_silent }
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
private
|
|
41
44
|
|
|
45
|
+
# A crash while confirming one survivor downgrades it to unconfirmed
|
|
46
|
+
# with one warning; it never ends the run (#1140).
|
|
42
47
|
def confirm(result)
|
|
48
|
+
confirm_checked(result)
|
|
49
|
+
rescue RefusedSpecPath => e
|
|
50
|
+
warn_refused(e)
|
|
51
|
+
result.with_confirmation("unconfirmed_map(a covering spec from the whole-suite map was refused)")
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
warn_failed(result, e)
|
|
54
|
+
unconfirmed(result, "confirmation failed: #{e.class}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The map named a spec that is not a spec file inside the project: the
|
|
58
|
+
# map cannot be trusted, so its survivors stay counted (#1286).
|
|
59
|
+
def warn_refused(error)
|
|
60
|
+
return if @warned_refused
|
|
61
|
+
|
|
62
|
+
@warned_refused = true
|
|
63
|
+
@diagnostics.warn("survivor confirmation refused the whole-suite coverage map: " \
|
|
64
|
+
"#{error.message.lines.first.strip}; its survivors stay counted; " \
|
|
65
|
+
"run `saboteur run --all` to rebuild it")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One line per run, however many survivors hit the same failure.
|
|
69
|
+
def warn_failed(result, error)
|
|
70
|
+
return if @warned_failure
|
|
71
|
+
|
|
72
|
+
@warned_failure = true
|
|
73
|
+
@diagnostics.warn("survivor confirmation failed for #{result.subject.id} #{result.digest} " \
|
|
74
|
+
"(#{error.class}: #{error.message.lines.map(&:strip).reject(&:empty?).first}); " \
|
|
75
|
+
"its survivors stay unconfirmed, outside the score")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def confirm_checked(result)
|
|
43
79
|
return unconfirmed(result, "the confirmation budget is spent") if @remaining <= 0
|
|
44
80
|
return unconfirmed(result, "the --max-duration budget is spent") if @budget_exceeded.call
|
|
45
81
|
|
|
46
|
-
|
|
47
|
-
|
|
82
|
+
# Nothing in the whole-suite map covers the edited lines (the tree
|
|
83
|
+
# drifted since the map was built): the map cannot confirm anything.
|
|
84
|
+
covering = covering_tests(result)
|
|
85
|
+
return map_silent(result) if covering.empty?
|
|
48
86
|
|
|
87
|
+
tests = unrun_covering_tests(result, covering)
|
|
88
|
+
return result.with_confirmation("confirmed") if tests.empty?
|
|
89
|
+
|
|
90
|
+
load_unselected_specs(tests)
|
|
49
91
|
outcome = run_confirmation(result, tests)
|
|
50
92
|
@remaining -= outcome.runtime || 0
|
|
51
93
|
case outcome.status
|
|
@@ -58,8 +100,14 @@ module Saboteur
|
|
|
58
100
|
# The whole-suite map's covering tests for the mutant's edited lines
|
|
59
101
|
# (branch evidence intersected, mirroring the scheduler), minus the
|
|
60
102
|
# tests the mutant already ran, fastest first, capped.
|
|
61
|
-
def unrun_covering_tests(result)
|
|
62
|
-
fastest_first(
|
|
103
|
+
def unrun_covering_tests(result, covering)
|
|
104
|
+
fastest_first(covering - (result.executed || result.covered_by)).first(MAX_SPECS_PER_SURVIVOR)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The covering specs may live in files this run never loaded; the
|
|
108
|
+
# forked confirmation child can only run examples already in its world.
|
|
109
|
+
def load_unselected_specs(tests)
|
|
110
|
+
@load_specs.call(tests.map { |id| id.sub(/\[[\d:]+\]\z/, "") })
|
|
63
111
|
end
|
|
64
112
|
|
|
65
113
|
def covering_tests(result)
|
|
@@ -86,11 +134,12 @@ module Saboteur
|
|
|
86
134
|
# becomes an error; a check the budgets never let run comes back
|
|
87
135
|
# unverified - outside the score and never cached.
|
|
88
136
|
def checked_kill(result, tests, outcome)
|
|
89
|
-
kill = result.with_confirmation(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
kill = result.with_confirmation(
|
|
138
|
+
"killed_by_confirmation", status: :killed, reason: outcome.message, killed_by: outcome.failed_ids,
|
|
139
|
+
covered_by: tests, kill_cause: outcome.kill_cause,
|
|
140
|
+
runtime: (result.runtime || 0) + (outcome.runtime || 0),
|
|
141
|
+
executed: (result.executed || []) + outcome.executed_ids
|
|
142
|
+
)
|
|
94
143
|
checked = VerdictCheck.new(run_unchanged: unchanged_runner(result.subject),
|
|
95
144
|
baseline_timeout: @config["baseline_timeout"]).call([kill]).first
|
|
96
145
|
return checked unless checked.status == :killed
|
|
@@ -108,7 +157,7 @@ module Saboteur
|
|
|
108
157
|
# (#1079). ResultCache#record replaces the subject+mutation entry, so
|
|
109
158
|
# the stale survivor verdict cannot win a later lookup.
|
|
110
159
|
def record_kill(result, kill)
|
|
111
|
-
@cache&.record(kill.with_confirmation("
|
|
160
|
+
@cache&.record(kill.with_confirmation("killed_by_confirmation", covered_by: covering_tests(result)))
|
|
112
161
|
end
|
|
113
162
|
|
|
114
163
|
# The unchanged-method re-run VerdictCheck checks a kill against, in
|
|
@@ -129,6 +178,13 @@ module Saboteur
|
|
|
129
178
|
end
|
|
130
179
|
end
|
|
131
180
|
|
|
181
|
+
# The map says nothing about the edited lines (the tree drifted since it
|
|
182
|
+
# was built): the survivor stays counted and the pass says so once.
|
|
183
|
+
def map_silent(result)
|
|
184
|
+
@map_silent += 1
|
|
185
|
+
result.with_confirmation("unconfirmed_map(no covering tests in the whole-suite map)")
|
|
186
|
+
end
|
|
187
|
+
|
|
132
188
|
def unconfirmed(result, reason)
|
|
133
189
|
result.with_confirmation("unconfirmed(#{reason})")
|
|
134
190
|
end
|
|
@@ -148,6 +204,14 @@ module Saboteur
|
|
|
148
204
|
durations.empty? ? nil : durations.sum / durations.size
|
|
149
205
|
end
|
|
150
206
|
|
|
207
|
+
def warn_map_silent
|
|
208
|
+
return if @map_silent.zero?
|
|
209
|
+
|
|
210
|
+
@diagnostics.warn("survivor confirmation: the whole-suite coverage map covers none of the edited lines of " \
|
|
211
|
+
"#{@map_silent} survivor#{'s' unless @map_silent == 1}, so they stay counted; " \
|
|
212
|
+
"run `saboteur run --all` to rebuild it")
|
|
213
|
+
end
|
|
214
|
+
|
|
151
215
|
def summarize(results)
|
|
152
216
|
counts = results.filter_map(&:confirmation).tally
|
|
153
217
|
return if counts.empty?
|
|
@@ -116,7 +116,8 @@ module Saboteur
|
|
|
116
116
|
# Why the subjectless files needed no spec.
|
|
117
117
|
def subjectless_message(since:)
|
|
118
118
|
detail = since ? "no changed mutation subjects" : "no mutation subjects"
|
|
119
|
-
|
|
119
|
+
who = subjectless.size == 1 ? "it has" : "they have"
|
|
120
|
+
"inferred specs: no spec is required for #{names(subjectless)}: #{who} #{detail}"
|
|
120
121
|
end
|
|
121
122
|
|
|
122
123
|
# The per-file warning when the run proceeds with the mapped
|
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
|
|
@@ -130,6 +125,12 @@ module Saboteur
|
|
|
130
125
|
@coverage_store&.whole_suite_map
|
|
131
126
|
end
|
|
132
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
|
+
|
|
133
134
|
# False when caching is off, where no coverage build can ever leave a
|
|
134
135
|
# whole-suite map to consult, so advising one would be wrong (review C1).
|
|
135
136
|
def whole_suite_map_lookup?
|
|
@@ -292,15 +293,21 @@ module Saboteur
|
|
|
292
293
|
confirm_survivors(run_candidates(subjects, lists, scheduler, cache, ignore_list), scheduler, cache, ignore_list)
|
|
293
294
|
end
|
|
294
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).
|
|
295
302
|
def confirm_survivors(results, scheduler, cache, ignore_list)
|
|
296
|
-
|
|
297
|
-
live = results.any? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
298
|
-
@diagnostics.inform(CONFIRMATION_SKIPPED) if live && config["partial_coverage"] == "use"
|
|
299
|
-
return results
|
|
300
|
-
end
|
|
303
|
+
return results if results.none? { |result| result.survived? && !ignore_list.accepts?(result) }
|
|
301
304
|
|
|
302
|
-
|
|
303
|
-
|
|
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) })
|
|
304
311
|
.call(results, ignore_list: ignore_list, early_stop: @early_stop)
|
|
305
312
|
end
|
|
306
313
|
|
|
@@ -350,10 +357,19 @@ module Saboteur
|
|
|
350
357
|
# baseline's own chatter (demoted to --verbose) would start.
|
|
351
358
|
def announce_quiet_start(store, files)
|
|
352
359
|
@map_cached = store.reusable?(files)
|
|
353
|
-
@diagnostics.quiet_start_header(scope:
|
|
360
|
+
@diagnostics.quiet_start_header(scope: quiet_scope,
|
|
354
361
|
seed: config["seed"], operators: config.operator_policy["effective"].size)
|
|
355
362
|
end
|
|
356
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
|
+
|
|
357
373
|
def inform_about_run(subjects, map)
|
|
358
374
|
if @diagnostics.quiet_start && !@diagnostics.verbose
|
|
359
375
|
@diagnostics.quiet_baseline(passes: @completed_baseline_passes, elapsed: @baseline_elapsed,
|
|
@@ -367,11 +383,13 @@ module Saboteur
|
|
|
367
383
|
end
|
|
368
384
|
|
|
369
385
|
# This run's map is the whole-suite map only when nothing scoped it:
|
|
370
|
-
# no --since, no subjects, and
|
|
371
|
-
#
|
|
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).
|
|
372
390
|
def whole_suite_run?
|
|
373
391
|
config["since"].nil? && config["subjects"].empty? &&
|
|
374
|
-
|
|
392
|
+
config["tests"].empty? && @guessed_tests.nil?
|
|
375
393
|
end
|
|
376
394
|
|
|
377
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)
|
|
@@ -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
|
|
@@ -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",
|
|
@@ -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
|
|
@@ -346,7 +348,7 @@ module Saboteur
|
|
|
346
348
|
untested = untested_subjects(report).size
|
|
347
349
|
[counted(counts[:killed], "killed", plural: false, color: :pass),
|
|
348
350
|
counted(live, "survived", plural: false, color: :fail),
|
|
349
|
-
counted(report.survivors.size - live, "accepted", plural: false),
|
|
351
|
+
counted(report.survivors.size - live, "accepted", plural: false), UnconfirmedSection.part(report),
|
|
350
352
|
counted(counts[:timeout], "timeout", color: :warn), counted(counts[:resource_limit], "resource limit"),
|
|
351
353
|
error_part(report, counts[:error]), counted(counts[:unverified], "unverified", plural: false),
|
|
352
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
|