mutineer 1.7.0 → 1.8.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 +432 -0
- data/README.md +157 -747
- data/lib/mutineer/baseline.rb +1 -1
- data/lib/mutineer/changed_lines.rb +2 -1
- data/lib/mutineer/cli.rb +119 -27
- data/lib/mutineer/concern_blocks.rb +168 -5
- data/lib/mutineer/config.rb +6 -0
- data/lib/mutineer/coverage_map.rb +92 -17
- data/lib/mutineer/daemon_backend.rb +75 -32
- data/lib/mutineer/daemon_client.rb +50 -17
- data/lib/mutineer/daemon_server.rb +92 -40
- data/lib/mutineer/external_backend.rb +150 -17
- data/lib/mutineer/isolation.rb +67 -25
- data/lib/mutineer/job_plan.rb +117 -33
- data/lib/mutineer/kill_channel.rb +24 -14
- data/lib/mutineer/migrate.rb +10 -8
- data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
- data/lib/mutineer/minitest_integration.rb +44 -14
- data/lib/mutineer/mutators/array_literal.rb +4 -2
- data/lib/mutineer/mutators/base.rb +22 -0
- data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
- data/lib/mutineer/mutators/chain_link.rb +6 -6
- data/lib/mutineer/mutators/condition_forcing.rb +6 -3
- data/lib/mutineer/mutators/operand_removal.rb +4 -2
- data/lib/mutineer/mutators/range_literal.rb +105 -4
- data/lib/mutineer/mutators/return_nil.rb +1 -0
- data/lib/mutineer/orphan_guard.rb +18 -6
- data/lib/mutineer/pairing.rb +62 -1
- data/lib/mutineer/project.rb +226 -12
- data/lib/mutineer/rails_worker_db.rb +200 -16
- data/lib/mutineer/redaction.rb +112 -11
- data/lib/mutineer/reporter.rb +109 -21
- data/lib/mutineer/result.rb +33 -5
- data/lib/mutineer/runner.rb +126 -18
- data/lib/mutineer/statement_lines.rb +14 -8
- data/lib/mutineer/subject.rb +13 -5
- data/lib/mutineer/test_runners/minitest.rb +2 -1
- data/lib/mutineer/test_runners.rb +4 -2
- data/lib/mutineer/triage.rb +1 -1
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +2 -1
- data/skills/mutineer/SKILL.md +84 -0
- metadata +5 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "outer_reporter"
|
|
4
|
+
require_relative "in_test_database_error"
|
|
4
5
|
require_relative "../kill_channel"
|
|
5
6
|
|
|
6
7
|
module Mutineer
|
|
@@ -22,7 +23,7 @@ module Mutineer
|
|
|
22
23
|
# Prepended on each loaded test class: one test's run. Serial and parallel
|
|
23
24
|
# runs both call it, in Minitest 5 and 6.
|
|
24
25
|
module MarkSkippedTest
|
|
25
|
-
# Runs the test inside a `skip` region once a serial test has
|
|
26
|
+
# Runs the test inside a `skip` region once a serial test has failed.
|
|
26
27
|
#
|
|
27
28
|
# @return [Minitest::Result]
|
|
28
29
|
def run
|
|
@@ -35,7 +36,7 @@ module Mutineer
|
|
|
35
36
|
# A run without `--matrix` skips both after a stop (see
|
|
36
37
|
# StopAtFirstFailure::SkipAfterStop6).
|
|
37
38
|
module MarkSkippedClass6
|
|
38
|
-
# Runs the class inside a `skip` region once a serial test has
|
|
39
|
+
# Runs the class inside a `skip` region once a serial test has failed.
|
|
39
40
|
#
|
|
40
41
|
# @param reporter [Minitest::CompositeReporter]
|
|
41
42
|
# @param options [Hash]
|
|
@@ -46,7 +47,7 @@ module Mutineer
|
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
# Runs one test's class-level step inside a `skip` region once a
|
|
49
|
-
# serial test has
|
|
50
|
+
# serial test has failed.
|
|
50
51
|
#
|
|
51
52
|
# @param klass [Class]
|
|
52
53
|
# @param method_name [String]
|
|
@@ -59,7 +60,7 @@ module Mutineer
|
|
|
59
60
|
|
|
60
61
|
# The Minitest 5 form of {MarkSkippedClass6}.
|
|
61
62
|
module MarkSkippedClass5
|
|
62
|
-
# Runs the class inside a `skip` region once a serial test has
|
|
63
|
+
# Runs the class inside a `skip` region once a serial test has failed.
|
|
63
64
|
#
|
|
64
65
|
# @param reporter [Minitest::CompositeReporter]
|
|
65
66
|
# @param options [Hash]
|
|
@@ -70,7 +71,7 @@ module Mutineer
|
|
|
70
71
|
end
|
|
71
72
|
|
|
72
73
|
# Runs one test's class-level step inside a `skip` region once a
|
|
73
|
-
# serial test has
|
|
74
|
+
# serial test has failed.
|
|
74
75
|
#
|
|
75
76
|
# @param klass [Class]
|
|
76
77
|
# @param method_name [String]
|
|
@@ -114,11 +115,11 @@ module Mutineer
|
|
|
114
115
|
# @return [Integer, nil]
|
|
115
116
|
attr_accessor :seen
|
|
116
117
|
|
|
117
|
-
# True once a serial test
|
|
118
|
-
# skips each later test and class.
|
|
118
|
+
# True once a serial test failed, including a database failure: from
|
|
119
|
+
# then on, a run without `--matrix` skips each later test and class.
|
|
119
120
|
#
|
|
120
121
|
# @return [Boolean, nil]
|
|
121
|
-
attr_accessor :
|
|
122
|
+
attr_accessor :serial_failed
|
|
122
123
|
|
|
123
124
|
# Installs the hooks, arms the recorder for this process, and writes the
|
|
124
125
|
# channel's `start` line. Call it after the test files load, so the
|
|
@@ -149,7 +150,7 @@ module Mutineer
|
|
|
149
150
|
# the run joins it without running, which would break the count.
|
|
150
151
|
self.runnables = runnables.dup
|
|
151
152
|
self.seen = 0
|
|
152
|
-
self.
|
|
153
|
+
self.serial_failed = false
|
|
153
154
|
KillChannel.write_start(channel)
|
|
154
155
|
true
|
|
155
156
|
end
|
|
@@ -186,7 +187,7 @@ module Mutineer
|
|
|
186
187
|
self.armed_reporter = nil
|
|
187
188
|
self.channel = nil
|
|
188
189
|
self.runnables = nil
|
|
189
|
-
self.
|
|
190
|
+
self.serial_failed = false
|
|
190
191
|
end
|
|
191
192
|
|
|
192
193
|
# Writes the `parallel` line when the first parallel class starts on the
|
|
@@ -206,7 +207,7 @@ module Mutineer
|
|
|
206
207
|
end
|
|
207
208
|
|
|
208
209
|
# Runs the block between `skip` and `unskip` when a serial test has
|
|
209
|
-
#
|
|
210
|
+
# failed in this armed process, else just runs it. No `ensure`: a block
|
|
210
211
|
# that never returns (an exit, a crash) must leave its region open, so
|
|
211
212
|
# the parent knows the child ended where a run without `--matrix`
|
|
212
213
|
# would not have been.
|
|
@@ -214,7 +215,7 @@ module Mutineer
|
|
|
214
215
|
# @yieldreturn [Object]
|
|
215
216
|
# @return [Object] the block's value.
|
|
216
217
|
def skipping
|
|
217
|
-
open = channel &&
|
|
218
|
+
open = channel && serial_failed && armed_here?
|
|
218
219
|
KillChannel.write_skip(channel) if open
|
|
219
220
|
value = yield
|
|
220
221
|
KillChannel.write_unskip(channel) if open
|
|
@@ -222,9 +223,10 @@ module Mutineer
|
|
|
222
223
|
end
|
|
223
224
|
|
|
224
225
|
# Sends one result recorded on `reporter`: a pass, or a kill for a
|
|
225
|
-
# failure or an error
|
|
226
|
-
# seen, and results on other
|
|
227
|
-
# `parallel` line goes out when the
|
|
226
|
+
# failure or an error, or an unavailable database outcome. Skips send
|
|
227
|
+
# no test line, though they count as seen, and results on other
|
|
228
|
+
# reporters count for nothing. The `parallel` line goes out when the
|
|
229
|
+
# first parallel class starts (see
|
|
228
230
|
# {.class_starting}), so a kill that precedes it came from a serial test.
|
|
229
231
|
#
|
|
230
232
|
# @param reporter [Minitest::CompositeReporter] the recording reporter.
|
|
@@ -251,8 +253,14 @@ module Mutineer
|
|
|
251
253
|
def send_result(result)
|
|
252
254
|
return if result.skipped?
|
|
253
255
|
|
|
254
|
-
event = result.passed?
|
|
255
|
-
|
|
256
|
+
event = if result.passed?
|
|
257
|
+
KillChannel::PASS
|
|
258
|
+
elsif InTestDatabaseError.database_error(result.failures.first)
|
|
259
|
+
KillChannel::UNAVAILABLE
|
|
260
|
+
else
|
|
261
|
+
KillChannel::KILL
|
|
262
|
+
end
|
|
263
|
+
self.serial_failed = true if event != KillChannel::PASS && !parallel_marked
|
|
256
264
|
file, line = Array(result.source_location)
|
|
257
265
|
KillChannel.write(channel, event, file, *name_and_id(result, line))
|
|
258
266
|
rescue StandardError
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "minitest_integration/stop_at_first_failure"
|
|
4
4
|
require_relative "minitest_integration/kill_recorder"
|
|
5
|
+
require_relative "minitest_integration/in_test_database_error"
|
|
5
6
|
|
|
6
7
|
module Mutineer
|
|
7
8
|
# Child-process-only: loads a test file in the current process and runs it
|
|
8
9
|
# programmatically, returning an exit status integer (0 = all passed,
|
|
9
|
-
# 1 = any failure/error
|
|
10
|
+
# 1 = any failure/error, 2 = the first failure was an Active Record
|
|
11
|
+
# connection or lock error).
|
|
10
12
|
#
|
|
11
13
|
# Never call this in the parent — it manipulates global Minitest state
|
|
12
14
|
# (autorun, runnables) that only makes sense in a throwaway forked child.
|
|
@@ -14,15 +16,39 @@ module Mutineer
|
|
|
14
16
|
# A missing minitest is rescued as FrameworkUnavailable (Isolation.run
|
|
15
17
|
# still turns that raise into exit 2). There is no rescue around the
|
|
16
18
|
# suite run itself: Isolation.run's fork block is the single exception
|
|
17
|
-
# boundary for unexpected errors. Swallowing those here would
|
|
18
|
-
#
|
|
19
|
+
# boundary for unexpected errors. Swallowing those here would hide a
|
|
20
|
+
# crash as a verdict. The one 2 this method returns on purpose comes from
|
|
21
|
+
# Minitest's own results: the run's first failing test failed with an
|
|
22
|
+
# Active Record connection or lock error (#296, {InTestDatabaseError}).
|
|
19
23
|
class MinitestIntegration
|
|
20
|
-
# The seed of
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
+
# The seed of every Minitest run Mutineer starts, unless `SEED` is set:
|
|
25
|
+
# coverage capture, the clean checks and the mutant runs (#282). A file's
|
|
26
|
+
# clean check then runs its tests in the order a mutant run of that file
|
|
27
|
+
# uses. Several covering files can still differ from the combined clean
|
|
28
|
+
# check: a smaller class list shuffles differently. With the stop,
|
|
29
|
+
# the test order can decide `killed` vs `timeout`; a fixed order keeps the
|
|
30
|
+
# verdict stable for a `--baseline` gate. A `--matrix` run uses the same
|
|
24
31
|
# order, so its first failure comes at the same point as the stop's.
|
|
25
|
-
|
|
32
|
+
#
|
|
33
|
+
# @api private
|
|
34
|
+
PINNED_SEED = 1
|
|
35
|
+
|
|
36
|
+
# The arguments that give `Minitest.run` the shared seed. With `SEED` set,
|
|
37
|
+
# none: Minitest reads `SEED` itself.
|
|
38
|
+
#
|
|
39
|
+
# @api private
|
|
40
|
+
# @return [Array<String>] `["--seed", "1"]`, or `[]` when `SEED` is set.
|
|
41
|
+
def self.seed_args
|
|
42
|
+
ENV["SEED"] ? [] : ["--seed", seed]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The seed the Minitest runs use: `SEED` when set, else {PINNED_SEED}.
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
# @return [String]
|
|
49
|
+
def self.seed
|
|
50
|
+
ENV.fetch("SEED", PINNED_SEED.to_s)
|
|
51
|
+
end
|
|
26
52
|
|
|
27
53
|
# Tested via runner_test.rb, not in isolation — a direct unit test
|
|
28
54
|
# would require forking and duplicate isolation_test's coverage.
|
|
@@ -36,7 +62,8 @@ module Mutineer
|
|
|
36
62
|
# test. Only the mutant path passes true.
|
|
37
63
|
# @param record_to [IO, nil] a `--matrix` run: send each test's outcome to
|
|
38
64
|
# this channel (see {KillRecorder}) and run every test.
|
|
39
|
-
# @return [Integer] 0 on success, 1 on failure
|
|
65
|
+
# @return [Integer] 0 on success, 1 on failure, 2 when the first failure
|
|
66
|
+
# was an Active Record connection or lock error (see {InTestDatabaseError}).
|
|
40
67
|
# @raise [ArgumentError] when both options are given: a matrix run never stops.
|
|
41
68
|
def self.run(test_files, stop_at_first_failure: false, record_to: nil)
|
|
42
69
|
if stop_at_first_failure && record_to
|
|
@@ -75,22 +102,25 @@ module Mutineer
|
|
|
75
102
|
elsif stop_at_first_failure
|
|
76
103
|
StopAtFirstFailure.arm!(Minitest::Runnable.runnables)
|
|
77
104
|
end
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
args = armed && !ENV["SEED"] ? ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] : []
|
|
105
|
+
# An unknown Minitest shape arms no hook and gets a full run, in the
|
|
106
|
+
# same seeded order.
|
|
81
107
|
keep_file_order(rank) if armed
|
|
108
|
+
# Every run, on its own line: `armed` decides the file order (#296).
|
|
109
|
+
InTestDatabaseError.arm!
|
|
82
110
|
# No silencing here: the fork boundary that calls this method has already
|
|
83
111
|
# pointed stdout at File::NULL (see ChildStdout).
|
|
84
|
-
passed = Minitest.run(
|
|
112
|
+
passed = Minitest.run(seed_args)
|
|
85
113
|
# Reached only when the suite returned: a test that exits the process
|
|
86
114
|
# skips it, and the parent then keeps the row incomplete.
|
|
87
115
|
KillRecorder.finish! if record_to
|
|
88
116
|
|
|
89
117
|
# A plugin can replace the summary reporter, so a stop decides by itself.
|
|
90
|
-
passed && !StopAtFirstFailure.stopped_here? ? 0 : 1
|
|
118
|
+
code = passed && !StopAtFirstFailure.stopped_here? ? 0 : 1
|
|
119
|
+
InTestDatabaseError.exit_code(code)
|
|
91
120
|
ensure
|
|
92
121
|
StopAtFirstFailure.disarm!
|
|
93
122
|
KillRecorder.disarm!
|
|
123
|
+
InTestDatabaseError.disarm!
|
|
94
124
|
end
|
|
95
125
|
|
|
96
126
|
# Runs the test classes in the order of their files (#203). Minitest
|
|
@@ -25,12 +25,14 @@ module Mutineer
|
|
|
25
25
|
#
|
|
26
26
|
# The operator skips an implicit array (`x = 1, 2`), because it has no
|
|
27
27
|
# brackets. It also skips an array that holds a heredoc, because the
|
|
28
|
-
# heredoc body stays behind as code.
|
|
28
|
+
# heredoc body stays behind as code. Only the heredoc skip counts as
|
|
29
|
+
# declined.
|
|
29
30
|
#
|
|
30
31
|
# @param node [Prism::ArrayNode] array node to inspect.
|
|
31
32
|
# @return [void]
|
|
32
33
|
def emit(node)
|
|
33
|
-
return if node.opening_loc.nil? || node.elements.empty?
|
|
34
|
+
return if node.opening_loc.nil? || node.elements.empty?
|
|
35
|
+
return decline if heredoc?(node)
|
|
34
36
|
|
|
35
37
|
@mutations << Mutation.new(
|
|
36
38
|
start_offset: node.location.start_offset,
|
|
@@ -20,6 +20,7 @@ module Mutineer
|
|
|
20
20
|
def mutations_for(subject, source)
|
|
21
21
|
@source = source
|
|
22
22
|
@mutations = []
|
|
23
|
+
@declined = 0
|
|
23
24
|
@body = subject.def_node.body
|
|
24
25
|
@body&.accept(self)
|
|
25
26
|
drop_dangling_heredocs
|
|
@@ -50,8 +51,26 @@ module Mutineer
|
|
|
50
51
|
@in_undiscovered = outer
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
# Returns how many candidates the last {#mutations_for} call declined: an
|
|
55
|
+
# edit that would split a heredoc from its body, or an equivalent skip
|
|
56
|
+
# listed in docs/equivalent-skips.md. A declined candidate is never a
|
|
57
|
+
# mutation, so it is not in the run's total.
|
|
58
|
+
#
|
|
59
|
+
# @return [Integer] declined candidates; 0 before the first call.
|
|
60
|
+
def declined
|
|
61
|
+
@declined || 0
|
|
62
|
+
end
|
|
63
|
+
|
|
53
64
|
private
|
|
54
65
|
|
|
66
|
+
# Counts one declined candidate.
|
|
67
|
+
#
|
|
68
|
+
# @return [nil] so a skip line reads `return decline if ...`.
|
|
69
|
+
def decline
|
|
70
|
+
@declined += 1
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
55
74
|
# Drops a mutation that splits a heredoc opener from its body.
|
|
56
75
|
#
|
|
57
76
|
# The body sits past the opener's node. Replacing the opener with text
|
|
@@ -61,10 +80,13 @@ module Mutineer
|
|
|
61
80
|
# the replaced range holds the body but the replacement keeps only the
|
|
62
81
|
# opener, the opener has no body and the source does not parse. A
|
|
63
82
|
# replacement that copies the opener and the body together stays.
|
|
83
|
+
# Each dropped mutation counts as declined.
|
|
64
84
|
#
|
|
65
85
|
# @return [void]
|
|
66
86
|
def drop_dangling_heredocs
|
|
87
|
+
before = @mutations.size
|
|
67
88
|
@mutations.reject! { |mutation| dangling_heredoc?(mutation) }
|
|
89
|
+
@declined += before - @mutations.size
|
|
68
90
|
end
|
|
69
91
|
|
|
70
92
|
# Returns whether the mutation replaces a heredoc opener and keeps the
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
require_relative "chain_link"
|
|
5
4
|
|
|
6
5
|
module Mutineer
|
|
7
6
|
module Mutators
|
|
@@ -21,11 +20,14 @@ module Mutineer
|
|
|
21
20
|
# that is the receiver of a dotted call, or of a call write or a call
|
|
22
21
|
# target, is a link, so it gets no mutant here. The two operators give the
|
|
23
22
|
# same source only when the last call repeats the link before it, as in
|
|
24
|
-
# `node.name.name`.
|
|
23
|
+
# `node.name.name`. When both edits start on the same line, the job plan
|
|
24
|
+
# runs one copy, the `chain_link` one (#275).
|
|
25
25
|
#
|
|
26
|
-
# Calls in {
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# Calls in {SKIPPED} are never replaced; each one counts as declined. The
|
|
27
|
+
# list is shorter than {ChainLink::SKIPPED}: on the last call, a conversion
|
|
28
|
+
# such as `to_s` or `to_i` decides the type of the value that the
|
|
29
|
+
# expression returns, so `m[1].to_i` becomes `m[1]`. `Foo.new` becomes the
|
|
30
|
+
# class, and almost any use of the class raises.
|
|
29
31
|
#
|
|
30
32
|
# A call is not replaced when its name is an operator (`a.+(1)`), or a
|
|
31
33
|
# setter (`a.b = 1`). A call with no dot (`a + b`, `a[0]`) is not replaced.
|
|
@@ -33,6 +35,17 @@ module Mutineer
|
|
|
33
35
|
# Matches the name of a call written as a word, not as an operator.
|
|
34
36
|
WORD_NAME = /\A[[:alpha:]_]/
|
|
35
37
|
|
|
38
|
+
# Method names whose last call is never replaced. A name is here when
|
|
39
|
+
# its mutant is most often equivalent, or when almost any test that runs
|
|
40
|
+
# the line kills it. `freeze` and `itself` return the receiver. `dup`
|
|
41
|
+
# and `clone` return a copy, so the mutant shows when code later changes
|
|
42
|
+
# the copy or the original, or compares the result with the original by
|
|
43
|
+
# identity. Dropping `to_set` most often leaves an Array, which answers
|
|
44
|
+
# `include?` and `empty?` as a Set does (it keeps duplicates, so `size`
|
|
45
|
+
# and `each` can differ). `new` leaves the class in place of an
|
|
46
|
+
# instance, which almost any test that runs the line kills.
|
|
47
|
+
SKIPPED = %i[new dup clone freeze itself to_set].freeze
|
|
48
|
+
|
|
36
49
|
# Resets the per-subject record of calls that are links.
|
|
37
50
|
#
|
|
38
51
|
# @param subject [Mutineer::Subject] subject whose body is visited.
|
|
@@ -104,7 +117,7 @@ module Mutineer
|
|
|
104
117
|
end
|
|
105
118
|
|
|
106
119
|
# Emits the replacement of a call by its receiver, unless the name is an
|
|
107
|
-
# operator, a setter, or in {
|
|
120
|
+
# operator, a setter, or in {SKIPPED}.
|
|
108
121
|
#
|
|
109
122
|
# @param node [Prism::CallNode] the dotted call to replace.
|
|
110
123
|
# @return [void]
|
|
@@ -112,7 +125,7 @@ module Mutineer
|
|
|
112
125
|
name = node.name.to_s
|
|
113
126
|
return unless name.match?(WORD_NAME)
|
|
114
127
|
return if name.end_with?("=")
|
|
115
|
-
return if
|
|
128
|
+
return decline if SKIPPED.include?(node.name)
|
|
116
129
|
|
|
117
130
|
receiver = node.receiver.location
|
|
118
131
|
@mutations << Mutation.new(
|
|
@@ -16,16 +16,16 @@ module Mutineer
|
|
|
16
16
|
# The mutant survives when no test tells the chain apart from the same chain
|
|
17
17
|
# without that step: a scope, a filter or a lookup the tests never see.
|
|
18
18
|
#
|
|
19
|
-
# Links in {SKIPPED} are never dropped
|
|
20
|
-
# target type or needs no copy, a conversion
|
|
21
|
-
# (`list.dup.sort`) is a no-op, so dropping
|
|
22
|
-
# equivalent mutant. Dropping `new` sends the next call to the class, which
|
|
19
|
+
# Links in {SKIPPED} are never dropped; each one counts as declined. On a
|
|
20
|
+
# value that already has the target type or needs no copy, a conversion
|
|
21
|
+
# (`name.to_s.strip`) or a copy (`list.dup.sort`) is a no-op, so dropping
|
|
22
|
+
# it most often makes an equivalent mutant. Dropping `new` sends the next call to the class, which
|
|
23
23
|
# raises (killed by any test that runs the line) or reaches a class method
|
|
24
24
|
# that builds the instance itself (equivalent); neither says anything about
|
|
25
25
|
# the tests.
|
|
26
26
|
class ChainLink < Base
|
|
27
27
|
# Method names whose link is never dropped: core conversions and copies,
|
|
28
|
-
# plus `new`. {CallToReceiver}
|
|
28
|
+
# plus `new`. {CallToReceiver} has its own shorter list for the last call.
|
|
29
29
|
SKIPPED = %i[
|
|
30
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
31
|
dup clone freeze itself
|
|
@@ -100,7 +100,7 @@ module Mutineer
|
|
|
100
100
|
link = top.receiver
|
|
101
101
|
while dotted?(link)
|
|
102
102
|
@links[link] = true
|
|
103
|
-
|
|
103
|
+
SKIPPED.include?(link.name) ? decline : drop(link)
|
|
104
104
|
link = link.receiver
|
|
105
105
|
end
|
|
106
106
|
end
|
|
@@ -22,7 +22,8 @@ module Mutineer
|
|
|
22
22
|
# as the `nil` that `statement_removal` or `return_nil` puts in place of the
|
|
23
23
|
# whole conditional. It is still made: dropping it would make the mutants
|
|
24
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.
|
|
25
|
+
# suppressed for them. The two then get the same verdict. Their text
|
|
26
|
+
# differs, so the job plan's drop of a repeated edit (#275) keeps both.
|
|
26
27
|
#
|
|
27
28
|
# `condition_negation` wraps the condition in `!( ... )`, which neither
|
|
28
29
|
# forced value is.
|
|
@@ -53,13 +54,15 @@ module Mutineer
|
|
|
53
54
|
private
|
|
54
55
|
|
|
55
56
|
# Emits the forced condition unless the condition is a literal or holds a
|
|
56
|
-
# heredoc, whose body would stay behind as code.
|
|
57
|
+
# heredoc, whose body would stay behind as code. The heredoc skip counts
|
|
58
|
+
# as declined; the literal skip does not.
|
|
57
59
|
#
|
|
58
60
|
# @param node [Prism::IfNode, Prism::UnlessNode] the conditional.
|
|
59
61
|
# @return [void]
|
|
60
62
|
def force(node)
|
|
61
63
|
predicate = node.predicate
|
|
62
|
-
return if LITERALS.include?(unwrap(predicate).class)
|
|
64
|
+
return if LITERALS.include?(unwrap(predicate).class)
|
|
65
|
+
return decline if heredoc?(predicate)
|
|
63
66
|
|
|
64
67
|
loc = predicate.location
|
|
65
68
|
@mutations << Mutation.new(
|
|
@@ -37,13 +37,15 @@ module Mutineer
|
|
|
37
37
|
|
|
38
38
|
# Emits one mutation that keeps the left operand, then one that keeps
|
|
39
39
|
# the right operand. The order is fixed, because the two mutants share
|
|
40
|
-
# a token and their occurrence ordinals tell them apart.
|
|
40
|
+
# a token and their occurrence ordinals tell them apart. A removed
|
|
41
|
+
# operand that holds a heredoc counts as declined; a kept jump does not.
|
|
41
42
|
#
|
|
42
43
|
# @param node [Prism::AndNode, Prism::OrNode] connector node.
|
|
43
44
|
# @return [void]
|
|
44
45
|
def emit(node)
|
|
45
46
|
[[node.left, node.right], [node.right, node.left]].each do |kept, removed|
|
|
46
|
-
next if JUMPS.any? { |type| kept.is_a?(type) }
|
|
47
|
+
next if JUMPS.any? { |type| kept.is_a?(type) }
|
|
48
|
+
next decline if heredoc?(removed)
|
|
47
49
|
|
|
48
50
|
@mutations << Mutation.new(
|
|
49
51
|
start_offset: node.location.start_offset,
|
|
@@ -10,24 +10,125 @@ module Mutineer
|
|
|
10
10
|
# `..` -> `...` mutant survives when no test checks the last element.
|
|
11
11
|
# Flip-flops are FlipFlopNode, not RangeNode, so this mutator skips them.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# An endless range (`1..`, `1..nil`) is skipped only when it is a direct
|
|
14
|
+
# index argument of a call in {SLICE_CALLS} or of an index write
|
|
15
|
+
# (`a[1..] += x`). There `(1..)` and `(1...)` select the same elements, so
|
|
16
|
+
# the mutant is equivalent, and each skipped range counts as declined.
|
|
17
|
+
# Every other endless range is swapped, because the two ranges differ on
|
|
18
|
+
# `==`, `hash`, `exclude_end?` and `to_s`, so a test can kill it. This
|
|
19
|
+
# includes the value of `h[:k] = 1..` and a call on a class, such as
|
|
20
|
+
# `Array[1..]`, which builds a value and does not slice.
|
|
16
21
|
class RangeLiteral < Base
|
|
17
22
|
# Maps each range operator to its opposite.
|
|
18
23
|
SWAPS = { ".." => "...", "..." => ".." }.freeze
|
|
19
24
|
|
|
25
|
+
# Calls whose direct endless-range index arguments are not mutated. They
|
|
26
|
+
# select the same elements for `(1..)` and `(1...)`. The check uses the
|
|
27
|
+
# name, and a class receiver is not skipped. The assigned value of `[]=`
|
|
28
|
+
# is not an index, so it is mutated.
|
|
29
|
+
SLICE_CALLS = %i[[] []= slice slice! byteslice bytesplice values_at].freeze
|
|
30
|
+
|
|
31
|
+
# Resets the per-subject record of endless ranges that are slice arguments.
|
|
32
|
+
#
|
|
33
|
+
# @param subject [Mutineer::Subject] subject whose body is visited.
|
|
34
|
+
# @param source [String] full source text for byte-based slicing.
|
|
35
|
+
# @return [Array<Mutineer::Mutation>] collected mutations.
|
|
36
|
+
def mutations_for(subject, source)
|
|
37
|
+
@slice_args = {}.compare_by_identity
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Visits a call. Records the range arguments of a call in {SLICE_CALLS},
|
|
42
|
+
# unless the receiver is a class.
|
|
43
|
+
#
|
|
44
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
45
|
+
# @return [void]
|
|
46
|
+
def visit_call_node(node)
|
|
47
|
+
record_slice_args(node) if SLICE_CALLS.include?(node.name)
|
|
48
|
+
super
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Visits `a[1..] += x`. Its index arguments are slice arguments.
|
|
52
|
+
#
|
|
53
|
+
# @param node [Prism::IndexOperatorWriteNode] node to inspect.
|
|
54
|
+
# @return [void]
|
|
55
|
+
def visit_index_operator_write_node(node)
|
|
56
|
+
record_slice_args(node)
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Visits `a[1..] ||= x`. Its index arguments are slice arguments.
|
|
61
|
+
#
|
|
62
|
+
# @param node [Prism::IndexOrWriteNode] node to inspect.
|
|
63
|
+
# @return [void]
|
|
64
|
+
def visit_index_or_write_node(node)
|
|
65
|
+
record_slice_args(node)
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Visits `a[1..] &&= x`. Its index arguments are slice arguments.
|
|
70
|
+
#
|
|
71
|
+
# @param node [Prism::IndexAndWriteNode] node to inspect.
|
|
72
|
+
# @return [void]
|
|
73
|
+
def visit_index_and_write_node(node)
|
|
74
|
+
record_slice_args(node)
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Visits an index used as an assignment target, as in `a[1..], b = x, y`.
|
|
79
|
+
# Its index arguments are slice arguments.
|
|
80
|
+
#
|
|
81
|
+
# @param node [Prism::IndexTargetNode] node to inspect.
|
|
82
|
+
# @return [void]
|
|
83
|
+
def visit_index_target_node(node)
|
|
84
|
+
record_slice_args(node)
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
|
|
20
88
|
# Visits range nodes and emits range mutations.
|
|
21
89
|
#
|
|
22
90
|
# @param node [Prism::RangeNode] range node to inspect.
|
|
23
91
|
# @return [void]
|
|
24
92
|
def visit_range_node(node)
|
|
25
|
-
|
|
93
|
+
if endless?(node) && @slice_args.key?(node)
|
|
94
|
+
decline
|
|
95
|
+
else
|
|
96
|
+
emit(node)
|
|
97
|
+
end
|
|
26
98
|
super # nested ranges ((1..2)...(3..4)) each get their own mutation
|
|
27
99
|
end
|
|
28
100
|
|
|
29
101
|
private
|
|
30
102
|
|
|
103
|
+
# Records each range that is a direct argument of the node. For a `[]=`
|
|
104
|
+
# call the last argument is the assigned value, not an index, so it is
|
|
105
|
+
# left out. Nothing is recorded when the receiver is a class, as in
|
|
106
|
+
# `Array[1..]` or `Registry[1..] ||= x`.
|
|
107
|
+
#
|
|
108
|
+
# @param node [Prism::Node] call or index node that responds to `arguments`.
|
|
109
|
+
# @return [void]
|
|
110
|
+
def record_slice_args(node)
|
|
111
|
+
return if class_receiver?(node)
|
|
112
|
+
|
|
113
|
+
args = node.arguments&.arguments || []
|
|
114
|
+
args = args[0...-1] if node.is_a?(Prism::CallNode) && node.name == :[]=
|
|
115
|
+
args.each { |arg| @slice_args[arg] = true if arg.is_a?(Prism::RangeNode) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Whether the receiver of a call or index write names a class or module, as in
|
|
119
|
+
# `Array[1..]` or `Foo::Bar[1..]`. Such a call builds a value, so it is not
|
|
120
|
+
# a slice. A constant in capitals only, as in `ARGV[1..]`, holds a value
|
|
121
|
+
# that is sliced, so it does not count.
|
|
122
|
+
#
|
|
123
|
+
# @param node [Prism::Node] call or index node that responds to `receiver`.
|
|
124
|
+
# @return [Boolean]
|
|
125
|
+
def class_receiver?(node)
|
|
126
|
+
receiver = node.receiver
|
|
127
|
+
return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
128
|
+
|
|
129
|
+
receiver.name.to_s.match?(/[a-z]/)
|
|
130
|
+
end
|
|
131
|
+
|
|
31
132
|
# Whether the range has no end: `1..` or `1..nil`.
|
|
32
133
|
#
|
|
33
134
|
# @param node [Prism::RangeNode] range node to inspect.
|
|
@@ -12,8 +12,19 @@ module Mutineer
|
|
|
12
12
|
POLL = 0.5
|
|
13
13
|
# `Tempfile.create([".mutineer_daemon", ".rb"])` names the file
|
|
14
14
|
# `.mutineer_daemon<YYYYMMDD>-<pid>-<rand>.rb` (without the dot before #253).
|
|
15
|
-
#
|
|
16
|
-
|
|
15
|
+
# An in-process `.mutineer_mutant` file has the same shape (#295). The pid
|
|
16
|
+
# is the child that is loading the mutant. An undotted name is a leftover
|
|
17
|
+
# of a version before 1.7.0, so its pid is not read: a reused pid must not
|
|
18
|
+
# keep a file that stops the next boot. A held file lock still protects it.
|
|
19
|
+
TEMP_PID = /\A\.mutineer_(?:daemon|mutant)\d{8}-(\d+)-/
|
|
20
|
+
# Basename glob for the mutant files of both modes. Tempfile puts hyphens in
|
|
21
|
+
# the name, so it is not a constant name. The leading dot hides a new file
|
|
22
|
+
# from Zeitwerk (#253); a file an older version left behind has none, so the
|
|
23
|
+
# glob matches both. Each boot sweep removes an orphan of either mode, so an
|
|
24
|
+
# old file of the other mode does not stop the boot (#287). The date and
|
|
25
|
+
# hyphen after the prefix keep a project's own file, such as
|
|
26
|
+
# `mutineer_mutant_helper.rb`, out of the sweep.
|
|
27
|
+
TEMP_BASENAME = "{.,}mutineer_{mutant,daemon}#{"[0-9]" * 8}-*.rb"
|
|
17
28
|
|
|
18
29
|
# Starts the watchdog in the current (child) process.
|
|
19
30
|
#
|
|
@@ -29,7 +40,7 @@ module Mutineer
|
|
|
29
40
|
end
|
|
30
41
|
end
|
|
31
42
|
|
|
32
|
-
# True when a
|
|
43
|
+
# True when a mutant file still belongs to a live process. A boot
|
|
33
44
|
# sweep must leave it alone. The database lock is taken later, so the
|
|
34
45
|
# sweep is the step that used to delete another run's file.
|
|
35
46
|
#
|
|
@@ -37,19 +48,20 @@ module Mutineer
|
|
|
37
48
|
# file lock counts too, even when that pid is already gone. A dead pid
|
|
38
49
|
# and a free lock is an orphan a killed child left behind.
|
|
39
50
|
#
|
|
40
|
-
# @param path [String] absolute path of a `.mutineer_daemon*.rb`
|
|
51
|
+
# @param path [String] absolute path of a `.mutineer_daemon*.rb` or
|
|
52
|
+
# `.mutineer_mutant*.rb` file.
|
|
41
53
|
# @return [Boolean]
|
|
42
54
|
def self.mutant_file_in_use?(path)
|
|
43
55
|
creator_alive?(path) || locked_by_another?(path)
|
|
44
56
|
end
|
|
45
57
|
|
|
46
|
-
# True when the pid embedded in a
|
|
58
|
+
# True when the pid embedded in a mutant file name is still running.
|
|
47
59
|
# Pid 0 is this process group, so it is never treated as an owner.
|
|
48
60
|
#
|
|
49
61
|
# @param path [String]
|
|
50
62
|
# @return [Boolean]
|
|
51
63
|
def self.creator_alive?(path)
|
|
52
|
-
pid_text = File.basename(path)[
|
|
64
|
+
pid_text = File.basename(path)[TEMP_PID, 1]
|
|
53
65
|
return false unless pid_text
|
|
54
66
|
|
|
55
67
|
pid = Integer(pid_text, 10)
|