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,123 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require_relative "agent_incomplete"
|
|
9
|
+
require_relative "history_record"
|
|
10
|
+
|
|
11
|
+
module Saboteur
|
|
12
|
+
module Reporter
|
|
13
|
+
# Opt-in append-only score observations, not a baseline or a CI gate.
|
|
14
|
+
class ScoreHistory
|
|
15
|
+
DIFFERENT_RUN = "score history: change unavailable (different selection/inventory or incomplete run)"
|
|
16
|
+
MISSING_SCORE = "score history: change unavailable (no tested mutants)"
|
|
17
|
+
MAX_RECORD_BYTES = 64 * 1024
|
|
18
|
+
OPEN_FLAGS = File::RDWR | File::CREAT | File::NONBLOCK | File::NOFOLLOW
|
|
19
|
+
|
|
20
|
+
def initialize(path)
|
|
21
|
+
@path = path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def append(report, head:, scope:)
|
|
25
|
+
record = build(report, head, scope)
|
|
26
|
+
HistoryRecord.validate(record)
|
|
27
|
+
line = "#{JSON.generate(record)}\n"
|
|
28
|
+
raise Error, "score history record exceeds byte limit" if line.bytesize > MAX_RECORD_BYTES
|
|
29
|
+
|
|
30
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
31
|
+
File.open(@path, OPEN_FLAGS, 0o600) do |file|
|
|
32
|
+
regular_file(file)
|
|
33
|
+
lock(file, File::LOCK_EX)
|
|
34
|
+
|
|
35
|
+
previous = previous_record(file)
|
|
36
|
+
file.seek(0, IO::SEEK_END)
|
|
37
|
+
file.write(line)
|
|
38
|
+
file.flush
|
|
39
|
+
change(previous, record)
|
|
40
|
+
end
|
|
41
|
+
rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError => e
|
|
42
|
+
raise Error, "score history was not recorded: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_existing
|
|
46
|
+
return unless File.exist?(@path) || File.symlink?(@path)
|
|
47
|
+
|
|
48
|
+
File.open(@path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
|
|
49
|
+
regular_file(file)
|
|
50
|
+
lock(file, File::LOCK_SH)
|
|
51
|
+
|
|
52
|
+
previous_record(file)
|
|
53
|
+
end
|
|
54
|
+
rescue SystemCallError, IOError => e
|
|
55
|
+
raise Error, "score history cannot be read: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build(report, head, scope)
|
|
61
|
+
{ "version" => 1, "date" => Time.now.utc.iso8601, "commit" => head,
|
|
62
|
+
"selection" => Digest::SHA256.hexdigest(JSON.generate(scope.sort_by { |key, _| key.to_s }.to_h)),
|
|
63
|
+
"inventory" => Digest::SHA256.hexdigest(JSON.generate(report.results.map(&:digest).sort)),
|
|
64
|
+
"score" => overall_score(report), "coveredScore" => report.score,
|
|
65
|
+
"counts" => report.counts.transform_keys(&:to_s),
|
|
66
|
+
"breakdown" => report.score_breakdown.to_h.transform_keys(&:to_s),
|
|
67
|
+
"incomplete" => incompleteness(report) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def overall_score(report)
|
|
71
|
+
score = report.score_breakdown
|
|
72
|
+
missing = report.results.count { |result| result.status == :skipped && result.reason == "no coverage" }
|
|
73
|
+
denominator = score.tested + missing
|
|
74
|
+
denominator.zero? ? nil : score.detected.fdiv(denominator)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def incompleteness(report)
|
|
78
|
+
reasons = Agent::Incomplete.reasons(report).map { |reason| reason[:reason] }
|
|
79
|
+
reasons << "baseline_incomplete" if report.run_evidence && !report.run_evidence["complete"]
|
|
80
|
+
reasons << "omissions" unless report.omissions.empty?
|
|
81
|
+
reasons.uniq
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def lock(file, mode)
|
|
85
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
|
86
|
+
until file.flock(mode | File::LOCK_NB)
|
|
87
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
88
|
+
raise Error,
|
|
89
|
+
"score history lock was not acquired"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sleep 0.01
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def regular_file(file)
|
|
97
|
+
raise Error, "score history must be a regular file" unless file.stat.file?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def previous_record(file)
|
|
101
|
+
while (line = file.gets(MAX_RECORD_BYTES + 1))
|
|
102
|
+
raise Error, "score history record exceeds byte limit" if line.bytesize > MAX_RECORD_BYTES
|
|
103
|
+
raise Error, "score history contains a truncated record" unless line.end_with?("\n")
|
|
104
|
+
|
|
105
|
+
previous = HistoryRecord.parse(line)
|
|
106
|
+
end
|
|
107
|
+
previous
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def change(previous, current)
|
|
111
|
+
return "score history: first record" unless previous
|
|
112
|
+
return DIFFERENT_RUN unless
|
|
113
|
+
previous["selection"] == current["selection"] && previous["inventory"] == current["inventory"] &&
|
|
114
|
+
previous["incomplete"].empty? && current["incomplete"].empty?
|
|
115
|
+
return MISSING_SCORE if
|
|
116
|
+
previous["coveredScore"].nil? || current["coveredScore"].nil?
|
|
117
|
+
|
|
118
|
+
delta = (current["coveredScore"] - previous["coveredScore"]) * 100
|
|
119
|
+
format("score history: covered score change %+.2f percentage points (same configured selection)", delta)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -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
|
|
@@ -16,14 +16,14 @@ module Saboteur
|
|
|
16
16
|
@path = path
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def call(report)
|
|
19
|
+
def call(report, exit_cause: nil)
|
|
20
20
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
21
|
-
File.write(@path, JSON.generate(self.class.build(report)))
|
|
21
|
+
File.write(@path, JSON.generate(self.class.build(report, exit_cause: exit_cause)))
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# Tests are listed once under testFiles with short ids; mutants refer to
|
|
25
25
|
# them by id in killedBy/coveredBy instead of repeating full test names.
|
|
26
|
-
def self.build(report)
|
|
26
|
+
def self.build(report, exit_cause: nil)
|
|
27
27
|
test_ids = test_index(report.results)
|
|
28
28
|
files = report.results.group_by { |result| result.subject.file }.to_h do |file, results|
|
|
29
29
|
[relative(file, report.root), file_entry(results, test_ids)]
|
|
@@ -35,13 +35,30 @@ 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)
|
|
42
|
+
saboteur: { findings: Findings.build(report), scores: scores(report),
|
|
43
|
+
inlineSuppressions: report.inline_suppressions.map do |entry|
|
|
44
|
+
entry.merge(file: relative(entry[:file], report.root))
|
|
45
|
+
end }
|
|
46
|
+
.merge(exit_cause ? { exitCause: exit_cause } : {})
|
|
42
47
|
}.merge(report.seed ? { config: { seed: report.seed } } : {})
|
|
43
48
|
end
|
|
44
49
|
|
|
50
|
+
# Score with its assertion and crash shares, for all mutants and without
|
|
51
|
+
# the whole-body ones (body_nil, body_true, body_false, body_raise).
|
|
52
|
+
def self.scores(report)
|
|
53
|
+
{ all: report.score_breakdown.to_h, withoutWholeBodyMutants: report.fine_score_breakdown.to_h }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# nil for a run that covered its whole selection.
|
|
57
|
+
def self.early_stop(report)
|
|
58
|
+
stop = report.early_stop
|
|
59
|
+
stop && { reason: stop.reason, unreachedSubjects: stop.unreached }
|
|
60
|
+
end
|
|
61
|
+
|
|
45
62
|
def self.skipped_subjects(report)
|
|
46
63
|
report.skipped_subjects.map do |subject, reason|
|
|
47
64
|
{ subject: subject.id, file: relative(subject.file, report.root), line: subject.start_line, reason: reason }
|
|
@@ -94,7 +111,10 @@ module Saboteur
|
|
|
94
111
|
when :survived then "Survived"
|
|
95
112
|
when :timeout then "Timeout"
|
|
96
113
|
when :error, :resource_limit then "RuntimeError"
|
|
97
|
-
when :skipped then result.reason
|
|
114
|
+
when :skipped then result.reason&.start_with?("no coverage") ? "NoCoverage" : "Ignored"
|
|
115
|
+
# The schema has no status for "ran, but no test can observe a
|
|
116
|
+
# change here". Ignored with the reason is the honest mapping.
|
|
117
|
+
when :ran_at_load, :unreachable then "Ignored"
|
|
98
118
|
end
|
|
99
119
|
end
|
|
100
120
|
|
|
@@ -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
|