saboteur 0.3.0 → 0.5.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 +280 -1
- data/README.md +635 -77
- data/exe/saboteur +6 -2
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +46 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +105 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +114 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +171 -63
- data/lib/saboteur/config.rb +101 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +57 -9
- data/lib/saboteur/engine/baseline_guard.rb +44 -5
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +59 -7
- data/lib/saboteur/engine/coverage_store.rb +53 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +176 -2
- data/lib/saboteur/engine/diff_scope.rb +18 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +40 -10
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +99 -36
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/test_guess.rb +52 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +257 -22
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +79 -14
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +113 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +63 -23
- data/lib/saboteur/ignore_list.rb +76 -17
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +68 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +30 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +57 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +106 -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 +67 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +60 -0
- data/lib/saboteur/reporter/agent_summary.rb +137 -0
- data/lib/saboteur/reporter/findings.rb +12 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_record.rb +132 -0
- data/lib/saboteur/reporter/html.rb +61 -2
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +123 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +25 -5
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +157 -42
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +86 -10
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +56 -0
- data/lib/saboteur.rb +10 -0
- metadata +58 -2
|
@@ -1,33 +1,92 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "next_steps"
|
|
5
|
+
require_relative "survivor_ranking"
|
|
6
|
+
require_relative "palette"
|
|
7
|
+
require_relative "score_table"
|
|
8
|
+
require_relative "survivor_list"
|
|
9
|
+
require_relative "test_matrix"
|
|
10
|
+
|
|
4
11
|
module Saboteur
|
|
5
12
|
module Reporter
|
|
6
|
-
# Plain-text
|
|
7
|
-
# errors, then a
|
|
13
|
+
# Plain-text report: survivors first (grouped by file, method and line),
|
|
14
|
+
# then errors and notes, then a short summary last so it stays on screen.
|
|
15
|
+
# Debug statistics (score split, timings, kill causes) need verbose.
|
|
8
16
|
class Terminal
|
|
9
17
|
MAX_ERRORS = 10
|
|
18
|
+
MAX_PASSING_TESTS = SurvivorList::MAX_PASSING_TESTS
|
|
10
19
|
|
|
11
|
-
def initialize(out)
|
|
20
|
+
def initialize(out, verbose: false, color: :auto)
|
|
12
21
|
@out = out
|
|
22
|
+
@verbose = verbose
|
|
23
|
+
@palette = Palette.for(out, mode: color)
|
|
24
|
+
@score_table = ScoreTable.new(out, @palette)
|
|
25
|
+
@next_steps = NextSteps.new(out, @palette)
|
|
13
26
|
end
|
|
14
27
|
|
|
15
28
|
def call(report)
|
|
16
|
-
|
|
29
|
+
survivors(report)
|
|
30
|
+
errors(report)
|
|
17
31
|
skipped_subjects(report)
|
|
32
|
+
outside_the_score(report)
|
|
33
|
+
inline_suppressions(report)
|
|
18
34
|
warnings(report)
|
|
19
|
-
|
|
20
|
-
|
|
35
|
+
early_stop(report)
|
|
36
|
+
test_matrix(report)
|
|
37
|
+
summary(report)
|
|
21
38
|
end
|
|
22
39
|
|
|
23
40
|
private
|
|
24
41
|
|
|
42
|
+
def test_matrix(report)
|
|
43
|
+
return unless report.matrix?
|
|
44
|
+
|
|
45
|
+
matrix = TestMatrix.new(report.results)
|
|
46
|
+
@out.puts "test matrix: #{matrix.tests.size} tests ran against mutants"
|
|
47
|
+
incomplete_matrix_note(matrix)
|
|
48
|
+
matrix_list("blind (covered mutants, killed none)", matrix.blind) do |test|
|
|
49
|
+
"#{matrix.mutants_covered(test)} covered"
|
|
50
|
+
end
|
|
51
|
+
matrix_list("redundant (every kill had another killer)", matrix.redundant) do |test|
|
|
52
|
+
"#{matrix.mutants_killed(test)} kills"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def incomplete_matrix_note(matrix)
|
|
57
|
+
return if matrix.incomplete.zero?
|
|
58
|
+
|
|
59
|
+
@out.puts " #{matrix.incomplete} mutants stopped before every covering test ran (incomplete): " \
|
|
60
|
+
"their tests get no blind or redundant verdict"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def matrix_list(title, tests)
|
|
64
|
+
@out.puts " #{title}: #{tests.size}"
|
|
65
|
+
tests.first(TestMatrix::MAX_LISTED).each { |test| @out.puts " #{test} #{yield(test)}" }
|
|
66
|
+
more = tests.size - TestMatrix::MAX_LISTED
|
|
67
|
+
@out.puts " ... #{more} more" if more.positive?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inline_suppressions(report)
|
|
71
|
+
return if report.inline_suppressions.empty?
|
|
72
|
+
|
|
73
|
+
@out.puts("Inline suppressions: #{report.inline_suppressions.size} (outside the score)")
|
|
74
|
+
report.inline_suppressions.each do |entry|
|
|
75
|
+
path = entry[:file].delete_prefix("#{report.root}/")
|
|
76
|
+
@out.puts(" #{path}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
|
|
77
|
+
"#{entry[:digest]}: #{entry[:reason]}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
25
81
|
def summary(report)
|
|
26
82
|
seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
|
|
27
83
|
@out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{report.results.size} mutants, " \
|
|
28
84
|
"wall #{format('%.2f', report.wall_time)} s#{seed}"
|
|
29
85
|
@out.puts status_line(report)
|
|
30
86
|
details(report)
|
|
87
|
+
@score_table.call(report)
|
|
88
|
+
@out.puts @score_table.legend
|
|
89
|
+
@next_steps.call(report)
|
|
31
90
|
end
|
|
32
91
|
|
|
33
92
|
def status_line(report)
|
|
@@ -35,26 +94,72 @@ module Saboteur
|
|
|
35
94
|
survived = "survived #{c[:survived]}"
|
|
36
95
|
ignored = report.survivors.size - report.unignored_survivors.size
|
|
37
96
|
survived += " (#{ignored} ignored)" if ignored.positive?
|
|
38
|
-
|
|
39
|
-
|
|
97
|
+
line = "#{@palette.paint(:green,
|
|
98
|
+
"killed #{c[:killed]}")} #{@palette.paint(:red,
|
|
99
|
+
survived)} timeout #{c[:timeout]} " \
|
|
100
|
+
"resource limit #{c[:resource_limit]} error #{c[:error]} skipped #{c[:skipped]}"
|
|
101
|
+
line += " ran at load #{c[:ran_at_load]}" if c[:ran_at_load].positive?
|
|
102
|
+
line += " unreachable #{c[:unreachable]}" if c[:unreachable].positive?
|
|
103
|
+
line
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def hazard_suppressions(report)
|
|
107
|
+
return unless report.hazard_suppressions.positive?
|
|
108
|
+
|
|
109
|
+
@out.puts "hazardous mutations suppressed: #{report.hazard_suppressions} " \
|
|
110
|
+
"(--allow-hazardous runs them anyway; the canary still watches the tree)"
|
|
40
111
|
end
|
|
41
112
|
|
|
42
113
|
def details(report)
|
|
43
|
-
|
|
44
|
-
@out.puts "score #{
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
report.invalid_mutations.positive?
|
|
114
|
+
breakdown = report.score_breakdown
|
|
115
|
+
@out.puts "score #{percent(breakdown.value)}" \
|
|
116
|
+
"#{if @verbose
|
|
117
|
+
"#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
|
|
118
|
+
'errors, resource limits and skips excluded'
|
|
119
|
+
end}"
|
|
50
120
|
report.skip_reasons.each { |reason, count| @out.puts "skipped: #{reason}: #{count}" }
|
|
51
121
|
reused = report.results.count(&:reused?)
|
|
52
122
|
@out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
|
|
53
123
|
reused.positive?
|
|
124
|
+
@out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
|
|
125
|
+
report.invalid_mutations.positive?
|
|
126
|
+
hazard_suppressions(report)
|
|
127
|
+
debug_details(report) if @verbose
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def debug_details(report)
|
|
131
|
+
whole_body_score(report)
|
|
132
|
+
mean = report.mean_runtime
|
|
133
|
+
@out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
|
|
54
134
|
@out.puts "kills rejected by unchanged-method check: #{report.rejected_kills}"
|
|
55
135
|
kill_causes(report)
|
|
56
136
|
end
|
|
57
137
|
|
|
138
|
+
# " (assertion 50.0 %, crash 50.0 %)": how much of the score is a failed
|
|
139
|
+
# check and how much is a crash. Timeouts and kills with no recorded
|
|
140
|
+
# cause are named only when there are some, so the parts add up.
|
|
141
|
+
def score_split(breakdown)
|
|
142
|
+
return "" unless breakdown.split_known?
|
|
143
|
+
|
|
144
|
+
" (#{breakdown.parts.map { |label, share| "#{label} #{percent(share)}" }.join(', ')})"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Whole-body mutants (body_nil, body_true, body_false, body_raise) are
|
|
148
|
+
# killed by almost any test that runs the method, so they get their own
|
|
149
|
+
# line.
|
|
150
|
+
def whole_body_score(report)
|
|
151
|
+
whole = report.coarse_score_breakdown
|
|
152
|
+
fine = report.fine_score_breakdown
|
|
153
|
+
return if whole.tested.zero? || fine.tested.zero?
|
|
154
|
+
|
|
155
|
+
@out.puts "score without whole-body mutants #{percent(fine.value)}#{score_split(fine)} " \
|
|
156
|
+
"(#{fine.tested} mutants); whole-body mutants alone #{percent(whole.value)} (#{whole.tested})"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def percent(share)
|
|
160
|
+
share ? format("%.1f %%", share * 100) : "n/a"
|
|
161
|
+
end
|
|
162
|
+
|
|
58
163
|
# How many kills came from a failed check and how many from a crash, and
|
|
59
164
|
# which operators mostly kill by crashing (their score says less).
|
|
60
165
|
def kill_causes(report)
|
|
@@ -91,6 +196,37 @@ module Saboteur
|
|
|
91
196
|
end
|
|
92
197
|
end
|
|
93
198
|
|
|
199
|
+
# Mutants no test can judge, listed apart so they are not read as
|
|
200
|
+
# misses: code that only ran while the app loaded, and methods a
|
|
201
|
+
# captured Method object still calls in their original form.
|
|
202
|
+
def outside_the_score(report)
|
|
203
|
+
{ ran_at_load: "Ran at load (out of the score):",
|
|
204
|
+
unreachable: "Unreachable (out of the score):" }.each do |status, heading|
|
|
205
|
+
results = report.results.select { |result| result.status == status }
|
|
206
|
+
next if results.empty?
|
|
207
|
+
|
|
208
|
+
@out.puts heading
|
|
209
|
+
results.each do |result|
|
|
210
|
+
location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
|
|
211
|
+
@out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
|
|
212
|
+
end
|
|
213
|
+
@out.puts " hint: #{results.first.reason}" if results.first.reason
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The verdict covers only the subjects that ran, so say it where the
|
|
218
|
+
# score is read.
|
|
219
|
+
def early_stop(report)
|
|
220
|
+
stop = report.early_stop
|
|
221
|
+
return unless stop
|
|
222
|
+
|
|
223
|
+
@out.puts @palette.paint(:yellow, "stopped early (--fail-fast): #{stop.unreached.size} selected " \
|
|
224
|
+
"subject#{'s' unless stop.unreached.one?} did not run; the score below " \
|
|
225
|
+
"covers only the subjects that ran")
|
|
226
|
+
stop.unreached.first(MAX_ERRORS).each { |id| @out.puts " not run: #{id}" }
|
|
227
|
+
@out.puts " ... #{stop.unreached.size - MAX_ERRORS} more" if stop.unreached.size > MAX_ERRORS
|
|
228
|
+
end
|
|
229
|
+
|
|
94
230
|
def warnings(report)
|
|
95
231
|
report.warnings.each { |warning| @out.puts "warning: #{warning}" }
|
|
96
232
|
end
|
|
@@ -108,35 +244,14 @@ module Saboteur
|
|
|
108
244
|
end
|
|
109
245
|
|
|
110
246
|
def survivors(report)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
@out.puts "\nSurvivors:"
|
|
114
|
-
shown(report).each do |result, hidden|
|
|
115
|
-
location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
|
|
116
|
-
@out.puts "\n#{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
|
|
117
|
-
@out.puts "ignored: #{result.ignored}" if result.ignored
|
|
118
|
-
@out.puts result.diff(root: report.root)
|
|
119
|
-
@out.puts "#{hidden} more survivor#{'s' if hidden > 1} on this line in the JSON report" if hidden.positive?
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# [[result, how many more on the same line]]. In review mode survivors
|
|
124
|
-
# on one line usually share a fate, so one is shown: live before
|
|
125
|
-
# ignored, then coarse before fine, then the earliest edit.
|
|
126
|
-
def shown(report)
|
|
127
|
-
return report.survivors.map { |result| [result, 0] } unless report.review?
|
|
128
|
-
|
|
129
|
-
lines = report.survivors.group_by { |result| [result.subject.file, edit_line(result)] }
|
|
130
|
-
lines.values.map do |same_line|
|
|
131
|
-
best = same_line.min_by do |result|
|
|
132
|
-
[result.ignored ? 1 : 0, result.mutation.coarse ? 0 : 1, result.mutation.edits.first.start_byte]
|
|
133
|
-
end
|
|
134
|
-
[best, same_line.size - 1]
|
|
135
|
-
end
|
|
247
|
+
SurvivorList.new(@out, @palette).call(report, ranked_survivors(report))
|
|
248
|
+
@out.puts unless report.survivors.empty?
|
|
136
249
|
end
|
|
137
250
|
|
|
138
|
-
|
|
139
|
-
|
|
251
|
+
# Live survivors precede accepted ones; each group follows SurvivorRanking.
|
|
252
|
+
def ranked_survivors(report)
|
|
253
|
+
live, accepted = report.survivors.partition(&:unignored_survivor?)
|
|
254
|
+
[live, accepted].flat_map { |group| SurvivorRanking.ordered(report, group) }
|
|
140
255
|
end
|
|
141
256
|
|
|
142
257
|
def relative(file, root)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# What a --matrix run says about the tests themselves. Every covering test
|
|
7
|
+
# ran against every mutant, so each killed mutant lists all of its
|
|
8
|
+
# killers. A blind test covered mutants and killed none of them. A
|
|
9
|
+
# redundant test killed some, and every one of those had another killer.
|
|
10
|
+
#
|
|
11
|
+
# Only mutants whose every planned test is known to have run (see
|
|
12
|
+
# MutantResult#complete?) count. A run that stopped early or could not
|
|
13
|
+
# report what it ran is an incomplete mutant: the tests planned for it
|
|
14
|
+
# are unverified, and no blind or redundant verdict is given for them.
|
|
15
|
+
class TestMatrix
|
|
16
|
+
MAX_LISTED = 20
|
|
17
|
+
|
|
18
|
+
attr_reader :blind, :redundant, :tests, :incomplete
|
|
19
|
+
|
|
20
|
+
def initialize(results)
|
|
21
|
+
executed = results.select { |result| %i[killed survived].include?(result.status) }
|
|
22
|
+
complete, partial = executed.partition(&:complete?)
|
|
23
|
+
@incomplete = partial.size
|
|
24
|
+
@covered = Hash.new(0)
|
|
25
|
+
@killed = Hash.new { |hash, test| hash[test] = [] }
|
|
26
|
+
complete.each { |result| tally(result) }
|
|
27
|
+
@unverified = partial.flat_map(&:covered_by).uniq
|
|
28
|
+
@tests = @covered.keys.sort
|
|
29
|
+
judged = @tests - @unverified
|
|
30
|
+
@blind = judged.select { |test| @killed[test].empty? }
|
|
31
|
+
@redundant = judged.select { |test| redundant?(test) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mutants_covered(test)
|
|
35
|
+
@covered.fetch(test)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mutants_killed(test)
|
|
39
|
+
@killed[test].size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def tally(result)
|
|
45
|
+
key = [result.subject.id, result.digest]
|
|
46
|
+
result.covered_by.each { |test| @covered[test] += 1 }
|
|
47
|
+
killers = result.killed_by & result.covered_by
|
|
48
|
+
killers.each { |test| @killed[test] << [key, killers.size - 1] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def redundant?(test)
|
|
52
|
+
kills = @killed[test]
|
|
53
|
+
kills.any? && kills.all? { |_key, others| others.positive? }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/saboteur/result.rb
CHANGED
|
@@ -2,22 +2,27 @@
|
|
|
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.
|
|
8
9
|
# Like error it is outside the score: a mutant that eats all memory was
|
|
9
10
|
# stopped by saboteur, not detected by a test.
|
|
10
|
-
|
|
11
|
+
# ran_at_load: the mutated code only ran while the app loaded; no forked
|
|
12
|
+
# test can observe a change there, so it is outside the score.
|
|
13
|
+
# unreachable: a Method object captured before re-evaluation still calls
|
|
14
|
+
# the original body, so no test can observe the mutant. Outside the score.
|
|
15
|
+
STATUSES = %i[killed survived timeout resource_limit error skipped ran_at_load unreachable].freeze
|
|
11
16
|
|
|
12
17
|
# Outcome of one mutation. reason carries the skip reason, the error
|
|
13
18
|
# message or the timeout limit. ignored is the ignore-list reason, if any.
|
|
14
19
|
class MutantResult
|
|
15
20
|
attr_reader :subject, :mutation, :status, :reason, :runtime, :killed_by, :covered_by, :mutated_source,
|
|
16
|
-
:kill_cause
|
|
21
|
+
:kill_cause, :executed
|
|
17
22
|
attr_accessor :ignored
|
|
18
23
|
|
|
19
24
|
def initialize(subject:, mutation:, status:, mutated_source:, reason: nil, runtime: nil, killed_by: [],
|
|
20
|
-
covered_by: [], kill_cause: nil, reused: false, rejected_kill: false)
|
|
25
|
+
covered_by: [], kill_cause: nil, reused: false, rejected_kill: false, executed: nil)
|
|
21
26
|
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
22
27
|
|
|
23
28
|
@subject = subject
|
|
@@ -29,6 +34,7 @@ module Saboteur
|
|
|
29
34
|
@covered_by = covered_by
|
|
30
35
|
@mutated_source = mutated_source
|
|
31
36
|
@kill_cause = kill_cause
|
|
37
|
+
@executed = executed
|
|
32
38
|
@rejected_kill = rejected_kill
|
|
33
39
|
@reused = reused
|
|
34
40
|
@ignored = nil
|
|
@@ -38,7 +44,14 @@ module Saboteur
|
|
|
38
44
|
def as_error(reason, rejected_kill: false)
|
|
39
45
|
MutantResult.new(subject: subject, mutation: mutation, status: :error, mutated_source: mutated_source,
|
|
40
46
|
reason: reason, runtime: runtime, killed_by: killed_by, covered_by: covered_by,
|
|
41
|
-
rejected_kill: rejected_kill)
|
|
47
|
+
executed: executed, rejected_kill: rejected_kill)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True when every test planned for this mutant is known to have run.
|
|
51
|
+
# A killed run that stopped early, a test that called exit, or a verdict
|
|
52
|
+
# whose executed tests were never recorded is not complete.
|
|
53
|
+
def complete?
|
|
54
|
+
!executed.nil? && (covered_by - executed).empty?
|
|
42
55
|
end
|
|
43
56
|
|
|
44
57
|
def rejected_kill?
|
|
@@ -59,6 +72,11 @@ module Saboteur
|
|
|
59
72
|
status == :killed && !kill_cause.nil? && kill_cause != "assertion"
|
|
60
73
|
end
|
|
61
74
|
|
|
75
|
+
# Killed because a test's check failed: the test looked at a value.
|
|
76
|
+
def assertion_kill?
|
|
77
|
+
status == :killed && kill_cause == "assertion"
|
|
78
|
+
end
|
|
79
|
+
|
|
62
80
|
# Taken from an earlier run (--incremental) instead of run again.
|
|
63
81
|
def reused?
|
|
64
82
|
@reused
|
|
@@ -82,14 +100,25 @@ module Saboteur
|
|
|
82
100
|
end
|
|
83
101
|
end
|
|
84
102
|
|
|
103
|
+
# A run that stopped before every selected subject was mutated (--fail-fast).
|
|
104
|
+
# unreached lists the subject ids that never ran; the verdict covers only
|
|
105
|
+
# the subjects that did.
|
|
106
|
+
EarlyStop = Data.define(:reason, :unreached)
|
|
107
|
+
|
|
85
108
|
# Everything one run produced.
|
|
86
109
|
class Report
|
|
87
|
-
|
|
110
|
+
# More than half of the executed mutants errored: the run tested
|
|
111
|
+
# almost nothing and must not pass a gate.
|
|
112
|
+
MAX_ERROR_FRACTION = 0.5
|
|
113
|
+
|
|
114
|
+
attr_reader :results, :subjects, :wall_time, :invalid_mutations, :warnings, :root, :seed, :skipped_subjects,
|
|
115
|
+
:generation_events, :omissions, :run_evidence, :hazard_suppressions, :early_stop, :inline_suppressions
|
|
88
116
|
|
|
89
117
|
# REVIEW: diff mode; the terminal then shows one survivor per line.
|
|
90
118
|
# seed: the random seed every test run started from (nil when unknown).
|
|
91
119
|
def initialize(results:, subjects:, wall_time:, root:, invalid_mutations: 0, warnings: [], review: false,
|
|
92
|
-
seed: nil, skipped_subjects: {}
|
|
120
|
+
seed: nil, skipped_subjects: {}, diff_scope: nil, generation_events: [], omissions: [],
|
|
121
|
+
run_evidence: nil, hazard_suppressions: 0, early_stop: nil, matrix: false, inline_suppressions: [])
|
|
93
122
|
@results = results
|
|
94
123
|
@subjects = subjects
|
|
95
124
|
@wall_time = wall_time
|
|
@@ -99,12 +128,46 @@ module Saboteur
|
|
|
99
128
|
@review = review
|
|
100
129
|
@seed = seed
|
|
101
130
|
@skipped_subjects = skipped_subjects
|
|
131
|
+
@diff_scope = diff_scope
|
|
132
|
+
@generation_events = generation_events.dup.freeze
|
|
133
|
+
@omissions = omissions.dup.freeze
|
|
134
|
+
@run_evidence = run_evidence
|
|
135
|
+
@hazard_suppressions = hazard_suppressions
|
|
136
|
+
@early_stop = early_stop
|
|
137
|
+
@matrix = matrix
|
|
138
|
+
@inline_suppressions = inline_suppressions.dup.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A --matrix run: every covering test ran per mutant.
|
|
142
|
+
def matrix?
|
|
143
|
+
@matrix
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# In diff mode, the changed lines (sorted) that a mutant touches; empty
|
|
147
|
+
# outside diff mode, or when the mutant touches none (a whole-body mutant
|
|
148
|
+
# of a method whose only change is its signature).
|
|
149
|
+
def changed_lines_for(result)
|
|
150
|
+
@diff_scope ? @diff_scope.changed_lines_for(result.subject, result.mutation) : []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def changed_line_distance(result)
|
|
154
|
+
@diff_scope&.distance_for(result.subject, result.mutation)
|
|
102
155
|
end
|
|
103
156
|
|
|
104
157
|
def rejected_kills
|
|
105
158
|
results.count(&:rejected_kill?)
|
|
106
159
|
end
|
|
107
160
|
|
|
161
|
+
# Errored mutants (a crash, a neutral check that failed) are outside
|
|
162
|
+
# the score, so a run whose executed mutants mostly errored tested
|
|
163
|
+
# almost nothing. Skipped mutants never ran: they are neither the
|
|
164
|
+
# numerator nor the denominator.
|
|
165
|
+
def majority_errored?
|
|
166
|
+
return false if executed.empty?
|
|
167
|
+
|
|
168
|
+
executed.count { |result| result.status == :error }.fdiv(executed.size) > MAX_ERROR_FRACTION
|
|
169
|
+
end
|
|
170
|
+
|
|
108
171
|
def review?
|
|
109
172
|
@review
|
|
110
173
|
end
|
|
@@ -115,10 +178,23 @@ module Saboteur
|
|
|
115
178
|
|
|
116
179
|
# (killed + timeout) / (killed + survived + timeout); nil with nothing tested.
|
|
117
180
|
def score
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
181
|
+
score_breakdown.value
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The score with its split into assertion kills and crash kills.
|
|
185
|
+
def score_breakdown
|
|
186
|
+
@score_breakdown ||= Score.of(results)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The same breakdown without the whole-body mutants (every
|
|
190
|
+
# mutation marked coarse: body_nil, body_true, body_false, body_raise). Almost any test that runs the method kills
|
|
191
|
+
# those, so they flatter the score.
|
|
192
|
+
def fine_score_breakdown
|
|
193
|
+
@fine_score_breakdown ||= Score.of(results.reject { |result| result.mutation.coarse })
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def coarse_score_breakdown
|
|
197
|
+
@coarse_score_breakdown ||= Score.of(results.select { |result| result.mutation.coarse })
|
|
122
198
|
end
|
|
123
199
|
|
|
124
200
|
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
|