mutineer 1.4.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +250 -0
  3. data/README.md +198 -27
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +112 -16
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutators/array_literal.rb +0 -7
  21. data/lib/mutineer/mutators/base.rb +63 -4
  22. data/lib/mutineer/mutators/chain_link.rb +0 -7
  23. data/lib/mutineer/mutators/collection_method.rb +1 -1
  24. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  25. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  26. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  27. data/lib/mutineer/mutators/return_nil.rb +5 -11
  28. data/lib/mutineer/mutators/string_literal.rb +1 -1
  29. data/lib/mutineer/pairing.rb +222 -15
  30. data/lib/mutineer/parser.rb +12 -3
  31. data/lib/mutineer/project.rb +7 -0
  32. data/lib/mutineer/rails_worker_db.rb +1 -1
  33. data/lib/mutineer/reporter.rb +288 -52
  34. data/lib/mutineer/result.rb +37 -1
  35. data/lib/mutineer/runner.rb +133 -16
  36. data/lib/mutineer/statement_lines.rb +143 -0
  37. data/lib/mutineer/test_runners/minitest.rb +4 -2
  38. data/lib/mutineer/test_runners/rspec.rb +243 -3
  39. data/lib/mutineer/test_runners.rb +5 -3
  40. data/lib/mutineer/version.rb +1 -1
  41. data/lib/mutineer/worker_pool.rb +4 -2
  42. data/lib/mutineer.rb +1 -0
  43. metadata +8 -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 equal?(StopAtFirstFailure.armed_reporter)
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
- runnable = ::Minitest::Runnable
178
- if ::Minitest.respond_to?(:run_all_suites) && runnable.respond_to?(:run_suite)
179
- [OuterReporter6, SkipAfterStop6]
180
- elsif ::Minitest.respond_to?(:__run) && runnable.respond_to?(:run_one_method)
181
- [OuterReporter5, SkipAfterStop5]
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, unless `SEED` is set.
20
- # With the stop, the test order can decide `killed` vs `timeout`; a fixed
21
- # order keeps the verdict stable for a `--baseline` gate.
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
- def self.run(test_files, stop_at_first_failure: false)
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
- args = []
58
- if stop_at_first_failure && StopAtFirstFailure.arm!(Minitest::Runnable.runnables)
59
- # Pin the seed only when the stop is armed; an unknown Minitest shape
60
- # gets the normal full, randomly ordered run.
61
- args = ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] unless ENV["SEED"]
62
- end
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
@@ -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. So it survives any edit outside the subject method,
16
- # where raw start/end offsets shift on every edit earlier in the file and
17
- # would silently stop matching. Moving or renaming the file changes the id.
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 across edits outside the subject.
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?
@@ -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&.accept(self)
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
- # ponytail: include? -> exclude? was specced but skipped — exclude? is not
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",
@@ -50,13 +50,6 @@ module Mutineer
50
50
  super
51
51
  end
52
52
 
53
- # Nested method definitions are discovered as their own subjects; do not
54
- # recurse into them (prevents double-counting their conditions).
55
- #
56
- # @param node [Prism::DefNode] nested definition node.
57
- # @return [void]
58
- def visit_def_node(node); end
59
-
60
53
  private
61
54
 
62
55
  # Emits the forced condition unless the condition is a literal or holds a
@@ -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
@@ -90,13 +90,6 @@ module Mutineer
90
90
  super
91
91
  end
92
92
 
93
- # Skips a nested method definition. The project finds it as a subject of
94
- # its own, so a visit here counts its compound assignments twice.
95
- #
96
- # @param node [Prism::DefNode] nested definition node.
97
- # @return [void]
98
- def visit_def_node(node); end
99
-
100
93
  private
101
94
 
102
95
  # Emits a mutation when the node's operator has a replacement.
@@ -16,11 +16,12 @@ module Mutineer
16
16
  def mutations_for(subject, source)
17
17
  @source = source
18
18
  @mutations = []
19
- body = subject.def_node.body
20
- if body
21
- body.accept(self) # rule 1 (explicit return nodes in this body)
22
- final_expression_nil(body) # rule 2 (method's final expression)
19
+ @body = subject.def_node.body
20
+ if @body
21
+ @body.accept(self) # rule 1 (explicit return nodes in this body)
22
+ final_expression_nil(@body) # rule 2 (method's final expression)
23
23
  end
24
+ drop_dangling_heredocs
24
25
  @mutations
25
26
  end
26
27
 
@@ -39,13 +40,6 @@ module Mutineer
39
40
  super
40
41
  end
41
42
 
42
- # Nested method definitions are discovered as their own subjects; do not
43
- # recurse into them (prevents double-counting their statements).
44
- #
45
- # @param node [Prism::DefNode] nested definition node.
46
- # @return [void]
47
- def visit_def_node(node); end
48
-
49
43
  private
50
44
 
51
45
  # Mutates a method's final expression to nil when eligible.
@@ -15,7 +15,7 @@ module Mutineer
15
15
  # @param node [Prism::StringNode] node to inspect.
16
16
  # @return [void]
17
17
  def visit_string_node(node)
18
- # ponytail: only plain "..." / '...' quotes. opening is nil for
18
+ # Only plain "..." / '...' quotes. opening is nil for
19
19
  # interpolation parts and %w[] elements; heredocs/%-literals use a
20
20
  # different opening token. Skipping them keeps mutants re-parseable.
21
21
  if %w[" '].include?(node.opening)