saboteur 0.11.1 → 0.11.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 +35 -0
- data/README.md +1 -1
- data/lib/saboteur/cli/exit_status.rb +6 -2
- data/lib/saboteur/cli/explain_command.rb +51 -0
- data/lib/saboteur/cli/flaky_exit.rb +23 -0
- data/lib/saboteur/cli/run_command.rb +13 -0
- data/lib/saboteur/cli/run_output.rb +10 -0
- data/lib/saboteur/cli/run_reporting.rb +13 -2
- data/lib/saboteur/cli/survivors_command.rb +1 -1
- data/lib/saboteur/cli.rb +3 -0
- data/lib/saboteur/config.rb +7 -2
- data/lib/saboteur/doctor.rb +8 -1
- data/lib/saboteur/engine/coverage_store.rb +54 -3
- data/lib/saboteur/engine/distribution.rb +6 -2
- data/lib/saboteur/engine/pipelines.rb +14 -2
- data/lib/saboteur/engine/scheduler.rb +1 -1
- data/lib/saboteur/engine/selection_announcement.rb +5 -4
- data/lib/saboteur/engine/subject_selection.rb +10 -0
- data/lib/saboteur/engine.rb +35 -5
- data/lib/saboteur/help.rb +11 -0
- data/lib/saboteur/ignore_list.rb +22 -2
- data/lib/saboteur/mutation_source/builtin.rb +1 -0
- data/lib/saboteur/mutation_source/operator_explain.rb +181 -0
- data/lib/saboteur/reporter/agent_commands.rb +23 -6
- data/lib/saboteur/reporter/agent_incomplete.rb +9 -2
- data/lib/saboteur/reporter/agent_summary.rb +2 -1
- data/lib/saboteur/reporter/gap_kinds.rb +80 -0
- data/lib/saboteur/reporter/html.rb +19 -4
- data/lib/saboteur/reporter/review_comments.rb +6 -5
- data/lib/saboteur/reporter/score_table.rb +8 -7
- data/lib/saboteur/reporter/survivor_classification.rb +26 -8
- data/lib/saboteur/reporter/survivor_hints.rb +3 -2
- data/lib/saboteur/reporter/survivor_list.rb +53 -3
- data/lib/saboteur/reporter/terminal.rb +27 -29
- data/lib/saboteur/reporter/wording.rb +37 -0
- data/lib/saboteur/result.rb +10 -0
- data/lib/saboteur/text.rb +6 -0
- data/lib/saboteur/version.rb +1 -1
- metadata +6 -1
data/lib/saboteur/ignore_list.rb
CHANGED
|
@@ -125,7 +125,7 @@ module Saboteur
|
|
|
125
125
|
# unscoped_digests (subject id => every digest generated before a diff
|
|
126
126
|
# scope cut) keeps a scoped run from calling a scoped-out entry stale
|
|
127
127
|
# (#284).
|
|
128
|
-
def apply(results, unscoped_digests: nil)
|
|
128
|
+
def apply(results, subjects: nil, unscoped_digests: nil, full_run: false)
|
|
129
129
|
seen = {}
|
|
130
130
|
results.each do |result|
|
|
131
131
|
key = [result.subject.id, result.digest]
|
|
@@ -139,7 +139,8 @@ module Saboteur
|
|
|
139
139
|
result.triage_reason = entry.reason
|
|
140
140
|
result.ignored = entry.reason if entry.state != "todo"
|
|
141
141
|
end
|
|
142
|
-
duplicate_warnings + stale_warnings(results, seen, unscoped_digests)
|
|
142
|
+
duplicate_warnings + stale_warnings(results, seen, unscoped_digests) +
|
|
143
|
+
missing_subject_warnings(results, subjects, full_run: full_run)
|
|
143
144
|
end
|
|
144
145
|
|
|
145
146
|
private
|
|
@@ -159,6 +160,25 @@ module Saboteur
|
|
|
159
160
|
.map { |(subject, mutation), _| "duplicate ignore entry #{subject} #{mutation}: the last entry wins" }
|
|
160
161
|
end
|
|
161
162
|
|
|
163
|
+
# A full run examines every in-scope subject, so an entry whose subject
|
|
164
|
+
# never appears points at code that was renamed or removed - say so, but
|
|
165
|
+
# only then: a scoped (--since/--subject) run cannot tell "renamed" from
|
|
166
|
+
# "not in this diff" (#1206). The run's subject list is the source of
|
|
167
|
+
# truth when given: an examined subject with no results (uncovered, or
|
|
168
|
+
# nothing mutable left) is not missing.
|
|
169
|
+
def missing_subject_warnings(results, subjects, full_run:)
|
|
170
|
+
return [] unless full_run
|
|
171
|
+
|
|
172
|
+
examined = (subjects || results).to_set { |item| item.respond_to?(:id) ? item.id : item.subject.id }
|
|
173
|
+
entries.reject { |entry| examined.include?(entry.subject) }
|
|
174
|
+
.map { |entry| missing_subject_message(entry) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def missing_subject_message(entry)
|
|
178
|
+
"stale ignore entry #{entry.subject} #{entry.mutation}: subject not found " \
|
|
179
|
+
"(renamed, removed, or its file is excluded?) - remove the entry or fix the subject name"
|
|
180
|
+
end
|
|
181
|
+
|
|
162
182
|
def stale_message(entry)
|
|
163
183
|
"stale ignore entry #{entry.subject} #{entry.mutation}: no such mutation " \
|
|
164
184
|
"- remove the entry or refresh its digest (saboteur survivors --show)"
|
|
@@ -13,6 +13,7 @@ require_relative "pair_removal"
|
|
|
13
13
|
require_relative "element_removal"
|
|
14
14
|
require_relative "endpoint_replacement"
|
|
15
15
|
require_relative "operator_registry"
|
|
16
|
+
require_relative "operator_explain"
|
|
16
17
|
require_relative "range_replacement"
|
|
17
18
|
require_relative "operator_assignment"
|
|
18
19
|
require_relative "condition_negation"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "operator_examples"
|
|
5
|
+
require_relative "operator_registry"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Curated text behind `saboteur explain NAME`. The definition and the
|
|
10
|
+
# example stay shared with --list-operators (one wording source); the
|
|
11
|
+
# survival and kill hints below are the only text owned here. Every
|
|
12
|
+
# registry operator needs an entry: the coverage spec fails the build
|
|
13
|
+
# when one ships without it.
|
|
14
|
+
module OperatorExplain
|
|
15
|
+
Entry = Data.define(:name, :definition, :example, :survives, :kill)
|
|
16
|
+
|
|
17
|
+
GUIDANCE = {
|
|
18
|
+
"body_nil" => {
|
|
19
|
+
survives: "the method is called for side effects and no test asserts the return value.",
|
|
20
|
+
kill: "an assertion on the exact return value, or on a caller behaviour that depends on it."
|
|
21
|
+
},
|
|
22
|
+
"body_true" => {
|
|
23
|
+
survives: "tests never check the predicate outcome, or only take the path true already forces.",
|
|
24
|
+
kill: "an example whose input makes the predicate false, asserting that path is taken."
|
|
25
|
+
},
|
|
26
|
+
"body_false" => {
|
|
27
|
+
survives: "tests never check the predicate outcome, or only take the path false already forces.",
|
|
28
|
+
kill: "an example whose input makes the predicate true, asserting that path is taken."
|
|
29
|
+
},
|
|
30
|
+
"body_raise" => {
|
|
31
|
+
survives: "tests stub or skip the method, or wrap the call in a broad rescue.",
|
|
32
|
+
kill: "any test that calls the method and lets it complete; the raise fails that example."
|
|
33
|
+
},
|
|
34
|
+
"comparison" => {
|
|
35
|
+
survives: "tests never use values at the boundary the change moves, such as equal values for < vs <=.",
|
|
36
|
+
kill: "a boundary example: the equal case, or the two values just either side of the operator."
|
|
37
|
+
},
|
|
38
|
+
"arithmetic" => {
|
|
39
|
+
survives: "tests assert the result loosely (type, presence, sign) instead of its exact value.",
|
|
40
|
+
kill: "an assertion on the exact computed value with operands where the two operators differ."
|
|
41
|
+
},
|
|
42
|
+
"boolean_logic" => {
|
|
43
|
+
survives: "tests never exercise the dropped operand deciding the outcome on its own.",
|
|
44
|
+
kill: "one example per operand where that operand alone flips the result, asserting each outcome."
|
|
45
|
+
},
|
|
46
|
+
"condition_true" => {
|
|
47
|
+
survives: "tests only exercise the branch the forced condition takes.",
|
|
48
|
+
kill: "an example with a falsy input, asserting the else branch still runs."
|
|
49
|
+
},
|
|
50
|
+
"condition_false" => {
|
|
51
|
+
survives: "tests only exercise the branch the forced condition takes.",
|
|
52
|
+
kill: "an example with a truthy input, asserting the then branch still runs."
|
|
53
|
+
},
|
|
54
|
+
"negation_removal" => {
|
|
55
|
+
survives: "tests assert loosely, or never cover the input the negation inverts.",
|
|
56
|
+
kill: "assert the behaviour for both a truthy and a falsy input through the public API."
|
|
57
|
+
},
|
|
58
|
+
"literal_number" => {
|
|
59
|
+
survives: "the literal flows into nothing a test asserts, or every fixture value works for both.",
|
|
60
|
+
kill: "an assertion on the exact value the literal feeds (a total, a limit, an index)."
|
|
61
|
+
},
|
|
62
|
+
"or_assign" => {
|
|
63
|
+
survives: "tests only cover the nil or false case, where both forms assign.",
|
|
64
|
+
kill: "an example where the variable is already truthy and must keep its value."
|
|
65
|
+
},
|
|
66
|
+
"and_assign" => {
|
|
67
|
+
survives: "tests only cover the truthy case, where both forms assign.",
|
|
68
|
+
kill: "an example where the variable is falsy and must stay untouched."
|
|
69
|
+
},
|
|
70
|
+
"safe_navigation" => {
|
|
71
|
+
survives: "the receiver is never nil in any covering test.",
|
|
72
|
+
kill: "an example with a nil receiver asserting nil comes back instead of NoMethodError."
|
|
73
|
+
},
|
|
74
|
+
"statement_removal" => {
|
|
75
|
+
survives: "the removed statement's effect is never observed: an unasserted side effect or return.",
|
|
76
|
+
kill: "assert the side effect (a spy, a state change) or the value the statement produced."
|
|
77
|
+
},
|
|
78
|
+
"copy_replacement" => {
|
|
79
|
+
survives: "tests never mutate the returned object, so the aliasing goes unnoticed.",
|
|
80
|
+
kill: "mutate what the method returns and assert the original is unchanged (or assert a copy)."
|
|
81
|
+
},
|
|
82
|
+
"filter_replacement" => {
|
|
83
|
+
survives: "tests assert loosely, or the fixture collection is empty or all one kind.",
|
|
84
|
+
kill: "assert the exact surviving elements of a mixed collection, in order."
|
|
85
|
+
},
|
|
86
|
+
"literal_string" => {
|
|
87
|
+
survives: "no test asserts the string's content, only that something was returned.",
|
|
88
|
+
kill: "an exact string equality assertion on the value that carries the literal."
|
|
89
|
+
},
|
|
90
|
+
"literal_boolean" => {
|
|
91
|
+
survives: "the boolean is never asserted, or both values pass the covering assertions.",
|
|
92
|
+
kill: "an example whose expected behaviour flips with the boolean."
|
|
93
|
+
},
|
|
94
|
+
"argument_swap" => {
|
|
95
|
+
survives: "tests use equal or interchangeable arguments, or assert the result loosely.",
|
|
96
|
+
kill: "asymmetric arguments with an assertion on the exact result order."
|
|
97
|
+
},
|
|
98
|
+
"argument_removal" => {
|
|
99
|
+
survives: "the removed argument changes nothing the covering tests observe.",
|
|
100
|
+
kill: "a case where that argument changes the result, asserted exactly."
|
|
101
|
+
},
|
|
102
|
+
"pair_removal" => {
|
|
103
|
+
survives: "tests never read the removed key, directly or through a consumer.",
|
|
104
|
+
kill: "assert the value stored under the removed key, or the whole hash."
|
|
105
|
+
},
|
|
106
|
+
"element_removal" => {
|
|
107
|
+
survives: "tests never exercise the removed element of the array.",
|
|
108
|
+
kill: "an example per element whose behaviour changes when that element is missing."
|
|
109
|
+
},
|
|
110
|
+
"endpoint_replacement" => {
|
|
111
|
+
survives: "fixture collections have one element, or the wrong end is never asserted.",
|
|
112
|
+
kill: "a multi-element collection with an assertion on exactly which end comes back."
|
|
113
|
+
},
|
|
114
|
+
"chain_removal" => {
|
|
115
|
+
survives: "in every tested case the skipped call returns an equivalent value.",
|
|
116
|
+
kill: "a case where the removed call changes the value, asserted exactly."
|
|
117
|
+
},
|
|
118
|
+
"range" => {
|
|
119
|
+
survives: "tests never use the endpoint the exclusive range drops.",
|
|
120
|
+
kill: "an example whose input is the boundary value, asserting it is included."
|
|
121
|
+
},
|
|
122
|
+
"operator_assignment" => {
|
|
123
|
+
survives: "tests check the variable changed, not its exact new value.",
|
|
124
|
+
kill: "assert the exact value after the assignment, where += and -= disagree."
|
|
125
|
+
},
|
|
126
|
+
"condition_negation" => {
|
|
127
|
+
survives: "tests only cover the side of the predicate the negation keeps.",
|
|
128
|
+
kill: "assert both branches with inputs on each side of the predicate."
|
|
129
|
+
},
|
|
130
|
+
"method_swap" => {
|
|
131
|
+
survives: "tests assert loosely, or min and max coincide in every fixture.",
|
|
132
|
+
kill: "a fixture where the two methods differ, asserting the exact expected one."
|
|
133
|
+
},
|
|
134
|
+
"operand_replacement" => {
|
|
135
|
+
survives: "tests assert the result loosely (present, positive, non-nil) or never exercise " \
|
|
136
|
+
"the removed operand.",
|
|
137
|
+
kill: "an assertion on the exact value through the public API, with inputs where the " \
|
|
138
|
+
"removed operand changes the result."
|
|
139
|
+
},
|
|
140
|
+
"interpolation_removal" => {
|
|
141
|
+
survives: "no test asserts the full interpolated content of the string.",
|
|
142
|
+
kill: "an exact string assertion with a known value in the interpolation."
|
|
143
|
+
},
|
|
144
|
+
"jump_value" => {
|
|
145
|
+
survives: "callers ignore the returned value, or tests never take that return path.",
|
|
146
|
+
kill: "an example that takes the early return and asserts the exact value it hands back."
|
|
147
|
+
},
|
|
148
|
+
"rescue_removal" => {
|
|
149
|
+
survives: "tests never raise the error the handler rescues.",
|
|
150
|
+
kill: "an example raising that error, asserting the handler's fallback behaviour."
|
|
151
|
+
},
|
|
152
|
+
"ensure_removal" => {
|
|
153
|
+
survives: "the cleanup's effect is never observed on the tested paths.",
|
|
154
|
+
kill: "assert the cleanup ran (resource released, flag reset), including on the error path."
|
|
155
|
+
},
|
|
156
|
+
"when_removal" => {
|
|
157
|
+
survives: "tests never hit the removed branch of the case.",
|
|
158
|
+
kill: "one example per when branch, asserting its exact outcome."
|
|
159
|
+
},
|
|
160
|
+
"unary_removal" => {
|
|
161
|
+
survives: "fixtures use zero or symmetric values, or tests assert loosely.",
|
|
162
|
+
kill: "an assertion on the exact signed or complemented value with a non-trivial input."
|
|
163
|
+
},
|
|
164
|
+
"regex_replacement" => {
|
|
165
|
+
survives: "every test input matches under both the original and the loosened regex.",
|
|
166
|
+
kill: "an input the anchor or quantifier excludes, asserting it is rejected."
|
|
167
|
+
}
|
|
168
|
+
}.freeze
|
|
169
|
+
|
|
170
|
+
# nil for an unknown name; the command turns that into the exit-2 error.
|
|
171
|
+
def self.for(name)
|
|
172
|
+
return unless OperatorRegistry::DESCRIPTIONS.key?(name)
|
|
173
|
+
|
|
174
|
+
guidance = GUIDANCE.fetch(name)
|
|
175
|
+
Entry.new(name:, definition: OperatorRegistry::DESCRIPTIONS.fetch(name),
|
|
176
|
+
example: OperatorExamples::EXAMPLES.fetch(name),
|
|
177
|
+
survives: guidance.fetch(:survives), kill: guidance.fetch(:kill))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -45,14 +45,30 @@ module Saboteur
|
|
|
45
45
|
|
|
46
46
|
# Replay one survivor by digest in the same selection. capped: false
|
|
47
47
|
# skips the protocol byte cap, for callers that print to a terminal.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
# report: narrows the replay to the mutant's covering spec files
|
|
49
|
+
# instead of the run's full inferred list (#1148).
|
|
50
|
+
def replay(provenance, digest, capped: true, report: nil)
|
|
51
|
+
words = ["saboteur", "run", "--id", digest,
|
|
52
|
+
*selection(provenance, replay: true, tests: report && covering_files(report, digest)),
|
|
53
|
+
"--seed", provenance.seed.to_s]
|
|
51
54
|
finish(words, provenance, capped: capped)
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
private
|
|
55
58
|
|
|
59
|
+
# A replay needs only the specs that cover the mutant, not every
|
|
60
|
+
# inferred file (#1148): covered_by holds RSpec ids like
|
|
61
|
+
# "./spec/fee_spec.rb[1:2]", so strip the example suffix. nil when
|
|
62
|
+
# the survivor has no covering ids - then the whole selection
|
|
63
|
+
# stays, since dropping it would widen the replay to inference.
|
|
64
|
+
def covering_files(report, digest)
|
|
65
|
+
result = report.results.find { |entry| entry.digest == digest }
|
|
66
|
+
return unless result
|
|
67
|
+
|
|
68
|
+
files = result.covered_by.filter_map { |id| id[%r{\A\.?/?(.+?\.rb)\[}, 1] }.uniq
|
|
69
|
+
files unless files.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
56
72
|
def finish(words, provenance, capped: true)
|
|
57
73
|
words += ["--", *provenance.sources] unless provenance.sources.empty?
|
|
58
74
|
command = Shellwords.join(words)
|
|
@@ -93,13 +109,14 @@ module Saboteur
|
|
|
93
109
|
|
|
94
110
|
# Whole-suite scope is a bare --all, only when no tests resolved;
|
|
95
111
|
# --all never combines with --test.
|
|
96
|
-
def selection(provenance, replay: false)
|
|
112
|
+
def selection(provenance, replay: false, tests: nil)
|
|
113
|
+
tests ||= provenance.tests
|
|
97
114
|
words = []
|
|
98
115
|
words.push("--config", provenance.config_path) if provenance.config_path
|
|
99
116
|
words.push("--since", provenance.ref) if provenance.ref
|
|
100
117
|
provenance.subjects.each { |subject| words.push("--subject", subject) }
|
|
101
|
-
words << "--all" if provenance.settings[:all] &&
|
|
102
|
-
words.push(*test_words(
|
|
118
|
+
words << "--all" if provenance.settings[:all] && tests.empty?
|
|
119
|
+
words.push(*test_words(tests))
|
|
103
120
|
words.concat(setting_words(provenance, replay: replay))
|
|
104
121
|
words.concat(mode_words(provenance))
|
|
105
122
|
words.push("--id", provenance.digest) if provenance.digest && !replay
|
|
@@ -40,8 +40,7 @@ module Saboteur
|
|
|
40
40
|
add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
|
|
41
41
|
add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
|
|
42
42
|
add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
|
|
43
|
-
|
|
44
|
-
"mutants the --max-duration budget left unverified; their kills are not confirmed")
|
|
43
|
+
unverified_reasons(reasons, report)
|
|
45
44
|
discovery_failures(reasons, report)
|
|
46
45
|
early_stop_reason(reasons, report)
|
|
47
46
|
inline_scope(reasons, report)
|
|
@@ -70,6 +69,14 @@ module Saboteur
|
|
|
70
69
|
"included untracked files could not be read; their subjects were not covered")
|
|
71
70
|
end
|
|
72
71
|
|
|
72
|
+
def unverified_reasons(reasons, report)
|
|
73
|
+
flaky = report.results.count(&:flaky?)
|
|
74
|
+
add(reasons, "unverified", report.counts[:unverified] - flaky,
|
|
75
|
+
"mutants the --max-duration budget left unverified; their kills are not confirmed")
|
|
76
|
+
add(reasons, "flaky", flaky,
|
|
77
|
+
"mutants gave a different verdict on the --flaky-check reordered rerun; they are unverified")
|
|
78
|
+
end
|
|
79
|
+
|
|
73
80
|
def early_stop_reason(reasons, report)
|
|
74
81
|
stop = report.early_stop
|
|
75
82
|
return unless stop
|
|
@@ -123,7 +123,8 @@ module Saboteur
|
|
|
123
123
|
steps = {}
|
|
124
124
|
add_command(steps, :rerun, Commands.rerun(provenance))
|
|
125
125
|
first = Findings.build(report)[:survivors].first
|
|
126
|
-
|
|
126
|
+
# report: narrows the replay to covering specs, as on the run screen (#1148).
|
|
127
|
+
add_command(steps, :replay, Commands.replay(provenance, first[:digest], report: report)) if first
|
|
127
128
|
steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
|
|
128
129
|
steps
|
|
129
130
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# The gap-kind vocabulary (D2): which sort of check a survivor suggests
|
|
7
|
+
# is missing, as a LABEL ONLY (#192) - a heuristic from the operator and
|
|
8
|
+
# the edit shape, never a diagnosis, and never a score input.
|
|
9
|
+
module GapKinds
|
|
10
|
+
KINDS = %w[boundary branch effect number] + ["error path", "result unchecked"].freeze
|
|
11
|
+
|
|
12
|
+
# Operator -> gap kind, every built-in operator exactly once except
|
|
13
|
+
# operand_replacement, whose kind depends on the replaced expression
|
|
14
|
+
# (a comparison loses its boundary; anything else is a plain result).
|
|
15
|
+
# Anything missing here - a new operator before its row lands - is
|
|
16
|
+
# the honest fallback: result unchecked.
|
|
17
|
+
BY_OPERATOR = {
|
|
18
|
+
"comparison" => "boundary",
|
|
19
|
+
"range" => "boundary",
|
|
20
|
+
"endpoint_replacement" => "boundary",
|
|
21
|
+
"condition_negation" => "branch",
|
|
22
|
+
"condition_true" => "branch",
|
|
23
|
+
"condition_false" => "branch",
|
|
24
|
+
"boolean_logic" => "branch",
|
|
25
|
+
"negation_removal" => "branch",
|
|
26
|
+
"when_removal" => "branch",
|
|
27
|
+
"safe_navigation" => "branch",
|
|
28
|
+
"filter_replacement" => "branch",
|
|
29
|
+
"statement_removal" => "effect",
|
|
30
|
+
"element_removal" => "effect",
|
|
31
|
+
"argument_removal" => "effect",
|
|
32
|
+
"pair_removal" => "effect",
|
|
33
|
+
"chain_removal" => "effect",
|
|
34
|
+
"copy_replacement" => "effect",
|
|
35
|
+
"literal_number" => "number",
|
|
36
|
+
"arithmetic" => "number",
|
|
37
|
+
"unary_removal" => "number",
|
|
38
|
+
"operator_assignment" => "number",
|
|
39
|
+
"rescue_removal" => "error path",
|
|
40
|
+
"ensure_removal" => "error path",
|
|
41
|
+
"body_raise" => "error path",
|
|
42
|
+
"body_nil" => "result unchecked",
|
|
43
|
+
"body_true" => "result unchecked",
|
|
44
|
+
"body_false" => "result unchecked",
|
|
45
|
+
"literal_string" => "result unchecked",
|
|
46
|
+
"literal_boolean" => "result unchecked",
|
|
47
|
+
"or_assign" => "result unchecked",
|
|
48
|
+
"and_assign" => "result unchecked",
|
|
49
|
+
"argument_swap" => "result unchecked",
|
|
50
|
+
"method_swap" => "result unchecked",
|
|
51
|
+
"interpolation_removal" => "result unchecked",
|
|
52
|
+
"jump_value" => "result unchecked",
|
|
53
|
+
"regex_replacement" => "result unchecked"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# The one operator whose kind comes from the edit shape, not the name.
|
|
57
|
+
SHAPE_DECIDED = %w[operand_replacement].freeze
|
|
58
|
+
|
|
59
|
+
FALLBACK = "result unchecked"
|
|
60
|
+
|
|
61
|
+
# The deterministic per-kind reason printed next to a survivor's rank.
|
|
62
|
+
REASONS = {
|
|
63
|
+
"boundary" => "the covering specs pass without pinning this boundary",
|
|
64
|
+
"branch" => "the covering specs pass without pinning this branch",
|
|
65
|
+
"effect" => "the covering specs pass without pinning this effect",
|
|
66
|
+
"number" => "the covering specs pass without pinning this number",
|
|
67
|
+
"error path" => "the covering specs pass without pinning this error path",
|
|
68
|
+
"result unchecked" => "the covering specs pass without pinning this result"
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
def kind(operator, comparison_edit: false)
|
|
74
|
+
return comparison_edit ? "boundary" : FALLBACK if SHAPE_DECIDED.include?(operator)
|
|
75
|
+
|
|
76
|
+
BY_OPERATOR.fetch(operator, FALLBACK)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -6,6 +6,7 @@ require "fileutils"
|
|
|
6
6
|
|
|
7
7
|
require_relative "../survivor_guidance"
|
|
8
8
|
require_relative "survivor_classification"
|
|
9
|
+
require_relative "wording"
|
|
9
10
|
|
|
10
11
|
module Saboteur
|
|
11
12
|
module Reporter
|
|
@@ -151,11 +152,13 @@ module Saboteur
|
|
|
151
152
|
def headline(report)
|
|
152
153
|
breakdown = report.score_breakdown
|
|
153
154
|
text = "<p><strong>Covered mutation score: #{score_text(report.score)}</strong>"
|
|
154
|
-
|
|
155
|
+
if breakdown.tested.positive?
|
|
156
|
+
text << " (#{breakdown.detected}/#{breakdown.tested} #{Wording.detected_word(breakdown)}, tested subjects)"
|
|
157
|
+
end
|
|
155
158
|
untested = report.skipped_subjects.count { |_, reason| reason == ScoreTable::NO_COVERAGE }
|
|
156
159
|
if untested.positive?
|
|
157
160
|
noun = untested == 1 ? "subject" : "subjects"
|
|
158
|
-
text << " - #{untested} #{noun} not scored -
|
|
161
|
+
text << " - #{untested} #{noun} not scored - #{Wording.no_execution(untested)}"
|
|
159
162
|
end
|
|
160
163
|
"#{text}</p>"
|
|
161
164
|
end
|
|
@@ -187,13 +190,18 @@ module Saboteur
|
|
|
187
190
|
"the score covers those changes, not the whole project.</p>"
|
|
188
191
|
end
|
|
189
192
|
|
|
193
|
+
# "1 subject, 2 mutants": each count pluralizes on its own (C9).
|
|
194
|
+
def counts_note(report)
|
|
195
|
+
"#{Text.counted(report.subjects.size, 'subject')}, #{Text.counted(report.results.size, 'mutant')}"
|
|
196
|
+
end
|
|
197
|
+
|
|
190
198
|
def summary(report, data)
|
|
191
199
|
seed = data.dig(:config, :seed)
|
|
192
200
|
seed_note = seed ? ", seed #{seed}" : ""
|
|
193
201
|
counts = data[:files].values.flat_map { |entry| entry[:mutants] }.map { |mutant| mutant[:status] }.tally
|
|
194
202
|
<<~HTML
|
|
195
203
|
<p><strong>Covered mutation score: #{score_text(report.score)}</strong>
|
|
196
|
-
(#{report
|
|
204
|
+
(#{counts_note(report)}, #{format('%.1f', report.wall_time)}s#{seed_note})</p>
|
|
197
205
|
<p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
|
|
198
206
|
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
|
|
199
207
|
Stryker JSON may show a different score, because the schema counts NoCoverage as undetected.</p>
|
|
@@ -310,7 +318,10 @@ module Saboteur
|
|
|
310
318
|
|
|
311
319
|
def file_section(file, entry, names, results, report, survivor_count:, open_file:)
|
|
312
320
|
open = open_file ? " open" : ""
|
|
313
|
-
|
|
321
|
+
# Attention rows (survivors, timeouts, errors) come first, so a big
|
|
322
|
+
# file's survivors are not buried between killed rows; position
|
|
323
|
+
# order holds within each group (#1233).
|
|
324
|
+
ordered = entry[:mutants].sort_by { |mutant| [attention_rank(mutant), *position_key(mutant)] }
|
|
314
325
|
rows = ordered.map { |mutant| mutant_row(mutant, names, results[[file, mutant[:id]]], report) }
|
|
315
326
|
"<details class=\"file\"#{open}><summary>#{escape(file)} " \
|
|
316
327
|
"(#{entry[:mutants].size} mutants#{survivors_note(survivor_count, open_file)})</summary>\n" \
|
|
@@ -330,6 +341,10 @@ module Saboteur
|
|
|
330
341
|
open_file || survivor_count.zero? ? "" : ", #{survivor_count} survivor#{'s' unless survivor_count == 1}"
|
|
331
342
|
end
|
|
332
343
|
|
|
344
|
+
def attention_rank(mutant)
|
|
345
|
+
ATTENTION_STATUSES.include?(mutant[:status]) ? 0 : 1
|
|
346
|
+
end
|
|
347
|
+
|
|
333
348
|
def position_key(mutant)
|
|
334
349
|
start = mutant[:location][:start]
|
|
335
350
|
[start[:line], start[:column], mutant[:id]]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "gap_kinds"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
module Reporter
|
|
6
8
|
# Pull-request review comments for surviving mutants (#246): decides what
|
|
@@ -25,11 +27,10 @@ module Saboteur
|
|
|
25
27
|
# Labels whose survivors are probably noise: never worth a comment.
|
|
26
28
|
QUIET = %w[likely_equivalent arid].freeze
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}.freeze
|
|
30
|
+
GAP_HINT = "A test reaches this line and still passes with the change. " \
|
|
31
|
+
"Add an assertion that pins this behaviour."
|
|
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] }.merge("tool_limit" => TOOL_LIMIT_HINT).freeze
|
|
33
34
|
DEFAULT_HINT = "No test fails with this change. Check whether a test should pin it."
|
|
34
35
|
|
|
35
36
|
Plan = Struct.new(:post, :delete, :summary, keyword_init: true)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "wording"
|
|
4
5
|
require_relative "../score"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
@@ -62,7 +63,7 @@ module Saboteur
|
|
|
62
63
|
detected: breakdown.detected, tested: breakdown.tested, untested: untested }
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
# "cart.rb 100.0% (2/2) · n/a - 2 subjects
|
|
66
|
+
# "cart.rb 100.0% (2/2) · n/a - 2 subjects - no test executes them: ...": the
|
|
66
67
|
# tested score and the untested count stand next to each other; a
|
|
67
68
|
# file with nothing tested gets no percentage at all.
|
|
68
69
|
def line(row, width)
|
|
@@ -74,18 +75,18 @@ module Saboteur
|
|
|
74
75
|
note ? "#{base} #{@palette.glyph(:middot)} #{note}" : base
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
# "n/a - 2 subjects
|
|
78
|
-
# the subjects the score cannot speak for, capped so a long
|
|
79
|
-
# not swallow the row; the Not scored block lists them all.
|
|
78
|
+
# "n/a - 2 subjects - no test executes them: Cart#total, Cart#biggest":
|
|
79
|
+
# names the subjects the score cannot speak for, capped so a long
|
|
80
|
+
# list does not swallow the row; the Not scored block lists them all.
|
|
81
|
+
# Wording owns the claim (#1145): a stubbed-away subject has tests,
|
|
82
|
+
# they just never execute it.
|
|
80
83
|
def untested_note(untested)
|
|
81
84
|
return if untested.empty?
|
|
82
85
|
|
|
83
86
|
names = untested.map(&:id)
|
|
84
87
|
shown = names.first(MAX_UNTESTED_NAMES)
|
|
85
88
|
shown << "+#{names.size - MAX_UNTESTED_NAMES} more" if names.size > MAX_UNTESTED_NAMES
|
|
86
|
-
|
|
87
|
-
@palette.paint(:warn, "n/a - #{count} subject#{'s' unless count == 1} " \
|
|
88
|
-
"#{count == 1 ? 'has' : 'have'} no tests: #{shown.join(', ')}")
|
|
89
|
+
@palette.paint(:warn, "n/a - #{Wording.untested_note(untested.size)}: #{shown.join(', ')}")
|
|
89
90
|
end
|
|
90
91
|
end
|
|
91
92
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "../engine/bytecode_equivalence"
|
|
5
|
+
require_relative "gap_kinds"
|
|
5
6
|
require_relative "../execution/external_runner"
|
|
6
7
|
require_relative "../mutation_source/builtin"
|
|
7
8
|
|
|
@@ -21,11 +22,14 @@ module Saboteur
|
|
|
21
22
|
# arid - every line an edit touches matches an arid pattern
|
|
22
23
|
# (logging and friends, #111/#187). The builtin hint list decides;
|
|
23
24
|
# a configured arid policy does not change the label.
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
25
|
+
# A gap kind (boundary, branch, effect, number, error path, or the
|
|
26
|
+
# result unchecked fallback) - a test covered the line and passed,
|
|
27
|
+
# which MAY mean the suite reaches the code without pinning this
|
|
28
|
+
# behaviour; the kind names WHAT sort of check is missing, from the
|
|
29
|
+
# operator and the edit shape only. The covering list is only the
|
|
30
|
+
# PLANNED coverage: whole-body (coarse) mutants carry method-level
|
|
31
|
+
# guesses, so they never get a gap kind, and neither does anything
|
|
32
|
+
# without real coverage.
|
|
29
33
|
# tool_limit - survivors saboteur could not observe faithfully: an
|
|
30
34
|
# external-runner run records only the pseudo-test id, never real
|
|
31
35
|
# coverage (#1071). A re-eval refusal records no signal on the
|
|
@@ -33,7 +37,7 @@ module Saboteur
|
|
|
33
37
|
#
|
|
34
38
|
# A survivor with no matching signal stays unlabeled.
|
|
35
39
|
module SurvivorClassification
|
|
36
|
-
LABELS = %w[likely_equivalent arid
|
|
40
|
+
LABELS = %w[likely_equivalent arid tool_limit].concat(GapKinds::KINDS).freeze
|
|
37
41
|
|
|
38
42
|
class << self
|
|
39
43
|
def label(result, arid: MutationSource::Builtin::DEFAULT_ARID)
|
|
@@ -41,7 +45,7 @@ module Saboteur
|
|
|
41
45
|
return "likely_equivalent" if equivalent?(result)
|
|
42
46
|
return "arid" if arid?(result, arid)
|
|
43
47
|
return "tool_limit" if tool_limited?(result)
|
|
44
|
-
return
|
|
48
|
+
return gap_kind(result) if possible_test_gap?(result)
|
|
45
49
|
|
|
46
50
|
nil
|
|
47
51
|
end
|
|
@@ -66,11 +70,25 @@ module Saboteur
|
|
|
66
70
|
|
|
67
71
|
# An external runner reports one pseudo id for every mutant and no
|
|
68
72
|
# coverage map, so a survivor there was never observed against real
|
|
69
|
-
# tests. Pseudo id next to a real id keeps
|
|
73
|
+
# tests. Pseudo id next to a real id keeps the gap kind (#305).
|
|
70
74
|
def tool_limited?(result)
|
|
71
75
|
result.covered_by.any? && result.covered_by.all? { |id| id == Execution::ExternalRunner::PSEUDO_TEST }
|
|
72
76
|
end
|
|
73
77
|
|
|
78
|
+
# The gap kind says which sort of check the survivor suggests:
|
|
79
|
+
# the operator decides for almost everything; operand_replacement
|
|
80
|
+
# looks at the replaced expression (comparison -> boundary).
|
|
81
|
+
def gap_kind(result)
|
|
82
|
+
GapKinds.kind(result.mutation.operator, comparison_edit: comparison_edit?(result))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def comparison_edit?(result)
|
|
86
|
+
result.mutation.edits.any? do |edit|
|
|
87
|
+
result.subject.file_source.byteslice(edit.start_byte...edit.end_byte)
|
|
88
|
+
&.match?(/<=>|<=|>=|==|!=|===|<|>/)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
74
92
|
# covered_by is a PLANNED covering list, not a verdict about what
|
|
75
93
|
# the suite asserts: coarse (whole-body) mutants carry method-level
|
|
76
94
|
# guesses, and a --test-command run records only the pseudo-test id
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "gap_kinds"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
module Reporter
|
|
6
8
|
# Suggests a check that might kill a surviving mutant, by operator. It does
|
|
@@ -94,9 +96,8 @@ module Saboteur
|
|
|
94
96
|
LABEL_REASONS = {
|
|
95
97
|
"likely_equivalent" => "compiles to the same instructions as the original",
|
|
96
98
|
"arid" => "every edited line matches an arid pattern",
|
|
97
|
-
"missing_test" => "the covering specs pass without pinning this change",
|
|
98
99
|
"tool_limit" => "the tool could not observe this mutant faithfully"
|
|
99
|
-
}.freeze
|
|
100
|
+
}.merge(GapKinds::REASONS).freeze
|
|
100
101
|
|
|
101
102
|
module_function
|
|
102
103
|
|