saboteur 0.2.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +145 -0
- data/exe/saboteur +12 -0
- data/lib/saboteur/cli.rb +112 -0
- data/lib/saboteur/config.rb +133 -0
- data/lib/saboteur/engine/boot.rb +83 -0
- data/lib/saboteur/engine/candidates.rb +63 -0
- data/lib/saboteur/engine/coverage_store.rb +99 -0
- data/lib/saboteur/engine/diagnostics.rb +37 -0
- data/lib/saboteur/engine/diff_scope.rb +32 -0
- data/lib/saboteur/engine/distribution.rb +74 -0
- data/lib/saboteur/engine/result_cache.rb +93 -0
- data/lib/saboteur/engine/scheduler.rb +215 -0
- data/lib/saboteur/engine/subject_selection.rb +63 -0
- data/lib/saboteur/engine/suite_files.rb +82 -0
- data/lib/saboteur/engine/verdict_check.rb +78 -0
- data/lib/saboteur/engine.rb +133 -0
- data/lib/saboteur/execution/process_table.rb +177 -0
- data/lib/saboteur/execution/process_tree.rb +99 -0
- data/lib/saboteur/execution/protocol.rb +41 -0
- data/lib/saboteur/execution/reevaluator.rb +144 -0
- data/lib/saboteur/execution/runner.rb +191 -0
- data/lib/saboteur/execution/supervisor.rb +337 -0
- data/lib/saboteur/execution/worker_pool.rb +249 -0
- data/lib/saboteur/frameworks/rails.rb +166 -0
- data/lib/saboteur/hooks.rb +51 -0
- data/lib/saboteur/ignore_list.rb +62 -0
- data/lib/saboteur/integration/base.rb +72 -0
- data/lib/saboteur/integration/rspec.rb +143 -0
- data/lib/saboteur/mutation_source/base.rb +26 -0
- data/lib/saboteur/mutation_source/builtin.rb +232 -0
- data/lib/saboteur/mutation_source/edit.rb +18 -0
- data/lib/saboteur/mutation_source/mutation.rb +28 -0
- data/lib/saboteur/reporter/diff.rb +97 -0
- data/lib/saboteur/reporter/stryker.rb +116 -0
- data/lib/saboteur/reporter/terminal.rb +136 -0
- data/lib/saboteur/result.rb +134 -0
- data/lib/saboteur/selection/coverage_map.rb +113 -0
- data/lib/saboteur/selection/git_diff.rb +64 -0
- data/lib/saboteur/selection/recorder.rb +72 -0
- data/lib/saboteur/subject.rb +166 -0
- data/lib/saboteur/subject_finder.rb +98 -0
- data/lib/saboteur/version.rb +6 -0
- data/lib/saboteur.rb +51 -0
- metadata +130 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Integration
|
|
6
|
+
# Loads spec files once in the parent. In a child, narrows the already
|
|
7
|
+
# loaded world to the requested example ids and runs it with a private
|
|
8
|
+
# reporter, so nothing is printed and no status file is written.
|
|
9
|
+
class RSpec < Base
|
|
10
|
+
def setup
|
|
11
|
+
require "rspec/core"
|
|
12
|
+
::RSpec::Core::Runner.disable_autorun!
|
|
13
|
+
options = ::RSpec::Core::ConfigurationOptions.new(rspec_args)
|
|
14
|
+
@runner = ::RSpec::Core::Runner.new(options)
|
|
15
|
+
@runner.setup($stderr, $stdout)
|
|
16
|
+
raise Error, "rspec quit while loading spec files" if world.wants_to_quit
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# RSpec only adds its default "spec" path when $0 is `rspec`.
|
|
20
|
+
def rspec_args
|
|
21
|
+
args = Array(config.fetch("rspec_args", []))
|
|
22
|
+
args.any? { |arg| !arg.start_with?("-") } ? args : args + ["spec"]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def all_tests
|
|
26
|
+
groups = world.ordered_example_groups.flat_map(&:descendants)
|
|
27
|
+
groups.flat_map { |group| world.filtered_examples[group] }
|
|
28
|
+
.map { |example| TestCase.new(id: example.id, file: example.metadata[:rerun_file_path]) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_files
|
|
32
|
+
world.example_groups.map { |group| File.expand_path(group.metadata[:file_path]) }.uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs the tests in the order given: groups by their earliest example,
|
|
36
|
+
# examples by position.
|
|
37
|
+
def run(ids)
|
|
38
|
+
wanted = Set.new(ids)
|
|
39
|
+
narrow_to(wanted)
|
|
40
|
+
order_by(ids)
|
|
41
|
+
configuration.fail_fast = 1
|
|
42
|
+
listener = Listener.new
|
|
43
|
+
passed = execute(listener)
|
|
44
|
+
RunResult.new(passed: passed && listener.failed.empty?, failed_ids: listener.failed,
|
|
45
|
+
executed_ids: listener.executed, runtime: listener.runtime,
|
|
46
|
+
failure_classes: listener.failure_classes, failure_messages: listener.failure_messages)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run_all(recorder)
|
|
50
|
+
configuration.fail_fast = false
|
|
51
|
+
execute(Listener.new(recorder))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def world
|
|
57
|
+
::RSpec.world
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def configuration
|
|
61
|
+
::RSpec.configuration
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def order_by(ids)
|
|
65
|
+
rank = ids.each_with_index.to_h
|
|
66
|
+
example_rank = ->(example) { rank.fetch(example.id, Float::INFINITY) }
|
|
67
|
+
configuration.register_ordering(:global) do |items|
|
|
68
|
+
items.sort_by do |item|
|
|
69
|
+
if item.is_a?(Class)
|
|
70
|
+
item.descendant_filtered_examples.map(&example_rank).min || Float::INFINITY
|
|
71
|
+
else
|
|
72
|
+
example_rank.call(item)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def narrow_to(wanted)
|
|
79
|
+
world.filtered_examples.clear
|
|
80
|
+
world.filtered_examples.default_proc = proc do |hash, group|
|
|
81
|
+
hash[group] = group.examples.select { |example| wanted.include?(example.id) }
|
|
82
|
+
end
|
|
83
|
+
world.example_groups.flat_map(&:descendants).each(&:reset_memoized)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def execute(listener)
|
|
87
|
+
reporter = ::RSpec::Core::Reporter.new(configuration)
|
|
88
|
+
reporter.register_listener(listener, *Listener::EVENTS)
|
|
89
|
+
groups = world.ordered_example_groups.select { |group| group.descendant_filtered_examples.any? }
|
|
90
|
+
count = world.example_count(groups)
|
|
91
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
passed = reporter.report(count) do |r|
|
|
93
|
+
configuration.with_suite_hooks { groups.map { |group| group.run(r) }.all? }
|
|
94
|
+
end
|
|
95
|
+
listener.runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
96
|
+
passed && !world.non_example_failure
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class Listener
|
|
100
|
+
EVENTS = %i[example_started example_finished example_failed].freeze
|
|
101
|
+
|
|
102
|
+
MESSAGE_LIMIT = 200
|
|
103
|
+
|
|
104
|
+
attr_reader :executed, :failed, :failure_classes, :failure_messages
|
|
105
|
+
attr_accessor :runtime
|
|
106
|
+
|
|
107
|
+
def initialize(recorder = nil)
|
|
108
|
+
@recorder = recorder
|
|
109
|
+
@executed = []
|
|
110
|
+
@failed = []
|
|
111
|
+
@failure_classes = {}
|
|
112
|
+
@failure_messages = {}
|
|
113
|
+
@runtime = 0.0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def example_started(notification)
|
|
117
|
+
example = notification.example
|
|
118
|
+
@recorder&.test_started(example.id, File.expand_path(example.metadata[:rerun_file_path]))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def example_finished(notification)
|
|
122
|
+
@executed << notification.example.id
|
|
123
|
+
@recorder&.test_finished(notification.example.id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def example_failed(notification)
|
|
127
|
+
@failed << notification.example.id
|
|
128
|
+
exception = notification.example.execution_result.exception
|
|
129
|
+
@failure_classes[notification.example.id] = exception&.class&.name
|
|
130
|
+
@failure_messages[notification.example.id] = first_line(exception&.message)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def first_line(message)
|
|
136
|
+
message.to_s.lines.map(&:strip).find { |line| !line.empty? }.to_s[0, MESSAGE_LIMIT]
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
register :rspec, RSpec
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Public extension API: implement Base#mutations_for and yield Mutation
|
|
5
|
+
# objects built from Edit values. Stable across minor versions.
|
|
6
|
+
require_relative "edit"
|
|
7
|
+
require_relative "mutation"
|
|
8
|
+
|
|
9
|
+
module Saboteur
|
|
10
|
+
module MutationSource
|
|
11
|
+
# Extension point. A source yields Mutation objects for a subject.
|
|
12
|
+
# Built-in operators are one source; external sources (for example a
|
|
13
|
+
# service that proposes edits) implement the same method and are
|
|
14
|
+
# registered in config. Every edit must lie inside the subject's byte range.
|
|
15
|
+
class Base
|
|
16
|
+
def name
|
|
17
|
+
self.class.name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# subject responds to #id, #file, #start_byte, #end_byte, #source.
|
|
21
|
+
def mutations_for(_subject)
|
|
22
|
+
raise NotImplementedError, "#{self.class} must implement #mutations_for"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
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
|
+
# The v1 operator set. Every mutation is a byte-range edit of the original
|
|
10
|
+
# file text; nothing is unparsed.
|
|
11
|
+
class Builtin < Base
|
|
12
|
+
DEFAULT_ARID = [
|
|
13
|
+
'\A(puts|pp|p|print|warn)\b',
|
|
14
|
+
'\A(::)?(Rails\.)?logger\b',
|
|
15
|
+
'\A(::)?(StatsD|Statsd|Datadog|NewRelic)\b',
|
|
16
|
+
'\.instrument\b'
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
# A comparison replacement: swap the operator, or put a literal in
|
|
20
|
+
# place of the whole comparison.
|
|
21
|
+
Swap = Data.define(:operator)
|
|
22
|
+
Literal = Data.define(:text)
|
|
23
|
+
|
|
24
|
+
# Sufficient relational replacements (Kaminski, Ammann, Offutt 2011):
|
|
25
|
+
# the others are subsumed by these. For == and != Ruby gets true/false:
|
|
26
|
+
# <= and >= raise on objects that are not Comparable, which only gives
|
|
27
|
+
# cheap crash kills.
|
|
28
|
+
COMPARISON = {
|
|
29
|
+
:< => [Swap["<="], Swap["!="], Literal["false"]], :<= => [Swap["<"], Swap["=="], Literal["true"]],
|
|
30
|
+
:> => [Swap[">="], Swap["!="], Literal["false"]], :>= => [Swap[">"], Swap["=="], Literal["true"]],
|
|
31
|
+
:== => [Literal["false"], Literal["true"]], :!= => [Literal["true"], Literal["false"]]
|
|
32
|
+
}.freeze
|
|
33
|
+
ARITHMETIC = { :+ => "-", :- => "+", :* => "/", :/ => "*" }.freeze
|
|
34
|
+
# Sufficient connector replacements (Just et al. 2012): each operand
|
|
35
|
+
# alone, then the constant the connector cannot produce from it.
|
|
36
|
+
ABSORBING = { "&&" => "false", "and" => "false", "||" => "true", "or" => "true" }.freeze
|
|
37
|
+
NON_NUMERIC = [Prism::StringNode, Prism::InterpolatedStringNode, Prism::XStringNode,
|
|
38
|
+
Prism::ArrayNode, Prism::HashNode, Prism::SymbolNode].freeze
|
|
39
|
+
OR_WRITE = [Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
|
|
40
|
+
Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode,
|
|
41
|
+
Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode,
|
|
42
|
+
Prism::CallOrWriteNode, Prism::IndexOrWriteNode].freeze
|
|
43
|
+
CATCH_ALL = %w[StandardError ::StandardError Exception ::Exception].freeze
|
|
44
|
+
SCOPE_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(arid: DEFAULT_ARID)
|
|
47
|
+
super()
|
|
48
|
+
@arid = arid.map { |pattern| pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def name
|
|
52
|
+
"builtin"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def mutations_for(subject)
|
|
56
|
+
body = subject.node.body
|
|
57
|
+
@subject = subject
|
|
58
|
+
@found = []
|
|
59
|
+
coarse(body)
|
|
60
|
+
visit(body)
|
|
61
|
+
@found
|
|
62
|
+
ensure
|
|
63
|
+
@subject = nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# --- coarse ---------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def coarse(body)
|
|
71
|
+
statements = body.is_a?(Prism::BeginNode) ? body.statements : body
|
|
72
|
+
return if statements.nil?
|
|
73
|
+
|
|
74
|
+
unless nothing_to_remove?(statements)
|
|
75
|
+
# A predicate's nil is just false; true is the value its tests must reject.
|
|
76
|
+
(@subject.name.end_with?("?") ? %w[true false] : %w[nil]).each do |value|
|
|
77
|
+
add("body_#{value}", statements, value, coarse: true)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
add("body_raise", statements, "raise", coarse: true) unless rescues_everything?(body)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Nothing but logging: replacing it changes nothing a test can see.
|
|
84
|
+
def nothing_to_remove?(statements)
|
|
85
|
+
statements.body.all? { |statement| arid?(statement) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A raise that the method's own rescue catches is absorbed by design. It
|
|
89
|
+
# would survive, and a surviving coarse mutant stops every fine mutant
|
|
90
|
+
# of the method, so it is not generated.
|
|
91
|
+
def rescues_everything?(body)
|
|
92
|
+
clause = body.is_a?(Prism::BeginNode) ? body.rescue_clause : nil
|
|
93
|
+
while clause
|
|
94
|
+
return true if clause.exceptions.empty? || clause.exceptions.any? { |e| CATCH_ALL.include?(e.slice) }
|
|
95
|
+
|
|
96
|
+
clause = clause.subsequent
|
|
97
|
+
end
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- fine -----------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def visit(node)
|
|
104
|
+
return if node.nil?
|
|
105
|
+
return if SCOPE_NODES.any? { |klass| node.is_a?(klass) }
|
|
106
|
+
|
|
107
|
+
if node.is_a?(Prism::StatementsNode)
|
|
108
|
+
visit_statements(node)
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
mutate(node)
|
|
113
|
+
node.compact_child_nodes.each { |child| visit(child) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Statement removal applies only to sequences of two or more statements.
|
|
117
|
+
# A single statement is either the whole method body (body_nil covers
|
|
118
|
+
# it) or an expression slot: a ternary branch, an interpolation, the
|
|
119
|
+
# body of a modifier if. Replacing those with nil is not removing a
|
|
120
|
+
# statement and duplicates the condition and coarse operators.
|
|
121
|
+
def visit_statements(node)
|
|
122
|
+
statements = node.body
|
|
123
|
+
statements.each do |statement|
|
|
124
|
+
next if arid?(statement)
|
|
125
|
+
|
|
126
|
+
add("statement_removal", statement, "nil") if statements.size > 1 && removable?(statement)
|
|
127
|
+
visit(statement)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def mutate(node)
|
|
132
|
+
case node
|
|
133
|
+
when Prism::CallNode then mutate_call(node)
|
|
134
|
+
when Prism::AndNode, Prism::OrNode then mutate_boolean(node)
|
|
135
|
+
when Prism::IfNode, Prism::UnlessNode then mutate_condition(node.predicate, hang_on: nil)
|
|
136
|
+
when Prism::WhileNode then mutate_condition(node.predicate, hang_on: "true")
|
|
137
|
+
when Prism::UntilNode then mutate_condition(node.predicate, hang_on: "false")
|
|
138
|
+
when Prism::IntegerNode, Prism::FloatNode then mutate_number(node)
|
|
139
|
+
when *OR_WRITE then add("or_assign", node.operator_loc, "=")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def mutate_call(node)
|
|
144
|
+
if binary?(node)
|
|
145
|
+
COMPARISON.fetch(node.name, []).each do |replacement|
|
|
146
|
+
case replacement
|
|
147
|
+
when Swap then add("comparison", node.message_loc, replacement.operator)
|
|
148
|
+
when Literal then add("comparison", node, replacement.text)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
arithmetic = ARITHMETIC[node.name]
|
|
152
|
+
add("arithmetic", node.message_loc, arithmetic) if arithmetic && numeric_operands?(node)
|
|
153
|
+
end
|
|
154
|
+
mutate_negation(node) if node.name == :! && node.receiver
|
|
155
|
+
add("safe_navigation", node.call_operator_loc, ".") if node.safe_navigation?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def binary?(node)
|
|
159
|
+
node.receiver && node.message_loc && node.call_operator_loc.nil? &&
|
|
160
|
+
node.arguments&.arguments&.size == 1 && node.block.nil?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def numeric_operands?(node)
|
|
164
|
+
[node.receiver, node.arguments.arguments.first].none? { |operand| NON_NUMERIC.any? { operand.is_a?(_1) } }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def mutate_negation(node)
|
|
168
|
+
add("negation_removal", node, node.receiver.slice)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# An operand of && / and binds at least as tightly as the expression it
|
|
172
|
+
# replaces, so it needs no parentheses (the re-parse check backs this).
|
|
173
|
+
def mutate_boolean(node)
|
|
174
|
+
[node.left, node.right].each { |operand| add("boolean_logic", node, operand.slice) }
|
|
175
|
+
add("boolean_logic", node, ABSORBING.fetch(node.operator_loc.slice))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Replacing a loop condition with the value that never ends it would
|
|
179
|
+
# only produce timeouts, so that one is skipped.
|
|
180
|
+
def mutate_condition(predicate, hang_on:)
|
|
181
|
+
(%w[true false] - [hang_on]).each { |literal| add("condition_#{literal}", predicate, literal) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def mutate_number(node)
|
|
185
|
+
value = node.value
|
|
186
|
+
candidates = [value + 1, value - 1, 0]
|
|
187
|
+
candidates.map! { |n| node.is_a?(Prism::FloatNode) ? n.to_f : n }
|
|
188
|
+
candidates.uniq.each do |n|
|
|
189
|
+
next if n == value
|
|
190
|
+
|
|
191
|
+
text = n.to_s
|
|
192
|
+
text = "(#{text})" if n.negative?
|
|
193
|
+
add("literal_number", node, text)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- helpers --------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
# Removing nil changes nothing. Removing an if/unless without an else
|
|
200
|
+
# is the same mutant as forcing its condition (condition_false /
|
|
201
|
+
# condition_true), so it would only report the same gap twice.
|
|
202
|
+
def removable?(statement)
|
|
203
|
+
case statement
|
|
204
|
+
when Prism::NilNode then false
|
|
205
|
+
when Prism::IfNode then else_branch?(statement, :subsequent)
|
|
206
|
+
when Prism::UnlessNode then else_branch?(statement, :else_clause)
|
|
207
|
+
else true
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def else_branch?(node, name)
|
|
212
|
+
!node.public_send(name).nil?
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def arid?(node)
|
|
216
|
+
source = node.slice
|
|
217
|
+
@arid.any? { |pattern| pattern.match?(source) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# node_or_location: a Prism node or location (both have offsets and slice).
|
|
221
|
+
def add(operator, node_or_location, replacement, coarse: false)
|
|
222
|
+
location = node_or_location
|
|
223
|
+
return if location.slice == replacement
|
|
224
|
+
|
|
225
|
+
edit = Edit.new(file: @subject.file, start_byte: location.start_offset,
|
|
226
|
+
end_byte: location.end_offset, replacement: replacement)
|
|
227
|
+
@found << Mutation.new(subject_id: @subject.id, operator: operator, edits: [edit],
|
|
228
|
+
coarse: coarse, source: name)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module MutationSource
|
|
6
|
+
# A replacement of source bytes [start_byte, end_byte) in file with text.
|
|
7
|
+
# Offsets are byte offsets into the file as read from disk (UTF-8),
|
|
8
|
+
# matching Prism::Location#start_offset / #end_offset.
|
|
9
|
+
Edit = Data.define(:file, :start_byte, :end_byte, :replacement) do
|
|
10
|
+
def initialize(file:, start_byte:, end_byte:, replacement:)
|
|
11
|
+
raise ArgumentError, "start_byte must be >= 0" if start_byte.negative?
|
|
12
|
+
raise ArgumentError, "end_byte must be >= start_byte" if end_byte < start_byte
|
|
13
|
+
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Saboteur
|
|
7
|
+
module MutationSource
|
|
8
|
+
# One candidate change to one subject. subject_id is "Klass#meth" or "Klass.meth".
|
|
9
|
+
# coarse: true for whole-body replacements, which run before fine-grained ones.
|
|
10
|
+
Mutation = Data.define(:subject_id, :operator, :edits, :coarse, :source) do
|
|
11
|
+
def initialize(subject_id:, operator:, edits:, coarse: false, source: "builtin")
|
|
12
|
+
raise ArgumentError, "a mutation needs at least one edit" if edits.empty?
|
|
13
|
+
|
|
14
|
+
super(subject_id:, operator:, edits: edits.freeze, coarse:, source:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Stable id for the ignore list. Offsets are made relative to subject_start
|
|
18
|
+
# so edits elsewhere in the file do not change the id.
|
|
19
|
+
def digest(subject_start:, original_source:)
|
|
20
|
+
parts = edits.map do |edit|
|
|
21
|
+
original = original_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte)
|
|
22
|
+
[edit.start_byte - subject_start, original, edit.replacement].join("\0")
|
|
23
|
+
end
|
|
24
|
+
Digest::SHA256.hexdigest([subject_id, operator, *parts].join("\x1F"))[0, 16]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Saboteur
|
|
5
|
+
module Reporter
|
|
6
|
+
# Minimal unified diff between two method texts (LCS over lines). Methods
|
|
7
|
+
# are short, so the quadratic table is fine.
|
|
8
|
+
module Diff
|
|
9
|
+
CONTEXT = 3
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def unified(original, mutated, path:, first_line: 1)
|
|
14
|
+
a = original.lines
|
|
15
|
+
b = mutated.lines
|
|
16
|
+
ops = operations(a, b)
|
|
17
|
+
hunks = hunks(ops)
|
|
18
|
+
return "" if hunks.empty?
|
|
19
|
+
|
|
20
|
+
out = "--- a/#{path}\n+++ b/#{path}\n"
|
|
21
|
+
hunks.each { |hunk| out << render(hunk, first_line) }
|
|
22
|
+
out
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# [[:same|:del|:add, a_index, b_index, text]]
|
|
26
|
+
def operations(old_lines, new_lines)
|
|
27
|
+
a = old_lines
|
|
28
|
+
b = new_lines
|
|
29
|
+
table = lcs_table(a, b)
|
|
30
|
+
ops = []
|
|
31
|
+
i = 0
|
|
32
|
+
j = 0
|
|
33
|
+
while i < a.size || j < b.size
|
|
34
|
+
if i < a.size && j < b.size && a[i] == b[j]
|
|
35
|
+
ops << [:same, i, j, a[i]]
|
|
36
|
+
i += 1
|
|
37
|
+
j += 1
|
|
38
|
+
elsif i < a.size && (j >= b.size || table[i + 1][j] >= table[i][j + 1])
|
|
39
|
+
ops << [:del, i, j, a[i]]
|
|
40
|
+
i += 1
|
|
41
|
+
else
|
|
42
|
+
ops << [:add, i, j, b[j]]
|
|
43
|
+
j += 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
ops
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def lcs_table(old_lines, new_lines)
|
|
50
|
+
a = old_lines
|
|
51
|
+
b = new_lines
|
|
52
|
+
table = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
|
|
53
|
+
(a.size - 1).downto(0) do |i|
|
|
54
|
+
(b.size - 1).downto(0) do |j|
|
|
55
|
+
table[i][j] = a[i] == b[j] ? table[i + 1][j + 1] + 1 : [table[i + 1][j], table[i][j + 1]].max
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
table
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def hunks(ops)
|
|
62
|
+
changed = ops.each_index.reject { |index| ops[index][0] == :same }
|
|
63
|
+
return [] if changed.empty?
|
|
64
|
+
|
|
65
|
+
ranges = changed.map { |index| [[index - CONTEXT, 0].max, [index + CONTEXT, ops.size - 1].min] }
|
|
66
|
+
merged = ranges.each_with_object([]) do |(from, to), acc|
|
|
67
|
+
if acc.any? && from <= acc.last[1] + 1
|
|
68
|
+
acc.last[1] = [acc.last[1], to].max
|
|
69
|
+
else
|
|
70
|
+
acc << [from, to]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
merged.map { |from, to| ops[from..to] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render(hunk, first_line)
|
|
77
|
+
a_start = hunk.first[1]
|
|
78
|
+
b_start = hunk.first[2]
|
|
79
|
+
a_count = hunk.count { |op| op[0] != :add }
|
|
80
|
+
b_count = hunk.count { |op| op[0] != :del }
|
|
81
|
+
out = "@@ -#{range(a_start + first_line, a_count)} +#{range(b_start + first_line, b_count)} @@\n"
|
|
82
|
+
hunk.each do |op, _, _, text|
|
|
83
|
+
prefix = { same: " ", del: "-", add: "+" }.fetch(op)
|
|
84
|
+
out << prefix << text
|
|
85
|
+
out << "\n" unless text.end_with?("\n")
|
|
86
|
+
end
|
|
87
|
+
out
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def range(start, count)
|
|
91
|
+
return "#{start - 1},0" if count.zero?
|
|
92
|
+
|
|
93
|
+
count == 1 ? start.to_s : "#{start},#{count}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
module Reporter
|
|
9
|
+
# JSON in the open mutation-testing-report-schema (schemaVersion 2), as
|
|
10
|
+
# read by the Stryker dashboard and mutation-testing-elements.
|
|
11
|
+
class Stryker
|
|
12
|
+
SCHEMA_VERSION = "2"
|
|
13
|
+
THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(report)
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
21
|
+
File.write(@path, JSON.generate(self.class.build(report)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Tests are listed once under testFiles with short ids; mutants refer to
|
|
25
|
+
# them by id in killedBy/coveredBy instead of repeating full test names.
|
|
26
|
+
def self.build(report)
|
|
27
|
+
test_ids = test_index(report.results)
|
|
28
|
+
files = report.results.group_by { |result| result.subject.file }.to_h do |file, results|
|
|
29
|
+
[relative(file, report.root), file_entry(results, test_ids)]
|
|
30
|
+
end
|
|
31
|
+
{
|
|
32
|
+
schemaVersion: SCHEMA_VERSION,
|
|
33
|
+
thresholds: THRESHOLDS,
|
|
34
|
+
projectRoot: report.root,
|
|
35
|
+
files: files,
|
|
36
|
+
testFiles: test_files(test_ids),
|
|
37
|
+
framework: { name: "saboteur", version: VERSION }
|
|
38
|
+
}.merge(report.seed ? { config: { seed: report.seed } } : {})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.test_index(results)
|
|
42
|
+
names = results.flat_map { |result| result.covered_by + result.killed_by }.uniq
|
|
43
|
+
names.each_with_index.to_h { |name, index| [name, index.to_s] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# RSpec ids look like "./spec/a_spec.rb[1:2]"; anything else is grouped
|
|
47
|
+
# under an empty path.
|
|
48
|
+
def self.test_files(test_ids)
|
|
49
|
+
test_ids.group_by { |name, _| name[%r{\A\.?/?(.+?\.rb)\[}, 1].to_s }.transform_values do |pairs|
|
|
50
|
+
{ tests: pairs.map { |name, id| { id: id, name: name } } }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.file_entry(results, test_ids)
|
|
55
|
+
source = results.first.subject.file_source.dup.force_encoding(Encoding::UTF_8)
|
|
56
|
+
{ language: "ruby", source: source.scrub,
|
|
57
|
+
mutants: results.map { |result| mutant(result, source, test_ids) } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.mutant(result, source, test_ids)
|
|
61
|
+
entry = {
|
|
62
|
+
id: result.digest,
|
|
63
|
+
mutatorName: result.mutation.operator,
|
|
64
|
+
replacement: result.mutation.edits.map(&:replacement).join(" | "),
|
|
65
|
+
location: location(result.mutation.edits.first, source),
|
|
66
|
+
status: status(result),
|
|
67
|
+
static: false
|
|
68
|
+
}
|
|
69
|
+
entry.merge(optional_fields(result, test_ids))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.optional_fields(result, test_ids)
|
|
73
|
+
{
|
|
74
|
+
statusReason: status_reason(result),
|
|
75
|
+
killedBy: result.killed_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
76
|
+
coveredBy: result.covered_by.map { |name| test_ids.fetch(name) }.then { |ids| ids.empty? ? nil : ids },
|
|
77
|
+
duration: result.runtime && (result.runtime * 1000).round
|
|
78
|
+
}.compact
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.status(result)
|
|
82
|
+
case result.status
|
|
83
|
+
when :killed then "Killed"
|
|
84
|
+
when :survived then "Survived"
|
|
85
|
+
when :timeout then "Timeout"
|
|
86
|
+
when :error, :resource_limit then "RuntimeError"
|
|
87
|
+
when :skipped then result.reason == "no coverage" ? "NoCoverage" : "Ignored"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.status_reason(result)
|
|
92
|
+
return "ignored: #{result.ignored}" if result.ignored
|
|
93
|
+
return "resource limit: #{result.reason}" if result.status == :resource_limit
|
|
94
|
+
return result.crash_kill? ? "crash: #{result.kill_cause}" : result.kill_cause if result.kill_cause
|
|
95
|
+
|
|
96
|
+
result.reason.to_s.lines.first&.strip
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.location(edit, source)
|
|
100
|
+
{ start: position(source, edit.start_byte), end: position(source, edit.end_byte) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 1-based line and column; column counts characters, not bytes.
|
|
104
|
+
def self.position(source, byte_offset)
|
|
105
|
+
before = source.byteslice(0, byte_offset).force_encoding(Encoding::UTF_8).scrub
|
|
106
|
+
line = before.count("\n") + 1
|
|
107
|
+
column = before.size - (before.rindex("\n") || -1)
|
|
108
|
+
{ line: line, column: column }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.relative(file, root)
|
|
112
|
+
file.delete_prefix("#{root}/")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|