mutineer 1.3.0 → 1.5.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 +317 -0
- data/README.md +200 -28
- data/lib/mutineer/changed_lines.rb +57 -2
- data/lib/mutineer/cli.rb +142 -92
- data/lib/mutineer/config.rb +215 -56
- data/lib/mutineer/coverage_map.rb +161 -14
- data/lib/mutineer/daemon_backend.rb +3 -0
- data/lib/mutineer/daemon_client.rb +170 -13
- data/lib/mutineer/daemon_server.rb +2 -1
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/isolation.rb +91 -6
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +27 -10
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +7 -0
- data/lib/mutineer/rails_worker_db.rb +1 -1
- data/lib/mutineer/reporter.rb +288 -52
- data/lib/mutineer/result.rb +37 -1
- data/lib/mutineer/runner.rb +133 -16
- data/lib/mutineer/statement_lines.rb +143 -0
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +243 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +3 -0
- metadata +10 -3
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
class MinitestIntegration
|
|
5
|
+
# Finds the outer reporter of a Minitest run, for the hooks that act only on
|
|
6
|
+
# the run's own results. A test can build a reporter of its own and record
|
|
7
|
+
# on it, or start a nested run, so the first reporter the run hands to its
|
|
8
|
+
# suites is the one that counts. Child-process only, like
|
|
9
|
+
# MinitestIntegration.
|
|
10
|
+
#
|
|
11
|
+
# A client module extends {Client}, registers here, and sets its armed pid.
|
|
12
|
+
# The hook then gives the outer reporter to each client armed in this
|
|
13
|
+
# process. Minitest 5 and 6 need different hook points; an unknown shape
|
|
14
|
+
# gets no hook.
|
|
15
|
+
module OuterReporter
|
|
16
|
+
# Prepended on the `Minitest` singleton class for Minitest 6.
|
|
17
|
+
module Hook6
|
|
18
|
+
# Hands the outer reporter to the armed clients. Only the first call
|
|
19
|
+
# counts, because a test can call this method with its own reporter.
|
|
20
|
+
#
|
|
21
|
+
# @param reporter [Minitest::CompositeReporter] the outer reporter.
|
|
22
|
+
# @param options [Hash] the Minitest options.
|
|
23
|
+
# @return [Object] whatever Minitest returns.
|
|
24
|
+
def run_all_suites(reporter, options)
|
|
25
|
+
OuterReporter.seen(reporter)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Prepended on the `Minitest` singleton class for Minitest 5.
|
|
31
|
+
module Hook5
|
|
32
|
+
# The Minitest 5 form of Hook6#run_all_suites.
|
|
33
|
+
#
|
|
34
|
+
# @param reporter [Minitest::CompositeReporter] the outer reporter.
|
|
35
|
+
# @param options [Hash] the Minitest options.
|
|
36
|
+
# @return [Object] whatever Minitest returns.
|
|
37
|
+
def __run(reporter, options)
|
|
38
|
+
OuterReporter.seen(reporter)
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The per-process state a client keeps. Forked workers inherit it, and
|
|
44
|
+
# the pid keeps them from acting on it.
|
|
45
|
+
module Client
|
|
46
|
+
# The pid of the armed process.
|
|
47
|
+
#
|
|
48
|
+
# @return [Integer, nil]
|
|
49
|
+
attr_accessor :armed_pid
|
|
50
|
+
|
|
51
|
+
# The outer reporter of the armed run.
|
|
52
|
+
#
|
|
53
|
+
# @return [Minitest::CompositeReporter, nil]
|
|
54
|
+
attr_accessor :armed_reporter
|
|
55
|
+
|
|
56
|
+
# True when the client is armed in this process.
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def armed_here?
|
|
60
|
+
armed_pid == Process.pid
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True when `reporter` is the outer reporter of the run armed in this
|
|
64
|
+
# process.
|
|
65
|
+
#
|
|
66
|
+
# @param reporter [Minitest::CompositeReporter] the recording reporter.
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def outer_reporter?(reporter)
|
|
69
|
+
armed_here? && reporter.equal?(armed_reporter)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
# The registered clients, in registration order.
|
|
75
|
+
#
|
|
76
|
+
# @return [Array<Module>]
|
|
77
|
+
def clients
|
|
78
|
+
@clients ||= []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Registers a client once.
|
|
82
|
+
#
|
|
83
|
+
# @param client [Module] a module that extends {Client}.
|
|
84
|
+
# @return [void]
|
|
85
|
+
def register(client)
|
|
86
|
+
clients << client unless clients.include?(client)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Gives `reporter` to every client armed in this process that has no
|
|
90
|
+
# outer reporter yet.
|
|
91
|
+
#
|
|
92
|
+
# @param reporter [Minitest::CompositeReporter] a reporter a run uses.
|
|
93
|
+
# @return [void]
|
|
94
|
+
def seen(reporter)
|
|
95
|
+
clients.each do |client|
|
|
96
|
+
client.armed_reporter ||= reporter if client.armed_here?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The outer-reporter hook for the loaded Minitest.
|
|
101
|
+
#
|
|
102
|
+
# @return [Module, nil] {Hook6}, {Hook5}, or nil for an unknown shape.
|
|
103
|
+
def hook_for_loaded_minitest
|
|
104
|
+
runnable = ::Minitest::Runnable
|
|
105
|
+
if ::Minitest.respond_to?(:run_all_suites) && runnable.respond_to?(:run_suite)
|
|
106
|
+
Hook6
|
|
107
|
+
elsif ::Minitest.respond_to?(:__run) && runnable.respond_to?(:run_one_method)
|
|
108
|
+
Hook5
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Prepends `mod` on `target` once. A copy on a superclass does not
|
|
113
|
+
# count, because it comes after the own methods of `target`.
|
|
114
|
+
#
|
|
115
|
+
# @param target [Module] the class or singleton class.
|
|
116
|
+
# @param mod [Module] the module to prepend.
|
|
117
|
+
# @return [void]
|
|
118
|
+
def prepend_once(target, mod)
|
|
119
|
+
return if target.ancestors.take_while { |a| !a.equal?(target) }.include?(mod)
|
|
120
|
+
|
|
121
|
+
target.prepend(mod)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "outer_reporter"
|
|
4
|
+
|
|
3
5
|
module Mutineer
|
|
4
6
|
class MinitestIntegration
|
|
5
7
|
# Stops a Minitest run at the first failing test: one failure already
|
|
@@ -10,8 +12,11 @@ module Mutineer
|
|
|
10
12
|
# remaining tests and classes then return before they start.
|
|
11
13
|
#
|
|
12
14
|
# Minitest 5 and 6 need different hook points. An unknown shape installs
|
|
13
|
-
# no hook, and the run is a full run.
|
|
15
|
+
# no hook, and the run is a full run. The outer reporter comes from
|
|
16
|
+
# {OuterReporter}, shared with {KillRecorder}.
|
|
14
17
|
module StopAtFirstFailure
|
|
18
|
+
extend OuterReporter::Client
|
|
19
|
+
|
|
15
20
|
# Prepended on `Minitest::CompositeReporter`.
|
|
16
21
|
module RecordFailure
|
|
17
22
|
# Records the result, then sets the stop flag on a failure or an
|
|
@@ -23,40 +28,12 @@ module Mutineer
|
|
|
23
28
|
def record(result)
|
|
24
29
|
super
|
|
25
30
|
return if result.passed? || result.skipped?
|
|
26
|
-
return unless
|
|
27
|
-
return unless StopAtFirstFailure.armed_here?
|
|
31
|
+
return unless StopAtFirstFailure.outer_reporter?(self)
|
|
28
32
|
|
|
29
33
|
StopAtFirstFailure.stopped = true
|
|
30
34
|
end
|
|
31
35
|
end
|
|
32
36
|
|
|
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
37
|
# Prepended on the singleton class of each test class for Minitest 6.
|
|
61
38
|
module SkipAfterStop6
|
|
62
39
|
# Skips the test class after a stop.
|
|
@@ -110,17 +87,6 @@ module Mutineer
|
|
|
110
87
|
end
|
|
111
88
|
|
|
112
89
|
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
90
|
# True after the outer reporter records a failure or an error.
|
|
125
91
|
#
|
|
126
92
|
# @return [Boolean, nil]
|
|
@@ -135,9 +101,10 @@ module Mutineer
|
|
|
135
101
|
outer, skip = hooks_for_loaded_minitest
|
|
136
102
|
return false unless outer
|
|
137
103
|
|
|
138
|
-
prepend_once(::Minitest::CompositeReporter, RecordFailure)
|
|
139
|
-
prepend_once(::Minitest.singleton_class, outer)
|
|
140
|
-
runnables.each { |klass| prepend_once(klass.singleton_class, skip) }
|
|
104
|
+
OuterReporter.prepend_once(::Minitest::CompositeReporter, RecordFailure)
|
|
105
|
+
OuterReporter.prepend_once(::Minitest.singleton_class, outer)
|
|
106
|
+
runnables.each { |klass| OuterReporter.prepend_once(klass.singleton_class, skip) }
|
|
107
|
+
OuterReporter.register(self)
|
|
141
108
|
self.armed_pid = Process.pid
|
|
142
109
|
self.armed_reporter = nil
|
|
143
110
|
self.stopped = false
|
|
@@ -153,13 +120,6 @@ module Mutineer
|
|
|
153
120
|
self.stopped = false
|
|
154
121
|
end
|
|
155
122
|
|
|
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
123
|
# True when the armed run in this process has stopped.
|
|
164
124
|
#
|
|
165
125
|
# @return [Boolean]
|
|
@@ -174,25 +134,11 @@ module Mutineer
|
|
|
174
134
|
# @return [Array(Module, Module), nil] the outer reporter hook and the
|
|
175
135
|
# skip hook, or nil for an unknown shape.
|
|
176
136
|
def hooks_for_loaded_minitest
|
|
177
|
-
|
|
178
|
-
if
|
|
179
|
-
|
|
180
|
-
elsif ::Minitest.respond_to?(:__run) && runnable.respond_to?(:run_one_method)
|
|
181
|
-
[OuterReporter5, SkipAfterStop5]
|
|
137
|
+
outer = OuterReporter.hook_for_loaded_minitest
|
|
138
|
+
if outer.equal?(OuterReporter::Hook6) then [outer, SkipAfterStop6]
|
|
139
|
+
elsif outer.equal?(OuterReporter::Hook5) then [outer, SkipAfterStop5]
|
|
182
140
|
end
|
|
183
141
|
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
142
|
end
|
|
197
143
|
end
|
|
198
144
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "minitest_integration/stop_at_first_failure"
|
|
4
|
+
require_relative "minitest_integration/kill_recorder"
|
|
4
5
|
|
|
5
6
|
module Mutineer
|
|
6
7
|
# Child-process-only: loads a test file in the current process and runs it
|
|
@@ -16,9 +17,11 @@ module Mutineer
|
|
|
16
17
|
# boundary for unexpected errors. Swallowing those here would create a
|
|
17
18
|
# second exit-2 path and break this method's 0/1 return contract.
|
|
18
19
|
class MinitestIntegration
|
|
19
|
-
# The seed of a run that stops at the first failure,
|
|
20
|
-
#
|
|
21
|
-
# order
|
|
20
|
+
# The seed of a mutant run (one that stops at the first failure, or a
|
|
21
|
+
# `--matrix` run that records every test), unless `SEED` is set. With the
|
|
22
|
+
# stop, the test order can decide `killed` vs `timeout`; a fixed order keeps
|
|
23
|
+
# the verdict stable for a `--baseline` gate. A matrix run uses the same
|
|
24
|
+
# order, so its first failure comes at the same point as the stop's.
|
|
22
25
|
STOP_AT_FIRST_FAILURE_SEED = 1
|
|
23
26
|
|
|
24
27
|
# Tested via runner_test.rb, not in isolation — a direct unit test
|
|
@@ -31,8 +34,15 @@ module Mutineer
|
|
|
31
34
|
# @param test_files [String, Array<String>] one file or many files.
|
|
32
35
|
# @param stop_at_first_failure [Boolean] end the run at the first failing
|
|
33
36
|
# test. Only the mutant path passes true.
|
|
37
|
+
# @param record_to [IO, nil] a `--matrix` run: send each test's outcome to
|
|
38
|
+
# this channel (see {KillRecorder}) and run every test.
|
|
34
39
|
# @return [Integer] 0 on success, 1 on failure.
|
|
35
|
-
|
|
40
|
+
# @raise [ArgumentError] when both options are given: a matrix run never stops.
|
|
41
|
+
def self.run(test_files, stop_at_first_failure: false, record_to: nil)
|
|
42
|
+
if stop_at_first_failure && record_to
|
|
43
|
+
raise ArgumentError, "stop_at_first_failure and record_to cannot be combined: a matrix run runs every test"
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
begin
|
|
37
47
|
require "minitest"
|
|
38
48
|
rescue LoadError
|
|
@@ -54,20 +64,27 @@ module Mutineer
|
|
|
54
64
|
Minitest::Runnable.reset
|
|
55
65
|
Array(test_files).each { |f| load f }
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
armed =
|
|
68
|
+
if record_to
|
|
69
|
+
KillRecorder.arm!(record_to, Minitest::Runnable.runnables)
|
|
70
|
+
elsif stop_at_first_failure
|
|
71
|
+
StopAtFirstFailure.arm!(Minitest::Runnable.runnables)
|
|
72
|
+
end
|
|
73
|
+
# Pin the seed only when a hook is armed; an unknown Minitest shape gets
|
|
74
|
+
# the normal full, randomly ordered run.
|
|
75
|
+
args = armed && !ENV["SEED"] ? ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] : []
|
|
63
76
|
# No silencing here: the fork boundary that calls this method has already
|
|
64
77
|
# pointed stdout at File::NULL (see ChildStdout).
|
|
65
78
|
passed = Minitest.run(args)
|
|
79
|
+
# Reached only when the suite returned: a test that exits the process
|
|
80
|
+
# skips it, and the parent then keeps the row incomplete.
|
|
81
|
+
KillRecorder.finish! if record_to
|
|
66
82
|
|
|
67
83
|
# A plugin can replace the summary reporter, so a stop decides by itself.
|
|
68
84
|
passed && !StopAtFirstFailure.stopped_here? ? 0 : 1
|
|
69
85
|
ensure
|
|
70
86
|
StopAtFirstFailure.disarm!
|
|
87
|
+
KillRecorder.disarm!
|
|
71
88
|
end
|
|
72
89
|
end
|
|
73
90
|
end
|
data/lib/mutineer/mutant_id.rb
CHANGED
|
@@ -12,9 +12,9 @@ module Mutineer
|
|
|
12
12
|
# qualified_name (a method, not a byte position) + operator + the normalized
|
|
13
13
|
# mutated token + an occurrence ordinal among same-(operator, token) twins
|
|
14
14
|
# WITHIN the subject + (only when positive) the subject's ordinal among
|
|
15
|
-
# same-named subjects in its file.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# same-named subjects in its file. Unrelated edits that only shift byte offsets
|
|
16
|
+
# can preserve the id. File moves, renames, project-root changes, and changes to
|
|
17
|
+
# repeated-name or repeated-mutation order can change it.
|
|
18
18
|
module MutantId
|
|
19
19
|
module_function
|
|
20
20
|
|
|
@@ -38,7 +38,8 @@ module Mutineer
|
|
|
38
38
|
# file sharing this qualified name (two owner-less `def index` in two DSL
|
|
39
39
|
# blocks). Hashed only when positive, so a subject whose name is unique in
|
|
40
40
|
# its file keeps the id it had without it.
|
|
41
|
-
# @return [String] a 12-character hex id, stable
|
|
41
|
+
# @return [String] a 12-character hex id, stable while its keyed identity and
|
|
42
|
+
# occurrence order stay unchanged.
|
|
42
43
|
def for(subject, mutation, source, occurrence = 0, path:, subject_ordinal: 0)
|
|
43
44
|
parts = [path, subject.qualified_name, mutation.operator, normalized_token(mutation, source), occurrence]
|
|
44
45
|
parts << subject_ordinal if subject_ordinal.positive?
|
|
@@ -17,6 +17,8 @@ require_relative "mutators/negation_removal"
|
|
|
17
17
|
require_relative "mutators/chain_link"
|
|
18
18
|
require_relative "mutators/operand_removal"
|
|
19
19
|
require_relative "mutators/array_literal"
|
|
20
|
+
require_relative "mutators/condition_forcing"
|
|
21
|
+
require_relative "mutators/operator_assignment"
|
|
20
22
|
|
|
21
23
|
module Mutineer
|
|
22
24
|
# Maps operator names to operator classes.
|
|
@@ -45,7 +47,10 @@ module Mutineer
|
|
|
45
47
|
"negation_removal" => Mutators::NegationRemoval,
|
|
46
48
|
"chain_link" => Mutators::ChainLink,
|
|
47
49
|
"operand_removal" => Mutators::OperandRemoval,
|
|
48
|
-
"array_literal" => Mutators::ArrayLiteral
|
|
50
|
+
"array_literal" => Mutators::ArrayLiteral,
|
|
51
|
+
"condition_true" => Mutators::ConditionTrue,
|
|
52
|
+
"condition_false" => Mutators::ConditionFalse,
|
|
53
|
+
"operator_assignment" => Mutators::OperatorAssignment
|
|
49
54
|
}.freeze
|
|
50
55
|
|
|
51
56
|
# The default Tier-1 operator set.
|
|
@@ -53,7 +58,8 @@ module Mutineer
|
|
|
53
58
|
# Tier-2 operators that remain opt-in.
|
|
54
59
|
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
55
60
|
safe_navigation range negation_removal
|
|
56
|
-
chain_link operand_removal array_literal
|
|
61
|
+
chain_link operand_removal array_literal
|
|
62
|
+
condition_true condition_false operator_assignment].freeze
|
|
57
63
|
|
|
58
64
|
# Short human-readable descriptions for each operator.
|
|
59
65
|
DESCRIPTIONS = {
|
|
@@ -73,7 +79,10 @@ module Mutineer
|
|
|
73
79
|
"negation_removal" => "!x, not x -> x",
|
|
74
80
|
"chain_link" => "drop one call from a chain: a.b.c -> a.c",
|
|
75
81
|
"operand_removal" => "a && b -> a, b",
|
|
76
|
-
"array_literal" => "[a, b] -> []"
|
|
82
|
+
"array_literal" => "[a, b] -> []",
|
|
83
|
+
"condition_true" => "if/elsif/unless/ternary/modifier/case-in guard condition -> true",
|
|
84
|
+
"condition_false" => "if/elsif/unless/ternary/modifier/case-in guard condition -> false",
|
|
85
|
+
"operator_assignment" => "+= <-> -=, *= <-> /=, %= -> *=, **= -> *= (not ||=, &&=)"
|
|
77
86
|
}.freeze
|
|
78
87
|
|
|
79
88
|
# Resolves operator names to classes.
|
|
@@ -19,13 +19,6 @@ module Mutineer
|
|
|
19
19
|
super # nested arrays each get their own mutation
|
|
20
20
|
end
|
|
21
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
22
|
private
|
|
30
23
|
|
|
31
24
|
# Emits a mutation for a non-empty array with brackets.
|
|
@@ -11,9 +11,6 @@ module Mutineer
|
|
|
11
11
|
# Subclasses override `visit_*` methods to push `Mutation` objects onto
|
|
12
12
|
# `@mutations`. Visiting only `def_node.body` is the body-only enforcement:
|
|
13
13
|
# the def signature line is never touched.
|
|
14
|
-
#
|
|
15
|
-
# ponytail: one implementor in M1; Base earns its keep at M4 when
|
|
16
|
-
# comparison/boolean operators land and share this contract.
|
|
17
14
|
class Base < Prism::Visitor
|
|
18
15
|
# Walks the subject body and collects mutations.
|
|
19
16
|
#
|
|
@@ -23,12 +20,74 @@ module Mutineer
|
|
|
23
20
|
def mutations_for(subject, source)
|
|
24
21
|
@source = source
|
|
25
22
|
@mutations = []
|
|
26
|
-
subject.def_node.body
|
|
23
|
+
@body = subject.def_node.body
|
|
24
|
+
@body&.accept(self)
|
|
25
|
+
drop_dangling_heredocs
|
|
27
26
|
@mutations
|
|
28
27
|
end
|
|
29
28
|
|
|
29
|
+
# Skips a nested method definition. The project finds it as a subject of
|
|
30
|
+
# its own, so a visit here counts its mutations twice. Inside
|
|
31
|
+
# `class << obj`, the project finds no subject, so the visit continues.
|
|
32
|
+
#
|
|
33
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
34
|
+
# @return [void]
|
|
35
|
+
def visit_def_node(node)
|
|
36
|
+
super if @in_undiscovered
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Tracks a `class << obj` whose `obj` is not `self`. The project does not
|
|
40
|
+
# recurse into it, so its defs are no subjects (see
|
|
41
|
+
# `Project::SubjectVisitor#visit_singleton_class_node`).
|
|
42
|
+
#
|
|
43
|
+
# @param node [Prism::SingletonClassNode] singleton-class node.
|
|
44
|
+
# @return [void]
|
|
45
|
+
def visit_singleton_class_node(node)
|
|
46
|
+
return super if node.expression.is_a?(Prism::SelfNode)
|
|
47
|
+
|
|
48
|
+
outer, @in_undiscovered = @in_undiscovered, true
|
|
49
|
+
super
|
|
50
|
+
@in_undiscovered = outer
|
|
51
|
+
end
|
|
52
|
+
|
|
30
53
|
private
|
|
31
54
|
|
|
55
|
+
# Drops a mutation that deletes a heredoc opener and leaves its body.
|
|
56
|
+
#
|
|
57
|
+
# The body sits past the opener's node. Replacing the opener with text
|
|
58
|
+
# that does not contain it turns the body into Ruby, which parses and
|
|
59
|
+
# then raises. A replacement that still contains the opener keeps the
|
|
60
|
+
# body attached, so that mutation stays.
|
|
61
|
+
#
|
|
62
|
+
# @return [void]
|
|
63
|
+
def drop_dangling_heredocs
|
|
64
|
+
@mutations.reject! { |mutation| dangling_heredoc?(mutation) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns whether the mutation removes a heredoc opener whose body hangs
|
|
68
|
+
# past the replaced range.
|
|
69
|
+
#
|
|
70
|
+
# @param mutation [Mutineer::Mutation] candidate edit.
|
|
71
|
+
# @param node [Prism::Node, nil] subtree to search. Defaults to the body.
|
|
72
|
+
# @return [Boolean] true when the edit would leave a heredoc body behind.
|
|
73
|
+
def dangling_heredoc?(mutation, node = @body)
|
|
74
|
+
return false if node.nil? || !node.is_a?(Prism::Node)
|
|
75
|
+
|
|
76
|
+
if node.respond_to?(:heredoc?) && node.heredoc? && node.respond_to?(:opening_loc)
|
|
77
|
+
opening = node.opening_loc
|
|
78
|
+
if opening && opening.start_offset >= mutation.start_offset && opening.start_offset < mutation.end_offset
|
|
79
|
+
closing = node.respond_to?(:closing_loc) ? node.closing_loc : nil
|
|
80
|
+
hangs = closing.nil? || closing.end_offset > mutation.end_offset
|
|
81
|
+
if hangs
|
|
82
|
+
opener = @source.byteslice(opening.start_offset, opening.end_offset - opening.start_offset)
|
|
83
|
+
return true if opener.empty? || !mutation.replacement.include?(opener)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
node.compact_child_nodes.any? { |child| dangling_heredoc?(mutation, child) }
|
|
89
|
+
end
|
|
90
|
+
|
|
32
91
|
# Returns whether a node is, or contains, a heredoc.
|
|
33
92
|
#
|
|
34
93
|
# A heredoc's body lies outside its node's byte range. A mutation that
|
|
@@ -88,13 +88,6 @@ module Mutineer
|
|
|
88
88
|
super
|
|
89
89
|
end
|
|
90
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
91
|
private
|
|
99
92
|
|
|
100
93
|
# Walks a final call's receiver chain, recording each link so it is not
|
|
@@ -12,7 +12,7 @@ module Mutineer
|
|
|
12
12
|
# Method-name swaps. All targets are core-Ruby Enumerable/Array methods so
|
|
13
13
|
# the mutant exercises real behaviour rather than always raising.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
15
|
+
# include? -> exclude? is left out: exclude? is not
|
|
16
16
|
# core Ruby, so that mutant would always NoMethodError (a weak mutant).
|
|
17
17
|
REPLACEMENTS = {
|
|
18
18
|
map: "each", each: "map",
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Shared base of the condition-forcing mutators (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces the condition of an `if`, `elsif`, `unless`, ternary, modifier
|
|
10
|
+
# guard (`x if y`) or `case`/`in` guard with a literal, so its branch always
|
|
11
|
+
# runs or never runs. {ConditionTrue} forces `true` and {ConditionFalse}
|
|
12
|
+
# forces `false`; one mutation per condition each.
|
|
13
|
+
#
|
|
14
|
+
# A condition that is already `true`, `false` or `nil`, even in
|
|
15
|
+
# parentheses, is left alone: forcing it changes nothing or repeats the
|
|
16
|
+
# `boolean_literal` flip. This holds even when `boolean_literal` does not run.
|
|
17
|
+
# A condition that holds a heredoc is left alone too: the heredoc body lies
|
|
18
|
+
# outside the condition, so it would stay behind as code. A condition that
|
|
19
|
+
# assigns a variable keeps its code and only its value is forced.
|
|
20
|
+
#
|
|
21
|
+
# The never-runs side of an else-less conditional can be the same program
|
|
22
|
+
# as the `nil` that `statement_removal` or `return_nil` puts in place of the
|
|
23
|
+
# whole conditional. It is still made: dropping it would make the mutants
|
|
24
|
+
# of one operator depend on which other operators run, and on what the user
|
|
25
|
+
# suppressed for them. The two then get the same verdict.
|
|
26
|
+
#
|
|
27
|
+
# `condition_negation` wraps the condition in `!( ... )`, which neither
|
|
28
|
+
# forced value is.
|
|
29
|
+
class ConditionForcing < Base
|
|
30
|
+
# Condition nodes that `boolean_literal` already mutates.
|
|
31
|
+
LITERALS = [Prism::TrueNode, Prism::FalseNode, Prism::NilNode].freeze
|
|
32
|
+
|
|
33
|
+
# Visits if nodes: `if`, `elsif`, a ternary, a modifier `if` and an
|
|
34
|
+
# `in ... if` guard.
|
|
35
|
+
#
|
|
36
|
+
# @param node [Prism::IfNode] node to inspect.
|
|
37
|
+
# @return [void]
|
|
38
|
+
def visit_if_node(node)
|
|
39
|
+
force(node)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Visits unless nodes: `unless`, a modifier `unless` and an
|
|
44
|
+
# `in ... unless` guard.
|
|
45
|
+
#
|
|
46
|
+
# @param node [Prism::UnlessNode] node to inspect.
|
|
47
|
+
# @return [void]
|
|
48
|
+
def visit_unless_node(node)
|
|
49
|
+
force(node)
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Emits the forced condition unless the condition is a literal or holds a
|
|
56
|
+
# heredoc, whose body would stay behind as code.
|
|
57
|
+
#
|
|
58
|
+
# @param node [Prism::IfNode, Prism::UnlessNode] the conditional.
|
|
59
|
+
# @return [void]
|
|
60
|
+
def force(node)
|
|
61
|
+
predicate = node.predicate
|
|
62
|
+
return if LITERALS.include?(unwrap(predicate).class) || heredoc?(predicate)
|
|
63
|
+
|
|
64
|
+
loc = predicate.location
|
|
65
|
+
@mutations << Mutation.new(
|
|
66
|
+
start_offset: loc.start_offset,
|
|
67
|
+
end_offset: loc.end_offset,
|
|
68
|
+
replacement: replacement(predicate),
|
|
69
|
+
operator: self.class::OPERATOR
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns the forced value for the condition, always in parentheses, so it
|
|
74
|
+
# cannot fuse with a keyword or a `?` next to it: `x if@a` becomes
|
|
75
|
+
# `x if(true)` and `@a?1:2` becomes `(true)?1:2`. A condition that assigns
|
|
76
|
+
# a variable keeps its code, so later reads still see the variable:
|
|
77
|
+
# `(m = x; true)`.
|
|
78
|
+
#
|
|
79
|
+
# @param predicate [Prism::Node] the condition.
|
|
80
|
+
# @return [String] the replacement source.
|
|
81
|
+
def replacement(predicate)
|
|
82
|
+
value = self.class::VALUE.to_s
|
|
83
|
+
writes?(predicate) ? "(#{predicate.slice}; #{value})" : "(#{value})"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns whether the node writes a variable or constant anywhere inside
|
|
87
|
+
# it: a local, instance, class or global variable, or a constant, in a
|
|
88
|
+
# plain, compound, multiple or pattern write, or a named regex capture.
|
|
89
|
+
#
|
|
90
|
+
# @param node [Prism::Node] the node to inspect.
|
|
91
|
+
# @return [Boolean] true when a write or target node is inside.
|
|
92
|
+
def writes?(node)
|
|
93
|
+
return true if node.type.name.end_with?("_write_node", "_target_node")
|
|
94
|
+
|
|
95
|
+
node.compact_child_nodes.any? { |child| writes?(child) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns the node inside parentheses that hold exactly one expression,
|
|
99
|
+
# so `(true)` and `((true))` read as `true`.
|
|
100
|
+
#
|
|
101
|
+
# @param node [Prism::Node] the condition.
|
|
102
|
+
# @return [Prism::Node] the innermost wrapped node, or `node` itself.
|
|
103
|
+
def unwrap(node)
|
|
104
|
+
body = node.body if node.is_a?(Prism::ParenthesesNode)
|
|
105
|
+
body = body.body.first if body.is_a?(Prism::StatementsNode) && body.body.size == 1
|
|
106
|
+
body && !body.is_a?(Prism::StatementsNode) ? unwrap(body) : node
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Condition-forcing mutator: the condition becomes `true` (Tier-2).
|
|
111
|
+
#
|
|
112
|
+
# An `if` branch always runs; an `unless` branch never runs. The mutant
|
|
113
|
+
# survives when no test runs the code with the condition false (for an
|
|
114
|
+
# `if`) or true (for an `unless`) and checks what changes.
|
|
115
|
+
class ConditionTrue < ConditionForcing
|
|
116
|
+
# The literal the condition is replaced with.
|
|
117
|
+
VALUE = true
|
|
118
|
+
# The operator name mutations carry.
|
|
119
|
+
OPERATOR = :condition_true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Condition-forcing mutator: the condition becomes `false` (Tier-2).
|
|
123
|
+
#
|
|
124
|
+
# An `if` branch never runs; an `unless` branch always runs. The mutant
|
|
125
|
+
# survives when no test needs the branch the condition guards (for an
|
|
126
|
+
# `if`) or skips it (for an `unless`).
|
|
127
|
+
class ConditionFalse < ConditionForcing
|
|
128
|
+
# The literal the condition is replaced with.
|
|
129
|
+
VALUE = false
|
|
130
|
+
# The operator name mutations carry.
|
|
131
|
+
OPERATOR = :condition_false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -33,13 +33,6 @@ module Mutineer
|
|
|
33
33
|
super
|
|
34
34
|
end
|
|
35
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
36
|
private
|
|
44
37
|
|
|
45
38
|
# Emits one mutation that keeps the left operand, then one that keeps
|