mutineer 1.0.2 → 1.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 +4 -4
- data/CHANGELOG.md +126 -0
- data/README.md +5 -2
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/coverage_map.rb +115 -56
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/isolation.rb +9 -3
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +199 -0
- data/lib/mutineer/minitest_integration.rb +22 -8
- data/lib/mutineer/mutator_registry.rb +24 -4
- data/lib/mutineer/mutators/array_literal.rb +51 -0
- data/lib/mutineer/mutators/base.rb +15 -0
- data/lib/mutineer/mutators/chain_link.rb +139 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +65 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/pairing.rb +10 -5
- data/lib/mutineer/runner.rb +28 -7
- data/lib/mutineer/test_runners/minitest.rb +5 -1
- data/lib/mutineer/test_runners/rspec.rb +9 -11
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +6 -0
- metadata +9 -15
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
class MinitestIntegration
|
|
5
|
+
# Stops a Minitest run at the first failing test: one failure already
|
|
6
|
+
# kills the mutant. Child-process only, like MinitestIntegration.
|
|
7
|
+
#
|
|
8
|
+
# It sets a flag instead of unwinding the stack, so that class-level
|
|
9
|
+
# wrappers (`after_all`, a block-form transaction) still finish. The
|
|
10
|
+
# remaining tests and classes then return before they start.
|
|
11
|
+
#
|
|
12
|
+
# Minitest 5 and 6 need different hook points. An unknown shape installs
|
|
13
|
+
# no hook, and the run is a full run.
|
|
14
|
+
module StopAtFirstFailure
|
|
15
|
+
# Prepended on `Minitest::CompositeReporter`.
|
|
16
|
+
module RecordFailure
|
|
17
|
+
# Records the result, then sets the stop flag on a failure or an
|
|
18
|
+
# error. Only the outer reporter counts: a test can build and record
|
|
19
|
+
# on its own reporter.
|
|
20
|
+
#
|
|
21
|
+
# @param result [Minitest::Result] the result of one test.
|
|
22
|
+
# @return [void]
|
|
23
|
+
def record(result)
|
|
24
|
+
super
|
|
25
|
+
return if result.passed? || result.skipped?
|
|
26
|
+
return unless equal?(StopAtFirstFailure.armed_reporter)
|
|
27
|
+
return unless StopAtFirstFailure.armed_here?
|
|
28
|
+
|
|
29
|
+
StopAtFirstFailure.stopped = true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Prepended on the `Minitest` singleton class for Minitest 6.
|
|
34
|
+
module OuterReporter6
|
|
35
|
+
# Keeps the outer reporter. Only the first call counts, because a test
|
|
36
|
+
# can call this method with its own reporter.
|
|
37
|
+
#
|
|
38
|
+
# @param reporter [Minitest::CompositeReporter] the outer reporter.
|
|
39
|
+
# @param options [Hash] the Minitest options.
|
|
40
|
+
# @return [Object] whatever Minitest returns.
|
|
41
|
+
def run_all_suites(reporter, options)
|
|
42
|
+
StopAtFirstFailure.armed_reporter ||= reporter if StopAtFirstFailure.armed_here?
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Prepended on the `Minitest` singleton class for Minitest 5.
|
|
48
|
+
module OuterReporter5
|
|
49
|
+
# The Minitest 5 form of OuterReporter6#run_all_suites.
|
|
50
|
+
#
|
|
51
|
+
# @param reporter [Minitest::CompositeReporter] the outer reporter.
|
|
52
|
+
# @param options [Hash] the Minitest options.
|
|
53
|
+
# @return [Object] whatever Minitest returns.
|
|
54
|
+
def __run(reporter, options)
|
|
55
|
+
StopAtFirstFailure.armed_reporter ||= reporter if StopAtFirstFailure.armed_here?
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Prepended on the singleton class of each test class for Minitest 6.
|
|
61
|
+
module SkipAfterStop6
|
|
62
|
+
# Skips the test class after a stop.
|
|
63
|
+
#
|
|
64
|
+
# @param reporter [Minitest::CompositeReporter] the reporter.
|
|
65
|
+
# @param options [Hash] the Minitest options.
|
|
66
|
+
# @return [Object, nil] whatever Minitest returns, or nil when skipped.
|
|
67
|
+
def run_suite(reporter, options = {})
|
|
68
|
+
return if StopAtFirstFailure.stopped_here?
|
|
69
|
+
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Skips one test after a stop, without a record.
|
|
74
|
+
#
|
|
75
|
+
# @param klass [Class] the test class.
|
|
76
|
+
# @param method_name [String] the test method.
|
|
77
|
+
# @param reporter [Minitest::CompositeReporter] the reporter.
|
|
78
|
+
# @return [Object, nil] whatever Minitest returns, or nil when skipped.
|
|
79
|
+
def run(klass, method_name, reporter)
|
|
80
|
+
return if StopAtFirstFailure.stopped_here?
|
|
81
|
+
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Prepended on the singleton class of each test class for Minitest 5.
|
|
87
|
+
module SkipAfterStop5
|
|
88
|
+
# Skips the test class after a stop.
|
|
89
|
+
#
|
|
90
|
+
# @param reporter [Minitest::CompositeReporter] the reporter.
|
|
91
|
+
# @param options [Hash] the Minitest options.
|
|
92
|
+
# @return [Object, nil] whatever Minitest returns, or nil when skipped.
|
|
93
|
+
def run(reporter, options = {})
|
|
94
|
+
return if StopAtFirstFailure.stopped_here?
|
|
95
|
+
|
|
96
|
+
super
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Skips one test after a stop, without a record.
|
|
100
|
+
#
|
|
101
|
+
# @param klass [Class] the test class.
|
|
102
|
+
# @param method_name [String] the test method.
|
|
103
|
+
# @param reporter [Minitest::CompositeReporter] the reporter.
|
|
104
|
+
# @return [Object, nil] whatever Minitest returns, or nil when skipped.
|
|
105
|
+
def run_one_method(klass, method_name, reporter)
|
|
106
|
+
return if StopAtFirstFailure.stopped_here?
|
|
107
|
+
|
|
108
|
+
super
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class << self
|
|
113
|
+
# The pid of the armed process. Forked workers inherit the other
|
|
114
|
+
# state, and the pid keeps them from acting on it.
|
|
115
|
+
#
|
|
116
|
+
# @return [Integer, nil]
|
|
117
|
+
attr_accessor :armed_pid
|
|
118
|
+
|
|
119
|
+
# The outer reporter of the armed run.
|
|
120
|
+
#
|
|
121
|
+
# @return [Minitest::CompositeReporter, nil]
|
|
122
|
+
attr_accessor :armed_reporter
|
|
123
|
+
|
|
124
|
+
# True after the outer reporter records a failure or an error.
|
|
125
|
+
#
|
|
126
|
+
# @return [Boolean, nil]
|
|
127
|
+
attr_accessor :stopped
|
|
128
|
+
|
|
129
|
+
# Installs the hooks and arms the stop for this process. Call it after
|
|
130
|
+
# the test files load, so that every test class gets its prepend.
|
|
131
|
+
#
|
|
132
|
+
# @param runnables [Array<Class>] the loaded test classes.
|
|
133
|
+
# @return [Boolean] false when the Minitest shape is unknown.
|
|
134
|
+
def arm!(runnables)
|
|
135
|
+
outer, skip = hooks_for_loaded_minitest
|
|
136
|
+
return false unless outer
|
|
137
|
+
|
|
138
|
+
prepend_once(::Minitest::CompositeReporter, RecordFailure)
|
|
139
|
+
prepend_once(::Minitest.singleton_class, outer)
|
|
140
|
+
runnables.each { |klass| prepend_once(klass.singleton_class, skip) }
|
|
141
|
+
self.armed_pid = Process.pid
|
|
142
|
+
self.armed_reporter = nil
|
|
143
|
+
self.stopped = false
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Clears the armed state.
|
|
148
|
+
#
|
|
149
|
+
# @return [void]
|
|
150
|
+
def disarm!
|
|
151
|
+
self.armed_pid = nil
|
|
152
|
+
self.armed_reporter = nil
|
|
153
|
+
self.stopped = false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# True when the run is armed in this process.
|
|
157
|
+
#
|
|
158
|
+
# @return [Boolean]
|
|
159
|
+
def armed_here?
|
|
160
|
+
armed_pid == Process.pid
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# True when the armed run in this process has stopped.
|
|
164
|
+
#
|
|
165
|
+
# @return [Boolean]
|
|
166
|
+
def stopped_here?
|
|
167
|
+
stopped == true && armed_here?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# Picks the hook modules for the loaded Minitest.
|
|
173
|
+
#
|
|
174
|
+
# @return [Array(Module, Module), nil] the outer reporter hook and the
|
|
175
|
+
# skip hook, or nil for an unknown shape.
|
|
176
|
+
def hooks_for_loaded_minitest
|
|
177
|
+
runnable = ::Minitest::Runnable
|
|
178
|
+
if ::Minitest.respond_to?(:run_all_suites) && runnable.respond_to?(:run_suite)
|
|
179
|
+
[OuterReporter6, SkipAfterStop6]
|
|
180
|
+
elsif ::Minitest.respond_to?(:__run) && runnable.respond_to?(:run_one_method)
|
|
181
|
+
[OuterReporter5, SkipAfterStop5]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Prepends `mod` on `target` once. A copy on a superclass does not
|
|
186
|
+
# count, because it comes after the own methods of `target`.
|
|
187
|
+
#
|
|
188
|
+
# @param target [Module] the class or singleton class.
|
|
189
|
+
# @param mod [Module] the module to prepend.
|
|
190
|
+
# @return [void]
|
|
191
|
+
def prepend_once(target, mod)
|
|
192
|
+
return if target.ancestors.take_while { |a| !a.equal?(target) }.include?(mod)
|
|
193
|
+
|
|
194
|
+
target.prepend(mod)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "minitest_integration/stop_at_first_failure"
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
# Child-process-only: loads a test file in the current process and runs it
|
|
@@ -16,6 +16,11 @@ module Mutineer
|
|
|
16
16
|
# boundary for unexpected errors. Swallowing those here would create a
|
|
17
17
|
# second exit-2 path and break this method's 0/1 return contract.
|
|
18
18
|
class MinitestIntegration
|
|
19
|
+
# The seed of a run that stops at the first failure, unless `SEED` is set.
|
|
20
|
+
# With the stop, the test order can decide `killed` vs `timeout`; a fixed
|
|
21
|
+
# order keeps the verdict stable for a `--baseline` gate.
|
|
22
|
+
STOP_AT_FIRST_FAILURE_SEED = 1
|
|
23
|
+
|
|
19
24
|
# Tested via runner_test.rb, not in isolation — a direct unit test
|
|
20
25
|
# would require forking and duplicate isolation_test's coverage.
|
|
21
26
|
#
|
|
@@ -24,8 +29,10 @@ module Mutineer
|
|
|
24
29
|
# Minitest.run.
|
|
25
30
|
#
|
|
26
31
|
# @param test_files [String, Array<String>] one file or many files.
|
|
32
|
+
# @param stop_at_first_failure [Boolean] end the run at the first failing
|
|
33
|
+
# test. Only the mutant path passes true.
|
|
27
34
|
# @return [Integer] 0 on success, 1 on failure.
|
|
28
|
-
def self.run(test_files)
|
|
35
|
+
def self.run(test_files, stop_at_first_failure: false)
|
|
29
36
|
begin
|
|
30
37
|
require "minitest"
|
|
31
38
|
rescue LoadError
|
|
@@ -47,13 +54,20 @@ module Mutineer
|
|
|
47
54
|
Minitest::Runnable.reset
|
|
48
55
|
Array(test_files).each { |f| load f }
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
args = []
|
|
58
|
+
if stop_at_first_failure && StopAtFirstFailure.arm!(Minitest::Runnable.runnables)
|
|
59
|
+
# Pin the seed only when the stop is armed; an unknown Minitest shape
|
|
60
|
+
# gets the normal full, randomly ordered run.
|
|
61
|
+
args = ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] unless ENV["SEED"]
|
|
62
|
+
end
|
|
63
|
+
# No silencing here: the fork boundary that calls this method has already
|
|
64
|
+
# pointed stdout at File::NULL (see ChildStdout).
|
|
65
|
+
passed = Minitest.run(args)
|
|
55
66
|
|
|
56
|
-
|
|
67
|
+
# A plugin can replace the summary reporter, so a stop decides by itself.
|
|
68
|
+
passed && !StopAtFirstFailure.stopped_here? ? 0 : 1
|
|
69
|
+
ensure
|
|
70
|
+
StopAtFirstFailure.disarm!
|
|
57
71
|
end
|
|
58
72
|
end
|
|
59
73
|
end
|
|
@@ -11,12 +11,18 @@ require_relative "mutators/condition_negation"
|
|
|
11
11
|
require_relative "mutators/string_literal"
|
|
12
12
|
require_relative "mutators/regex_literal"
|
|
13
13
|
require_relative "mutators/collection_method"
|
|
14
|
+
require_relative "mutators/safe_navigation"
|
|
15
|
+
require_relative "mutators/range_literal"
|
|
16
|
+
require_relative "mutators/negation_removal"
|
|
17
|
+
require_relative "mutators/chain_link"
|
|
18
|
+
require_relative "mutators/operand_removal"
|
|
19
|
+
require_relative "mutators/array_literal"
|
|
14
20
|
|
|
15
21
|
module Mutineer
|
|
16
22
|
# Maps operator names to operator classes.
|
|
17
23
|
#
|
|
18
24
|
# DEFAULT_NAMES is the v1 default set (Tier-1 plus statement-removal).
|
|
19
|
-
# The
|
|
25
|
+
# The Tier-2 operators live in ALL but are OFF by default — they only
|
|
20
26
|
# run when named via `--operators` or `operators:` in `.mutineer.yml`.
|
|
21
27
|
# Keeping DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps
|
|
22
28
|
# the default survivor set unchanged.
|
|
@@ -33,13 +39,21 @@ module Mutineer
|
|
|
33
39
|
"condition_negation" => Mutators::ConditionNegation,
|
|
34
40
|
"string_literal" => Mutators::StringLiteral,
|
|
35
41
|
"regex" => Mutators::RegexLiteral,
|
|
36
|
-
"collection_method" => Mutators::CollectionMethod
|
|
42
|
+
"collection_method" => Mutators::CollectionMethod,
|
|
43
|
+
"safe_navigation" => Mutators::SafeNavigation,
|
|
44
|
+
"range" => Mutators::RangeLiteral,
|
|
45
|
+
"negation_removal" => Mutators::NegationRemoval,
|
|
46
|
+
"chain_link" => Mutators::ChainLink,
|
|
47
|
+
"operand_removal" => Mutators::OperandRemoval,
|
|
48
|
+
"array_literal" => Mutators::ArrayLiteral
|
|
37
49
|
}.freeze
|
|
38
50
|
|
|
39
51
|
# The default Tier-1 operator set.
|
|
40
52
|
DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
|
|
41
53
|
# Tier-2 operators that remain opt-in.
|
|
42
|
-
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
54
|
+
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
55
|
+
safe_navigation range negation_removal
|
|
56
|
+
chain_link operand_removal array_literal].freeze
|
|
43
57
|
|
|
44
58
|
# Short human-readable descriptions for each operator.
|
|
45
59
|
DESCRIPTIONS = {
|
|
@@ -53,7 +67,13 @@ module Mutineer
|
|
|
53
67
|
"condition_negation" => "wrap if/unless/ternary condition in !( ... )",
|
|
54
68
|
"string_literal" => "non-empty string -> \"\", empty string -> \"mutineer\"",
|
|
55
69
|
"regex" => "drop leading ^ / trailing $, swap + <-> *",
|
|
56
|
-
"collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject"
|
|
70
|
+
"collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject",
|
|
71
|
+
"safe_navigation" => "&. -> .",
|
|
72
|
+
"range" => ".. <-> ...",
|
|
73
|
+
"negation_removal" => "!x, not x -> x",
|
|
74
|
+
"chain_link" => "drop one call from a chain: a.b.c -> a.c",
|
|
75
|
+
"operand_removal" => "a && b -> a, b",
|
|
76
|
+
"array_literal" => "[a, b] -> []"
|
|
57
77
|
}.freeze
|
|
58
78
|
|
|
59
79
|
# Resolves operator names to classes.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Array-literal mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces a non-empty array literal with an empty one, one mutation per
|
|
10
|
+
# literal: `[a, b]` and `%i[a b]` become `[]`. The mutant survives when no
|
|
11
|
+
# test checks the contents of the array.
|
|
12
|
+
class ArrayLiteral < Base
|
|
13
|
+
# Visits array nodes and emits array-literal mutations.
|
|
14
|
+
#
|
|
15
|
+
# @param node [Prism::ArrayNode] array node to inspect.
|
|
16
|
+
# @return [void]
|
|
17
|
+
def visit_array_node(node)
|
|
18
|
+
emit(node)
|
|
19
|
+
super # nested arrays each get their own mutation
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Skips a nested method definition. The project finds it as a subject of
|
|
23
|
+
# its own, so a visit here counts its arrays twice.
|
|
24
|
+
#
|
|
25
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
26
|
+
# @return [void]
|
|
27
|
+
def visit_def_node(node); end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Emits a mutation for a non-empty array with brackets.
|
|
32
|
+
#
|
|
33
|
+
# The operator skips an implicit array (`x = 1, 2`), because it has no
|
|
34
|
+
# brackets. It also skips an array that holds a heredoc, because the
|
|
35
|
+
# heredoc body stays behind as code.
|
|
36
|
+
#
|
|
37
|
+
# @param node [Prism::ArrayNode] array node to inspect.
|
|
38
|
+
# @return [void]
|
|
39
|
+
def emit(node)
|
|
40
|
+
return if node.opening_loc.nil? || node.elements.empty? || heredoc?(node)
|
|
41
|
+
|
|
42
|
+
@mutations << Mutation.new(
|
|
43
|
+
start_offset: node.location.start_offset,
|
|
44
|
+
end_offset: node.location.end_offset,
|
|
45
|
+
replacement: "[]",
|
|
46
|
+
operator: :array_literal
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -26,6 +26,21 @@ module Mutineer
|
|
|
26
26
|
subject.def_node.body&.accept(self)
|
|
27
27
|
@mutations
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Returns whether a node is, or contains, a heredoc.
|
|
33
|
+
#
|
|
34
|
+
# A heredoc's body lies outside its node's byte range. A mutation that
|
|
35
|
+
# deletes the node leaves the body behind as code, so the mutant
|
|
36
|
+
# always raises.
|
|
37
|
+
#
|
|
38
|
+
# @param node [Prism::Node] node to inspect.
|
|
39
|
+
# @return [Boolean] true when a heredoc is inside the node.
|
|
40
|
+
def heredoc?(node)
|
|
41
|
+
(node.respond_to?(:heredoc?) && node.heredoc?) ||
|
|
42
|
+
node.compact_child_nodes.any? { |child| heredoc?(child) }
|
|
43
|
+
end
|
|
29
44
|
end
|
|
30
45
|
end
|
|
31
46
|
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Chain-link mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Drops one dotted call from a chain of calls, with its arguments and block:
|
|
10
|
+
# `user.account.owner.name` becomes `user.owner.name` and `user.account.name`.
|
|
11
|
+
# The chain's receiver and its final call stay, so a chain of n dotted calls
|
|
12
|
+
# gives at most n - 1 mutations. A chain begins at a receiver that is not a
|
|
13
|
+
# dotted call: a local, a constant, `self`, `list[i]`, `(a + b)`. A call in
|
|
14
|
+
# an argument or a block starts a chain of its own.
|
|
15
|
+
#
|
|
16
|
+
# The mutant survives when no test tells the chain apart from the same chain
|
|
17
|
+
# without that step: a scope, a filter or a lookup the tests never see.
|
|
18
|
+
#
|
|
19
|
+
# Links in {SKIPPED} are never dropped. On a value that already has the
|
|
20
|
+
# target type or needs no copy, a conversion (`name.to_s.strip`) or a copy
|
|
21
|
+
# (`list.dup.sort`) is a no-op, so dropping it most often makes an
|
|
22
|
+
# equivalent mutant. Dropping `new` sends the next call to the class, which
|
|
23
|
+
# raises (killed by any test that runs the line) or reaches a class method
|
|
24
|
+
# that builds the instance itself (equivalent); neither says anything about
|
|
25
|
+
# the tests.
|
|
26
|
+
class ChainLink < Base
|
|
27
|
+
# Method names whose link is never dropped: core conversions and copies,
|
|
28
|
+
# plus `new`.
|
|
29
|
+
SKIPPED = %i[
|
|
30
|
+
to_s to_str to_sym to_i to_int to_f to_r to_c to_a to_ary to_h to_hash to_proc to_set
|
|
31
|
+
dup clone freeze itself
|
|
32
|
+
new
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Resets the per-subject record of calls already placed in a chain.
|
|
36
|
+
#
|
|
37
|
+
# @param subject [Mutineer::Subject] subject whose body is visited.
|
|
38
|
+
# @param source [String] full source text for byte-based slicing.
|
|
39
|
+
# @return [Array<Mutineer::Mutation>] collected mutations.
|
|
40
|
+
def mutations_for(subject, source)
|
|
41
|
+
@links = {}.compare_by_identity
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Visits a call. The outermost dotted call of a chain is its final call;
|
|
46
|
+
# the dotted calls below it in the receiver are its links.
|
|
47
|
+
#
|
|
48
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
49
|
+
# @return [void]
|
|
50
|
+
def visit_call_node(node)
|
|
51
|
+
chain(node) if dotted?(node) && !@links.key?(node)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Visits `a.b.c += 1`, whose final call Prism parses as its own node.
|
|
56
|
+
#
|
|
57
|
+
# @param node [Prism::CallOperatorWriteNode] node to inspect.
|
|
58
|
+
# @return [void]
|
|
59
|
+
def visit_call_operator_write_node(node)
|
|
60
|
+
chain(node)
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Visits `a.b.c ||= 1`.
|
|
65
|
+
#
|
|
66
|
+
# @param node [Prism::CallOrWriteNode] node to inspect.
|
|
67
|
+
# @return [void]
|
|
68
|
+
def visit_call_or_write_node(node)
|
|
69
|
+
chain(node)
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Visits `a.b.c &&= 1`.
|
|
74
|
+
#
|
|
75
|
+
# @param node [Prism::CallAndWriteNode] node to inspect.
|
|
76
|
+
# @return [void]
|
|
77
|
+
def visit_call_and_write_node(node)
|
|
78
|
+
chain(node)
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Visits a call used as an assignment target, as in `a.b.c, d = 1, 2`.
|
|
83
|
+
#
|
|
84
|
+
# @param node [Prism::CallTargetNode] node to inspect.
|
|
85
|
+
# @return [void]
|
|
86
|
+
def visit_call_target_node(node)
|
|
87
|
+
chain(node)
|
|
88
|
+
super
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Nested method definitions are discovered as their own subjects; do not
|
|
92
|
+
# recurse into them (prevents double-counting their chains).
|
|
93
|
+
#
|
|
94
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
95
|
+
# @return [void]
|
|
96
|
+
def visit_def_node(node); end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Walks a final call's receiver chain, recording each link so it is not
|
|
101
|
+
# taken for the final call of a chain of its own, and dropping each link
|
|
102
|
+
# not in {SKIPPED}.
|
|
103
|
+
#
|
|
104
|
+
# @param top [Prism::Node] the chain's final call; responds to `receiver`.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def chain(top)
|
|
107
|
+
link = top.receiver
|
|
108
|
+
while dotted?(link)
|
|
109
|
+
@links[link] = true
|
|
110
|
+
drop(link) unless SKIPPED.include?(link.name)
|
|
111
|
+
link = link.receiver
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Emits the removal of one link: from the end of its receiver to the end
|
|
116
|
+
# of its arguments and block, so a chain split across lines keeps the
|
|
117
|
+
# layout of the lines that remain.
|
|
118
|
+
#
|
|
119
|
+
# @param link [Prism::CallNode] the dotted call to drop.
|
|
120
|
+
# @return [void]
|
|
121
|
+
def drop(link)
|
|
122
|
+
@mutations << Mutation.new(
|
|
123
|
+
start_offset: link.receiver.location.end_offset,
|
|
124
|
+
end_offset: link.location.end_offset,
|
|
125
|
+
replacement: "",
|
|
126
|
+
operator: :chain_link
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Returns whether a node is a call through `.`, `&.` or `::`.
|
|
131
|
+
#
|
|
132
|
+
# @param node [Prism::Node, nil] node to inspect.
|
|
133
|
+
# @return [Boolean] true for a call node with a call operator.
|
|
134
|
+
def dotted?(node)
|
|
135
|
+
node.is_a?(Prism::CallNode) && !node.call_operator_loc.nil?
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Negation-removal mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Removes the `!` or `not` of a negation, one mutation per occurrence:
|
|
10
|
+
# `!x` becomes `x` and `not x` becomes ` x`. The mutant survives when no
|
|
11
|
+
# test depends on the negated value.
|
|
12
|
+
class NegationRemoval < Base
|
|
13
|
+
# Visits call nodes and emits negation-removal mutations.
|
|
14
|
+
#
|
|
15
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
16
|
+
# @return [void]
|
|
17
|
+
def visit_call_node(node)
|
|
18
|
+
emit(node)
|
|
19
|
+
super # nested negations (!!x) each get their own mutation
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Emits a mutation when the node is a prefix `!` or `not`.
|
|
25
|
+
#
|
|
26
|
+
# The explicit form `x.!` is skipped: without its message, `x.` does
|
|
27
|
+
# not parse.
|
|
28
|
+
#
|
|
29
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
30
|
+
# @return [void]
|
|
31
|
+
def emit(node)
|
|
32
|
+
return unless node.name == :! && node.call_operator_loc.nil?
|
|
33
|
+
|
|
34
|
+
loc = node.message_loc
|
|
35
|
+
@mutations << Mutation.new(
|
|
36
|
+
start_offset: loc.start_offset,
|
|
37
|
+
end_offset: loc.end_offset,
|
|
38
|
+
replacement: "",
|
|
39
|
+
operator: :negation_removal
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Operand-removal mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces a boolean expression with one of its operands, two mutations
|
|
10
|
+
# per `&&`, `||`, `and` or `or`: `a && b` becomes `(a)` and `(b)`. The
|
|
11
|
+
# parentheses keep the precedence of `and` and `or`. A surviving mutant
|
|
12
|
+
# shows that no test needs the operand that was removed.
|
|
13
|
+
class OperandRemoval < Base
|
|
14
|
+
# Node types for jumps. A jump in a value context does not parse, so
|
|
15
|
+
# the operator never keeps a jump alone.
|
|
16
|
+
JUMPS = [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode].freeze
|
|
17
|
+
|
|
18
|
+
# Visits `and` nodes.
|
|
19
|
+
#
|
|
20
|
+
# @param node [Prism::AndNode] node to inspect.
|
|
21
|
+
# @return [void]
|
|
22
|
+
def visit_and_node(node)
|
|
23
|
+
emit(node)
|
|
24
|
+
super # nested connectors (a && b && c) each get their own mutations
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Visits `or` nodes.
|
|
28
|
+
#
|
|
29
|
+
# @param node [Prism::OrNode] node to inspect.
|
|
30
|
+
# @return [void]
|
|
31
|
+
def visit_or_node(node)
|
|
32
|
+
emit(node)
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Skips a nested method definition. The project finds it as a subject of
|
|
37
|
+
# its own, so a visit here counts its connectors twice.
|
|
38
|
+
#
|
|
39
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
40
|
+
# @return [void]
|
|
41
|
+
def visit_def_node(node); end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Emits one mutation that keeps the left operand, then one that keeps
|
|
46
|
+
# the right operand. The order is fixed, because the two mutants share
|
|
47
|
+
# a token and their occurrence ordinals tell them apart.
|
|
48
|
+
#
|
|
49
|
+
# @param node [Prism::AndNode, Prism::OrNode] connector node.
|
|
50
|
+
# @return [void]
|
|
51
|
+
def emit(node)
|
|
52
|
+
[[node.left, node.right], [node.right, node.left]].each do |kept, removed|
|
|
53
|
+
next if JUMPS.any? { |type| kept.is_a?(type) } || heredoc?(removed)
|
|
54
|
+
|
|
55
|
+
@mutations << Mutation.new(
|
|
56
|
+
start_offset: node.location.start_offset,
|
|
57
|
+
end_offset: node.location.end_offset,
|
|
58
|
+
replacement: "(#{kept.slice})",
|
|
59
|
+
operator: :operand_removal
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Range mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Swaps `..` with `...` and `...` with `..`, one mutation per range. The
|
|
10
|
+
# `..` -> `...` mutant survives when no test checks the last element.
|
|
11
|
+
# Flip-flops are FlipFlopNode, not RangeNode, so this mutator skips them.
|
|
12
|
+
#
|
|
13
|
+
# Endless ranges (`1..`, `1..nil`) are skipped. `(1..)` and `(1...)` give
|
|
14
|
+
# the same result for slicing, `include?`, `===`, `size` and patterns, so
|
|
15
|
+
# the mutant is equivalent and no normal test can kill it.
|
|
16
|
+
class RangeLiteral < Base
|
|
17
|
+
# Maps each range operator to its opposite.
|
|
18
|
+
SWAPS = { ".." => "...", "..." => ".." }.freeze
|
|
19
|
+
|
|
20
|
+
# Visits range nodes and emits range mutations.
|
|
21
|
+
#
|
|
22
|
+
# @param node [Prism::RangeNode] range node to inspect.
|
|
23
|
+
# @return [void]
|
|
24
|
+
def visit_range_node(node)
|
|
25
|
+
emit(node) unless endless?(node)
|
|
26
|
+
super # nested ranges ((1..2)...(3..4)) each get their own mutation
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Whether the range has no end: `1..` or `1..nil`.
|
|
32
|
+
#
|
|
33
|
+
# @param node [Prism::RangeNode] range node to inspect.
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def endless?(node)
|
|
36
|
+
node.right.nil? || node.right.is_a?(Prism::NilNode)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Emits the mutation that swaps the range operator.
|
|
40
|
+
#
|
|
41
|
+
# @param node [Prism::RangeNode] range node to mutate.
|
|
42
|
+
# @return [void]
|
|
43
|
+
def emit(node)
|
|
44
|
+
loc = node.operator_loc
|
|
45
|
+
@mutations << Mutation.new(
|
|
46
|
+
start_offset: loc.start_offset,
|
|
47
|
+
end_offset: loc.end_offset,
|
|
48
|
+
replacement: SWAPS.fetch(loc.slice),
|
|
49
|
+
operator: :range
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|