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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. 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 killed.
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 killed.
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 killed.
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 killed.
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 killed.
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 killed: from then on, a run without `--matrix`
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 :serial_killed
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.serial_killed = false
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.serial_killed = false
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
- # killed in this armed process, else just runs it. No `ensure`: a block
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 && serial_killed && armed_here?
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. Skips send no test line, though they count as
226
- # seen, and results on other reporters count for nothing. The
227
- # `parallel` line goes out when the first parallel class starts (see
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? ? KillChannel::PASS : KillChannel::KILL
255
- self.serial_killed = true if event == KillChannel::KILL && !parallel_marked
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 create a
18
- # second exit-2 path and break this method's 0/1 return contract.
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 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
+ # 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
- STOP_AT_FIRST_FAILURE_SEED = 1
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
- # Pin the seed only when a hook is armed; an unknown Minitest shape gets
79
- # the normal full, randomly ordered run.
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(args)
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? || heredoc?(node)
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 {ChainLink::SKIPPED} are never replaced. On the last call,
27
- # `x.to_s` is the same as `x` for the same reason as on a link. `Foo.new`
28
- # becomes the class, and almost any use of the class raises.
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 {ChainLink::SKIPPED}.
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 ChainLink::SKIPPED.include?(node.name)
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. 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
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} uses the same list for the last call.
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
- drop(link) unless SKIPPED.include?(link.name)
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) || heredoc?(predicate)
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) } || heredoc?(removed)
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
- # 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.
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
- emit(node) unless endless?(node)
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.
@@ -16,6 +16,7 @@ module Mutineer
16
16
  def mutations_for(subject, source)
17
17
  @source = source
18
18
  @mutations = []
19
+ @declined = 0
19
20
  @body = subject.def_node.body
20
21
  if @body
21
22
  @body.accept(self) # rule 1 (explicit return nodes in this body)
@@ -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
- # The pid is the child that is loading the mutant.
16
- DAEMON_TEMP_PID = /\A\.?mutineer_daemon\d{8}-(\d+)-/
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 daemon mutant file still belongs to a live process. A boot
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` file.
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 daemon mutant file name is still running.
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)[DAEMON_TEMP_PID, 1]
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)