saboteur 0.3.0 → 0.4.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 +113 -1
- data/README.md +248 -63
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/cli.rb +150 -22
- data/lib/saboteur/config.rb +27 -15
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +17 -1
- data/lib/saboteur/engine/baseline_guard.rb +20 -3
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/coverage_store.rb +31 -4
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/run_fingerprint.rb +34 -6
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine.rb +74 -13
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +33 -17
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/rspec.rb +47 -3
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +8 -2
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +1 -1
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +8 -0
- data/lib/saboteur/reporter/html.rb +49 -1
- data/lib/saboteur/reporter/stryker.rb +11 -4
- data/lib/saboteur/reporter/terminal.rb +65 -2
- data/lib/saboteur/result.rb +46 -5
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +4 -0
- metadata +17 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "findings"
|
|
5
|
+
require_relative "agent_incomplete"
|
|
6
|
+
require_relative "agent_clipping"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Reporter
|
|
10
|
+
module Agent
|
|
11
|
+
# Builds the payload hash for one run: status, verdict, exit code, counts,
|
|
12
|
+
# provenance, a few findings and the next commands.
|
|
13
|
+
module Summary
|
|
14
|
+
class << self
|
|
15
|
+
include Clipping
|
|
16
|
+
|
|
17
|
+
# Public entry first; the helpers below are private.
|
|
18
|
+
def build(report, provenance, shown, snippet_limit)
|
|
19
|
+
incomplete = Incomplete.reasons(report)
|
|
20
|
+
status = status_of(report, incomplete)
|
|
21
|
+
verdict = verdict_of(report, status)
|
|
22
|
+
{
|
|
23
|
+
version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
|
|
24
|
+
provenance: provenance_hash(provenance),
|
|
25
|
+
counts: counts(report), score: score(report),
|
|
26
|
+
scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
|
|
27
|
+
incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
|
|
28
|
+
findings: findings(report, shown, snippet_limit),
|
|
29
|
+
warnings: warnings(report),
|
|
30
|
+
next: next_steps(report, provenance)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def status_of(report, incomplete)
|
|
37
|
+
return "not_applicable" if report.results.empty? && report.skipped_subjects.empty?
|
|
38
|
+
|
|
39
|
+
incomplete.empty? ? "complete" : "partial"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def verdict_of(report, status)
|
|
43
|
+
return "survivors" if report.unignored_survivors.any?
|
|
44
|
+
|
|
45
|
+
{ "complete" => "clean", "partial" => "incomplete", "not_applicable" => "not_applicable" }.fetch(status)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def exit_code_for(verdict)
|
|
49
|
+
case verdict
|
|
50
|
+
when "clean" then EXIT_CLEAN
|
|
51
|
+
when "survivors" then EXIT_REVIEW
|
|
52
|
+
else EXIT_INCOMPLETE
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def counts(report)
|
|
57
|
+
report.counts.transform_keys { |status| camel(status) }.merge(executed: report.executed.size)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def camel(status)
|
|
61
|
+
status.to_s.gsub(/_(\w)/) { Regexp.last_match(1).upcase }.to_sym
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def score(report)
|
|
65
|
+
report.score&.round(4)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Display values are clipped; the commands under next use the exact values or are left out.
|
|
69
|
+
def provenance_hash(provenance)
|
|
70
|
+
{ ref: clip_value(provenance.ref, 120), seed: bounded_number(provenance.seed),
|
|
71
|
+
head: clip_value(provenance.head, 64),
|
|
72
|
+
workingTree: provenance.working_tree, changedFiles: provenance.changed_files,
|
|
73
|
+
mode: provenance.mode, selection: selection_hash(provenance) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def selection_hash(provenance)
|
|
77
|
+
%i[subjects tests sources].to_h do |key|
|
|
78
|
+
list = provenance.public_send(key)
|
|
79
|
+
[key, { count: list.size, first: list.first(3).map { |entry| clip_value(entry, 120) } }]
|
|
80
|
+
end.merge(config: clip_value(provenance.config_path, 120), digest: provenance.digest,
|
|
81
|
+
settings: provenance.settings.transform_values { |value| clip_value(value, 120) })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def findings(report, shown, snippet_limit)
|
|
85
|
+
all = Findings.build(report)
|
|
86
|
+
live = report.unignored_survivors.size
|
|
87
|
+
{
|
|
88
|
+
survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
|
|
89
|
+
omitted: live - [shown, all[:survivors].size].min,
|
|
90
|
+
accepted: report.survivors.size - live, ordering: all[:ordering]
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def shown_findings(listed, shown, snippet_limit)
|
|
95
|
+
listed.first(shown).map do |finding|
|
|
96
|
+
finding.slice(:digest, :line, :endLine, :operator, :coarse)
|
|
97
|
+
.merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
|
|
98
|
+
original: clip(finding[:original], snippet_limit),
|
|
99
|
+
replacement: clip(finding[:replacement], snippet_limit))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def warnings(report)
|
|
104
|
+
report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def next_steps(report, provenance)
|
|
108
|
+
steps = {}
|
|
109
|
+
add_command(steps, :rerun, Commands.rerun(provenance))
|
|
110
|
+
first = Findings.build(report)[:survivors].first
|
|
111
|
+
add_command(steps, :replay, Commands.replay(provenance, first[:digest])) if first
|
|
112
|
+
steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
|
|
113
|
+
steps
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def short_path?(path)
|
|
117
|
+
path && path.bytesize <= 400
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add_command(steps, key, command)
|
|
121
|
+
steps[key] = command
|
|
122
|
+
return if command
|
|
123
|
+
|
|
124
|
+
steps[:"#{key}Unavailable"] =
|
|
125
|
+
"command longer than #{Commands::MAX_BYTES} bytes; rebuild it from provenance"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -76,9 +76,17 @@ module Saboteur
|
|
|
76
76
|
edits: entries,
|
|
77
77
|
coarse: result.mutation.coarse
|
|
78
78
|
}
|
|
79
|
+
entry = with_changed_lines(report, result, entry)
|
|
79
80
|
result.ignored ? entry.merge(reason: result.ignored) : entry
|
|
80
81
|
end
|
|
81
82
|
|
|
83
|
+
# In diff mode, the changed lines the mutant touches. line above is where
|
|
84
|
+
# the mutated statement starts, which can be above the changed line.
|
|
85
|
+
def with_changed_lines(report, result, entry)
|
|
86
|
+
lines = report.changed_lines_for(result)
|
|
87
|
+
lines.empty? ? entry : entry.merge(changedLines: lines)
|
|
88
|
+
end
|
|
89
|
+
|
|
82
90
|
# Subject#apply applies edits in start-byte order; findings use the
|
|
83
91
|
# same order, so an unsorted edit list from a custom mutation source
|
|
84
92
|
# still gets a sane range and one entry per edit.
|
|
@@ -76,6 +76,7 @@ module Saboteur
|
|
|
76
76
|
<p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
|
|
77
77
|
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
|
|
78
78
|
Stryker JSON may show a different score, because the schema counts NoCoverage as undetected.</p>
|
|
79
|
+
#{score_split(report)}
|
|
79
80
|
<p class="note">Wide tables scroll sideways.</p>
|
|
80
81
|
<p>#{counts.map { |status, count| "#{status_label(status)} #{count}" }.join(' ')}</p>
|
|
81
82
|
<p>Survivors: #{report.unignored_survivors.size} to fix, #{report.survivors.size - report.unignored_survivors.size}
|
|
@@ -83,6 +84,37 @@ module Saboteur
|
|
|
83
84
|
HTML
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
# How the score splits into assertion and crash kills, and the score
|
|
88
|
+
# without the whole-body mutants, which almost any test that runs the
|
|
89
|
+
# method kills. Each part shows only when it has something to say.
|
|
90
|
+
def score_split(report)
|
|
91
|
+
breakdown = report.score_breakdown
|
|
92
|
+
lines = []
|
|
93
|
+
lines << kill_split_line(breakdown) if breakdown.split_known?
|
|
94
|
+
lines << whole_body_line(report)
|
|
95
|
+
lines.compact.join("\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def kill_split_line(breakdown)
|
|
99
|
+
"<p>Of the tested mutants: #{parts_text(breakdown)}. A crash shows the code ran and broke, " \
|
|
100
|
+
"not that a test checked a value.</p>"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parts_text(breakdown)
|
|
104
|
+
breakdown.parts.map { |label, share| "#{score_text(share)} #{label}" }.join(", ")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def whole_body_line(report)
|
|
108
|
+
whole = report.coarse_score_breakdown
|
|
109
|
+
fine = report.fine_score_breakdown
|
|
110
|
+
return if whole.tested.zero? || fine.tested.zero?
|
|
111
|
+
|
|
112
|
+
split = fine.split_known? ? " (#{parts_text(fine)})" : ""
|
|
113
|
+
"<p>Without whole-body mutants (body_nil, body_true, body_false, body_raise): #{score_text(fine.value)}" \
|
|
114
|
+
"#{split} over #{fine.tested} mutants. Whole-body mutants alone: #{score_text(whole.value)} " \
|
|
115
|
+
"over #{whole.tested}.</p>"
|
|
116
|
+
end
|
|
117
|
+
|
|
86
118
|
def score_text(score)
|
|
87
119
|
score ? format("%.1f%%", score * 100) : "n/a"
|
|
88
120
|
end
|
|
@@ -126,13 +158,29 @@ module Saboteur
|
|
|
126
158
|
def mutant_row(mutant, names, result)
|
|
127
159
|
start = mutant[:location][:start]
|
|
128
160
|
tests = Array(mutant[:killedBy]).map { |id| names.fetch(id) }.join(", ")
|
|
129
|
-
notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}"
|
|
161
|
+
notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}",
|
|
162
|
+
passing_tests_note(mutant, names, result)].compact.join("; ")
|
|
130
163
|
"<tr><td class=\"fit\">line #{start[:line]}, column #{start[:column]}</td><td>#{row_status(mutant,
|
|
131
164
|
result)}</td>" \
|
|
132
165
|
"<td>#{escape(mutant[:mutatorName])}</td><td class=\"wide\">#{change(mutant, result)}</td>" \
|
|
133
166
|
"<td class=\"fit\"><code>#{escape(mutant[:id])}</code></td><td class=\"wide\">#{escape(notes)}</td></tr>"
|
|
134
167
|
end
|
|
135
168
|
|
|
169
|
+
# A survivor's covering tests all passed with the mutant applied, or, for
|
|
170
|
+
# a reused verdict, in the earlier run it came from.
|
|
171
|
+
def passing_tests_note(mutant, names, result)
|
|
172
|
+
return unless mutant[:status] == "Survived"
|
|
173
|
+
|
|
174
|
+
passed = Array(mutant[:coveredBy]).map { |id| names.fetch(id) }
|
|
175
|
+
return if passed.empty?
|
|
176
|
+
|
|
177
|
+
shown = passed.first(Terminal::MAX_PASSING_TESTS).join(", ")
|
|
178
|
+
more = passed.size - Terminal::MAX_PASSING_TESTS
|
|
179
|
+
extra = more.positive? ? " (+#{more} more)" : ""
|
|
180
|
+
when_passed = result.reused? ? "passed in an earlier run" : "passed"
|
|
181
|
+
"covered by #{passed.size} #{passed.one? ? 'test' : 'tests'} that #{when_passed}: #{shown}#{extra}"
|
|
182
|
+
end
|
|
183
|
+
|
|
136
184
|
def row_status(mutant, result)
|
|
137
185
|
badge = status_label(mutant[:status])
|
|
138
186
|
# Only survivors are ever put on the ignore list.
|
|
@@ -16,14 +16,14 @@ module Saboteur
|
|
|
16
16
|
@path = path
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def call(report)
|
|
19
|
+
def call(report, exit_cause: nil)
|
|
20
20
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
21
|
-
File.write(@path, JSON.generate(self.class.build(report)))
|
|
21
|
+
File.write(@path, JSON.generate(self.class.build(report, exit_cause: exit_cause)))
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# Tests are listed once under testFiles with short ids; mutants refer to
|
|
25
25
|
# them by id in killedBy/coveredBy instead of repeating full test names.
|
|
26
|
-
def self.build(report)
|
|
26
|
+
def self.build(report, exit_cause: nil)
|
|
27
27
|
test_ids = test_index(report.results)
|
|
28
28
|
files = report.results.group_by { |result| result.subject.file }.to_h do |file, results|
|
|
29
29
|
[relative(file, report.root), file_entry(results, test_ids)]
|
|
@@ -38,10 +38,17 @@ module Saboteur
|
|
|
38
38
|
rejectedKills: report.rejected_kills,
|
|
39
39
|
warnings: report.warnings,
|
|
40
40
|
framework: { name: "saboteur", version: VERSION },
|
|
41
|
-
saboteur: { findings: Findings.build(report) }
|
|
41
|
+
saboteur: { findings: Findings.build(report), scores: scores(report) }
|
|
42
|
+
.merge(exit_cause ? { exitCause: exit_cause } : {})
|
|
42
43
|
}.merge(report.seed ? { config: { seed: report.seed } } : {})
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
# Score with its assertion and crash shares, for all mutants and without
|
|
47
|
+
# the whole-body ones (body_nil, body_true, body_false, body_raise).
|
|
48
|
+
def self.scores(report)
|
|
49
|
+
{ all: report.score_breakdown.to_h, withoutWholeBodyMutants: report.fine_score_breakdown.to_h }
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
def self.skipped_subjects(report)
|
|
46
53
|
report.skipped_subjects.map do |subject, reason|
|
|
47
54
|
{ subject: subject.id, file: relative(subject.file, report.root), line: subject.start_line, reason: reason }
|
|
@@ -7,6 +7,8 @@ module Saboteur
|
|
|
7
7
|
# errors, then a unified diff for each survivor.
|
|
8
8
|
class Terminal
|
|
9
9
|
MAX_ERRORS = 10
|
|
10
|
+
MAX_PASSING_TESTS = 5
|
|
11
|
+
MAX_CHANGED_LINES = 3
|
|
10
12
|
|
|
11
13
|
def initialize(out)
|
|
12
14
|
@out = out
|
|
@@ -40,9 +42,10 @@ module Saboteur
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def details(report)
|
|
43
|
-
|
|
44
|
-
@out.puts "score #{
|
|
45
|
+
breakdown = report.score_breakdown
|
|
46
|
+
@out.puts "score #{percent(breakdown.value)}#{score_split(breakdown)} " \
|
|
45
47
|
"(killed+timeout)/(killed+survived+timeout); errors, resource limits and skips excluded"
|
|
48
|
+
whole_body_score(report)
|
|
46
49
|
mean = report.mean_runtime
|
|
47
50
|
@out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
|
|
48
51
|
@out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
|
|
@@ -55,6 +58,31 @@ module Saboteur
|
|
|
55
58
|
kill_causes(report)
|
|
56
59
|
end
|
|
57
60
|
|
|
61
|
+
# " (assertion 50.0 %, crash 50.0 %)": how much of the score is a failed
|
|
62
|
+
# check and how much is a crash. Timeouts and kills with no recorded
|
|
63
|
+
# cause are named only when there are some, so the parts add up.
|
|
64
|
+
def score_split(breakdown)
|
|
65
|
+
return "" unless breakdown.split_known?
|
|
66
|
+
|
|
67
|
+
" (#{breakdown.parts.map { |label, share| "#{label} #{percent(share)}" }.join(', ')})"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Whole-body mutants (body_nil, body_true, body_false, body_raise) are
|
|
71
|
+
# killed by almost any test that runs the method, so they get their own
|
|
72
|
+
# line.
|
|
73
|
+
def whole_body_score(report)
|
|
74
|
+
whole = report.coarse_score_breakdown
|
|
75
|
+
fine = report.fine_score_breakdown
|
|
76
|
+
return if whole.tested.zero? || fine.tested.zero?
|
|
77
|
+
|
|
78
|
+
@out.puts "score without whole-body mutants #{percent(fine.value)}#{score_split(fine)} " \
|
|
79
|
+
"(#{fine.tested} mutants); whole-body mutants alone #{percent(whole.value)} (#{whole.tested})"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def percent(share)
|
|
83
|
+
share ? format("%.1f %%", share * 100) : "n/a"
|
|
84
|
+
end
|
|
85
|
+
|
|
58
86
|
# How many kills came from a failed check and how many from a crash, and
|
|
59
87
|
# which operators mostly kill by crashing (their score says less).
|
|
60
88
|
def kill_causes(report)
|
|
@@ -114,12 +142,47 @@ module Saboteur
|
|
|
114
142
|
shown(report).each do |result, hidden|
|
|
115
143
|
location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
|
|
116
144
|
@out.puts "\n#{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
|
|
145
|
+
changed_lines(report, result)
|
|
117
146
|
@out.puts "ignored: #{result.ignored}" if result.ignored
|
|
118
147
|
@out.puts result.diff(root: report.root)
|
|
148
|
+
passing_tests(result)
|
|
119
149
|
@out.puts "#{hidden} more survivor#{'s' if hidden > 1} on this line in the JSON report" if hidden.positive?
|
|
120
150
|
end
|
|
121
151
|
end
|
|
122
152
|
|
|
153
|
+
# The tests that ran with the mutant applied and still passed: the
|
|
154
|
+
# missing assertion belongs in one of them. They are listed likeliest
|
|
155
|
+
# killers first, the order the run used. Line coverage can overstate which
|
|
156
|
+
# tests exercise the line, so the list is a lead, not proof.
|
|
157
|
+
def passing_tests(result)
|
|
158
|
+
tests = result.covered_by
|
|
159
|
+
return if tests.empty?
|
|
160
|
+
|
|
161
|
+
@out.puts "#{tests.size} #{tests.one? ? 'test' : 'tests'} covered this line and #{passed_phrase(result)} " \
|
|
162
|
+
"(the missing check may belong in one of them):"
|
|
163
|
+
tests.first(MAX_PASSING_TESTS).each { |name| @out.puts " #{name}" }
|
|
164
|
+
more = tests.size - MAX_PASSING_TESTS
|
|
165
|
+
@out.puts " ... #{more} more in the JSON report (coveredBy)" if more.positive?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A reused verdict was not run again: its tests passed in the earlier run.
|
|
169
|
+
def passed_phrase(result)
|
|
170
|
+
result.reused? ? "passed in an earlier run (--incremental)" : "still passed"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# In diff mode: the changed lines this mutant touches. The location above
|
|
174
|
+
# is where the mutated statement starts, which can be a line or two above
|
|
175
|
+
# the line that changed.
|
|
176
|
+
def changed_lines(report, result)
|
|
177
|
+
lines = report.changed_lines_for(result)
|
|
178
|
+
return if lines.empty?
|
|
179
|
+
|
|
180
|
+
path = relative(result.subject.file, report.root)
|
|
181
|
+
shown = lines.first(MAX_CHANGED_LINES).map { |line| "#{path}:#{line}" }.join(", ")
|
|
182
|
+
more = lines.size - MAX_CHANGED_LINES
|
|
183
|
+
@out.puts "changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{" (+#{more} more)" if more.positive?}"
|
|
184
|
+
end
|
|
185
|
+
|
|
123
186
|
# [[result, how many more on the same line]]. In review mode survivors
|
|
124
187
|
# on one line usually share a fate, so one is shown: live before
|
|
125
188
|
# ignored, then coarse before fine, then the earliest edit.
|
data/lib/saboteur/result.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "reporter/diff"
|
|
5
|
+
require_relative "score"
|
|
5
6
|
|
|
6
7
|
module Saboteur
|
|
7
8
|
# resource_limit: the mutant's process tree hit the memory or process cap.
|
|
@@ -59,6 +60,11 @@ module Saboteur
|
|
|
59
60
|
status == :killed && !kill_cause.nil? && kill_cause != "assertion"
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
# Killed because a test's check failed: the test looked at a value.
|
|
64
|
+
def assertion_kill?
|
|
65
|
+
status == :killed && kill_cause == "assertion"
|
|
66
|
+
end
|
|
67
|
+
|
|
62
68
|
# Taken from an earlier run (--incremental) instead of run again.
|
|
63
69
|
def reused?
|
|
64
70
|
@reused
|
|
@@ -84,12 +90,16 @@ module Saboteur
|
|
|
84
90
|
|
|
85
91
|
# Everything one run produced.
|
|
86
92
|
class Report
|
|
93
|
+
# More than half of the executed mutants errored: the run tested
|
|
94
|
+
# almost nothing and must not pass a gate.
|
|
95
|
+
MAX_ERROR_FRACTION = 0.5
|
|
96
|
+
|
|
87
97
|
attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects
|
|
88
98
|
|
|
89
99
|
# REVIEW: diff mode; the terminal then shows one survivor per line.
|
|
90
100
|
# seed: the random seed every test run started from (nil when unknown).
|
|
91
101
|
def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
|
|
92
|
-
seed: nil, skipped_subjects: {})
|
|
102
|
+
seed: nil, skipped_subjects: {}, diff_scope: nil)
|
|
93
103
|
@results = results
|
|
94
104
|
@subjects = subjects
|
|
95
105
|
@wall_time = wall_time
|
|
@@ -99,12 +109,30 @@ module Saboteur
|
|
|
99
109
|
@review = review
|
|
100
110
|
@seed = seed
|
|
101
111
|
@skipped_subjects = skipped_subjects
|
|
112
|
+
@diff_scope = diff_scope
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# In diff mode, the changed lines (sorted) that a mutant touches; empty
|
|
116
|
+
# outside diff mode, or when the mutant touches none (a whole-body mutant
|
|
117
|
+
# of a method whose only change is its signature).
|
|
118
|
+
def changed_lines_for(result)
|
|
119
|
+
@diff_scope ? @diff_scope.changed_lines_for(result.subject, result.mutation) : []
|
|
102
120
|
end
|
|
103
121
|
|
|
104
122
|
def rejected_kills
|
|
105
123
|
results.count(&:rejected_kill?)
|
|
106
124
|
end
|
|
107
125
|
|
|
126
|
+
# Errored mutants (a crash, a neutral check that failed) are outside
|
|
127
|
+
# the score, so a run whose executed mutants mostly errored tested
|
|
128
|
+
# almost nothing. Skipped mutants never ran: they are neither the
|
|
129
|
+
# numerator nor the denominator.
|
|
130
|
+
def majority_errored?
|
|
131
|
+
return false if executed.empty?
|
|
132
|
+
|
|
133
|
+
executed.count { |result| result.status == :error }.fdiv(executed.size) > MAX_ERROR_FRACTION
|
|
134
|
+
end
|
|
135
|
+
|
|
108
136
|
def review?
|
|
109
137
|
@review
|
|
110
138
|
end
|
|
@@ -115,10 +143,23 @@ module Saboteur
|
|
|
115
143
|
|
|
116
144
|
# (killed + timeout) / (killed + survived + timeout); nil with nothing tested.
|
|
117
145
|
def score
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
146
|
+
score_breakdown.value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The score with its split into assertion kills and crash kills.
|
|
150
|
+
def score_breakdown
|
|
151
|
+
@score_breakdown ||= Score.of(results)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The same breakdown without the whole-body mutants (every
|
|
155
|
+
# mutation marked coarse: body_nil, body_true, body_false, body_raise). Almost any test that runs the method kills
|
|
156
|
+
# those, so they flatter the score.
|
|
157
|
+
def fine_score_breakdown
|
|
158
|
+
@fine_score_breakdown ||= Score.of(results.reject { |result| result.mutation.coarse })
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def coarse_score_breakdown
|
|
162
|
+
@coarse_score_breakdown ||= Score.of(results.select { |result| result.mutation.coarse })
|
|
122
163
|
end
|
|
123
164
|
|
|
124
165
|
def survivors
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# The mutation score of a group of results, with the share of it that came
|
|
6
|
+
# from failed assertions and the share that came from crashes.
|
|
7
|
+
#
|
|
8
|
+
# value is (killed + timeout) / (killed + survived + timeout). A crash kill
|
|
9
|
+
# shows the code ran and broke, not that a test checked a value, so the
|
|
10
|
+
# split lets a reader see how much of the score is checked behavior.
|
|
11
|
+
# Errors, resource limits and skips are outside every number here.
|
|
12
|
+
class Score
|
|
13
|
+
def self.of(results)
|
|
14
|
+
new(results)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :assertion_kills, :crash_kills, :unclassified_kills, :timeouts, :survivors
|
|
18
|
+
|
|
19
|
+
def initialize(results)
|
|
20
|
+
killed = results.select { |result| result.status == :killed }
|
|
21
|
+
@assertion_kills = killed.count(&:assertion_kill?)
|
|
22
|
+
@crash_kills = killed.count(&:crash_kill?)
|
|
23
|
+
@unclassified_kills = killed.size - assertion_kills - crash_kills
|
|
24
|
+
@timeouts = results.count { |result| result.status == :timeout }
|
|
25
|
+
@survivors = results.count(&:survived?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def detected
|
|
29
|
+
assertion_kills + crash_kills + unclassified_kills + timeouts
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tested
|
|
33
|
+
detected + survivors
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# nil with nothing tested.
|
|
37
|
+
def value
|
|
38
|
+
share(detected)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def assertion_share
|
|
42
|
+
share(assertion_kills)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def crash_share
|
|
46
|
+
share(crash_kills)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unclassified_share
|
|
50
|
+
share(unclassified_kills)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def timeout_share
|
|
54
|
+
share(timeouts)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# False when no kill has a recorded cause: a split would then say
|
|
58
|
+
# nothing. A cause is missing for verdicts reused from a run made before
|
|
59
|
+
# causes were recorded, for a test that called exit, and for a failure
|
|
60
|
+
# outside any example (suite level).
|
|
61
|
+
def split_known?
|
|
62
|
+
assertion_kills.positive? || crash_kills.positive?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# [[label, share]]: assertion and crash always, then timeout and
|
|
66
|
+
# "cause not recorded" only when there are some, so the parts add up to
|
|
67
|
+
# value.
|
|
68
|
+
def parts
|
|
69
|
+
list = [["assertion", assertion_share], ["crash", crash_share]]
|
|
70
|
+
list << ["timeout", timeout_share] if timeouts.positive?
|
|
71
|
+
list << ["cause not recorded", unclassified_share] if unclassified_kills.positive?
|
|
72
|
+
list
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{
|
|
77
|
+
score: value, tested: tested, assertionKills: assertion_kills, crashKills: crash_kills,
|
|
78
|
+
unclassifiedKills: unclassified_kills, timeouts: timeouts, survivors: survivors,
|
|
79
|
+
assertionShare: assertion_share, crashShare: crash_share
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def share(count)
|
|
86
|
+
tested.zero? ? nil : count.fdiv(tested)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/saboteur/version.rb
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
# The state of the working source a run looked at: the commit it was on and
|
|
8
|
+
# (the full SHA) and whether files differed from it, tracked changes and each
|
|
9
|
+
# untracked file alike.
|
|
10
|
+
# Saboteur's own output (cache and reports) is passed in as ignored paths, so a
|
|
11
|
+
# report from an earlier run does not make the tree look dirty. Without git, or
|
|
12
|
+
# outside a repository, the state is unknown.
|
|
13
|
+
class WorkingTree
|
|
14
|
+
State = Data.define(:head, :dirty, :changed_files) do
|
|
15
|
+
def label
|
|
16
|
+
return "unknown" if dirty.nil?
|
|
17
|
+
|
|
18
|
+
dirty ? "dirty" : "clean"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
UNKNOWN = State.new(head: nil, dirty: nil, changed_files: nil)
|
|
23
|
+
|
|
24
|
+
# ignored: paths relative to root that are not source (saboteur's cache and reports).
|
|
25
|
+
def initialize(root, ignored: [])
|
|
26
|
+
@root = root
|
|
27
|
+
@ignored = ignored
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def state
|
|
31
|
+
head, head_status = Open3.capture2e("git", "rev-parse", "HEAD", chdir: @root)
|
|
32
|
+
changes, changes_status = Open3.capture2e(*status_command, chdir: @root)
|
|
33
|
+
return UNKNOWN unless head_status.success? && changes_status.success?
|
|
34
|
+
|
|
35
|
+
count = changes.lines.size
|
|
36
|
+
State.new(head: head.strip, dirty: count.positive?, changed_files: count)
|
|
37
|
+
rescue SystemCallError
|
|
38
|
+
UNKNOWN
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def status_command
|
|
44
|
+
excluded = @ignored.map { |path| ":(exclude)#{path}" }
|
|
45
|
+
["git", "status", "--porcelain", "--untracked-files=all", "--", ".", *excluded]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/saboteur.rb
CHANGED
|
@@ -43,11 +43,15 @@ require_relative "saboteur/selection/coverage_map"
|
|
|
43
43
|
require_relative "saboteur/selection/recorder"
|
|
44
44
|
require_relative "saboteur/selection/git_diff"
|
|
45
45
|
require_relative "saboteur/result"
|
|
46
|
+
require_relative "saboteur/custom_map"
|
|
46
47
|
require_relative "saboteur/config"
|
|
47
48
|
require_relative "saboteur/ignore_list"
|
|
48
49
|
require_relative "saboteur/frameworks/rails"
|
|
49
50
|
require_relative "saboteur/reporter/diff"
|
|
50
51
|
require_relative "saboteur/reporter/findings"
|
|
52
|
+
require_relative "saboteur/reporter/agent"
|
|
53
|
+
require_relative "saboteur/working_tree"
|
|
54
|
+
require_relative "saboteur/agent_skill"
|
|
51
55
|
require_relative "saboteur/reporter/terminal"
|
|
52
56
|
require_relative "saboteur/reporter/stryker"
|
|
53
57
|
require_relative "saboteur/reporter/html"
|