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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "findings"
|
|
5
|
+
require_relative "survivor_hints"
|
|
6
|
+
require_relative "agent_incomplete"
|
|
7
|
+
require_relative "agent_clipping"
|
|
8
|
+
|
|
9
|
+
module Saboteur
|
|
10
|
+
module Reporter
|
|
11
|
+
module Agent
|
|
12
|
+
# Builds the payload hash for one run: status, verdict, exit code, counts,
|
|
13
|
+
# provenance, a few findings and the next commands.
|
|
14
|
+
module Summary
|
|
15
|
+
class << self
|
|
16
|
+
include Clipping
|
|
17
|
+
|
|
18
|
+
# Public entry first; the helpers below are private.
|
|
19
|
+
def build(report, provenance, shown, snippet_limit)
|
|
20
|
+
incomplete = Incomplete.reasons(report)
|
|
21
|
+
status = status_of(report, incomplete)
|
|
22
|
+
verdict = verdict_of(report, status)
|
|
23
|
+
{
|
|
24
|
+
version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
|
|
25
|
+
provenance: provenance_hash(provenance),
|
|
26
|
+
counts: counts(report), score: score(report),
|
|
27
|
+
scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
|
|
28
|
+
incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
|
|
29
|
+
findings: findings(report, shown, snippet_limit),
|
|
30
|
+
warnings: report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] },
|
|
31
|
+
next: next_steps(report, provenance)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def status_of(report, incomplete)
|
|
38
|
+
return "not_applicable" if report.results.empty? && incomplete.empty?
|
|
39
|
+
|
|
40
|
+
incomplete.empty? ? "complete" : "partial"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def verdict_of(report, status)
|
|
44
|
+
return "survivors" if report.unignored_survivors.any?
|
|
45
|
+
|
|
46
|
+
{ "complete" => "clean", "partial" => "incomplete", "not_applicable" => "not_applicable" }.fetch(status)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def exit_code_for(verdict)
|
|
50
|
+
case verdict
|
|
51
|
+
when "clean" then EXIT_CLEAN
|
|
52
|
+
when "survivors" then EXIT_REVIEW
|
|
53
|
+
else EXIT_INCOMPLETE
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def counts(report)
|
|
58
|
+
report.counts.transform_keys { |status| camel(status) }.merge(executed: report.executed.size)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def camel(status)
|
|
62
|
+
status.to_s.gsub(/_(\w)/) { Regexp.last_match(1).upcase }.to_sym
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def score(report)
|
|
66
|
+
report.score&.round(4)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Display values are clipped; the commands under next use the exact values or are left out.
|
|
70
|
+
def provenance_hash(provenance)
|
|
71
|
+
{ ref: clip_value(provenance.ref, 120), seed: bounded_number(provenance.seed),
|
|
72
|
+
head: clip_value(provenance.head, 64),
|
|
73
|
+
workingTree: provenance.working_tree, changedFiles: provenance.changed_files,
|
|
74
|
+
mode: provenance.mode, selection: selection_hash(provenance) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def selection_hash(provenance)
|
|
78
|
+
%i[subjects tests sources].to_h do |key|
|
|
79
|
+
list = provenance.public_send(key)
|
|
80
|
+
[key, { count: list.size, first: list.first(3).map { |entry| clip_value(entry, 120) } }]
|
|
81
|
+
end.merge(config: clip_value(provenance.config_path, 120), digest: provenance.digest,
|
|
82
|
+
settings: provenance.settings.transform_values { |value| clip_value(value, 120) })
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def findings(report, shown, snippet_limit)
|
|
86
|
+
all = Findings.build(report)
|
|
87
|
+
live = report.unignored_survivors.size
|
|
88
|
+
{
|
|
89
|
+
survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
|
|
90
|
+
omitted: live - [shown, all[:survivors].size].min,
|
|
91
|
+
accepted: report.survivors.size - live, ordering: all[:ordering],
|
|
92
|
+
hintCaveat: SurvivorHints::CAVEAT
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def shown_findings(listed, shown, snippet_limit)
|
|
97
|
+
listed.first(shown).map do |finding|
|
|
98
|
+
finding.slice(:digest, :line, :endLine, :operator, :coarse)
|
|
99
|
+
.merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
|
|
100
|
+
original: clip(finding[:original], snippet_limit),
|
|
101
|
+
replacement: clip(finding[:replacement], snippet_limit))
|
|
102
|
+
.merge(finding_hints(finding))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def finding_hints(finding)
|
|
107
|
+
structured = SurvivorHints.structured(operator: finding[:operator], original: finding[:original],
|
|
108
|
+
replacement: finding[:replacement], coarse: finding[:coarse])
|
|
109
|
+
{ hint: SurvivorHints.for(finding[:operator], coarse: finding[:coarse]) }.compact
|
|
110
|
+
.merge(testHint: structured)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def next_steps(report, provenance)
|
|
114
|
+
steps = {}
|
|
115
|
+
add_command(steps, :rerun, Commands.rerun(provenance))
|
|
116
|
+
first = Findings.build(report)[:survivors].first
|
|
117
|
+
add_command(steps, :replay, Commands.replay(provenance, first[:digest])) if first
|
|
118
|
+
steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
|
|
119
|
+
steps
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def short_path?(path)
|
|
123
|
+
path && path.bytesize <= 400
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def add_command(steps, key, command)
|
|
127
|
+
steps[key] = command
|
|
128
|
+
return if command
|
|
129
|
+
|
|
130
|
+
steps[:"#{key}Unavailable"] =
|
|
131
|
+
"command longer than #{Commands::MAX_BYTES} bytes; rebuild it from provenance"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-License-Identifier: MIT
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "survivor_ranking"
|
|
5
|
+
|
|
4
6
|
module Saboteur
|
|
5
7
|
module Reporter
|
|
6
8
|
# Findings for a code-review surface such as the saboteur GitHub Action:
|
|
@@ -15,12 +17,7 @@ module Saboteur
|
|
|
15
17
|
# GitHub shows at most 10 annotations of one kind per step.
|
|
16
18
|
LIMIT = 10
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
# replaced the whole method body, which hints that no test pins the
|
|
20
|
-
# method's behavior - though it can also be a re-evaluation limit or an
|
|
21
|
-
# equivalent mutant, so this is a heuristic, not a guarantee. Ties break
|
|
22
|
-
# by file, line and digest, so one report always reads the same way.
|
|
23
|
-
ORDERING = "coarse survivors first, then file, line and digest"
|
|
20
|
+
ORDERING = SurvivorRanking::ORDERING
|
|
24
21
|
|
|
25
22
|
class << self
|
|
26
23
|
def build(report)
|
|
@@ -46,20 +43,8 @@ module Saboteur
|
|
|
46
43
|
[results.size - LIMIT, 0].max
|
|
47
44
|
end
|
|
48
45
|
|
|
49
|
-
# partition keeps the coarse group ahead; inside a group the key is
|
|
50
|
-
# total (no two mutants share a digest), so the order is stable.
|
|
51
46
|
def ordered(report, results)
|
|
52
|
-
|
|
53
|
-
.map { |group| group.sort_by { |result| tie_break(report, result) } }
|
|
54
|
-
.flatten
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def tie_break(report, result)
|
|
58
|
-
[path(report, result), first_line(result), result.digest]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def first_line(result)
|
|
62
|
-
result.subject.line_of(result.mutation.edits.map(&:start_byte).min)
|
|
47
|
+
SurvivorRanking.ordered(report, results)
|
|
63
48
|
end
|
|
64
49
|
|
|
65
50
|
def finding(report, result)
|
|
@@ -76,9 +61,17 @@ module Saboteur
|
|
|
76
61
|
edits: entries,
|
|
77
62
|
coarse: result.mutation.coarse
|
|
78
63
|
}
|
|
64
|
+
entry = with_changed_lines(report, result, entry)
|
|
79
65
|
result.ignored ? entry.merge(reason: result.ignored) : entry
|
|
80
66
|
end
|
|
81
67
|
|
|
68
|
+
# In diff mode, the changed lines the mutant touches. line above is where
|
|
69
|
+
# the mutated statement starts, which can be above the changed line.
|
|
70
|
+
def with_changed_lines(report, result, entry)
|
|
71
|
+
lines = report.changed_lines_for(result)
|
|
72
|
+
lines.empty? ? entry : entry.merge(changedLines: lines)
|
|
73
|
+
end
|
|
74
|
+
|
|
82
75
|
# Subject#apply applies edits in start-byte order; findings use the
|
|
83
76
|
# same order, so an unsorted edit list from a custom mutation source
|
|
84
77
|
# still gets a sane range and one entry per edit.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Refuses linked or non-regular history/report destinations before writes.
|
|
7
|
+
module HistoryPaths
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate(paths)
|
|
11
|
+
paths = paths.compact.map { |path| File.expand_path(path) }
|
|
12
|
+
identities = paths.map do |path|
|
|
13
|
+
reject_links(path)
|
|
14
|
+
next unless File.exist?(path)
|
|
15
|
+
|
|
16
|
+
stat = File.stat(path)
|
|
17
|
+
raise Error, "score history/report destination must be a regular file" unless stat.file?
|
|
18
|
+
|
|
19
|
+
[stat.dev, stat.ino]
|
|
20
|
+
end.compact
|
|
21
|
+
raise Error, "score history/report destinations alias the same file" unless identities.uniq == identities
|
|
22
|
+
rescue SystemCallError => e
|
|
23
|
+
raise Error, "score history/report paths cannot be verified: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reject_links(path)
|
|
27
|
+
loop do
|
|
28
|
+
raise Error, "score history/report paths must not contain symlinks" if File.symlink?(path)
|
|
29
|
+
|
|
30
|
+
parent = File.dirname(path)
|
|
31
|
+
break if parent == path
|
|
32
|
+
|
|
33
|
+
path = parent
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module Reporter
|
|
9
|
+
# Strict version-one observation schema. Never repairs imported evidence.
|
|
10
|
+
module HistoryRecord
|
|
11
|
+
FIELDS = %w[version date commit selection inventory score coveredScore counts breakdown incomplete].freeze
|
|
12
|
+
BREAKDOWN = %w[score tested assertionKills crashKills unclassifiedKills timeouts survivors
|
|
13
|
+
assertionShare crashShare].freeze
|
|
14
|
+
COUNTS = STATUSES.map(&:to_s).freeze
|
|
15
|
+
|
|
16
|
+
class UniqueObject < Hash
|
|
17
|
+
def []=(key, value)
|
|
18
|
+
raise Error, "score history contains duplicate keys" if key?(key)
|
|
19
|
+
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def parse(line)
|
|
27
|
+
record = JSON.parse(line, object_class: UniqueObject)
|
|
28
|
+
validate(record)
|
|
29
|
+
record
|
|
30
|
+
rescue JSON::ParserError, ArgumentError, TypeError => e
|
|
31
|
+
raise Error, "score history contains an invalid record: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate(record)
|
|
35
|
+
fields(record, FIELDS)
|
|
36
|
+
unless record.fetch("version").is_a?(Integer) && record.fetch("version") == 1
|
|
37
|
+
raise Error,
|
|
38
|
+
"score history contains an invalid version"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
date(record.fetch("date"))
|
|
42
|
+
commit = record.fetch("commit")
|
|
43
|
+
raise Error, "score history contains an invalid commit" unless commit.nil? || sha?(commit, 40)
|
|
44
|
+
|
|
45
|
+
%w[selection inventory].each do |key|
|
|
46
|
+
raise Error, "score history contains an invalid digest" unless sha?(record.fetch(key), 64)
|
|
47
|
+
end
|
|
48
|
+
%w[score coveredScore].each { |key| fraction(record.fetch(key)) }
|
|
49
|
+
counts = record.fetch("counts")
|
|
50
|
+
fields(counts, COUNTS)
|
|
51
|
+
counts.each_value { |value| count(value) }
|
|
52
|
+
breakdown(record.fetch("breakdown"), counts, record.fetch("coveredScore"))
|
|
53
|
+
score_consistency(record)
|
|
54
|
+
reasons(record.fetch("incomplete"))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def date(value)
|
|
58
|
+
return if Time.iso8601(value).utc.iso8601 == value
|
|
59
|
+
|
|
60
|
+
raise Error,
|
|
61
|
+
"score history contains an invalid date"
|
|
62
|
+
rescue ArgumentError, TypeError, NoMethodError => e
|
|
63
|
+
raise Error, "score history contains an invalid date: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reasons(value)
|
|
67
|
+
unless value.is_a?(Array) && value.all? do |reason|
|
|
68
|
+
reason.is_a?(String) && !reason.empty?
|
|
69
|
+
end && value.uniq == value
|
|
70
|
+
raise Error, "score history contains invalid incomplete reasons"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def score_consistency(record)
|
|
75
|
+
value = record.fetch("breakdown")
|
|
76
|
+
detected = value.values_at("assertionKills", "crashKills", "unclassifiedKills", "timeouts").sum
|
|
77
|
+
tested = value.fetch("tested")
|
|
78
|
+
expected = tested.zero? ? nil : detected.fdiv(tested)
|
|
79
|
+
raise Error, "score history contains an inconsistent score" unless record.fetch("coveredScore") == expected
|
|
80
|
+
|
|
81
|
+
%w[assertion crash].each do |kind|
|
|
82
|
+
expected_share = tested.zero? ? nil : value.fetch("#{kind}Kills").fdiv(tested)
|
|
83
|
+
unless value.fetch("#{kind}Share") == expected_share
|
|
84
|
+
raise Error,
|
|
85
|
+
"score history contains an inconsistent share"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
overall = record.fetch("score")
|
|
89
|
+
return unless overall && ((record.fetch("coveredScore").nil? && !overall.zero?) ||
|
|
90
|
+
(record.fetch("coveredScore") && overall > record.fetch("coveredScore")))
|
|
91
|
+
|
|
92
|
+
raise Error, "score history contains an inconsistent overall score"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fields(object, expected)
|
|
96
|
+
return if object.is_a?(Hash) && object.keys.sort == expected.sort
|
|
97
|
+
|
|
98
|
+
raise Error,
|
|
99
|
+
"score history contains an invalid record schema"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def sha?(value, length)
|
|
103
|
+
value.is_a?(String) && value.match?(/\A[0-9a-f]{#{length}}\z/)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def fraction(value)
|
|
107
|
+
return if value.nil? || (0..1).cover?(value)
|
|
108
|
+
|
|
109
|
+
raise Error,
|
|
110
|
+
"score history contains an invalid score"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def count(value)
|
|
114
|
+
raise Error, "score history contains an invalid count" unless value.is_a?(Integer) && value >= 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def breakdown(value, counts, covered)
|
|
118
|
+
fields(value, BREAKDOWN)
|
|
119
|
+
%w[score assertionShare crashShare].each { |key| fraction(value.fetch(key)) }
|
|
120
|
+
(BREAKDOWN - %w[score assertionShare crashShare]).each { |key| count(value.fetch(key)) }
|
|
121
|
+
kills = value.values_at("assertionKills", "crashKills", "unclassifiedKills").sum
|
|
122
|
+
tested = counts.values_at("killed", "survived", "timeout").sum
|
|
123
|
+
unless kills == counts.fetch("killed") && value.fetch("tested") == tested &&
|
|
124
|
+
value.fetch("timeouts") == counts.fetch("timeout") &&
|
|
125
|
+
value.fetch("survivors") == counts.fetch("survived") &&
|
|
126
|
+
value.fetch("score") == covered
|
|
127
|
+
raise Error, "score history contains inconsistent counts"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -57,7 +57,7 @@ module Saboteur
|
|
|
57
57
|
<title>saboteur report</title>
|
|
58
58
|
<style>#{STYLE}</style></head><body>
|
|
59
59
|
<h1>saboteur report</h1>
|
|
60
|
-
#{summary(report, data)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
|
|
60
|
+
#{summary(report, data)}#{inline_suppressions(report)}#{warnings(data[:warnings])}#{skipped_subjects(data[:skippedSubjects])}
|
|
61
61
|
<h2>Files</h2>
|
|
62
62
|
#{data[:files].map { |file, entry| file_section(file, entry, names, results) }.join("\n")}
|
|
63
63
|
</body></html>
|
|
@@ -66,6 +66,17 @@ module Saboteur
|
|
|
66
66
|
|
|
67
67
|
private
|
|
68
68
|
|
|
69
|
+
def inline_suppressions(report)
|
|
70
|
+
return "" if report.inline_suppressions.empty?
|
|
71
|
+
|
|
72
|
+
items = report.inline_suppressions.map do |entry|
|
|
73
|
+
path = entry[:file].delete_prefix("#{report.root}/")
|
|
74
|
+
"<li>#{escape(path)}:#{entry[:line]} #{escape(entry[:subject])} " \
|
|
75
|
+
"#{escape(entry[:operator])} <code>#{escape(entry[:digest])}</code>: #{escape(entry[:reason])}</li>"
|
|
76
|
+
end
|
|
77
|
+
"<h2>Inline suppressions (#{items.size}, outside the score)</h2><ul>#{items.join}</ul>"
|
|
78
|
+
end
|
|
79
|
+
|
|
69
80
|
def summary(report, data)
|
|
70
81
|
seed = data.dig(:config, :seed)
|
|
71
82
|
seed_note = seed ? ", seed #{seed}" : ""
|
|
@@ -76,6 +87,7 @@ module Saboteur
|
|
|
76
87
|
<p class="note">Covered mutation score = #{SCORE_FORMULA}. NoCoverage, errors, resource limits and skipped
|
|
77
88
|
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
89
|
Stryker JSON may show a different score, because the schema counts NoCoverage as undetected.</p>
|
|
90
|
+
#{score_split(report)}
|
|
79
91
|
<p class="note">Wide tables scroll sideways.</p>
|
|
80
92
|
<p>#{counts.map { |status, count| "#{status_label(status)} #{count}" }.join(' ')}</p>
|
|
81
93
|
<p>Survivors: #{report.unignored_survivors.size} to fix, #{report.survivors.size - report.unignored_survivors.size}
|
|
@@ -83,6 +95,37 @@ module Saboteur
|
|
|
83
95
|
HTML
|
|
84
96
|
end
|
|
85
97
|
|
|
98
|
+
# How the score splits into assertion and crash kills, and the score
|
|
99
|
+
# without the whole-body mutants, which almost any test that runs the
|
|
100
|
+
# method kills. Each part shows only when it has something to say.
|
|
101
|
+
def score_split(report)
|
|
102
|
+
breakdown = report.score_breakdown
|
|
103
|
+
lines = []
|
|
104
|
+
lines << kill_split_line(breakdown) if breakdown.split_known?
|
|
105
|
+
lines << whole_body_line(report)
|
|
106
|
+
lines.compact.join("\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def kill_split_line(breakdown)
|
|
110
|
+
"<p>Of the tested mutants: #{parts_text(breakdown)}. A crash shows the code ran and broke, " \
|
|
111
|
+
"not that a test checked a value.</p>"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parts_text(breakdown)
|
|
115
|
+
breakdown.parts.map { |label, share| "#{score_text(share)} #{label}" }.join(", ")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def whole_body_line(report)
|
|
119
|
+
whole = report.coarse_score_breakdown
|
|
120
|
+
fine = report.fine_score_breakdown
|
|
121
|
+
return if whole.tested.zero? || fine.tested.zero?
|
|
122
|
+
|
|
123
|
+
split = fine.split_known? ? " (#{parts_text(fine)})" : ""
|
|
124
|
+
"<p>Without whole-body mutants (body_nil, body_true, body_false, body_raise): #{score_text(fine.value)}" \
|
|
125
|
+
"#{split} over #{fine.tested} mutants. Whole-body mutants alone: #{score_text(whole.value)} " \
|
|
126
|
+
"over #{whole.tested}.</p>"
|
|
127
|
+
end
|
|
128
|
+
|
|
86
129
|
def score_text(score)
|
|
87
130
|
score ? format("%.1f%%", score * 100) : "n/a"
|
|
88
131
|
end
|
|
@@ -126,13 +169,29 @@ module Saboteur
|
|
|
126
169
|
def mutant_row(mutant, names, result)
|
|
127
170
|
start = mutant[:location][:start]
|
|
128
171
|
tests = Array(mutant[:killedBy]).map { |id| names.fetch(id) }.join(", ")
|
|
129
|
-
notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}"
|
|
172
|
+
notes = [mutant[:statusReason], tests.empty? ? nil : "killed by #{tests}",
|
|
173
|
+
passing_tests_note(mutant, names, result)].compact.join("; ")
|
|
130
174
|
"<tr><td class=\"fit\">line #{start[:line]}, column #{start[:column]}</td><td>#{row_status(mutant,
|
|
131
175
|
result)}</td>" \
|
|
132
176
|
"<td>#{escape(mutant[:mutatorName])}</td><td class=\"wide\">#{change(mutant, result)}</td>" \
|
|
133
177
|
"<td class=\"fit\"><code>#{escape(mutant[:id])}</code></td><td class=\"wide\">#{escape(notes)}</td></tr>"
|
|
134
178
|
end
|
|
135
179
|
|
|
180
|
+
# A survivor's covering tests all passed with the mutant applied, or, for
|
|
181
|
+
# a reused verdict, in the earlier run it came from.
|
|
182
|
+
def passing_tests_note(mutant, names, result)
|
|
183
|
+
return unless mutant[:status] == "Survived"
|
|
184
|
+
|
|
185
|
+
passed = Array(mutant[:coveredBy]).map { |id| names.fetch(id) }
|
|
186
|
+
return if passed.empty?
|
|
187
|
+
|
|
188
|
+
shown = passed.first(Terminal::MAX_PASSING_TESTS).join(", ")
|
|
189
|
+
more = passed.size - Terminal::MAX_PASSING_TESTS
|
|
190
|
+
extra = more.positive? ? " (+#{more} more)" : ""
|
|
191
|
+
when_passed = result.reused? ? "passed in an earlier run" : "passed"
|
|
192
|
+
"covered by #{passed.size} #{passed.one? ? 'test' : 'tests'} that #{when_passed}: #{shown}#{extra}"
|
|
193
|
+
end
|
|
194
|
+
|
|
136
195
|
def row_status(mutant, result)
|
|
137
196
|
badge = status_label(mutant[:status])
|
|
138
197
|
# Only survivors are ever put on the ignore list.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# The original and replacement text of one mutant, and the line it
|
|
7
|
+
# changes, for compact one-line output.
|
|
8
|
+
module MutationText
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def line(result)
|
|
12
|
+
result.subject.line_of(sorted_edits(result).first.start_byte)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def original(result)
|
|
16
|
+
join(result) { |edit| result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def replacement(result)
|
|
20
|
+
join(result, &:replacement)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sorted_edits(result)
|
|
24
|
+
result.mutation.edits.sort_by(&:start_byte)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Whole-body mutants carry a long text; one line keeps the output compact.
|
|
28
|
+
def join(result, &pick)
|
|
29
|
+
text = sorted_edits(result).map { |edit| pick.call(edit).to_s.dup.force_encoding(Encoding::UTF_8).scrub }
|
|
30
|
+
.join(" | ")
|
|
31
|
+
flat = text.gsub(/\s*\n\s*/, " ").strip
|
|
32
|
+
flat.length > 60 ? "#{flat[0, 57]}..." : flat
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# The footer under a run that left survivors: how to look at one, how to
|
|
7
|
+
# accept one, and how to hand the run to an agent. Silent when nothing
|
|
8
|
+
# survived, so a clean run stays short.
|
|
9
|
+
class NextSteps
|
|
10
|
+
def initialize(out, palette)
|
|
11
|
+
@out = out
|
|
12
|
+
@palette = palette
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(report)
|
|
16
|
+
survivor = report.unignored_survivors.first
|
|
17
|
+
return unless survivor
|
|
18
|
+
|
|
19
|
+
@out.puts "\n#{@palette.paint(:bold, 'Next steps:')}"
|
|
20
|
+
seed = " --seed #{report.seed}" if report.seed
|
|
21
|
+
@out.puts " replay one survivor: saboteur run --id #{survivor.digest}#{seed}"
|
|
22
|
+
@out.puts " (add this run's own --config, --since, --subject, --test and source options)"
|
|
23
|
+
@out.puts " accept an equivalent one: add to your ignore file (c.ignore_file, default #{Config::IGNORE_FILE}):"
|
|
24
|
+
entry = %(i.mutation "#{survivor.digest}", subject: "#{survivor.subject.id}", reason: "why it is equivalent")
|
|
25
|
+
@out.puts " #{entry}"
|
|
26
|
+
@out.puts " let an agent close the gaps: saboteur run --agent, with the same options"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Wraps text in ANSI colour codes when colour is on, and returns it
|
|
7
|
+
# unchanged when it is off. Colour is on only when asked for or when the
|
|
8
|
+
# output is a terminal and NO_COLOR is not set (https://no-color.org).
|
|
9
|
+
class Palette
|
|
10
|
+
CODES = { red: 31, green: 32, yellow: 33, magenta: 35, cyan: 36, dim: 2, bold: 1 }.freeze
|
|
11
|
+
|
|
12
|
+
# mode: :auto, :always or :never.
|
|
13
|
+
def self.for(out, mode: :auto, environment: ENV)
|
|
14
|
+
enabled = case mode
|
|
15
|
+
when :always then true
|
|
16
|
+
when :never then false
|
|
17
|
+
else out.respond_to?(:tty?) && out.tty? && environment["NO_COLOR"].to_s.empty?
|
|
18
|
+
end
|
|
19
|
+
new(enabled: enabled)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(enabled:)
|
|
23
|
+
@enabled = enabled
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def enabled?
|
|
27
|
+
@enabled
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def paint(color, text)
|
|
31
|
+
return text unless @enabled
|
|
32
|
+
|
|
33
|
+
"\e[#{CODES.fetch(color)}m#{text}\e[0m"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|