saboteur 0.12.2 → 0.12.3
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 +17 -0
- data/README.md +4 -2
- data/lib/saboteur/cli/survivors_command.rb +5 -4
- data/lib/saboteur/engine/result_cache.rb +8 -5
- data/lib/saboteur/engine/scheduler.rb +3 -2
- data/lib/saboteur/execution/runner.rb +11 -4
- data/lib/saboteur/execution/site_probe.rb +161 -0
- data/lib/saboteur/reporter/findings.rb +2 -1
- data/lib/saboteur/reporter/gap_kinds.rb +12 -0
- data/lib/saboteur/reporter/review_comments.rb +4 -1
- data/lib/saboteur/reporter/stryker.rb +5 -0
- data/lib/saboteur/reporter/survivor_classification.rb +39 -7
- data/lib/saboteur/reporter/survivor_hints.rb +13 -9
- data/lib/saboteur/reporter/survivor_list.rb +1 -1
- data/lib/saboteur/result.rb +7 -2
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19bac537a82f6aa32ade9a7a1955cd793793d5a95b026dc56274c7c97560cfa6
|
|
4
|
+
data.tar.gz: 40086d139cbb82e418c9b7de5aa31b2aedd19eeb61ae238ba01f80c0e3f0d5b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dda55357cc1866e39f052b3b69424e6dde81ec551b695822e35e58a5ec65fe00b0e795b650338c12e2a2869e4a046f3a6e0839742e86bb3bd7ae52db809e124
|
|
7
|
+
data.tar.gz: f094725207c45c6b5c73d848790f2a6d8a91e849dcd281eabf616c6de414fbf6f9f9f589c7a15c0d6e15c72d1e21971be4a2edc19802691bc2c508b965fe99e9
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ All notable changes are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.12.3] - 2026-10-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Survivors are labeled `input_gap` or `assertion_gap` from the value the
|
|
14
|
+
mutated expression actually produced (side-effect-free sites only).
|
|
15
|
+
`input_gap` means the tests never gave the expression an input where its
|
|
16
|
+
value differs, or no test reached it. `assertion_gap` means the value
|
|
17
|
+
changed and the tests still passed. The JSON report gains `valueDiffed`
|
|
18
|
+
and `gapKind` fields (#1290, #1305, #1306, #1307).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- The cache stores the observed value with each verdict (cache format 2),
|
|
23
|
+
so cached results from before this release are rebuilt once on the next
|
|
24
|
+
run (#1290, #1307).
|
|
25
|
+
|
|
9
26
|
## [0.12.2] - 2026-10-22
|
|
10
27
|
|
|
11
28
|
### Added
|
data/README.md
CHANGED
|
@@ -80,8 +80,10 @@ tree refuses with exit 2 and points at the generator. Engine gem with
|
|
|
80
80
|
assertion, 1 by a crash`), and an error-dominated run prints
|
|
81
81
|
`Score: n/a` instead of a number you would misread.
|
|
82
82
|
- 🔍 **Survivors you can act on.** Survivors group per statement with a
|
|
83
|
-
triage label (`likely_equivalent`, `arid`, `tool_limit`, `input_gap`
|
|
84
|
-
|
|
83
|
+
triage label (`likely_equivalent`, `arid`, `tool_limit`, `input_gap` (the tests
|
|
84
|
+
never gave the mutated expression an input where its value differs, or no
|
|
85
|
+
test reached it), `assertion_gap` (the value changed and the tests still
|
|
86
|
+
passed), or a gap kind) and their
|
|
85
87
|
covering tests - split into the tests that ran against the mutant and
|
|
86
88
|
the ones that never did. `saboteur survivors` browses them read-only:
|
|
87
89
|
one digest shows the full diff and the exact replay command.
|
|
@@ -138,7 +138,7 @@ module Saboteur
|
|
|
138
138
|
def detail_lines(survivor, finding)
|
|
139
139
|
[
|
|
140
140
|
"#{survivor[:path]}:#{survivor[:line]} #{survivor[:operator]} #{survivor[:digest]}",
|
|
141
|
-
*classification_line(survivor[:classification]),
|
|
141
|
+
*classification_line(survivor[:classification], survivor[:gap_kind]),
|
|
142
142
|
"",
|
|
143
143
|
"- #{finding['original'] || original_line(survivor)}",
|
|
144
144
|
"+ #{finding['replacement'] || survivor[:replacement]}",
|
|
@@ -149,10 +149,10 @@ module Saboteur
|
|
|
149
149
|
end
|
|
150
150
|
|
|
151
151
|
# The same wording the terminal survivor block uses (#1183).
|
|
152
|
-
def classification_line(label)
|
|
152
|
+
def classification_line(label, kind = nil)
|
|
153
153
|
return [] unless label
|
|
154
154
|
|
|
155
|
-
reason = Reporter::SurvivorHints.label_reason(label)
|
|
155
|
+
reason = Reporter::SurvivorHints.label_reason(label, kind: kind)
|
|
156
156
|
["classification: #{label}#{" - #{reason}" if reason}"]
|
|
157
157
|
end
|
|
158
158
|
|
|
@@ -241,7 +241,8 @@ module Saboteur
|
|
|
241
241
|
entry.fetch("mutants", []).select { |m| m["status"] == "Survived" }.map do |m|
|
|
242
242
|
{ path: path, line: m.dig("location", "start", "line").to_i, operator: m["mutatorName"],
|
|
243
243
|
digest: m["id"], replacement: m["replacement"], classification: m["classification"],
|
|
244
|
-
subject: m["subject"], covered: m["coveredBy"] || [],
|
|
244
|
+
gap_kind: m["gapKind"], subject: m["subject"], covered: m["coveredBy"] || [],
|
|
245
|
+
source: entry["source"] }
|
|
245
246
|
end
|
|
246
247
|
end
|
|
247
248
|
survivors
|
|
@@ -26,7 +26,9 @@ module Saboteur
|
|
|
26
26
|
# every source file is watched instead: reuse turns conservative, never
|
|
27
27
|
# stale.
|
|
28
28
|
class ResultCache
|
|
29
|
-
|
|
29
|
+
# 2: entries carry value_differed (#1290); a version 1 file is dropped so
|
|
30
|
+
# no cached survivor reads as "never differed" without having been observed.
|
|
31
|
+
FORMAT = 2
|
|
30
32
|
MAX_BYTES = 64 * 1024 * 1024
|
|
31
33
|
STORED = %i[killed survived timeout].freeze
|
|
32
34
|
LOCKFILES = %w[Gemfile.lock gems.locked].freeze
|
|
@@ -62,13 +64,13 @@ module Saboteur
|
|
|
62
64
|
end
|
|
63
65
|
|
|
64
66
|
# The stored verdict as a hash (status, reason, killed_by, kill_cause,
|
|
65
|
-
# runtime), or nil when it may no longer hold.
|
|
67
|
+
# runtime, value_differed), or nil when it may no longer hold.
|
|
66
68
|
def lookup(subject, mutation, tests)
|
|
67
69
|
entry = @entries[key(subject, mutation)]
|
|
68
70
|
return nil unless entry && entry["fingerprint"] == fingerprint(subject, tests)
|
|
69
71
|
|
|
70
72
|
{ status: entry["status"].to_sym, reason: entry["reason"], killed_by: entry["killed_by"],
|
|
71
|
-
kill_cause: entry["kill_cause"], runtime: entry["runtime"] }
|
|
73
|
+
kill_cause: entry["kill_cause"], runtime: entry["runtime"], value_differed: entry["value_differed"] }
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
def record(result)
|
|
@@ -77,7 +79,7 @@ module Saboteur
|
|
|
77
79
|
@entries[key(result.subject, result.mutation)] = {
|
|
78
80
|
"fingerprint" => fingerprint(result.subject, result.covered_by), "status" => result.status.to_s,
|
|
79
81
|
"reason" => result.reason, "killed_by" => result.killed_by, "kill_cause" => result.kill_cause,
|
|
80
|
-
"runtime" => result.runtime
|
|
82
|
+
"runtime" => result.runtime, "value_differed" => result.value_differed
|
|
81
83
|
}
|
|
82
84
|
checkpoint if @clock.call - @last_saved >= @save_interval
|
|
83
85
|
end
|
|
@@ -154,7 +156,8 @@ module Saboteur
|
|
|
154
156
|
def typed?(entry)
|
|
155
157
|
STORED.map(&:to_s).include?(entry["status"]) && (entry["runtime"].nil? || entry["runtime"].is_a?(Numeric)) &&
|
|
156
158
|
%w[reason kill_cause].all? { |key| entry[key].nil? || entry[key].is_a?(String) } &&
|
|
157
|
-
entry["killed_by"].is_a?(Array) && entry["killed_by"].all?(String)
|
|
159
|
+
entry["killed_by"].is_a?(Array) && entry["killed_by"].all?(String) &&
|
|
160
|
+
[nil, true, false].include?(entry["value_differed"])
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
def key(subject, mutation)
|
|
@@ -235,7 +235,8 @@ module Saboteur
|
|
|
235
235
|
|
|
236
236
|
MutantResult.new(subject: plan.subject, mutation: mutation, status: hit[:status], mutated_source: text,
|
|
237
237
|
reason: hit[:reason], runtime: hit[:runtime], killed_by: hit[:killed_by],
|
|
238
|
-
covered_by: tests, kill_cause: hit[:kill_cause], reused: true
|
|
238
|
+
covered_by: tests, kill_cause: hit[:kill_cause], reused: true,
|
|
239
|
+
value_differed: hit[:value_differed])
|
|
239
240
|
end
|
|
240
241
|
|
|
241
242
|
def execute(plan, mutation, text)
|
|
@@ -247,7 +248,7 @@ module Saboteur
|
|
|
247
248
|
result = MutantResult.new(subject: plan.subject, mutation: mutation, status: outcome.status,
|
|
248
249
|
mutated_source: text, reason: outcome.message, runtime: outcome.runtime,
|
|
249
250
|
killed_by: outcome.failed_ids, covered_by: tests, kill_cause: outcome.kill_cause,
|
|
250
|
-
executed: outcome.executed_ids)
|
|
251
|
+
executed: outcome.executed_ids, value_differed: outcome.value_differed)
|
|
251
252
|
outcome.failed_ids.each { |id| plan.killers[id] += 1 }
|
|
252
253
|
note_leftovers(plan.subject, "#{mutation.operator} #{result.digest}", outcome)
|
|
253
254
|
@diagnostics.progress(result)
|
|
@@ -18,10 +18,13 @@ module Saboteur
|
|
|
18
18
|
# else the class of the error it died with (a crash, not a check).
|
|
19
19
|
# executed_ids: the tests the child reports it ran; empty when it could
|
|
20
20
|
# not say (a test called exit).
|
|
21
|
+
# value_differed: whether the mutated expression ever produced a value
|
|
22
|
+
# different from the original (SiteProbe); nil when the site is not
|
|
23
|
+
# probeable, never ran, or the run did not survive (#1290).
|
|
21
24
|
Outcome = Data.define(:status, :runtime, :failed_ids, :message, :leftover_pids, :kill_cause,
|
|
22
|
-
:executed_ids) do
|
|
25
|
+
:executed_ids, :value_differed) do
|
|
23
26
|
def initialize(status:, runtime:, failed_ids: [], message: nil, leftover_pids: [], kill_cause: nil,
|
|
24
|
-
executed_ids: [])
|
|
27
|
+
executed_ids: [], value_differed: nil)
|
|
25
28
|
super
|
|
26
29
|
end
|
|
27
30
|
end
|
|
@@ -98,6 +101,7 @@ module Saboteur
|
|
|
98
101
|
def run(subject:, text:, test_ids:, timeout:, mutation: nil, eval_prefix: nil)
|
|
99
102
|
@hooks.run(:before_fork, mutation)
|
|
100
103
|
@sequence = @sequence.to_i + 1
|
|
104
|
+
text = SiteProbe.text(subject, mutation, text) || text if mutation
|
|
101
105
|
job = Job.new(subject:, text:, test_ids:, timeout:, mutation:, eval_prefix:,
|
|
102
106
|
log_path: File.join(@log_dir, "child-#{Process.pid}-#{@sequence}.log"))
|
|
103
107
|
log_file = open_log(job)
|
|
@@ -132,6 +136,7 @@ module Saboteur
|
|
|
132
136
|
# Before the evaluation: a whole-file subject runs the file's own
|
|
133
137
|
# top-level code there, and it must see the run's random numbers.
|
|
134
138
|
Kernel.srand(@seed)
|
|
139
|
+
SiteProbe.reset
|
|
135
140
|
evaluate_mutant(writer, job)
|
|
136
141
|
stale = stale_captures(job)
|
|
137
142
|
Protocol.emit(writer, "captures", site: stale.first, stale: stale.size) if stale.any?
|
|
@@ -170,7 +175,8 @@ module Saboteur
|
|
|
170
175
|
Protocol.emit(writer, "error", message: "around_mutant hook did not run the tests")
|
|
171
176
|
else
|
|
172
177
|
called = flags[:replacement_called]
|
|
173
|
-
Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called
|
|
178
|
+
Protocol.emit(writer, "result", **with_crash_detail(result, started_at), replacement_called: called,
|
|
179
|
+
value_differed: SiteProbe.differed)
|
|
174
180
|
# A reload after the mutant already ran (an after-hook restore)
|
|
175
181
|
# is no reason to doubt the verdict; only a never-invoked mutant
|
|
176
182
|
# can have been hidden by the overwrite.
|
|
@@ -394,7 +400,8 @@ module Saboteur
|
|
|
394
400
|
return mismatch(runtime, "executed #{executed.size} of #{test_ids.size} requested tests")
|
|
395
401
|
end
|
|
396
402
|
|
|
397
|
-
Outcome.new(status: :survived, runtime: runtime, executed_ids: executed
|
|
403
|
+
Outcome.new(status: :survived, runtime: runtime, executed_ids: executed,
|
|
404
|
+
value_differed: result["value_differed"])
|
|
398
405
|
end
|
|
399
406
|
|
|
400
407
|
def killed_outcome(result, runtime, failed, executed, verbose_failure)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Execution
|
|
8
|
+
# Records, in the mutant's child, whether the mutated expression produced
|
|
9
|
+
# a different value from the original one (#1290). The mutated site is
|
|
10
|
+
# wrapped as (observe((mutated), [operands]) { original }). The mutated
|
|
11
|
+
# expression runs exactly once and first, as it does unwrapped, so the
|
|
12
|
+
# suite sees the same calls and the same verdict. The original runs
|
|
13
|
+
# only when every operand is a core immutable (Integer, Float, Rational,
|
|
14
|
+
# Symbol, nil, true, false, a frozen String), so only built-in operators
|
|
15
|
+
# execute; with any other operand the site is not probed and reports
|
|
16
|
+
# nil. Only side-effect-free site shapes are wrapped at all (literals,
|
|
17
|
+
# local and instance variable reads, arithmetic and comparison on
|
|
18
|
+
# those).
|
|
19
|
+
module SiteProbe
|
|
20
|
+
OPERATORS = %i[+ - * / % ** < <= > >= == != <=> & | ^ ! -@ +@ ~].freeze
|
|
21
|
+
LEAVES = [Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode, Prism::SymbolNode, Prism::NilNode,
|
|
22
|
+
Prism::TrueNode, Prism::FalseNode, Prism::LocalVariableReadNode,
|
|
23
|
+
Prism::InstanceVariableReadNode].freeze
|
|
24
|
+
|
|
25
|
+
@hits = 0
|
|
26
|
+
@differed = false
|
|
27
|
+
@comparable = true
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# The def text with the mutated site wrapped, or nil when the site
|
|
31
|
+
# cannot be probed safely. mutated_text is what the run would
|
|
32
|
+
# evaluate unwrapped; the wrap is only offered when it is exactly
|
|
33
|
+
# the source with the mutation's edits applied.
|
|
34
|
+
def text(subject, mutation, mutated_text)
|
|
35
|
+
return nil unless subject.respond_to?(:whole_file_only?) && mutation.respond_to?(:edits)
|
|
36
|
+
return nil if subject.whole_file_only? || mutation.edits.size != 1
|
|
37
|
+
|
|
38
|
+
source = subject.source
|
|
39
|
+
return nil unless subject.apply(mutation.edits) == mutated_text
|
|
40
|
+
|
|
41
|
+
edit = mutation.edits.first
|
|
42
|
+
range = (edit.start_byte - subject.start_byte)...(edit.end_byte - subject.start_byte)
|
|
43
|
+
wrap(source.b, range, edit.replacement.b)&.force_encoding(Encoding::UTF_8)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil # a probe must never turn a mutant run into an error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def reset
|
|
49
|
+
@hits = 0
|
|
50
|
+
@differed = false
|
|
51
|
+
@comparable = true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# nil: the site never ran, or its values could not be compared.
|
|
55
|
+
def differed
|
|
56
|
+
return nil if @hits.zero? || !@comparable
|
|
57
|
+
|
|
58
|
+
@differed
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def observe(mutated, operands, &)
|
|
62
|
+
compare(mutated, &) if operands.all? { |operand| immutable?(operand) }
|
|
63
|
+
mutated
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def immutable?(value)
|
|
69
|
+
case value
|
|
70
|
+
when Integer, Float, Rational, Symbol, nil, true, false then true
|
|
71
|
+
when String then value.frozen?
|
|
72
|
+
else false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def compare(mutated)
|
|
77
|
+
original = yield
|
|
78
|
+
@hits += 1
|
|
79
|
+
@differed ||= original != mutated
|
|
80
|
+
rescue StandardError
|
|
81
|
+
@comparable = false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def wrap(source, range, replacement)
|
|
85
|
+
top = site(Prism.parse(source).value, range)
|
|
86
|
+
# A bare edited literal differs from itself by construction: no signal.
|
|
87
|
+
return nil if top.nil? || LEAVES.any? { |leaf| top.is_a?(leaf) }
|
|
88
|
+
|
|
89
|
+
from = top.location.start_offset
|
|
90
|
+
to = top.location.end_offset
|
|
91
|
+
original = source.byteslice(from...to)
|
|
92
|
+
mutated = original.byteslice(0, range.begin - from) + replacement + original.byteslice((range.end - from)..)
|
|
93
|
+
return nil if (original + mutated).include?("\n")
|
|
94
|
+
|
|
95
|
+
source.byteslice(0, from) + probe(top, original, mutated) + source.byteslice(to..)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def probe(top, original, mutated)
|
|
99
|
+
reads = []
|
|
100
|
+
collect_reads(top, reads)
|
|
101
|
+
operands = reads.uniq.join(", ")
|
|
102
|
+
"(::Saboteur::Execution::SiteProbe.observe((#{mutated}), [#{operands}]) { #{original} })".b
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def collect_reads(node, reads)
|
|
106
|
+
reads << node.slice if node.is_a?(Prism::LocalVariableReadNode) || node.is_a?(Prism::InstanceVariableReadNode)
|
|
107
|
+
node.compact_child_nodes.each { |child| collect_reads(child, reads) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The outermost pure expression around the edit, climbing while the
|
|
111
|
+
# parent is pure too.
|
|
112
|
+
def site(root, range)
|
|
113
|
+
path = path_to(root, range).grep_v(Prism::ArgumentsNode)
|
|
114
|
+
index = path.rindex { |node| pure?(node) }
|
|
115
|
+
return nil unless index
|
|
116
|
+
|
|
117
|
+
index -= 1 while index.positive? && climbable?(path[index - 1])
|
|
118
|
+
path[index]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def climbable?(node)
|
|
122
|
+
(node.is_a?(Prism::CallNode) || node.is_a?(Prism::ParenthesesNode)) && pure?(node)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def path_to(node, range)
|
|
126
|
+
path = [node]
|
|
127
|
+
loop do
|
|
128
|
+
child = node.compact_child_nodes.find do |c|
|
|
129
|
+
c.location.start_offset <= range.begin && range.end <= c.location.end_offset
|
|
130
|
+
end
|
|
131
|
+
return path unless child
|
|
132
|
+
|
|
133
|
+
path << child
|
|
134
|
+
node = child
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def pure?(node)
|
|
139
|
+
case node
|
|
140
|
+
when *LEAVES then true
|
|
141
|
+
when Prism::ParenthesesNode then pure_parentheses?(node)
|
|
142
|
+
when Prism::CallNode then pure_call?(node)
|
|
143
|
+
else false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def pure_parentheses?(node)
|
|
148
|
+
body = node.body
|
|
149
|
+
body.is_a?(Prism::StatementsNode) && body.body.size == 1 && pure?(body.body.first)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def pure_call?(node)
|
|
153
|
+
return false unless OPERATORS.include?(node.name) && node.block.nil? && node.receiver
|
|
154
|
+
|
|
155
|
+
pure?(node.receiver) && Array(node.arguments&.arguments).size <= 1 &&
|
|
156
|
+
Array(node.arguments&.arguments).all? { |arg| pure?(arg) }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -71,7 +71,8 @@ module Saboteur
|
|
|
71
71
|
edits: entries,
|
|
72
72
|
coarse: result.mutation.coarse,
|
|
73
73
|
# Why the survivor is worth a look, as a label only (#192).
|
|
74
|
-
classification: SurvivorClassification.label(result)
|
|
74
|
+
classification: SurvivorClassification.label(result),
|
|
75
|
+
gapKind: SurvivorClassification.kind(result)
|
|
75
76
|
}.compact
|
|
76
77
|
entry = with_changed_lines(report, result, entry)
|
|
77
78
|
result.ignored ? entry.merge(reason: result.ignored) : entry
|
|
@@ -74,6 +74,18 @@ module Saboteur
|
|
|
74
74
|
"result unchecked" => "the covering specs pass without pinning this result"
|
|
75
75
|
}.freeze
|
|
76
76
|
|
|
77
|
+
# What an observed label (input_gap/assertion_gap, #1290) replaces
|
|
78
|
+
# the kind with, and the advice the kind gave: kept in the reason.
|
|
79
|
+
OBSERVED = %w[input_gap assertion_gap].freeze
|
|
80
|
+
ADVICE = {
|
|
81
|
+
"boundary" => "exercise both sides of this boundary",
|
|
82
|
+
"branch" => "exercise both branches",
|
|
83
|
+
"effect" => "assert the effect of this statement",
|
|
84
|
+
"number" => "no test depends on this exact number",
|
|
85
|
+
"error path" => "exercise the error path",
|
|
86
|
+
"result unchecked" => "assert this result"
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
77
89
|
module_function
|
|
78
90
|
|
|
79
91
|
def kind(operator, comparison_edit: false)
|
|
@@ -32,7 +32,10 @@ module Saboteur
|
|
|
32
32
|
TOOL_LIMIT_HINT = "Saboteur could not observe this mutant faithfully. Check it by hand."
|
|
33
33
|
HINTS = GapKinds::KINDS.to_h { |label| [label, GAP_HINT] }
|
|
34
34
|
.merge("tool_limit" => TOOL_LIMIT_HINT,
|
|
35
|
-
"input_gap" => "No test
|
|
35
|
+
"input_gap" => "No test input makes this change differ. " \
|
|
36
|
+
"Exercise it with a varied input.",
|
|
37
|
+
"assertion_gap" => "A test reaches this line, the value changes, and it still " \
|
|
38
|
+
"passes. Add an assertion that pins this behaviour.").freeze
|
|
36
39
|
DEFAULT_HINT = "No test fails with this change. Check whether a test should pin it."
|
|
37
40
|
|
|
38
41
|
Plan = Struct.new(:post, :delete, :summary, keyword_init: true)
|
|
@@ -139,9 +139,14 @@ 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 (#1290): the gap kind an observed label replaced.
|
|
143
|
+
gapKind: SurvivorClassification.kind(result),
|
|
142
144
|
# Additive (#1286): confirmed, killed_by_confirmation or unconfirmed.
|
|
143
145
|
# An unconfirmed survivor reports Ignored: outside the score.
|
|
144
146
|
confirmation: result.confirmation_state,
|
|
147
|
+
# Additive (#1290): whether the mutated expression ever produced a
|
|
148
|
+
# different value from the original; absent when not probed.
|
|
149
|
+
valueDiffered: result.value_differed,
|
|
145
150
|
creditedTest: result.credited_test&.then { |name| test_ids.fetch(name) },
|
|
146
151
|
killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
147
152
|
coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
@@ -29,11 +29,14 @@ module Saboteur
|
|
|
29
29
|
# operator and the edit shape only. The covering list is only the
|
|
30
30
|
# PLANNED coverage: whole-body (coarse) mutants carry method-level
|
|
31
31
|
# guesses, so they never get a gap kind.
|
|
32
|
-
# input_gap -
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
32
|
+
# input_gap - the tests never gave the mutated expression an input
|
|
33
|
+
# where its value differs (the runner recorded it, #1290), or no
|
|
34
|
+
# real test covered the mutant at all (D6). Coarse mutants stay
|
|
35
|
+
# unlabeled: their planned coverage is a method-level guess.
|
|
36
|
+
# assertion_gap - the mutated expression produced a different value
|
|
37
|
+
# while the covering tests ran, and they still passed: a test
|
|
38
|
+
# reaches the code and no assertion pins it (#1290). Only side-effect
|
|
39
|
+
# free sites are observed; an unprobed site keeps its gap kind.
|
|
37
40
|
# tool_limit - survivors saboteur could not observe faithfully: an
|
|
38
41
|
# external-runner run records only the pseudo-test id, never real
|
|
39
42
|
# coverage (#1071). A re-eval refusal records no signal on the
|
|
@@ -41,7 +44,10 @@ module Saboteur
|
|
|
41
44
|
#
|
|
42
45
|
# A survivor with no matching signal stays unlabeled.
|
|
43
46
|
module SurvivorClassification
|
|
44
|
-
LABELS = %w[likely_equivalent arid tool_limit input_gap].concat(GapKinds::KINDS).freeze
|
|
47
|
+
LABELS = %w[likely_equivalent arid tool_limit input_gap assertion_gap].concat(GapKinds::KINDS).freeze
|
|
48
|
+
# assertion_gap decides a statement's label ahead of every gap kind: the
|
|
49
|
+
# value changed and the tests passed.
|
|
50
|
+
GROUP_ORDER = (["assertion_gap"] + GapKinds::PRECEDENCE).freeze
|
|
45
51
|
|
|
46
52
|
class << self
|
|
47
53
|
def label(result, arid: MutationSource::Builtin::DEFAULT_ARID)
|
|
@@ -49,6 +55,9 @@ module Saboteur
|
|
|
49
55
|
return "likely_equivalent" if equivalent?(result)
|
|
50
56
|
return "arid" if arid?(result, arid)
|
|
51
57
|
return "tool_limit" if tool_limited?(result)
|
|
58
|
+
|
|
59
|
+
observed = observed_gap(result)
|
|
60
|
+
return observed if observed
|
|
52
61
|
return gap_kind(result) if possible_test_gap?(result)
|
|
53
62
|
return "input_gap" unless result.mutation.coarse
|
|
54
63
|
|
|
@@ -67,7 +76,21 @@ module Saboteur
|
|
|
67
76
|
# subsumes a missing branch, boundary or effect check on it.
|
|
68
77
|
def group_label(results)
|
|
69
78
|
labels = results.map { |result| label(result) }.compact
|
|
70
|
-
|
|
79
|
+
GROUP_ORDER.find { |kind| labels.include?(kind) } || labels.first
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The gap kind an observed label (input_gap/assertion_gap) replaced:
|
|
83
|
+
# what the edit shape says is unchecked. nil unless the observation
|
|
84
|
+
# decided the label, so a plain kind label stays its own kind.
|
|
85
|
+
def kind(result)
|
|
86
|
+
return nil unless GapKinds::OBSERVED.include?(observed_gap(result)) && possible_test_gap?(result)
|
|
87
|
+
|
|
88
|
+
gap_kind(result)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def group_kind(results)
|
|
92
|
+
kinds = results.map { |result| kind(result) }.compact
|
|
93
|
+
GapKinds::PRECEDENCE.find { |kind| kinds.include?(kind) }
|
|
71
94
|
end
|
|
72
95
|
|
|
73
96
|
private
|
|
@@ -91,6 +114,15 @@ module Saboteur
|
|
|
91
114
|
# An external runner reports one pseudo id for every mutant and no
|
|
92
115
|
# coverage map, so a survivor there was never observed against real
|
|
93
116
|
# tests. Pseudo id next to a real id keeps the gap kind (#305).
|
|
117
|
+
# The runner's observation (#1290): the mutated expression never gave
|
|
118
|
+
# a different value (input_gap), or gave one and the tests passed
|
|
119
|
+
# (assertion_gap). nil when the site was not probed.
|
|
120
|
+
def observed_gap(result)
|
|
121
|
+
return nil if result.mutation.coarse || result.value_differed.nil?
|
|
122
|
+
|
|
123
|
+
result.value_differed ? "assertion_gap" : "input_gap"
|
|
124
|
+
end
|
|
125
|
+
|
|
94
126
|
def tool_limited?(result)
|
|
95
127
|
result.covered_by.any? && result.covered_by.all? { |id| id == Execution::ExternalRunner::PSEUDO_TEST }
|
|
96
128
|
end
|
|
@@ -48,9 +48,8 @@ module Saboteur
|
|
|
48
48
|
UNEQUAL_CASE = [%w[< >], %w[<= >=]].map(&:to_set).freeze
|
|
49
49
|
|
|
50
50
|
# Labels that never get a hint: a generated assertion on an equivalent
|
|
51
|
-
# mutant is the worst kind of hint noise.
|
|
52
|
-
#
|
|
53
|
-
# label keeps the operator hint unchanged.
|
|
51
|
+
# mutant is the worst kind of hint noise. Every other label, including
|
|
52
|
+
# input_gap and assertion_gap, keeps the operator hint.
|
|
54
53
|
NO_HINT_LABELS = %w[likely_equivalent].freeze
|
|
55
54
|
|
|
56
55
|
# One deterministic template sentence per operator: what the change
|
|
@@ -91,13 +90,14 @@ module Saboteur
|
|
|
91
90
|
|
|
92
91
|
# The deterministic per-label reason printed next to a survivor's rank.
|
|
93
92
|
# Labels come from SurvivorClassification; they are heuristics, not
|
|
94
|
-
# diagnoses.
|
|
95
|
-
#
|
|
93
|
+
# diagnoses. input_gap and assertion_gap keep the operator hint: it is
|
|
94
|
+
# the right place to look in both cases (#1290).
|
|
96
95
|
LABEL_REASONS = {
|
|
97
96
|
"likely_equivalent" => "compiles to the same instructions as the original",
|
|
98
97
|
"arid" => "every edited line matches an arid pattern",
|
|
99
98
|
"tool_limit" => "the tool could not observe this mutant faithfully",
|
|
100
|
-
"input_gap" => "no test
|
|
99
|
+
"input_gap" => "no test input made this mutation change the value, so its input never varied",
|
|
100
|
+
"assertion_gap" => "a test ran this mutant, the value changed, and no assertion noticed"
|
|
101
101
|
}.merge(GapKinds::REASONS).freeze
|
|
102
102
|
|
|
103
103
|
module_function
|
|
@@ -114,9 +114,13 @@ module Saboteur
|
|
|
114
114
|
WHY_IT_MATTERS.fetch(operator, WHY_FALLBACK)
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
# The per-label reason sentence; nil when the label has none.
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
# The per-label reason sentence; nil when the label has none. An
|
|
118
|
+
# observed label (input_gap/assertion_gap) keeps the advice of the gap
|
|
119
|
+
# kind it replaced, so the kind is not lost behind the observation.
|
|
120
|
+
def label_reason(label, kind: nil)
|
|
121
|
+
reason = LABEL_REASONS[label]
|
|
122
|
+
advice = GapKinds::ADVICE[kind]
|
|
123
|
+
advice && GapKinds::OBSERVED.include?(label) ? "#{reason}; #{advice}" : reason
|
|
120
124
|
end
|
|
121
125
|
|
|
122
126
|
def for(operator, coarse: false)
|
|
@@ -78,7 +78,7 @@ module Saboteur
|
|
|
78
78
|
path = relative(first.subject.file, report.root)
|
|
79
79
|
@out.puts "\n#{@palette.paint(:head, "#{rank}. #{path}:#{line}")} #{first.subject.id} " \
|
|
80
80
|
"#{@palette.paint(:dim, label_suffix(group, rank, total))}".rstrip
|
|
81
|
-
reason = SurvivorHints.label_reason(@classifier.group_label(group))
|
|
81
|
+
reason = SurvivorHints.label_reason(@classifier.group_label(group), kind: @classifier.group_kind(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)}")}"
|
data/lib/saboteur/result.rb
CHANGED
|
@@ -35,9 +35,13 @@ module Saboteur
|
|
|
35
35
|
# status keeps its meaning either way.
|
|
36
36
|
attr_reader :confirmation
|
|
37
37
|
|
|
38
|
+
# Whether the mutated expression ever produced a value different from the
|
|
39
|
+
# original one while the covering tests ran; nil when not probed (#1290).
|
|
40
|
+
attr_reader :value_differed
|
|
41
|
+
|
|
38
42
|
def initialize(subject:, mutation:, status:, mutated_source:, reason: nil, runtime: nil, killed_by: [],
|
|
39
43
|
covered_by: [], kill_cause: nil, reused: false, rejected_kill: false, executed: nil,
|
|
40
|
-
confirmation: nil)
|
|
44
|
+
confirmation: nil, value_differed: nil)
|
|
41
45
|
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
42
46
|
|
|
43
47
|
@subject = subject
|
|
@@ -53,6 +57,7 @@ module Saboteur
|
|
|
53
57
|
@rejected_kill = rejected_kill
|
|
54
58
|
@reused = reused
|
|
55
59
|
@confirmation = confirmation
|
|
60
|
+
@value_differed = value_differed
|
|
56
61
|
@ignored = nil
|
|
57
62
|
# triage_state/triage_reason stay unset until the ignore list writes
|
|
58
63
|
# them: the accessors read as nil before that, and there is no
|
|
@@ -69,7 +74,7 @@ module Saboteur
|
|
|
69
74
|
MutantResult.new(subject: subject, mutation: mutation, status: status, mutated_source: mutated_source,
|
|
70
75
|
reason: reason, runtime: runtime, killed_by: killed_by, covered_by: covered_by,
|
|
71
76
|
kill_cause: kill_cause, reused: reused?, rejected_kill: rejected_kill?,
|
|
72
|
-
executed: executed, confirmation: confirmation)
|
|
77
|
+
executed: executed, confirmation: confirmation, value_differed: value_differed)
|
|
73
78
|
end
|
|
74
79
|
|
|
75
80
|
# The reason a --flaky-check disagreement starts with: unverified, but
|
data/lib/saboteur/version.rb
CHANGED
data/lib/saboteur.rb
CHANGED
|
@@ -70,6 +70,7 @@ require_relative "saboteur/execution/marker_sweep"
|
|
|
70
70
|
require_relative "saboteur/execution/supervisor"
|
|
71
71
|
require_relative "saboteur/execution/reevaluator"
|
|
72
72
|
require_relative "saboteur/execution/wrapper"
|
|
73
|
+
require_relative "saboteur/execution/site_probe"
|
|
73
74
|
require_relative "saboteur/execution/runner"
|
|
74
75
|
require_relative "saboteur/execution/bundle_gemfile_pin"
|
|
75
76
|
require_relative "saboteur/execution/gemfile_path_sources"
|
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.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vamsi Pavan Mahesh Gunturu
|
|
@@ -164,6 +164,7 @@ files:
|
|
|
164
164
|
- lib/saboteur/execution/runner.rb
|
|
165
165
|
- lib/saboteur/execution/sandbox.rb
|
|
166
166
|
- lib/saboteur/execution/seatbelt.rb
|
|
167
|
+
- lib/saboteur/execution/site_probe.rb
|
|
167
168
|
- lib/saboteur/execution/stale_captures.rb
|
|
168
169
|
- lib/saboteur/execution/supervisor.rb
|
|
169
170
|
- lib/saboteur/execution/supervisor_batch.rb
|