mutineer 1.0.1 → 1.1.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 +93 -0
- data/README.md +10 -4
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/coverage_map.rb +86 -47
- data/lib/mutineer/daemon_client.rb +1 -1
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/file_swap.rb +2 -0
- data/lib/mutineer/isolation.rb +8 -2
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +199 -0
- data/lib/mutineer/minitest_integration.rb +30 -14
- data/lib/mutineer/mutant_id.rb +1 -1
- data/lib/mutineer/mutator_registry.rb +22 -9
- data/lib/mutineer/mutators/chain_link.rb +139 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/runner.rb +2 -1
- 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 +4 -0
- metadata +7 -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
|
|
@@ -10,20 +10,29 @@ module Mutineer
|
|
|
10
10
|
# Never call this in the parent — it manipulates global Minitest state
|
|
11
11
|
# (autorun, runnables) that only makes sense in a throwaway forked child.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# A missing minitest is rescued as FrameworkUnavailable (Isolation.run
|
|
14
|
+
# still turns that raise into exit 2). There is no rescue around the
|
|
15
|
+
# suite run itself: Isolation.run's fork block is the single exception
|
|
16
|
+
# boundary for unexpected errors. Swallowing those here would create a
|
|
17
|
+
# second exit-2 path and break this method's 0/1 return contract.
|
|
16
18
|
class MinitestIntegration
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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
|
+
|
|
24
|
+
# Tested via runner_test.rb, not in isolation — a direct unit test
|
|
25
|
+
# would require forking and duplicate isolation_test's coverage.
|
|
19
26
|
#
|
|
20
|
-
# `test_files` is one path or an Array of paths (
|
|
27
|
+
# `test_files` is one path or an Array of paths (coverage selection
|
|
21
28
|
# passes the covering subset); each is loaded before the single
|
|
22
29
|
# Minitest.run.
|
|
23
30
|
#
|
|
24
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.
|
|
25
34
|
# @return [Integer] 0 on success, 1 on failure.
|
|
26
|
-
def self.run(test_files)
|
|
35
|
+
def self.run(test_files, stop_at_first_failure: false)
|
|
27
36
|
begin
|
|
28
37
|
require "minitest"
|
|
29
38
|
rescue LoadError
|
|
@@ -45,13 +54,20 @@ module Mutineer
|
|
|
45
54
|
Minitest::Runnable.reset
|
|
46
55
|
Array(test_files).each { |f| load f }
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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)
|
|
53
66
|
|
|
54
|
-
|
|
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!
|
|
55
71
|
end
|
|
56
72
|
end
|
|
57
73
|
end
|
data/lib/mutineer/mutant_id.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Mutineer
|
|
|
19
19
|
# Computes the stable id for a single mutant.
|
|
20
20
|
#
|
|
21
21
|
# NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
|
|
22
|
-
# with the separator; SHA256[0,12] gives a fixed-length, copy-pasteable key.
|
|
22
|
+
# with the separator; `SHA256[0,12]` gives a fixed-length, copy-pasteable key.
|
|
23
23
|
#
|
|
24
24
|
# @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
|
|
25
25
|
# its `qualified_name` anchors the id to a method rather than a byte position.
|
|
@@ -11,16 +11,19 @@ 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"
|
|
14
18
|
|
|
15
19
|
module Mutineer
|
|
16
20
|
# Maps operator names to operator classes.
|
|
17
21
|
#
|
|
18
|
-
# DEFAULT_NAMES is the v1 default set
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# survivor set unchanged.
|
|
22
|
+
# DEFAULT_NAMES is the v1 default set (Tier-1 plus statement-removal).
|
|
23
|
+
# The Tier-2 operators live in ALL but are OFF by default — they only
|
|
24
|
+
# run when named via `--operators` or `operators:` in `.mutineer.yml`.
|
|
25
|
+
# Keeping DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps
|
|
26
|
+
# the default survivor set unchanged.
|
|
24
27
|
class MutatorRegistry
|
|
25
28
|
# All available mutator classes keyed by operator name.
|
|
26
29
|
ALL = {
|
|
@@ -34,13 +37,19 @@ module Mutineer
|
|
|
34
37
|
"condition_negation" => Mutators::ConditionNegation,
|
|
35
38
|
"string_literal" => Mutators::StringLiteral,
|
|
36
39
|
"regex" => Mutators::RegexLiteral,
|
|
37
|
-
"collection_method" => Mutators::CollectionMethod
|
|
40
|
+
"collection_method" => Mutators::CollectionMethod,
|
|
41
|
+
"safe_navigation" => Mutators::SafeNavigation,
|
|
42
|
+
"range" => Mutators::RangeLiteral,
|
|
43
|
+
"negation_removal" => Mutators::NegationRemoval,
|
|
44
|
+
"chain_link" => Mutators::ChainLink
|
|
38
45
|
}.freeze
|
|
39
46
|
|
|
40
47
|
# The default Tier-1 operator set.
|
|
41
48
|
DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
|
|
42
49
|
# Tier-2 operators that remain opt-in.
|
|
43
|
-
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
50
|
+
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
51
|
+
safe_navigation range negation_removal
|
|
52
|
+
chain_link].freeze
|
|
44
53
|
|
|
45
54
|
# Short human-readable descriptions for each operator.
|
|
46
55
|
DESCRIPTIONS = {
|
|
@@ -54,7 +63,11 @@ module Mutineer
|
|
|
54
63
|
"condition_negation" => "wrap if/unless/ternary condition in !( ... )",
|
|
55
64
|
"string_literal" => "non-empty string -> \"\", empty string -> \"mutineer\"",
|
|
56
65
|
"regex" => "drop leading ^ / trailing $, swap + <-> *",
|
|
57
|
-
"collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject"
|
|
66
|
+
"collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject",
|
|
67
|
+
"safe_navigation" => "&. -> .",
|
|
68
|
+
"range" => ".. <-> ...",
|
|
69
|
+
"negation_removal" => "!x, not x -> x",
|
|
70
|
+
"chain_link" => "drop one call from a chain: a.b.c -> a.c"
|
|
58
71
|
}.freeze
|
|
59
72
|
|
|
60
73
|
# Resolves operator names to classes.
|
|
@@ -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,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
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Safe-navigation mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces `&.` with `.`, one mutation per occurrence. The mutant raises
|
|
10
|
+
# NoMethodError on a nil receiver, so it survives when no test passes nil.
|
|
11
|
+
class SafeNavigation < Base
|
|
12
|
+
# Visits call nodes and emits safe-navigation mutations.
|
|
13
|
+
#
|
|
14
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
15
|
+
# @return [void]
|
|
16
|
+
def visit_call_node(node)
|
|
17
|
+
emit(node)
|
|
18
|
+
super # chained calls (a&.b&.c) each get their own mutation
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Visits `a&.b += 1`, which Prism parses as its own node, not a CallNode.
|
|
22
|
+
#
|
|
23
|
+
# @param node [Prism::CallOperatorWriteNode] node to inspect.
|
|
24
|
+
# @return [void]
|
|
25
|
+
def visit_call_operator_write_node(node)
|
|
26
|
+
emit(node)
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Visits `a&.b ||= 1`.
|
|
31
|
+
#
|
|
32
|
+
# @param node [Prism::CallOrWriteNode] node to inspect.
|
|
33
|
+
# @return [void]
|
|
34
|
+
def visit_call_or_write_node(node)
|
|
35
|
+
emit(node)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Visits `a&.b &&= 1`.
|
|
40
|
+
#
|
|
41
|
+
# @param node [Prism::CallAndWriteNode] node to inspect.
|
|
42
|
+
# @return [void]
|
|
43
|
+
def visit_call_and_write_node(node)
|
|
44
|
+
emit(node)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Visits a call used as an assignment target, as in `for a&.b in list`.
|
|
49
|
+
#
|
|
50
|
+
# @param node [Prism::CallTargetNode] node to inspect.
|
|
51
|
+
# @return [void]
|
|
52
|
+
def visit_call_target_node(node)
|
|
53
|
+
emit(node)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Emits a mutation when the node's call operator is `&.`.
|
|
60
|
+
#
|
|
61
|
+
# @param node [Prism::Node] a node with `safe_navigation?` and `call_operator_loc`.
|
|
62
|
+
# @return [void]
|
|
63
|
+
def emit(node)
|
|
64
|
+
return unless node.safe_navigation?
|
|
65
|
+
|
|
66
|
+
loc = node.call_operator_loc
|
|
67
|
+
@mutations << Mutation.new(
|
|
68
|
+
start_offset: loc.start_offset,
|
|
69
|
+
end_offset: loc.end_offset,
|
|
70
|
+
replacement: ".",
|
|
71
|
+
operator: :safe_navigation
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/mutineer/runner.rb
CHANGED
|
@@ -434,7 +434,8 @@ module Mutineer
|
|
|
434
434
|
else
|
|
435
435
|
Isolation.apply_whole_file(mutated, source_file)
|
|
436
436
|
end
|
|
437
|
-
|
|
437
|
+
# One failing test already kills the mutant, so the child stops there.
|
|
438
|
+
TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
|
|
438
439
|
end
|
|
439
440
|
end
|
|
440
441
|
|
|
@@ -9,8 +9,12 @@ module Mutineer
|
|
|
9
9
|
# Runs the given Minitest files.
|
|
10
10
|
#
|
|
11
11
|
# @param test_files [String, Array<String>] one file or many files.
|
|
12
|
+
# @param stop_at_first_failure [Boolean] when true, the run ends at the
|
|
13
|
+
# first failing test.
|
|
12
14
|
# @return [Integer] 0 on success, 1 on failure.
|
|
13
|
-
def self.run(test_files
|
|
15
|
+
def self.run(test_files, stop_at_first_failure: false)
|
|
16
|
+
MinitestIntegration.run(test_files, stop_at_first_failure: stop_at_first_failure)
|
|
17
|
+
end
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|