saboteur 0.2.0 → 0.3.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 +40 -0
- data/README.md +322 -54
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +169 -22
- data/lib/saboteur/config.rb +39 -5
- data/lib/saboteur/doctor.rb +72 -0
- data/lib/saboteur/engine/baseline_guard.rb +99 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +9 -2
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +316 -0
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +53 -14
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/help.rb +194 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +23 -2
- data/lib/saboteur/mutation_source/builtin.rb +6 -1
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/findings.rb +123 -0
- data/lib/saboteur/reporter/html.rb +165 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +11 -1
- data/lib/saboteur/reporter/terminal.rb +11 -0
- data/lib/saboteur/result.rb +16 -5
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +3 -0
- metadata +15 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
# The text printed by `saboteur --help` and `saboteur help <command>`.
|
|
9
|
+
#
|
|
10
|
+
# It is a map of everything saboteur can do, for people and for AI agents that
|
|
11
|
+
# have no docs. The layout is fixed: section headers are upper case on their
|
|
12
|
+
# own line, command and key rows start with two spaces. Option lines come from
|
|
13
|
+
# the CLI's own option parser, so a new option shows up here without an edit.
|
|
14
|
+
class Help
|
|
15
|
+
Command = Data.define(:name, :summary, :description, :takes_options, :exit_codes, :examples)
|
|
16
|
+
|
|
17
|
+
PURPOSE = "mutation testing for Ruby and RSpec"
|
|
18
|
+
DOCUMENTATION = "https://github.com/vamsipavanmahesh/saboteur"
|
|
19
|
+
|
|
20
|
+
EXIT_CODE_MEANINGS = {
|
|
21
|
+
0 => "Success. Without --threshold: no unignored survivors. With --threshold: the score is at or above " \
|
|
22
|
+
"it, even if mutants survived.",
|
|
23
|
+
1 => "Without --threshold: at least one mutant survived and is not on the ignore list. With --threshold " \
|
|
24
|
+
"(survivors alone no longer fail the run): the score is below it, or no mutant was tested so " \
|
|
25
|
+
"there is no score.",
|
|
26
|
+
2 => "Usage or configuration error: fix the command or .saboteur.yml. A replay --id that matches no " \
|
|
27
|
+
"mutant is only found after the target has booted. A named source or test file that does not exist.",
|
|
28
|
+
3 => "Tool error: saboteur could not finish, or every executed mutant errored. With --threshold, " \
|
|
29
|
+
"also any errored or resource-limited mutant."
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
DOCTOR_EXIT_CODE_MEANINGS = {
|
|
33
|
+
0 => "Healthy: the environment checks passed and the baseline passed twice in the same order.",
|
|
34
|
+
2 => "Usage or configuration error: fix the command or .saboteur.yml.",
|
|
35
|
+
3 => "A check failed: the target did not boot, or the baseline failed or changed between runs."
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
COMMANDS = [
|
|
39
|
+
Command.new(
|
|
40
|
+
name: "run",
|
|
41
|
+
summary: "Mutate your code and report the mutants your tests do not catch",
|
|
42
|
+
description: "Builds a per-test coverage map, makes small changes to your methods, runs only the " \
|
|
43
|
+
"tests that cover each change, and reports survivors (changes no test noticed). Source files or " \
|
|
44
|
+
"directories after run replace include and exclude; --test limits the specs that run; " \
|
|
45
|
+
"--threshold PERCENT replaces the survivor check: exit 0 when the score is at or above " \
|
|
46
|
+
"PERCENT, exit 1 when it is below.",
|
|
47
|
+
takes_options: true,
|
|
48
|
+
exit_codes: EXIT_CODE_MEANINGS,
|
|
49
|
+
examples: [
|
|
50
|
+
["Pull request: only methods changed since the target branch", "saboteur run --since origin/main"],
|
|
51
|
+
["Whole project, four workers, repeatable seed", "saboteur run --jobs 4 --seed 42"],
|
|
52
|
+
["One class or method", "saboteur run --subject 'Person#adult?'"],
|
|
53
|
+
["Replay one mutant by the digest printed next to a survivor", "saboteur run --id 1a2b3c4d5e6f7a8b"],
|
|
54
|
+
["Mutate named files against named specs, skipping the whole-suite coverage run",
|
|
55
|
+
"saboteur run lib/a.rb lib/b.rb --test spec/a_spec.rb --test spec/b_spec.rb"],
|
|
56
|
+
["CI gate: exit 1 when the score is below 90 percent", "saboteur run --threshold 90"]
|
|
57
|
+
]
|
|
58
|
+
),
|
|
59
|
+
Command.new(
|
|
60
|
+
name: "doctor",
|
|
61
|
+
summary: "Check the environment and run the baseline twice, without mutating anything",
|
|
62
|
+
description: "Boots the target like a run, checks runtime, coverage and boot files, then runs your " \
|
|
63
|
+
"suite twice in the same order with one seed and reports whether both runs passed. " \
|
|
64
|
+
"It never mutates anything.",
|
|
65
|
+
takes_options: true,
|
|
66
|
+
exit_codes: DOCTOR_EXIT_CODE_MEANINGS,
|
|
67
|
+
examples: [["Check the setup before the first run", "saboteur doctor"],
|
|
68
|
+
["Use another config file and seed", "saboteur doctor --config ci.yml --seed 7"]]
|
|
69
|
+
),
|
|
70
|
+
Command.new(
|
|
71
|
+
name: "version",
|
|
72
|
+
summary: "Print the installed version",
|
|
73
|
+
description: "Prints the version number and nothing else.",
|
|
74
|
+
takes_options: false,
|
|
75
|
+
exit_codes: nil,
|
|
76
|
+
examples: [["Print the version", "saboteur version"]]
|
|
77
|
+
),
|
|
78
|
+
Command.new(
|
|
79
|
+
name: "help",
|
|
80
|
+
summary: "Show this map, or details for one command: saboteur help <command>",
|
|
81
|
+
description: "Without an argument, prints every command, option, config key and exit code. " \
|
|
82
|
+
"With a command name, prints that command's usage, options and examples.",
|
|
83
|
+
takes_options: false,
|
|
84
|
+
exit_codes: nil,
|
|
85
|
+
examples: [["Show everything", "saboteur help"], ["Show one command", "saboteur help run"]]
|
|
86
|
+
)
|
|
87
|
+
].freeze
|
|
88
|
+
|
|
89
|
+
FILES = [
|
|
90
|
+
[".saboteur.yml", "Settings. Command line options override it."],
|
|
91
|
+
[".saboteur-ignore.yml", "Accepted survivors: entries with subject, mutation (digest) and a required reason."],
|
|
92
|
+
[".saboteur/report.json", "Stryker mutation-testing-report-schema JSON (--json changes the path)."],
|
|
93
|
+
[".saboteur/report.html", "Self-contained HTML report (--html changes the path)."],
|
|
94
|
+
[".saboteur/", "Cache: coverage map and incremental verdicts. Safe to delete."]
|
|
95
|
+
].freeze
|
|
96
|
+
|
|
97
|
+
# option_lines: the option lines of each command's parser, keyed by command name.
|
|
98
|
+
def initialize(option_lines:, commands: COMMANDS)
|
|
99
|
+
@option_lines = option_lines
|
|
100
|
+
@commands = commands
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def overview
|
|
104
|
+
[title, usage_section, commands_section, *option_sections, examples_section(all_examples),
|
|
105
|
+
*exit_code_sections, config_section, files_section, "Documentation: #{DOCUMENTATION}\n"].join("\n")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Details for one command, or nil when there is no such command.
|
|
109
|
+
def command(name)
|
|
110
|
+
found = @commands.find { |candidate| candidate.name == name }
|
|
111
|
+
return unless found
|
|
112
|
+
|
|
113
|
+
sections = ["saboteur #{found.name} - #{found.summary}\n", "#{found.description}\n", command_usage(found)]
|
|
114
|
+
sections << options_section(found) if found.takes_options
|
|
115
|
+
sections << examples_section(found.examples)
|
|
116
|
+
sections << exit_codes_section(found.exit_codes) if found.exit_codes
|
|
117
|
+
sections.join("\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def command_names
|
|
121
|
+
@commands.map(&:name)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# One section for every command that takes options, so the overview needs no other page.
|
|
127
|
+
def option_sections
|
|
128
|
+
@commands.select(&:takes_options).map { |command| options_section(command) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def exit_code_sections
|
|
132
|
+
@commands.select(&:exit_codes).map { |command| exit_codes_section(command.exit_codes, command.name) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def title
|
|
136
|
+
"saboteur #{VERSION} - #{PURPOSE}\n"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def usage_section
|
|
140
|
+
"USAGE\n saboteur <command> [options]\n saboteur help [command]\n"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def command_usage(command)
|
|
144
|
+
suffix = command.takes_options ? " [options]" : ""
|
|
145
|
+
"USAGE\n saboteur #{command.name}#{suffix}\n"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def commands_section
|
|
149
|
+
rows = @commands.map { |command| " #{command.name.ljust(name_width)} #{command.summary}" }
|
|
150
|
+
"COMMANDS\n#{rows.join("\n")}\n"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def name_width
|
|
154
|
+
@commands.map { |command| command.name.length }.max
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def options_section(command)
|
|
158
|
+
"OPTIONS (saboteur #{command.name})\n#{@option_lines.fetch(command.name).map(&:rstrip).join("\n")}\n"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def all_examples
|
|
162
|
+
@commands.flat_map(&:examples)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def examples_section(examples)
|
|
166
|
+
"EXAMPLES\n#{examples.map { |note, line| " # #{note}\n #{line}" }.join("\n")}\n"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def exit_codes_section(codes, command_name = nil)
|
|
170
|
+
rows = codes.map { |code, meaning| " #{code} #{meaning}" }
|
|
171
|
+
title = command_name ? "EXIT CODES (saboteur #{command_name})" : "EXIT CODES"
|
|
172
|
+
"#{title}\n#{rows.join("\n")}\n"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def config_section
|
|
176
|
+
rows = Config::DEFAULTS.map { |key, value| " #{key}: #{describe_default(value)}" }
|
|
177
|
+
intro = "Every key with its default value, as YAML. Unknown keys are an error."
|
|
178
|
+
"CONFIG FILE (.saboteur.yml)\n#{intro}\n#{rows.join("\n")}\n"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Valid YAML (JSON flow style), so a copied line keeps the default's meaning.
|
|
182
|
+
def describe_default(value)
|
|
183
|
+
JSON.generate(value)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def files_section
|
|
187
|
+
"FILES\n#{FILES.map { |path, meaning| " #{path.ljust(files_width)} #{meaning}" }.join("\n")}\n"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def files_width
|
|
191
|
+
FILES.map { |path, _| path.length }.max
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -61,6 +61,14 @@ module Saboteur
|
|
|
61
61
|
raise NotImplementedError
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Checks the unchanged suite in a fresh child. Existing adapters can
|
|
65
|
+
# use their selected-test runner; adapters may override to retain full
|
|
66
|
+
# suite ordering and report every executed test.
|
|
67
|
+
def run_baseline(seed:)
|
|
68
|
+
Kernel.srand(seed)
|
|
69
|
+
run(all_tests.map(&:id))
|
|
70
|
+
end
|
|
71
|
+
|
|
64
72
|
# Files whose content decides which tests exist (for the cache key).
|
|
65
73
|
def test_files
|
|
66
74
|
[]
|
|
@@ -10,9 +10,9 @@ module Saboteur
|
|
|
10
10
|
def setup
|
|
11
11
|
require "rspec/core"
|
|
12
12
|
::RSpec::Core::Runner.disable_autorun!
|
|
13
|
-
|
|
14
|
-
@runner = ::RSpec::Core::Runner.new(options)
|
|
13
|
+
@runner = ::RSpec::Core::Runner.new(configuration_options)
|
|
15
14
|
@runner.setup($stderr, $stdout)
|
|
15
|
+
configuration.order = "rand:#{config.fetch('seed', 1)}"
|
|
16
16
|
raise Error, "rspec quit while loading spec files" if world.wants_to_quit
|
|
17
17
|
end
|
|
18
18
|
|
|
@@ -22,6 +22,16 @@ module Saboteur
|
|
|
22
22
|
args.any? { |arg| !arg.start_with?("-") } ? args : args + ["spec"]
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# RSpec's own parser reads rspec_args and .rspec, so option values such
|
|
26
|
+
# as `--require path` stay intact. Explicit tests then replace only the
|
|
27
|
+
# files and directories to run.
|
|
28
|
+
def configuration_options
|
|
29
|
+
::RSpec::Core::ConfigurationOptions.new(rspec_args).tap do |options|
|
|
30
|
+
explicit = Array(config.fetch("tests", []))
|
|
31
|
+
options.options[:files_or_directories_to_run] = explicit if explicit.any?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
25
35
|
def all_tests
|
|
26
36
|
groups = world.ordered_example_groups.flat_map(&:descendants)
|
|
27
37
|
groups.flat_map { |group| world.filtered_examples[group] }
|
|
@@ -47,10 +57,21 @@ module Saboteur
|
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
def run_all(recorder)
|
|
60
|
+
configuration.order = "rand:#{config.fetch('seed', 1)}"
|
|
50
61
|
configuration.fail_fast = false
|
|
51
62
|
execute(Listener.new(recorder))
|
|
52
63
|
end
|
|
53
64
|
|
|
65
|
+
def run_baseline(seed:)
|
|
66
|
+
configuration.order = "rand:#{seed}"
|
|
67
|
+
configuration.fail_fast = false
|
|
68
|
+
listener = Listener.new
|
|
69
|
+
passed = execute(listener)
|
|
70
|
+
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
71
|
+
executed_ids: listener.executed, runtime: listener.runtime,
|
|
72
|
+
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
|
|
73
|
+
end
|
|
74
|
+
|
|
54
75
|
private
|
|
55
76
|
|
|
56
77
|
def world
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
require "prism"
|
|
5
5
|
require_relative "base"
|
|
6
|
+
require_relative "filter_replacement"
|
|
7
|
+
require_relative "literal_replacement"
|
|
6
8
|
|
|
7
9
|
module Saboteur
|
|
8
10
|
module MutationSource
|
|
@@ -46,6 +48,8 @@ module Saboteur
|
|
|
46
48
|
def initialize(arid: DEFAULT_ARID)
|
|
47
49
|
super()
|
|
48
50
|
@arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
|
|
51
|
+
@filter_replacement = FilterReplacement.new(arid: arid)
|
|
52
|
+
@literal_replacement = LiteralReplacement.new(arid: arid)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
def name
|
|
@@ -58,7 +62,8 @@ module Saboteur
|
|
|
58
62
|
@found = []
|
|
59
63
|
coarse(body)
|
|
60
64
|
visit(body)
|
|
61
|
-
@found
|
|
65
|
+
@found.concat(@filter_replacement.mutations_for(subject))
|
|
66
|
+
@found.concat(@literal_replacement.mutations_for(subject))
|
|
62
67
|
ensure
|
|
63
68
|
@subject = nil
|
|
64
69
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
# Swaps the inclusion rule of a filter call with its exclusion rule.
|
|
10
|
+
# Only message bytes change; arguments, receiver and block stay intact.
|
|
11
|
+
class FilterReplacement < Base
|
|
12
|
+
REPLACEMENTS = { select: "reject", reject: "select", select!: "reject!", reject!: "select!" }.freeze
|
|
13
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
14
|
+
|
|
15
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
16
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
17
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
"filter_replacement"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def mutations_for(subject)
|
|
25
|
+
found = []
|
|
26
|
+
visit(subject.node.body, subject, found)
|
|
27
|
+
found
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def visit(node, subject, found)
|
|
33
|
+
return if node.nil? || SCOPE_NODES.any? { |scope| node.is_a?(scope) }
|
|
34
|
+
|
|
35
|
+
if node.is_a?(Prism::StatementsNode)
|
|
36
|
+
node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
replace_filter(node, subject, found) if node.is_a?(Prism::CallNode)
|
|
41
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def replace_filter(node, subject, found)
|
|
45
|
+
replacement = REPLACEMENTS[node.name]
|
|
46
|
+
return unless replacement && node.receiver && node.block
|
|
47
|
+
return unless node.arguments.nil?
|
|
48
|
+
|
|
49
|
+
location = node.message_loc
|
|
50
|
+
edit = Edit.new(file: subject.file, start_byte: location.start_offset,
|
|
51
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
52
|
+
found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def arid?(node)
|
|
56
|
+
@arid.any? { |pattern| pattern.match?(node.slice) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
# Changes literal values without rebuilding the surrounding source.
|
|
10
|
+
class LiteralReplacement < Base
|
|
11
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
12
|
+
TEXT_NODES = [Prism::InterpolatedStringNode, Prism::SymbolNode, Prism::InterpolatedSymbolNode,
|
|
13
|
+
Prism::RegularExpressionNode, Prism::InterpolatedRegularExpressionNode,
|
|
14
|
+
Prism::XStringNode, Prism::InterpolatedXStringNode].freeze
|
|
15
|
+
PARAMETER_NODES = [Prism::ParametersNode, Prism::BlockParametersNode].freeze
|
|
16
|
+
BOUNDARIES = (SCOPE_NODES + TEXT_NODES + PARAMETER_NODES).freeze
|
|
17
|
+
|
|
18
|
+
# Base inherits Object's empty initializer; there is no parent state to set.
|
|
19
|
+
def initialize(arid: []) # rubocop:disable Lint/MissingSuper
|
|
20
|
+
@arid = arid.map { |pattern| Regexp.new(pattern) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
"literal_replacement"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def mutations_for(subject)
|
|
28
|
+
found = []
|
|
29
|
+
visit(subject.node.body, subject, found)
|
|
30
|
+
found
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def visit(node, subject, found)
|
|
36
|
+
return if node.nil? || boundary?(node)
|
|
37
|
+
|
|
38
|
+
if node.is_a?(Prism::StatementsNode)
|
|
39
|
+
node.body.each { |statement| visit(statement, subject, found) unless arid?(statement) }
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
case node
|
|
44
|
+
when Prism::TrueNode then add(subject, found, node, "literal_boolean", "false")
|
|
45
|
+
when Prism::FalseNode then add(subject, found, node, "literal_boolean", "true")
|
|
46
|
+
when Prism::StringNode
|
|
47
|
+
replace_string(node, subject, found)
|
|
48
|
+
end
|
|
49
|
+
node.compact_child_nodes.each { |child| visit(child, subject, found) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def boundary?(node)
|
|
53
|
+
BOUNDARIES.any? { |type| node.is_a?(type) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def replace_string(node, subject, found)
|
|
57
|
+
return if node.unescaped.empty?
|
|
58
|
+
return unless node.opening_loc && !node.opening_loc.slice.start_with?("<<")
|
|
59
|
+
|
|
60
|
+
add(subject, found, node, "literal_string", '""')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def add(subject, found, node, operator, replacement)
|
|
64
|
+
edit = Edit.new(file: subject.file, start_byte: node.location.start_offset,
|
|
65
|
+
end_byte: node.location.end_offset, replacement: replacement)
|
|
66
|
+
found << Mutation.new(subject_id: subject.id, operator: operator, edits: [edit], source: name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def arid?(node)
|
|
70
|
+
@arid.any? { |pattern| pattern.match?(node.slice) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
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 or coarse gating.
|
|
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,123 @@
|
|
|
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
|
+
result.ignored ? entry.merge(reason: result.ignored) : entry
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Subject#apply applies edits in start-byte order; findings use the
|
|
83
|
+
# same order, so an unsorted edit list from a custom mutation source
|
|
84
|
+
# still gets a sane range and one entry per edit.
|
|
85
|
+
def sorted_edits(result)
|
|
86
|
+
result.mutation.edits.sort_by(&:start_byte)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Stryker joins a multi-edit mutant's replacements the same way, so
|
|
90
|
+
# both reports describe one mutant with one text.
|
|
91
|
+
def joined_texts(result, &pick)
|
|
92
|
+
sorted_edits(result).map { |edit| safe_text(pick.call(edit)) }.join(" | ")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One entry per edit, so a multi-edit finding can point each joined
|
|
96
|
+
# text at its own spot on the diff.
|
|
97
|
+
def edit_entries(result)
|
|
98
|
+
sorted_edits(result).map do |edit|
|
|
99
|
+
{
|
|
100
|
+
line: result.subject.line_of(edit.start_byte),
|
|
101
|
+
endLine: result.subject.line_of([edit.end_byte - 1, edit.start_byte].max),
|
|
102
|
+
original: safe_text(original_text(result, edit)),
|
|
103
|
+
replacement: safe_text(edit.replacement)
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def original_text(result, edit)
|
|
109
|
+
result.subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A byte slice of source may be invalid UTF-8, which JSON cannot hold.
|
|
113
|
+
def safe_text(text)
|
|
114
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def path(report, result)
|
|
118
|
+
result.subject.file.delete_prefix("#{report.root}/")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|