saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -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 +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- 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 +62 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -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 +7 -5
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- 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 +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- 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_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- 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 +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- 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 +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -6
- 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 +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +46 -2
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../score"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Reporter
|
|
8
|
+
# One row per source file, worst score first, with survivor counts, and a
|
|
9
|
+
# one-line legend for the statuses in the summary.
|
|
10
|
+
class ScoreTable
|
|
11
|
+
MAX_ROWS = 15
|
|
12
|
+
LEGEND = ["killed: a test failed survived: every selected test passed",
|
|
13
|
+
"timeout: exceeded its deadline",
|
|
14
|
+
"error, resource limit, skipped and the other statuses: not scored"].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(out, palette)
|
|
17
|
+
@out = out
|
|
18
|
+
@palette = palette
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(report)
|
|
22
|
+
rows = rows(report)
|
|
23
|
+
return if rows.size < 2
|
|
24
|
+
|
|
25
|
+
@out.puts "\nScore by file (worst first):"
|
|
26
|
+
width = rows.first(MAX_ROWS).map { |row| row[:path].length }.max
|
|
27
|
+
rows.first(MAX_ROWS).each { |row| @out.puts line(row, width) }
|
|
28
|
+
@out.puts " ... #{rows.size - MAX_ROWS} more files" if rows.size > MAX_ROWS
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def legend
|
|
32
|
+
LEGEND.each_with_index.map do |text, index|
|
|
33
|
+
@palette.paint(:dim, "#{index.zero? ? 'legend: ' : ' '}#{text}")
|
|
34
|
+
end.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def rows(report)
|
|
40
|
+
by_file = report.results.group_by { |result| result.subject.file }
|
|
41
|
+
rows = by_file.map { |file, results| row(report, file, results) }
|
|
42
|
+
rows.reject { |row| row[:tested].zero? }.sort_by { |row| [row[:value], -row[:survivors], row[:path]] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def row(report, file, results)
|
|
46
|
+
breakdown = Score.of(results)
|
|
47
|
+
{ path: file.delete_prefix("#{report.root}/"), value: breakdown.value,
|
|
48
|
+
survivors: results.count(&:unignored_survivor?), tested: breakdown.tested }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def line(row, width)
|
|
52
|
+
score = format("%5.1f %%", row[:value] * 100)
|
|
53
|
+
" #{row[:path].ljust(width)} #{score} #{row[:survivors]} survivor#{'s' unless row[:survivors] == 1}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -35,20 +35,32 @@ module Saboteur
|
|
|
35
35
|
files: files,
|
|
36
36
|
testFiles: test_files(test_ids),
|
|
37
37
|
skippedSubjects: skipped_subjects(report),
|
|
38
|
+
earlyStop: early_stop(report),
|
|
38
39
|
rejectedKills: report.rejected_kills,
|
|
39
40
|
warnings: report.warnings,
|
|
40
41
|
framework: { name: "saboteur", version: VERSION },
|
|
41
|
-
saboteur: { findings: Findings.build(report), scores: scores(report)
|
|
42
|
-
|
|
42
|
+
saboteur: { findings: Findings.build(report), scores: scores(report),
|
|
43
|
+
inlineSuppressions: inline_audit(report) }
|
|
44
|
+
.merge(operatorPolicy: report.operator_policy).merge(exit_cause ? { exitCause: exit_cause } : {})
|
|
43
45
|
}.merge(report.seed ? { config: { seed: report.seed } } : {})
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
def self.inline_audit(report)
|
|
49
|
+
report.inline_suppressions.map { |entry| entry.merge(file: relative(entry[:file], report.root)) }
|
|
50
|
+
end
|
|
51
|
+
|
|
46
52
|
# Score with its assertion and crash shares, for all mutants and without
|
|
47
53
|
# the whole-body ones (body_nil, body_true, body_false, body_raise).
|
|
48
54
|
def self.scores(report)
|
|
49
55
|
{ all: report.score_breakdown.to_h, withoutWholeBodyMutants: report.fine_score_breakdown.to_h }
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
# nil for a run that covered its whole selection.
|
|
59
|
+
def self.early_stop(report)
|
|
60
|
+
stop = report.early_stop
|
|
61
|
+
stop && { reason: stop.reason, unreachedSubjects: stop.unreached }
|
|
62
|
+
end
|
|
63
|
+
|
|
52
64
|
def self.skipped_subjects(report)
|
|
53
65
|
report.skipped_subjects.map do |subject, reason|
|
|
54
66
|
{ subject: subject.id, file: relative(subject.file, report.root), line: subject.start_line, reason: reason }
|
|
@@ -101,7 +113,10 @@ module Saboteur
|
|
|
101
113
|
when :survived then "Survived"
|
|
102
114
|
when :timeout then "Timeout"
|
|
103
115
|
when :error, :resource_limit then "RuntimeError"
|
|
104
|
-
when :skipped then result.reason
|
|
116
|
+
when :skipped then result.reason&.start_with?("no coverage") ? "NoCoverage" : "Ignored"
|
|
117
|
+
# The schema has no status for "ran, but no test can observe a
|
|
118
|
+
# change here". Ignored with the reason is the honest mapping.
|
|
119
|
+
when :ran_at_load, :unreachable then "Ignored"
|
|
105
120
|
end
|
|
106
121
|
end
|
|
107
122
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Suggests a check that might kill a surviving mutant, by operator. It does
|
|
7
|
+
# not say what is missing: the survivor can also be an equivalent mutant.
|
|
8
|
+
module SurvivorHints
|
|
9
|
+
BY_OPERATOR = {
|
|
10
|
+
"comparison" => "try an example at the boundary: equal values, or values one apart",
|
|
11
|
+
"arithmetic" => "try asserting the exact computed number",
|
|
12
|
+
"boolean_logic" => "try an example where each side of the condition alone decides the result",
|
|
13
|
+
"negation_removal" => "try an example where the negated value changes the result",
|
|
14
|
+
"literal_number" => "try asserting something that depends on this number",
|
|
15
|
+
"literal_boolean" => "try an example that depends on this true/false value",
|
|
16
|
+
"literal_string" => "try asserting on this text",
|
|
17
|
+
"statement_removal" => "try asserting the effect of this statement",
|
|
18
|
+
"or_assign" => "try an example where the variable is already set (||= vs =)",
|
|
19
|
+
"safe_navigation" => "try an example where the receiver is nil",
|
|
20
|
+
"argument_removal" => "try have_received(...).with(...), or assert a result that depends on this argument",
|
|
21
|
+
"pair_removal" => "try have_received(...).with(...), or assert the hash content under this key",
|
|
22
|
+
"endpoint_replacement" => "try a collection with 2 or more elements, to tell first from last",
|
|
23
|
+
"filter_replacement" => "try items that match and items that do not, to tell select from reject"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
COARSE = "try asserting this method's result or its effect"
|
|
27
|
+
|
|
28
|
+
# Printed once after the survivors: a hint is a place to look, not a diagnosis.
|
|
29
|
+
CAVEAT = "Hints are suggestions, not diagnoses: a survivor can also be an equivalent mutant, or a change " \
|
|
30
|
+
"the selected tests cannot observe."
|
|
31
|
+
|
|
32
|
+
# Comparison operator swaps where the two operators give the same answer
|
|
33
|
+
# except in one named case, so a test that hits that case can tell them
|
|
34
|
+
# apart. Anything else gets the plain text hint.
|
|
35
|
+
EQUAL_CASE = [%w[< <=], %w[> >=]].map(&:to_set).freeze
|
|
36
|
+
UNEQUAL_CASE = [%w[< >], %w[<= >=]].map(&:to_set).freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def for(operator, coarse: false)
|
|
41
|
+
BY_OPERATOR.fetch(operator) { coarse ? COARSE : nil }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The hint as data for `--agent`: { kind:, text: } and, for a comparison
|
|
45
|
+
# swap, case: the relation between the two operands that separates the
|
|
46
|
+
# original from the mutant ("left == right"). It is where to look, not a
|
|
47
|
+
# promise that a test there will kill the mutant. nil when there is none.
|
|
48
|
+
def structured(operator:, original:, replacement:, coarse: false)
|
|
49
|
+
text = self.for(operator, coarse: coarse)
|
|
50
|
+
return unless text
|
|
51
|
+
|
|
52
|
+
operands = boundary_case(operator, original, replacement)
|
|
53
|
+
return { kind: "boundary", case: operands, text: text } if operands
|
|
54
|
+
|
|
55
|
+
{ kind: coarse ? "method_result" : "assertion", text: text }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def boundary_case(operator, original, replacement)
|
|
59
|
+
return unless operator == "comparison"
|
|
60
|
+
|
|
61
|
+
pair = Set[original, replacement]
|
|
62
|
+
return "left == right" if EQUAL_CASE.include?(pair)
|
|
63
|
+
|
|
64
|
+
"left != right" if UNEQUAL_CASE.include?(pair)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "mutation_text"
|
|
5
|
+
require_relative "survivor_hints"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module Reporter
|
|
9
|
+
# Survivors grouped by file, then method, then line. The source line and
|
|
10
|
+
# the tests that covered it are printed once; each mutation is one compact
|
|
11
|
+
# line: operator, original -> replacement, digest.
|
|
12
|
+
class SurvivorList
|
|
13
|
+
MAX_PASSING_TESTS = 5
|
|
14
|
+
|
|
15
|
+
def initialize(out, palette)
|
|
16
|
+
@out = out
|
|
17
|
+
@palette = palette
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Every survivor, in report order; the grouping keeps them readable.
|
|
21
|
+
def call(report, survivors)
|
|
22
|
+
return if survivors.empty?
|
|
23
|
+
|
|
24
|
+
@out.puts "Survivors (#{survivors.size}):"
|
|
25
|
+
survivors.group_by { |result| result.subject.file }.sort_by { |file, _| file }.each do |file, in_file|
|
|
26
|
+
file_section(report, relative(file, report.root), in_file)
|
|
27
|
+
end
|
|
28
|
+
@out.puts "\n#{@palette.paint(:dim, SurvivorHints::CAVEAT)}" if survivors.any? { |result| hint_for(result) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def file_section(report, path, in_file)
|
|
34
|
+
@out.puts "\n#{@palette.paint(:bold, path)}"
|
|
35
|
+
in_file.group_by { |result| result.subject.id }.each do |subject_id, in_subject|
|
|
36
|
+
@out.puts " #{subject_id}"
|
|
37
|
+
lines(report, path, in_subject)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def lines(report, path, in_subject)
|
|
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
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Mutants on one line share the printed evidence only when it is the
|
|
51
|
+
# same for all of them: covering tests (in order), reuse and changed
|
|
52
|
+
# lines can each differ per mutant, so each run of neighbours with the
|
|
53
|
+
# same tuple is one group and shares evidence above its own mutations.
|
|
54
|
+
# Evidence that repeats after another group is printed again, which
|
|
55
|
+
# keeps the ranked order.
|
|
56
|
+
def evidence_groups(report, on_line)
|
|
57
|
+
on_line.chunk { |result| [result.covered_by, result.reused?, report.changed_lines_for(result)] }
|
|
58
|
+
.map(&:last)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def evidence_group(report, path, group)
|
|
62
|
+
first = group.first
|
|
63
|
+
changed_lines(report, path, first)
|
|
64
|
+
passing_tests(first)
|
|
65
|
+
group.each { |result| mutation(result) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def source_line(result, line)
|
|
69
|
+
result.subject.file_source.lines[line - 1].to_s.strip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mutation(result)
|
|
73
|
+
text = " #{@palette.paint(:red, '-')} #{result.mutation.operator} " \
|
|
74
|
+
"#{MutationText.original(result)} -> #{@palette.paint(:green, MutationText.replacement(result))} " \
|
|
75
|
+
"#{result.digest}"
|
|
76
|
+
text += " (ignored: #{result.ignored})" if result.ignored
|
|
77
|
+
@out.puts text
|
|
78
|
+
hint(result)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def hint(result)
|
|
82
|
+
text = hint_for(result)
|
|
83
|
+
@out.puts " #{@palette.paint(:cyan, 'hint:')} #{text}" if text
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def hint_for(result)
|
|
87
|
+
return nil if result.ignored
|
|
88
|
+
|
|
89
|
+
SurvivorHints.for(result.mutation.operator, coarse: result.mutation.coarse)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The tests that ran with the mutant applied and still passed: the
|
|
93
|
+
# missing assertion belongs in one of them. They are listed likeliest
|
|
94
|
+
# killers first, the order the run used. Line coverage can overstate which
|
|
95
|
+
# tests exercise the line, so the list is a lead, not proof.
|
|
96
|
+
def passing_tests(result)
|
|
97
|
+
tests = result.covered_by
|
|
98
|
+
return if tests.empty?
|
|
99
|
+
|
|
100
|
+
@out.puts " #{tests.size} #{tests.one? ? 'test' : 'tests'} covered this line and " \
|
|
101
|
+
"#{passed_phrase(result)} (the missing check may belong in one of them):"
|
|
102
|
+
tests.first(MAX_PASSING_TESTS).each { |name| @out.puts " #{name}" }
|
|
103
|
+
more = tests.size - MAX_PASSING_TESTS
|
|
104
|
+
@out.puts " ... #{more} more in the JSON report (coveredBy)" if more.positive?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def passed_phrase(result)
|
|
108
|
+
result.reused? ? "passed in an earlier run (--incremental)" : "still passed"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# In diff mode: the changed lines this mutant touches.
|
|
112
|
+
def changed_lines(report, path, result)
|
|
113
|
+
lines = report.changed_lines_for(result)
|
|
114
|
+
return if lines.empty?
|
|
115
|
+
|
|
116
|
+
shown = lines.first(3).map { |line| "#{path}:#{line}" }.join(", ")
|
|
117
|
+
more = lines.size - 3
|
|
118
|
+
@out.puts " changed #{lines.one? ? 'line' : 'lines'}: #{shown}#{" (+#{more} more)" if more.positive?}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def relative(file, root)
|
|
122
|
+
file.delete_prefix("#{root}/")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Review priority, not a claim that a survivor is a real defect.
|
|
7
|
+
module SurvivorRanking
|
|
8
|
+
ORDERING = "coarse first, then more covering tests, larger edited-line span, " \
|
|
9
|
+
"nearer changed lines, then file, line and digest"
|
|
10
|
+
|
|
11
|
+
def self.ordered(report, results)
|
|
12
|
+
results.sort_by { |result| key(report, result) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.key(report, result)
|
|
16
|
+
[result.mutation.coarse ? 0 : 1, -result.covered_by.uniq.size, -edited_lines(result).size,
|
|
17
|
+
report.changed_line_distance(result) || Float::INFINITY,
|
|
18
|
+
result.subject.file.delete_prefix("#{report.root}/"), first_line(result), result.digest]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.first_line(result)
|
|
22
|
+
result.subject.line_of(result.mutation.edits.map(&:start_byte).min)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.edited_lines(result)
|
|
26
|
+
result.mutation.edits.flat_map do |edit|
|
|
27
|
+
first = result.subject.line_of(edit.start_byte)
|
|
28
|
+
last = result.subject.line_of([edit.end_byte - 1, edit.start_byte].max)
|
|
29
|
+
(first..last).to_a
|
|
30
|
+
end.uniq
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -1,35 +1,101 @@
|
|
|
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
|
|
10
|
-
MAX_PASSING_TESTS =
|
|
11
|
-
MAX_CHANGED_LINES = 3
|
|
18
|
+
MAX_PASSING_TESTS = SurvivorList::MAX_PASSING_TESTS
|
|
12
19
|
|
|
13
|
-
def initialize(out)
|
|
20
|
+
def initialize(out, verbose: false, color: :auto)
|
|
14
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)
|
|
15
26
|
end
|
|
16
27
|
|
|
17
28
|
def call(report)
|
|
18
|
-
|
|
29
|
+
survivors(report)
|
|
30
|
+
errors(report)
|
|
19
31
|
skipped_subjects(report)
|
|
32
|
+
outside_the_score(report)
|
|
33
|
+
inline_suppressions(report)
|
|
20
34
|
warnings(report)
|
|
21
|
-
|
|
22
|
-
|
|
35
|
+
early_stop(report)
|
|
36
|
+
test_matrix(report)
|
|
37
|
+
operator_policy(report)
|
|
38
|
+
summary(report)
|
|
23
39
|
end
|
|
24
40
|
|
|
25
41
|
private
|
|
26
42
|
|
|
43
|
+
def operator_policy(report)
|
|
44
|
+
policy = report.operator_policy
|
|
45
|
+
return unless policy && policy["excluded"].any?
|
|
46
|
+
|
|
47
|
+
@out.puts("Excluded operators: #{policy['excluded'].join(', ')} " \
|
|
48
|
+
"(policy #{policy['version']}; #{policy['effective'].size} enabled)")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_matrix(report)
|
|
52
|
+
return unless report.matrix?
|
|
53
|
+
|
|
54
|
+
matrix = TestMatrix.new(report.results)
|
|
55
|
+
@out.puts "test matrix: #{matrix.tests.size} tests ran against mutants"
|
|
56
|
+
incomplete_matrix_note(matrix)
|
|
57
|
+
matrix_list("blind (covered mutants, killed none)", matrix.blind) do |test|
|
|
58
|
+
"#{matrix.mutants_covered(test)} covered"
|
|
59
|
+
end
|
|
60
|
+
matrix_list("redundant (every kill had another killer)", matrix.redundant) do |test|
|
|
61
|
+
"#{matrix.mutants_killed(test)} kills"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def incomplete_matrix_note(matrix)
|
|
66
|
+
return if matrix.incomplete.zero?
|
|
67
|
+
|
|
68
|
+
@out.puts " #{matrix.incomplete} mutants stopped before every covering test ran (incomplete): " \
|
|
69
|
+
"their tests get no blind or redundant verdict"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def matrix_list(title, tests)
|
|
73
|
+
@out.puts " #{title}: #{tests.size}"
|
|
74
|
+
tests.first(TestMatrix::MAX_LISTED).each { |test| @out.puts " #{test} #{yield(test)}" }
|
|
75
|
+
more = tests.size - TestMatrix::MAX_LISTED
|
|
76
|
+
@out.puts " ... #{more} more" if more.positive?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inline_suppressions(report)
|
|
80
|
+
return if report.inline_suppressions.empty?
|
|
81
|
+
|
|
82
|
+
@out.puts("Inline suppressions: #{report.inline_suppressions.size} (outside the score)")
|
|
83
|
+
report.inline_suppressions.each do |entry|
|
|
84
|
+
path = entry[:file].delete_prefix("#{report.root}/")
|
|
85
|
+
@out.puts(" #{path}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
|
|
86
|
+
"#{entry[:digest]}: #{entry[:reason]}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
27
90
|
def summary(report)
|
|
28
91
|
seed = report.seed && ", seed #{report.seed} (repeat with --seed #{report.seed})"
|
|
29
92
|
@out.puts "saboteur #{VERSION}: #{report.subjects.size} subjects, #{report.results.size} mutants, " \
|
|
30
93
|
"wall #{format('%.2f', report.wall_time)} s#{seed}"
|
|
31
94
|
@out.puts status_line(report)
|
|
32
95
|
details(report)
|
|
96
|
+
@score_table.call(report)
|
|
97
|
+
@out.puts @score_table.legend
|
|
98
|
+
@next_steps.call(report)
|
|
33
99
|
end
|
|
34
100
|
|
|
35
101
|
def status_line(report)
|
|
@@ -37,23 +103,43 @@ module Saboteur
|
|
|
37
103
|
survived = "survived #{c[:survived]}"
|
|
38
104
|
ignored = report.survivors.size - report.unignored_survivors.size
|
|
39
105
|
survived += " (#{ignored} ignored)" if ignored.positive?
|
|
40
|
-
|
|
41
|
-
|
|
106
|
+
line = "#{@palette.paint(:green,
|
|
107
|
+
"killed #{c[:killed]}")} #{@palette.paint(:red,
|
|
108
|
+
survived)} timeout #{c[:timeout]} " \
|
|
109
|
+
"resource limit #{c[:resource_limit]} error #{c[:error]} skipped #{c[:skipped]}"
|
|
110
|
+
line += " ran at load #{c[:ran_at_load]}" if c[:ran_at_load].positive?
|
|
111
|
+
line += " unreachable #{c[:unreachable]}" if c[:unreachable].positive?
|
|
112
|
+
line
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def hazard_suppressions(report)
|
|
116
|
+
return unless report.hazard_suppressions.positive?
|
|
117
|
+
|
|
118
|
+
@out.puts "hazardous mutations suppressed: #{report.hazard_suppressions} " \
|
|
119
|
+
"(--allow-hazardous runs them anyway; the canary still watches the tree)"
|
|
42
120
|
end
|
|
43
121
|
|
|
44
122
|
def details(report)
|
|
45
123
|
breakdown = report.score_breakdown
|
|
46
|
-
@out.puts "score #{percent(breakdown.value)}
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
|
|
52
|
-
report.invalid_mutations.positive?
|
|
124
|
+
@out.puts "score #{percent(breakdown.value)}" \
|
|
125
|
+
"#{if @verbose
|
|
126
|
+
"#{score_split(breakdown)} (killed+timeout)/(killed+survived+timeout); " \
|
|
127
|
+
'errors, resource limits and skips excluded'
|
|
128
|
+
end}"
|
|
53
129
|
report.skip_reasons.each { |reason, count| @out.puts "skipped: #{reason}: #{count}" }
|
|
54
130
|
reused = report.results.count(&:reused?)
|
|
55
131
|
@out.puts "reused #{reused} of #{report.results.size} verdicts from earlier runs (--incremental)" if
|
|
56
132
|
reused.positive?
|
|
133
|
+
@out.puts "invalid mutations dropped (did not parse): #{report.invalid_mutations}" if
|
|
134
|
+
report.invalid_mutations.positive?
|
|
135
|
+
hazard_suppressions(report)
|
|
136
|
+
debug_details(report) if @verbose
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def debug_details(report)
|
|
140
|
+
whole_body_score(report)
|
|
141
|
+
mean = report.mean_runtime
|
|
142
|
+
@out.puts "mean time per executed mutant #{format('%.3f', mean)} s" if mean
|
|
57
143
|
@out.puts "kills rejected by unchanged-method check: #{report.rejected_kills}"
|
|
58
144
|
kill_causes(report)
|
|
59
145
|
end
|
|
@@ -119,6 +205,37 @@ module Saboteur
|
|
|
119
205
|
end
|
|
120
206
|
end
|
|
121
207
|
|
|
208
|
+
# Mutants no test can judge, listed apart so they are not read as
|
|
209
|
+
# misses: code that only ran while the app loaded, and methods a
|
|
210
|
+
# captured Method object still calls in their original form.
|
|
211
|
+
def outside_the_score(report)
|
|
212
|
+
{ ran_at_load: "Ran at load (out of the score):",
|
|
213
|
+
unreachable: "Unreachable (out of the score):" }.each do |status, heading|
|
|
214
|
+
results = report.results.select { |result| result.status == status }
|
|
215
|
+
next if results.empty?
|
|
216
|
+
|
|
217
|
+
@out.puts heading
|
|
218
|
+
results.each do |result|
|
|
219
|
+
location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
|
|
220
|
+
@out.puts " #{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
|
|
221
|
+
end
|
|
222
|
+
@out.puts " hint: #{results.first.reason}" if results.first.reason
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The verdict covers only the subjects that ran, so say it where the
|
|
227
|
+
# score is read.
|
|
228
|
+
def early_stop(report)
|
|
229
|
+
stop = report.early_stop
|
|
230
|
+
return unless stop
|
|
231
|
+
|
|
232
|
+
@out.puts @palette.paint(:yellow, "stopped early (--fail-fast): #{stop.unreached.size} selected " \
|
|
233
|
+
"subject#{'s' unless stop.unreached.one?} did not run; the score below " \
|
|
234
|
+
"covers only the subjects that ran")
|
|
235
|
+
stop.unreached.first(MAX_ERRORS).each { |id| @out.puts " not run: #{id}" }
|
|
236
|
+
@out.puts " ... #{stop.unreached.size - MAX_ERRORS} more" if stop.unreached.size > MAX_ERRORS
|
|
237
|
+
end
|
|
238
|
+
|
|
122
239
|
def warnings(report)
|
|
123
240
|
report.warnings.each { |warning| @out.puts "warning: #{warning}" }
|
|
124
241
|
end
|
|
@@ -136,70 +253,14 @@ module Saboteur
|
|
|
136
253
|
end
|
|
137
254
|
|
|
138
255
|
def survivors(report)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@out.puts "\nSurvivors:"
|
|
142
|
-
shown(report).each do |result, hidden|
|
|
143
|
-
location = "#{relative(result.subject.file, report.root)}:#{result.subject.start_line}"
|
|
144
|
-
@out.puts "\n#{result.subject.id} #{result.mutation.operator} #{result.digest} #{location}"
|
|
145
|
-
changed_lines(report, result)
|
|
146
|
-
@out.puts "ignored: #{result.ignored}" if result.ignored
|
|
147
|
-
@out.puts result.diff(root: report.root)
|
|
148
|
-
passing_tests(result)
|
|
149
|
-
@out.puts "#{hidden} more survivor#{'s' if hidden > 1} on this line in the JSON report" if hidden.positive?
|
|
150
|
-
end
|
|
151
|
-
end
|
|
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
|
-
|
|
186
|
-
# [[result, how many more on the same line]]. In review mode survivors
|
|
187
|
-
# on one line usually share a fate, so one is shown: live before
|
|
188
|
-
# ignored, then coarse before fine, then the earliest edit.
|
|
189
|
-
def shown(report)
|
|
190
|
-
return report.survivors.map { |result| [result, 0] } unless report.review?
|
|
191
|
-
|
|
192
|
-
lines = report.survivors.group_by { |result| [result.subject.file, edit_line(result)] }
|
|
193
|
-
lines.values.map do |same_line|
|
|
194
|
-
best = same_line.min_by do |result|
|
|
195
|
-
[result.ignored ? 1 : 0, result.mutation.coarse ? 0 : 1, result.mutation.edits.first.start_byte]
|
|
196
|
-
end
|
|
197
|
-
[best, same_line.size - 1]
|
|
198
|
-
end
|
|
256
|
+
SurvivorList.new(@out, @palette).call(report, ranked_survivors(report))
|
|
257
|
+
@out.puts unless report.survivors.empty?
|
|
199
258
|
end
|
|
200
259
|
|
|
201
|
-
|
|
202
|
-
|
|
260
|
+
# Live survivors precede accepted ones; each group follows SurvivorRanking.
|
|
261
|
+
def ranked_survivors(report)
|
|
262
|
+
live, accepted = report.survivors.partition(&:unignored_survivor?)
|
|
263
|
+
[live, accepted].flat_map { |group| SurvivorRanking.ordered(report, group) }
|
|
203
264
|
end
|
|
204
265
|
|
|
205
266
|
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
|