saboteur 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -1
- data/README.md +526 -73
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +305 -30
- data/lib/saboteur/config.rb +65 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +88 -0
- data/lib/saboteur/engine/baseline_guard.rb +116 -0
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +40 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +344 -0
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +125 -25
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +210 -0
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +69 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +14 -3
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +131 -0
- data/lib/saboteur/reporter/html.rb +213 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +21 -4
- data/lib/saboteur/reporter/terminal.rb +76 -2
- data/lib/saboteur/result.rb +61 -9
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +7 -0
- metadata +30 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "prism"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module MutationSource
|
|
9
|
+
# Deletes one association from a hash literal or keyword argument list.
|
|
10
|
+
class PairRemoval < Base
|
|
11
|
+
BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
|
|
12
|
+
Prism::ParametersNode, Prism::BlockParametersNode].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
15
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name
|
|
19
|
+
"pair_removal"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def mutations_for(subject)
|
|
23
|
+
found = []
|
|
24
|
+
tokens = Prism.lex(subject.file_source).value.map(&:first)
|
|
25
|
+
visit(subject.node.body, subject, found, tokens)
|
|
26
|
+
found
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def visit(node, subject, found, tokens)
|
|
32
|
+
return if node.nil? || BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
33
|
+
|
|
34
|
+
if node.is_a?(Prism::StatementsNode)
|
|
35
|
+
node.body.each do |child|
|
|
36
|
+
visit(child, subject, found, tokens) unless @arid.any? { |p| p.match?(child.slice) }
|
|
37
|
+
end
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
remove_pairs(node, subject, found, tokens) if node.is_a?(Prism::HashNode)
|
|
41
|
+
remove_keywords(node, subject, found, tokens) if node.is_a?(Prism::CallNode)
|
|
42
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found, tokens) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def remove_keywords(node, subject, found, tokens)
|
|
46
|
+
return unless node.arguments
|
|
47
|
+
|
|
48
|
+
node.arguments.arguments.grep(Prism::KeywordHashNode).each do |hash|
|
|
49
|
+
remove_pairs(hash, subject, found, tokens, call: node)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def remove_pairs(node, subject, found, tokens, call: nil)
|
|
54
|
+
pairs = node.elements
|
|
55
|
+
pairs.each_with_index do |pair, index|
|
|
56
|
+
next unless pair.is_a?(Prism::AssocNode)
|
|
57
|
+
next if heredoc?(pair)
|
|
58
|
+
|
|
59
|
+
edits = [delete(subject, pair.location)]
|
|
60
|
+
separator = separator_for(node, pairs, index, tokens, call)
|
|
61
|
+
edits << delete(subject, separator) if separator
|
|
62
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def separator_for(node, pairs, index, tokens, call)
|
|
67
|
+
pair = pairs[index]
|
|
68
|
+
ending = ending_for(node, call)
|
|
69
|
+
after = comma_between(pair.location.end_offset, ending, tokens)
|
|
70
|
+
return after if after
|
|
71
|
+
|
|
72
|
+
previous = pairs[0...index].last
|
|
73
|
+
previous ||= call.arguments.arguments[0...-1].last if call
|
|
74
|
+
comma_between(previous.location.end_offset, pair.location.start_offset, tokens) if previous
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ending_for(node, call)
|
|
78
|
+
return node.closing_loc.start_offset if node.is_a?(Prism::HashNode)
|
|
79
|
+
return call.closing_loc.start_offset if call.closing_loc
|
|
80
|
+
return call.block.location.start_offset if call.block
|
|
81
|
+
|
|
82
|
+
node.location.end_offset
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def comma_between(first, last, tokens)
|
|
86
|
+
tokens.find do |token|
|
|
87
|
+
token.type == :COMMA && token.location.start_offset >= first && token.location.end_offset <= last
|
|
88
|
+
end&.location
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def heredoc?(node)
|
|
92
|
+
(node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")) ||
|
|
93
|
+
node.compact_child_nodes.any? { |child| heredoc?(child) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete(subject, location)
|
|
97
|
+
Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset, replacement: "")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
# Resolves one exact digest from current source, before caps, ignore suppression or incremental reuse.
|
|
6
|
+
class Replay
|
|
7
|
+
def initialize(digest:)
|
|
8
|
+
unless digest.match?(/\A[0-9a-f]{16}\z/)
|
|
9
|
+
raise ConfigError, "--id needs a full 16 lowercase hexadecimal mutant digest"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
@digest = digest
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def select(subjects, candidates, scope: nil)
|
|
16
|
+
match = nil
|
|
17
|
+
subjects.each do |subject|
|
|
18
|
+
list = candidates.for(subject)
|
|
19
|
+
list = scope.restrict(subject, list) if scope
|
|
20
|
+
list.each do |mutation, text|
|
|
21
|
+
digest = mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source)
|
|
22
|
+
next unless digest == @digest
|
|
23
|
+
raise ConfigError, "mutant #{@digest} is ambiguous; narrow --subject or include files" if match
|
|
24
|
+
|
|
25
|
+
match = [subject, [mutation, text]]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
raise ConfigError, "mutant #{@digest} not found in the selected current source" unless match
|
|
29
|
+
|
|
30
|
+
match
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "agent_summary"
|
|
6
|
+
require_relative "agent_commands"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Reporter
|
|
10
|
+
# A small JSON summary for an AI agent that runs saboteur (`saboteur run --agent`).
|
|
11
|
+
#
|
|
12
|
+
# The payload stays under BYTE_BUDGET: it never embeds file sources or the full
|
|
13
|
+
# Stryker report. It names the verdict, says plainly what was not covered, shows
|
|
14
|
+
# the first few findings with short edit snippets, counts the rest, and points
|
|
15
|
+
# at the commands and files that hold the full detail.
|
|
16
|
+
#
|
|
17
|
+
# Exit codes in this mode are stricter than the classic ones: 0 means the run
|
|
18
|
+
# was complete and clean, nothing less.
|
|
19
|
+
module Agent
|
|
20
|
+
VERSION = 1
|
|
21
|
+
BYTE_BUDGET = 8 * 1024
|
|
22
|
+
FINDING_LIMIT = 3
|
|
23
|
+
SNIPPET_LIMITS = [200, 80, 0].freeze
|
|
24
|
+
MAX_NUMBER_DIGITS = 20
|
|
25
|
+
WARNING_LIMIT = 3
|
|
26
|
+
WARNING_LENGTH = 160
|
|
27
|
+
|
|
28
|
+
EXIT_CLEAN = 0
|
|
29
|
+
EXIT_REVIEW = 1
|
|
30
|
+
EXIT_INVOCATION = 2
|
|
31
|
+
EXIT_INCOMPLETE = 3
|
|
32
|
+
|
|
33
|
+
# Where a verdict comes from: the exact selection and the state of the working
|
|
34
|
+
# source before the run started. subjects, tests and sources are arrays.
|
|
35
|
+
Provenance = Data.define(:ref, :seed, :head, :working_tree, :changed_files, :mode, :json_report, :subjects,
|
|
36
|
+
:tests, :sources, :config_path, :digest, :cache, :incremental, :settings)
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def call(report, provenance)
|
|
40
|
+
fit(report, provenance)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render(payload)
|
|
44
|
+
"#{JSON.pretty_generate(payload)}\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exit_code(payload)
|
|
48
|
+
payload.fetch(:exitCode)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The payload for a run that never started or could not finish.
|
|
52
|
+
def failure(message, exit_code)
|
|
53
|
+
verdict = exit_code == EXIT_INVOCATION ? "invalid_invocation" : "tool_failure"
|
|
54
|
+
{ version: VERSION, status: "error", verdict: verdict, exitCode: exit_code,
|
|
55
|
+
message: message.to_s[0, 500], next: { help: "saboteur help run" } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def fit(report, provenance)
|
|
61
|
+
FINDING_LIMIT.downto(0) do |shown|
|
|
62
|
+
SNIPPET_LIMITS.each do |snippet_limit|
|
|
63
|
+
payload = Summary.build(report, provenance, shown, snippet_limit)
|
|
64
|
+
return payload if render(payload).bytesize <= BYTE_BUDGET
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
bounded_last_resort(report, provenance)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The minimal payload, or four fixed keys when even that is over the budget.
|
|
71
|
+
def bounded_last_resort(report, provenance)
|
|
72
|
+
small = minimal(report, provenance)
|
|
73
|
+
return small if render(small).bytesize <= BYTE_BUDGET
|
|
74
|
+
|
|
75
|
+
small.slice(:version, :status, :verdict, :exitCode).merge(truncated: true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The last resort: fixed keys and short clipped values, so it always fits.
|
|
79
|
+
def minimal(report, provenance)
|
|
80
|
+
small = Summary.build(report, provenance, 0, SNIPPET_LIMITS.last)
|
|
81
|
+
small.merge(warnings: [], next: {}, provenance: small[:provenance].slice(:seed, :workingTree, :mode),
|
|
82
|
+
incomplete: small[:incomplete].map { |reason| reason.slice(:reason, :count) })
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
module Agent
|
|
7
|
+
# Keeps display values short so the agent payload stays inside its byte budget.
|
|
8
|
+
module Clipping
|
|
9
|
+
# A seed or setting stays a number while it is short; a huge value cannot take the budget.
|
|
10
|
+
def bounded_number(value)
|
|
11
|
+
value.to_s.length <= MAX_NUMBER_DIGITS ? value : clip_value(value, MAX_NUMBER_DIGITS)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def clip_value(value, limit)
|
|
15
|
+
return nil if value.nil?
|
|
16
|
+
|
|
17
|
+
text = value.to_s
|
|
18
|
+
text.length > limit ? "#{text[0, limit]}..." : text
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clip(text, limit)
|
|
22
|
+
return "" if limit.zero?
|
|
23
|
+
|
|
24
|
+
text.length > limit ? "#{text[0, limit]}..." : text
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module Reporter
|
|
8
|
+
module Agent
|
|
9
|
+
# The shell commands an agent is told to run next. Every word is escaped with
|
|
10
|
+
# Shellwords, so a ref, path or subject holding shell syntax stays one literal
|
|
11
|
+
# argument. A command is never cut short: when it is longer than MAX_BYTES it
|
|
12
|
+
# is left out, because half a command could run something else.
|
|
13
|
+
module Commands
|
|
14
|
+
MAX_BYTES = 1024
|
|
15
|
+
|
|
16
|
+
# Options typed on the command line that change how a run behaves.
|
|
17
|
+
SETTING_FLAGS = { reeval: "--reeval", integration: "--integration", jobs: "--jobs",
|
|
18
|
+
timeout_factor: "--timeout-factor", json_report: "--json", html_report: "--html" }.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Run the same selection again: ref, subjects, sources, tests, config,
|
|
22
|
+
# cache mode and replay digest, with the recorded seed.
|
|
23
|
+
def rerun(provenance)
|
|
24
|
+
words = ["saboteur", "run", "--agent", *selection(provenance), "--seed", provenance.seed.to_s]
|
|
25
|
+
finish(words, provenance)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Replay one survivor by digest in the same selection.
|
|
29
|
+
def replay(provenance, digest)
|
|
30
|
+
words = ["saboteur", "run", "--id", digest, *selection(provenance, replay: true), "--seed",
|
|
31
|
+
provenance.seed.to_s]
|
|
32
|
+
finish(words, provenance)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def finish(words, provenance)
|
|
38
|
+
words += ["--", *provenance.sources] unless provenance.sources.empty?
|
|
39
|
+
command = Shellwords.join(words)
|
|
40
|
+
command.bytesize <= MAX_BYTES ? command : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def selection(provenance, replay: false)
|
|
44
|
+
words = []
|
|
45
|
+
words.push("--config", provenance.config_path) if provenance.config_path
|
|
46
|
+
words.push("--since", provenance.ref) if provenance.ref
|
|
47
|
+
provenance.subjects.each { |subject| words.push("--subject", subject) }
|
|
48
|
+
provenance.tests.each { |test| words.push("--test", test) }
|
|
49
|
+
SETTING_FLAGS.each do |key, flag|
|
|
50
|
+
words.push(flag, provenance.settings[key].to_s) if provenance.settings.key?(key)
|
|
51
|
+
end
|
|
52
|
+
words << "--no-cache" if provenance.cache == false
|
|
53
|
+
words << "--incremental" if provenance.incremental
|
|
54
|
+
words.push("--id", provenance.digest) if provenance.digest && !replay
|
|
55
|
+
words
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
module Agent
|
|
7
|
+
# What makes a verdict cover less than the whole selection. Each reason has a
|
|
8
|
+
# stable code, a count and a plain meaning.
|
|
9
|
+
module Incomplete
|
|
10
|
+
class << self
|
|
11
|
+
def reasons(report)
|
|
12
|
+
reasons = []
|
|
13
|
+
add(reasons, "no_coverage", skipped_for_coverage(report), "subjects or mutants had no covering test")
|
|
14
|
+
add(reasons, "mutant_cap", omitted_by_cap(report), "mutants omitted by max_mutants_per_method")
|
|
15
|
+
add(reasons, "errors", report.counts[:error], "mutants errored; their verdict did not hold up")
|
|
16
|
+
add(reasons, "resource_limit", report.counts[:resource_limit], "mutants hit the memory or process cap")
|
|
17
|
+
reasons
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def add(reasons, code, count, meaning)
|
|
23
|
+
reasons << { reason: code, count: count, meaning: meaning } if count.positive?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def skipped_for_coverage(report)
|
|
27
|
+
report.skipped_subjects.size + report.counts[:skipped]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def omitted_by_cap(report)
|
|
31
|
+
report.warnings.sum { |warning| warning[/omitted (\d+) mutant/, 1].to_i }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "findings"
|
|
5
|
+
require_relative "agent_incomplete"
|
|
6
|
+
require_relative "agent_clipping"
|
|
7
|
+
|
|
8
|
+
module Saboteur
|
|
9
|
+
module Reporter
|
|
10
|
+
module Agent
|
|
11
|
+
# Builds the payload hash for one run: status, verdict, exit code, counts,
|
|
12
|
+
# provenance, a few findings and the next commands.
|
|
13
|
+
module Summary
|
|
14
|
+
class << self
|
|
15
|
+
include Clipping
|
|
16
|
+
|
|
17
|
+
# Public entry first; the helpers below are private.
|
|
18
|
+
def build(report, provenance, shown, snippet_limit)
|
|
19
|
+
incomplete = Incomplete.reasons(report)
|
|
20
|
+
status = status_of(report, incomplete)
|
|
21
|
+
verdict = verdict_of(report, status)
|
|
22
|
+
{
|
|
23
|
+
version: VERSION, status: status, verdict: verdict, exitCode: exit_code_for(verdict),
|
|
24
|
+
provenance: provenance_hash(provenance),
|
|
25
|
+
counts: counts(report), score: score(report),
|
|
26
|
+
scoreDefinition: "(killed + timeout) / (killed + survived + timeout); null when nothing was tested",
|
|
27
|
+
incomplete: incomplete, timeoutCountedAsDetected: report.counts[:timeout],
|
|
28
|
+
findings: findings(report, shown, snippet_limit),
|
|
29
|
+
warnings: warnings(report),
|
|
30
|
+
next: next_steps(report, provenance)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def status_of(report, incomplete)
|
|
37
|
+
return "not_applicable" if report.results.empty? && report.skipped_subjects.empty?
|
|
38
|
+
|
|
39
|
+
incomplete.empty? ? "complete" : "partial"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def verdict_of(report, status)
|
|
43
|
+
return "survivors" if report.unignored_survivors.any?
|
|
44
|
+
|
|
45
|
+
{ "complete" => "clean", "partial" => "incomplete", "not_applicable" => "not_applicable" }.fetch(status)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def exit_code_for(verdict)
|
|
49
|
+
case verdict
|
|
50
|
+
when "clean" then EXIT_CLEAN
|
|
51
|
+
when "survivors" then EXIT_REVIEW
|
|
52
|
+
else EXIT_INCOMPLETE
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def counts(report)
|
|
57
|
+
report.counts.transform_keys { |status| camel(status) }.merge(executed: report.executed.size)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def camel(status)
|
|
61
|
+
status.to_s.gsub(/_(\w)/) { Regexp.last_match(1).upcase }.to_sym
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def score(report)
|
|
65
|
+
report.score&.round(4)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Display values are clipped; the commands under next use the exact values or are left out.
|
|
69
|
+
def provenance_hash(provenance)
|
|
70
|
+
{ ref: clip_value(provenance.ref, 120), seed: bounded_number(provenance.seed),
|
|
71
|
+
head: clip_value(provenance.head, 64),
|
|
72
|
+
workingTree: provenance.working_tree, changedFiles: provenance.changed_files,
|
|
73
|
+
mode: provenance.mode, selection: selection_hash(provenance) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def selection_hash(provenance)
|
|
77
|
+
%i[subjects tests sources].to_h do |key|
|
|
78
|
+
list = provenance.public_send(key)
|
|
79
|
+
[key, { count: list.size, first: list.first(3).map { |entry| clip_value(entry, 120) } }]
|
|
80
|
+
end.merge(config: clip_value(provenance.config_path, 120), digest: provenance.digest,
|
|
81
|
+
settings: provenance.settings.transform_values { |value| clip_value(value, 120) })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def findings(report, shown, snippet_limit)
|
|
85
|
+
all = Findings.build(report)
|
|
86
|
+
live = report.unignored_survivors.size
|
|
87
|
+
{
|
|
88
|
+
survivors: live, shown: shown_findings(all[:survivors], shown, snippet_limit),
|
|
89
|
+
omitted: live - [shown, all[:survivors].size].min,
|
|
90
|
+
accepted: report.survivors.size - live, ordering: all[:ordering]
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def shown_findings(listed, shown, snippet_limit)
|
|
95
|
+
listed.first(shown).map do |finding|
|
|
96
|
+
finding.slice(:digest, :line, :endLine, :operator, :coarse)
|
|
97
|
+
.merge(path: clip_value(finding[:path], 200), subject: clip_value(finding[:subject], 200),
|
|
98
|
+
original: clip(finding[:original], snippet_limit),
|
|
99
|
+
replacement: clip(finding[:replacement], snippet_limit))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def warnings(report)
|
|
104
|
+
report.warnings.first(WARNING_LIMIT).map { |warning| warning.to_s[0, WARNING_LENGTH] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def next_steps(report, provenance)
|
|
108
|
+
steps = {}
|
|
109
|
+
add_command(steps, :rerun, Commands.rerun(provenance))
|
|
110
|
+
first = Findings.build(report)[:survivors].first
|
|
111
|
+
add_command(steps, :replay, Commands.replay(provenance, first[:digest])) if first
|
|
112
|
+
steps[:fullReport] = provenance.json_report if short_path?(provenance.json_report)
|
|
113
|
+
steps
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def short_path?(path)
|
|
117
|
+
path && path.bytesize <= 400
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add_command(steps, key, command)
|
|
121
|
+
steps[key] = command
|
|
122
|
+
return if command
|
|
123
|
+
|
|
124
|
+
steps[:"#{key}Unavailable"] =
|
|
125
|
+
"command longer than #{Commands::MAX_BYTES} bytes; rebuild it from provenance"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Findings for a code-review surface such as the saboteur GitHub Action:
|
|
7
|
+
# a bounded list of survivors with everything a note on the pull-request
|
|
8
|
+
# diff needs (file, line, digest, operator, original and replacement).
|
|
9
|
+
# Survivors accepted on the ignore list are listed apart, with their
|
|
10
|
+
# reason. The full, unbounded mutant data stays in the report's files
|
|
11
|
+
# section; survivorsTruncated says how many more findings live only there.
|
|
12
|
+
module Findings
|
|
13
|
+
VERSION = 1
|
|
14
|
+
|
|
15
|
+
# GitHub shows at most 10 annotations of one kind per step.
|
|
16
|
+
LIMIT = 10
|
|
17
|
+
|
|
18
|
+
# Published ordering, coarse survivors first: a surviving coarse mutant
|
|
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"
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def build(report)
|
|
27
|
+
live, accepted = report.survivors.partition { |result| result.ignored.nil? }
|
|
28
|
+
{
|
|
29
|
+
version: VERSION,
|
|
30
|
+
ordering: ORDERING,
|
|
31
|
+
limit: LIMIT,
|
|
32
|
+
survivors: listed(report, live),
|
|
33
|
+
survivorsTruncated: truncated(live),
|
|
34
|
+
accepted: listed(report, accepted),
|
|
35
|
+
acceptedTruncated: truncated(accepted)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def listed(report, results)
|
|
42
|
+
ordered(report, results).first(LIMIT).map { |result| finding(report, result) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def truncated(results)
|
|
46
|
+
[results.size - LIMIT, 0].max
|
|
47
|
+
end
|
|
48
|
+
|
|
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
|
+
def ordered(report, results)
|
|
52
|
+
results.partition { |result| result.mutation.coarse }
|
|
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)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def finding(report, result)
|
|
66
|
+
entries = edit_entries(result)
|
|
67
|
+
entry = {
|
|
68
|
+
digest: result.digest,
|
|
69
|
+
path: path(report, result),
|
|
70
|
+
line: entries.map { |edit| edit[:line] }.min,
|
|
71
|
+
endLine: entries.map { |edit| edit[:endLine] }.max,
|
|
72
|
+
subject: result.subject.id,
|
|
73
|
+
operator: result.mutation.operator,
|
|
74
|
+
original: joined_texts(result) { |edit| original_text(result, edit) },
|
|
75
|
+
replacement: joined_texts(result, &:replacement),
|
|
76
|
+
edits: entries,
|
|
77
|
+
coarse: result.mutation.coarse
|
|
78
|
+
}
|
|
79
|
+
entry = with_changed_lines(report, result, entry)
|
|
80
|
+
result.ignored ? entry.merge(reason: result.ignored) : entry
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# In diff mode, the changed lines the mutant touches. line above is where
|
|
84
|
+
# the mutated statement starts, which can be above the changed line.
|
|
85
|
+
def with_changed_lines(report, result, entry)
|
|
86
|
+
lines = report.changed_lines_for(result)
|
|
87
|
+
lines.empty? ? entry : entry.merge(changedLines: lines)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Subject#apply applies edits in start-byte order; findings use the
|
|
91
|
+
# same order, so an unsorted edit list from a custom mutation source
|
|
92
|
+
# still gets a sane range and one entry per edit.
|
|
93
|
+
def sorted_edits(result)
|
|
94
|
+
result.mutation.edits.sort_by(&:start_byte)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Stryker joins a multi-edit mutant's replacements the same way, so
|
|
98
|
+
# both reports describe one mutant with one text.
|
|
99
|
+
def joined_texts(result, &pick)
|
|
100
|
+
sorted_edits(result).map { |edit| safe_text(pick.call(edit)) }.join(" | ")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# One entry per edit, so a multi-edit finding can point each joined
|
|
104
|
+
# text at its own spot on the diff.
|
|
105
|
+
def edit_entries(result)
|
|
106
|
+
sorted_edits(result).map do |edit|
|
|
107
|
+
{
|
|
108
|
+
line: result.subject.line_of(edit.start_byte),
|
|
109
|
+
endLine: result.subject.line_of([edit.end_byte - 1, edit.start_byte].max),
|
|
110
|
+
original: safe_text(original_text(result, edit)),
|
|
111
|
+
replacement: safe_text(edit.replacement)
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def original_text(result, edit)
|
|
117
|
+
result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A byte slice of source may be invalid UTF-8, which JSON cannot hold.
|
|
121
|
+
def safe_text(text)
|
|
122
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def path(report, result)
|
|
126
|
+
result.subject.file.delete_prefix("#{report.root}/")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|