saboteur 0.9.0 → 0.11.0
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 +66 -0
- data/README.md +109 -247
- data/exe/saboteur +2 -2
- data/lib/saboteur/baseline/loader.rb +41 -0
- data/lib/saboteur/baseline/run_evidence.rb +1 -1
- data/lib/saboteur/baseline/run_gate.rb +51 -0
- data/lib/saboteur/baseline/workflow.rb +2 -15
- data/lib/saboteur/cli/baseline_gate.rb +58 -0
- data/lib/saboteur/cli/error_cause.rb +16 -0
- data/lib/saboteur/cli/exit_status.rb +14 -13
- data/lib/saboteur/cli/failure_output.rb +23 -0
- data/lib/saboteur/cli/output_options.rb +24 -2
- data/lib/saboteur/cli/run_budget_options.rb +6 -0
- data/lib/saboteur/cli/run_command.rb +14 -1
- data/lib/saboteur/cli/run_reporting.rb +34 -6
- data/lib/saboteur/cli/run_validation.rb +36 -3
- data/lib/saboteur/cli/selection_options.rb +2 -0
- data/lib/saboteur/cli.rb +6 -1
- data/lib/saboteur/config.rb +23 -2
- data/lib/saboteur/crash_report.rb +16 -7
- data/lib/saboteur/engine/baseline_guard.rb +2 -2
- data/lib/saboteur/engine/coverage_partial.rb +8 -1
- data/lib/saboteur/engine/coverage_store.rb +4 -5
- data/lib/saboteur/engine/diagnostics.rb +120 -15
- data/lib/saboteur/engine/run_fingerprint.rb +1 -1
- data/lib/saboteur/engine/scheduler.rb +20 -0
- data/lib/saboteur/engine/selection_announcement.rb +8 -1
- data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
- data/lib/saboteur/engine.rb +43 -6
- data/lib/saboteur/help.rb +4 -1
- data/lib/saboteur/mutation_source/builtin.rb +9 -3
- data/lib/saboteur/mutation_source/copy_replacement.rb +72 -0
- data/lib/saboteur/mutation_source/ensure_removal.rb +49 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +2 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +3 -1
- data/lib/saboteur/reporter/agent_summary.rb +3 -1
- data/lib/saboteur/reporter/assets/mutation_report.LICENSE +201 -0
- data/lib/saboteur/reporter/assets/mutation_report.js +331 -0
- data/lib/saboteur/reporter/baseline_tail.rb +93 -0
- data/lib/saboteur/reporter/diff.rb +10 -0
- data/lib/saboteur/reporter/html.rb +69 -14
- data/lib/saboteur/reporter/kill_causes.rb +53 -0
- data/lib/saboteur/reporter/next_steps.rb +4 -3
- data/lib/saboteur/reporter/palette.rb +64 -13
- data/lib/saboteur/reporter/safe_out.rb +1 -1
- data/lib/saboteur/reporter/score_table.rb +53 -18
- data/lib/saboteur/reporter/stryker.rb +4 -1
- data/lib/saboteur/reporter/survivor_classification.rb +12 -3
- data/lib/saboteur/reporter/survivor_hints.rb +72 -0
- data/lib/saboteur/reporter/survivor_list.rb +207 -75
- data/lib/saboteur/reporter/terminal.rb +315 -69
- data/lib/saboteur/result.rb +28 -1
- data/lib/saboteur/score.rb +6 -0
- data/lib/saboteur/version.rb +1 -1
- metadata +12 -2
|
@@ -26,8 +26,10 @@ module Saboteur
|
|
|
26
26
|
"endpoint_replacement" => "try a collection with 2 or more elements, to tell first from last",
|
|
27
27
|
"jump_value" => "try asserting the value this early exit returns",
|
|
28
28
|
"rescue_removal" => "try an example where the guarded code raises, and assert what the handler returns",
|
|
29
|
+
"ensure_removal" => "try asserting the cleanup effect of the ensure clause",
|
|
29
30
|
"when_removal" => "try a value that only this when branch matches, and assert its result",
|
|
30
31
|
"unary_removal" => "try a value where the sign or complement changes the result",
|
|
32
|
+
"copy_replacement" => "try changing the result after the call and check the original is untouched",
|
|
31
33
|
"filter_replacement" => "try items that match and items that do not, to tell select from reject"
|
|
32
34
|
}.freeze
|
|
33
35
|
|
|
@@ -43,8 +45,78 @@ module Saboteur
|
|
|
43
45
|
EQUAL_CASE = [%w[< <=], %w[> >=]].map(&:to_set).freeze
|
|
44
46
|
UNEQUAL_CASE = [%w[< >], %w[<= >=]].map(&:to_set).freeze
|
|
45
47
|
|
|
48
|
+
# Labels that never get a hint: a generated assertion on an equivalent
|
|
49
|
+
# mutant is the worst kind of hint noise. The assertion_gap/input_gap
|
|
50
|
+
# split (#1064) narrows this further; until it lands, every other
|
|
51
|
+
# label keeps the operator hint unchanged.
|
|
52
|
+
NO_HINT_LABELS = %w[likely_equivalent].freeze
|
|
53
|
+
|
|
54
|
+
# One deterministic template sentence per operator: what the change
|
|
55
|
+
# means for behaviour, never an inference beyond the operator.
|
|
56
|
+
WHY_IT_MATTERS = {
|
|
57
|
+
"comparison" => "the boundary between the two comparisons is untested",
|
|
58
|
+
"arithmetic" => "the computed value can drift without a test noticing",
|
|
59
|
+
"boolean_logic" => "one side of the condition no longer decides the result",
|
|
60
|
+
"condition_negation" => "the condition now picks the opposite branch",
|
|
61
|
+
"negation_removal" => "the negated value is no longer distinguished",
|
|
62
|
+
"literal_number" => "no test depends on this exact number",
|
|
63
|
+
"literal_boolean" => "no test depends on this exact true/false value",
|
|
64
|
+
"literal_string" => "no test asserts on this exact text",
|
|
65
|
+
"statement_removal" => "the statement's effect goes unobserved",
|
|
66
|
+
"and_assign" => "the nil-or-false case of &&= is untested",
|
|
67
|
+
"or_assign" => "the already-set case of ||= is untested",
|
|
68
|
+
"safe_navigation" => "a nil receiver now behaves like any other value",
|
|
69
|
+
"argument_removal" => "the call no longer depends on this argument",
|
|
70
|
+
"argument_swap" => "the argument order no longer matters to any test",
|
|
71
|
+
"pair_removal" => "the hash no longer depends on this key",
|
|
72
|
+
"chain_removal" => "part of the call chain has no observable effect",
|
|
73
|
+
"endpoint_replacement" => "first and last are no longer distinguished",
|
|
74
|
+
"jump_value" => "the early exit's value goes unobserved",
|
|
75
|
+
"rescue_removal" => "the rescue handler's behaviour goes unobserved",
|
|
76
|
+
"ensure_removal" => "the ensure cleanup's effect goes unobserved",
|
|
77
|
+
"when_removal" => "the values only this branch matches are untested",
|
|
78
|
+
"unary_removal" => "the sign or complement change goes unobserved",
|
|
79
|
+
"copy_replacement" => "aliasing and copying are no longer distinguished",
|
|
80
|
+
"filter_replacement" => "select and reject are no longer distinguished",
|
|
81
|
+
"interpolation_removal" => "the interpolated value goes unobserved",
|
|
82
|
+
"regex_replacement" => "the pattern's matched set changed unnoticed",
|
|
83
|
+
"method_swap" => "the swapped method's different behaviour goes unobserved",
|
|
84
|
+
"element_removal" => "the removed element goes unobserved",
|
|
85
|
+
"operand_replacement" => "the result no longer depends on the replaced operand",
|
|
86
|
+
"operator_assignment" => "the combined assignment operation goes unobserved"
|
|
87
|
+
}.freeze
|
|
88
|
+
WHY_FALLBACK = "no test observes this change"
|
|
89
|
+
|
|
90
|
+
# The deterministic per-label reason printed next to a survivor's rank.
|
|
91
|
+
# Labels come from SurvivorClassification; they are heuristics, not
|
|
92
|
+
# diagnoses. #1064 lands the assertion_gap label: add its reason here
|
|
93
|
+
# and its gate in NO_HINT_LABELS when it does.
|
|
94
|
+
LABEL_REASONS = {
|
|
95
|
+
"likely_equivalent" => "compiles to the same instructions as the original",
|
|
96
|
+
"arid" => "every edited line matches an arid pattern",
|
|
97
|
+
"missing_test" => "the covering specs pass without pinning this change",
|
|
98
|
+
"tool_limit" => "the tool could not observe this mutant faithfully"
|
|
99
|
+
}.freeze
|
|
100
|
+
|
|
46
101
|
module_function
|
|
47
102
|
|
|
103
|
+
# The hint for a labeled survivor; nil when the label suppresses hints.
|
|
104
|
+
def for_label(operator, label:, coarse: false)
|
|
105
|
+
return nil if NO_HINT_LABELS.include?(label)
|
|
106
|
+
|
|
107
|
+
self.for(operator, coarse: coarse)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The per-operator "why it matters" sentence for a survivor block.
|
|
111
|
+
def why(operator)
|
|
112
|
+
WHY_IT_MATTERS.fetch(operator, WHY_FALLBACK)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The per-label reason sentence; nil when the label has none.
|
|
116
|
+
def label_reason(label)
|
|
117
|
+
LABEL_REASONS[label]
|
|
118
|
+
end
|
|
119
|
+
|
|
48
120
|
def for(operator, coarse: false)
|
|
49
121
|
BY_OPERATOR.fetch(operator) { coarse ? COARSE : nil }
|
|
50
122
|
end
|
|
@@ -1,119 +1,168 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "diff"
|
|
4
5
|
require_relative "mutation_text"
|
|
6
|
+
require_relative "survivor_classification"
|
|
5
7
|
require_relative "survivor_hints"
|
|
6
8
|
|
|
7
9
|
module Saboteur
|
|
8
10
|
module Reporter
|
|
9
|
-
# Survivors
|
|
10
|
-
#
|
|
11
|
-
# line
|
|
11
|
+
# Survivors as per-statement blocks: the finding is the code. Each block
|
|
12
|
+
# is numbered and headed by file:line and subject, shows the enclosing
|
|
13
|
+
# def with the changed line as a -/+ pair (a ^^^ marker under the exact
|
|
14
|
+
# changed span), a per-operator "why it matters" sentence, the tests that
|
|
15
|
+
# covered the line and still passed, and an optional hint. Sibling
|
|
16
|
+
# mutants on the same statement collapse onto one dim line;
|
|
17
|
+
# --all-mutants gives every survivor its own block. Accepted
|
|
18
|
+
# (ignored) survivors get one quoted dim line each, never a block.
|
|
12
19
|
class SurvivorList
|
|
13
|
-
|
|
20
|
+
# Rendered rows in the method fence, elision markers included.
|
|
21
|
+
FENCE_CAP = 10
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
# "- " + three right-aligned line-number columns + " ".
|
|
24
|
+
GUTTER_WIDTH = 6
|
|
25
|
+
|
|
26
|
+
# Sibling digests named on the collapse line; the rest are a count.
|
|
27
|
+
SIBLING_DIGEST_CAP = 3
|
|
28
|
+
|
|
29
|
+
def initialize(out, palette, all_mutants: false, classifier: SurvivorClassification)
|
|
16
30
|
@out = out
|
|
17
31
|
@palette = palette
|
|
32
|
+
@all_mutants = all_mutants
|
|
33
|
+
@classifier = classifier
|
|
18
34
|
end
|
|
19
35
|
|
|
20
|
-
#
|
|
36
|
+
# Survivors in report (ranked) order; accepted ones trail as dim lines.
|
|
21
37
|
def call(report, survivors)
|
|
22
38
|
return if survivors.empty?
|
|
23
39
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
live, accepted = survivors.partition(&:unignored_survivor?)
|
|
41
|
+
@out.puts @palette.paint(:head, "Survivors (#{live.size}) - changes your tests did not catch")
|
|
42
|
+
groups(live).each_with_index { |group, index| block(report, group, index + 1, live.size) }
|
|
43
|
+
accepted_section(report, accepted)
|
|
44
|
+
hinted = live.select { |result| hint_for(result) }
|
|
45
|
+
@out.puts "\n#{@palette.paint(:dim, SurvivorHints::CAVEAT)}" if hinted.any?
|
|
29
46
|
end
|
|
30
47
|
|
|
31
48
|
private
|
|
32
49
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
39
|
-
end
|
|
50
|
+
# Statement + status is the group key: every survivor here survived.
|
|
51
|
+
# --all-mutants restores the per-mutant list: no collapse, one block
|
|
52
|
+
# each, in the same ranked order.
|
|
53
|
+
def groups(live)
|
|
54
|
+
return live.map { |result| [result] } if @all_mutants
|
|
40
55
|
|
|
41
|
-
|
|
42
|
-
in_subject.group_by { |result| MutationText.line(result) }.sort.each do |line, on_line|
|
|
43
|
-
first = on_line.first
|
|
44
|
-
@out.puts " #{path}:#{line} in #{first.subject.id}"
|
|
45
|
-
@out.puts " #{@palette.paint(:dim, "#{line}:")} #{source_line(first, line)}"
|
|
46
|
-
evidence_groups(report, on_line).each { |group| evidence_group(report, path, group) }
|
|
47
|
-
end
|
|
56
|
+
live.group_by { |result| [result.subject.id, MutationText.line(result)] }.values
|
|
48
57
|
end
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
# same tests: the covering-test list is compared as a set (#502), so
|
|
52
|
-
# one line's mutants no longer reprint an identical block only because
|
|
53
|
-
# the run measured the killer order per mutant. Reuse and changed
|
|
54
|
-
# lines still group exactly. Each run of neighbours with the same key
|
|
55
|
-
# is one group and shares evidence above its own mutations; evidence
|
|
56
|
-
# that repeats after another group is printed again, which keeps the
|
|
57
|
-
# ranked order. The block prints in the first mutant's run order.
|
|
58
|
-
def evidence_groups(report, on_line)
|
|
59
|
-
on_line.chunk { |result| [result.covered_by.uniq.sort, result.reused?, report.changed_lines_for(result)] }
|
|
60
|
-
.map(&:last)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def evidence_group(report, path, group)
|
|
59
|
+
def block(report, group, rank, total)
|
|
64
60
|
first = group.first
|
|
61
|
+
line = MutationText.line(first)
|
|
62
|
+
path = relative(first.subject.file, report.root)
|
|
63
|
+
@out.puts "\n#{@palette.paint(:head, "#{rank}. #{path}:#{line}")} #{first.subject.id} " \
|
|
64
|
+
"#{@palette.paint(:dim, label_suffix(first, rank, total))}".rstrip
|
|
65
|
+
fence(first, line)
|
|
66
|
+
@out.puts " #{@palette.paint(:dim, "why it matters: #{why(first.mutation.operator)}")}"
|
|
65
67
|
changed_lines(report, path, first)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
covered(group)
|
|
69
|
+
hint_line(first)
|
|
70
|
+
siblings(report, group)
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
def
|
|
71
|
-
|
|
73
|
+
def label_suffix(result, rank, total)
|
|
74
|
+
label = @classifier.label(result)
|
|
75
|
+
parts = [label, "##{rank} of #{total}"].compact
|
|
76
|
+
reason = SurvivorHints.label_reason(label)
|
|
77
|
+
"[#{parts.join(" \u00B7 ")}#{": #{reason}" if reason}]"
|
|
72
78
|
end
|
|
73
79
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
# The enclosing def with the changed line as a -/+ pair; longer
|
|
81
|
+
# methods keep head, the changed line and tail, the middle elided.
|
|
82
|
+
def fence(result, changed_line)
|
|
83
|
+
all = result.subject.source.lines
|
|
84
|
+
index = changed_line - result.subject.start_line
|
|
85
|
+
lines = fenced_rows(all, index).map do |row|
|
|
86
|
+
if row == :elision
|
|
87
|
+
@palette.paint(:dim, "#{gutter(' ', '')}...")
|
|
88
|
+
elsif row == index
|
|
89
|
+
changed_pair(result, all[row], changed_line)
|
|
90
|
+
else
|
|
91
|
+
@palette.paint(:dim, "#{gutter(' ', result.subject.start_line + row)}#{all[row].rstrip}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
@out.puts lines
|
|
85
95
|
end
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
# Head, the changed line with one neighbour each side, and the tail
|
|
98
|
+
# always survive the cap; only true middle lines elide.
|
|
99
|
+
def fenced_rows(all, changed_index)
|
|
100
|
+
return (0...all.size).to_a if all.size <= FENCE_CAP
|
|
101
|
+
|
|
102
|
+
keep = [0, 1, 2, changed_index - 1, changed_index, changed_index + 1, all.size - 2, all.size - 1]
|
|
103
|
+
.uniq.grep(0...all.size)
|
|
104
|
+
keep.each_with_object([]) do |i, rows|
|
|
105
|
+
rows << :elision if rows.any? && !rows.include?(i - 1)
|
|
106
|
+
rows << i
|
|
107
|
+
end
|
|
90
108
|
end
|
|
91
109
|
|
|
92
|
-
def
|
|
93
|
-
|
|
110
|
+
def changed_pair(result, original_line, line_number)
|
|
111
|
+
replacement_line = replaced_line(result, original_line, line_number)
|
|
112
|
+
minus = @palette.paint(:fail, "#{gutter('-', line_number)}#{original_line.rstrip}")
|
|
113
|
+
return [minus] if replacement_line.strip.empty?
|
|
94
114
|
|
|
95
|
-
|
|
115
|
+
plus = "#{gutter('+', line_number)}#{replacement_line.rstrip}"
|
|
116
|
+
span = Diff.edit_span(line_start(result, line_number), MutationText.sorted_edits(result))
|
|
117
|
+
column, width = char_span(original_line, span)
|
|
118
|
+
[minus,
|
|
119
|
+
@palette.paint(:pass, plus) +
|
|
120
|
+
@palette.paint(:dim, " #{result.mutation.operator} \u00B7 #{result.digest}"),
|
|
121
|
+
@palette.paint(:pass, "#{' ' * (GUTTER_WIDTH + column)}#{'^' * width}")]
|
|
96
122
|
end
|
|
97
123
|
|
|
98
|
-
# The
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
124
|
+
# The span comes in file bytes; the caret lines up in printed
|
|
125
|
+
# characters, so a multibyte character before the edit must not push
|
|
126
|
+
# the caret one column right per byte.
|
|
127
|
+
def char_span(line, (offset, length))
|
|
128
|
+
bytes = line.b
|
|
129
|
+
# scrub() would not change the count: invalid bytes count as one
|
|
130
|
+
# character with or without it.
|
|
131
|
+
decode = ->(part) { part.force_encoding(line.encoding).length }
|
|
132
|
+
[decode.call(bytes.byteslice(0, offset)), decode.call(bytes.byteslice(offset, length))]
|
|
133
|
+
end
|
|
105
134
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
# The changed line with this mutant's edits applied, so a line count
|
|
136
|
+
# change in the full def text cannot misalign the pair.
|
|
137
|
+
def replaced_line(result, original_line, line_number)
|
|
138
|
+
start = line_start(result, line_number)
|
|
139
|
+
MutationText.sorted_edits(result).reverse_each.reduce(original_line.b) do |text, edit|
|
|
140
|
+
from = edit.start_byte - start
|
|
141
|
+
text[0...from].to_s + edit.replacement.b + text[(from + (edit.end_byte - edit.start_byte))..].to_s
|
|
142
|
+
end.force_encoding(original_line.encoding)
|
|
111
143
|
end
|
|
112
144
|
|
|
113
|
-
def
|
|
114
|
-
|
|
145
|
+
# Byte offset where the printable form of this def line starts. The
|
|
146
|
+
# first line of a subject begins at the def keyword (subject.source is
|
|
147
|
+
# not the whole file line), later lines start at the file line start.
|
|
148
|
+
def line_start(result, line_number)
|
|
149
|
+
subject = result.subject
|
|
150
|
+
return subject.start_byte if line_number == subject.start_line
|
|
151
|
+
|
|
152
|
+
subject.file_source.lines.take(line_number - 1).join.bytesize
|
|
115
153
|
end
|
|
116
154
|
|
|
155
|
+
def gutter(sign, number)
|
|
156
|
+
"#{sign} #{number.to_s.rjust(3)} "
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def why(operator)
|
|
160
|
+
SurvivorHints.why(operator)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The tests that ran with the mutant applied and still passed: the
|
|
164
|
+
# missing assertion belongs in one of them. Line coverage can
|
|
165
|
+
# overstate which tests exercise the line, so the list is a lead.
|
|
117
166
|
# In diff mode: the changed lines this mutant touches.
|
|
118
167
|
def changed_lines(report, path, result)
|
|
119
168
|
lines = report.changed_lines_for(result)
|
|
@@ -121,7 +170,90 @@ module Saboteur
|
|
|
121
170
|
|
|
122
171
|
shown = lines.first(3).map { |line| "#{path}:#{line}" }.join(", ")
|
|
123
172
|
more = lines.size - 3
|
|
124
|
-
|
|
173
|
+
suffix = more.positive? ? " (+#{more} more)" : ""
|
|
174
|
+
@out.puts @palette.paint(:dim, " changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{suffix}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The covering tests are a group union (#502): the first names where an
|
|
178
|
+
# assertion would pin the change, the count says the rest also passed.
|
|
179
|
+
def covered(group)
|
|
180
|
+
tests = group.flat_map(&:covered_by).uniq
|
|
181
|
+
return if tests.empty?
|
|
182
|
+
|
|
183
|
+
first = group.find { |result| result.covered_by.include?(tests.first) }
|
|
184
|
+
@out.puts " covered by #{tests.first} #{@palette.paint(:dim, "- #{passed_phrase(first)}")}"
|
|
185
|
+
line = more_covered_line(group, tests)
|
|
186
|
+
@out.puts @palette.paint(:dim, " #{line}") if line
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The verdict names what this run did: executed is nil when the run
|
|
190
|
+
# did not record which tests ran, so only the bare verdict is safe.
|
|
191
|
+
def more_covered_line(group, tests)
|
|
192
|
+
more = tests.size - 1
|
|
193
|
+
return unless more.positive?
|
|
194
|
+
|
|
195
|
+
executed = group.filter_map(&:executed).flatten.uniq
|
|
196
|
+
ran = tests.drop(1).count { |test| executed.include?(test) }
|
|
197
|
+
suffix = if executed.empty?
|
|
198
|
+
# Only a run that recorded nothing about this group may
|
|
199
|
+
# keep the bare verdict; recorded-but-not-run is named.
|
|
200
|
+
if group.all? { |result| result.executed.nil? }
|
|
201
|
+
", all passed"
|
|
202
|
+
else
|
|
203
|
+
" (none ran this run)"
|
|
204
|
+
end
|
|
205
|
+
elsif ran == more
|
|
206
|
+
" (all #{more} ran, all passed)"
|
|
207
|
+
elsif ran.zero?
|
|
208
|
+
" (none ran this run)"
|
|
209
|
+
else
|
|
210
|
+
" (#{ran} ran, all passed; #{more - ran} did not run this run)"
|
|
211
|
+
end
|
|
212
|
+
"+#{more} more covered#{suffix}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def passed_phrase(result)
|
|
216
|
+
result.reused? ? "passed in an earlier run (--incremental)" : "ran, still passed"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def hint_line(result)
|
|
220
|
+
text = hint_for(result)
|
|
221
|
+
@out.puts " #{@palette.paint(:info, "\u2192")} #{text}" if text
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def hint_for(result)
|
|
225
|
+
SurvivorHints.for_label(result.mutation.operator, label: @classifier.label(result),
|
|
226
|
+
coarse: result.mutation.coarse)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def siblings(report, group)
|
|
230
|
+
rest = group.drop(1)
|
|
231
|
+
return if rest.empty?
|
|
232
|
+
|
|
233
|
+
list = rest.first(SIBLING_DIGEST_CAP)
|
|
234
|
+
.map { |result| "#{result.digest} (#{result.mutation.operator})" }.join(", ")
|
|
235
|
+
overflow = rest.size - SIBLING_DIGEST_CAP
|
|
236
|
+
list = "#{list} (+#{overflow} more)" if overflow.positive?
|
|
237
|
+
@out.puts @palette.paint(:dim, " #{rest.size} more #{rest.one? ? 'mutant' : 'mutants'} here: #{list}")
|
|
238
|
+
@out.puts @palette.paint(:dim, " expand: saboteur run --all-mutants#{' --since main' if report.diff?}")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Accepted (ignored) survivors stay visible but never earn a block:
|
|
242
|
+
# one quoted dim line each, marked not scored.
|
|
243
|
+
def accepted_section(report, accepted)
|
|
244
|
+
return if accepted.empty?
|
|
245
|
+
|
|
246
|
+
@out.puts ""
|
|
247
|
+
entries = accepted.map do |result|
|
|
248
|
+
"#{relative(result.subject.file, report.root)}:#{MutationText.line(result)} " \
|
|
249
|
+
"#{result.mutation.operator} - \"#{result.ignored}\""
|
|
250
|
+
end
|
|
251
|
+
if accepted.one?
|
|
252
|
+
@out.puts @palette.paint(:dim, "Accepted (1, not scored): #{entries.join}")
|
|
253
|
+
else
|
|
254
|
+
lines = ["Accepted (#{accepted.size}, not scored):"] + entries.map { |entry| " #{entry}" }
|
|
255
|
+
@out.puts @palette.paint(:dim, lines.join("\n"))
|
|
256
|
+
end
|
|
125
257
|
end
|
|
126
258
|
|
|
127
259
|
def relative(file, root)
|