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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +317 -0
  3. data/README.md +200 -28
  4. data/lib/mutineer/changed_lines.rb +57 -2
  5. data/lib/mutineer/cli.rb +142 -92
  6. data/lib/mutineer/config.rb +215 -56
  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/mutator_registry.rb +12 -3
  21. data/lib/mutineer/mutators/array_literal.rb +0 -7
  22. data/lib/mutineer/mutators/base.rb +63 -4
  23. data/lib/mutineer/mutators/chain_link.rb +0 -7
  24. data/lib/mutineer/mutators/collection_method.rb +1 -1
  25. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  26. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  27. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  28. data/lib/mutineer/mutators/return_nil.rb +5 -11
  29. data/lib/mutineer/mutators/string_literal.rb +1 -1
  30. data/lib/mutineer/pairing.rb +222 -15
  31. data/lib/mutineer/parser.rb +12 -3
  32. data/lib/mutineer/project.rb +7 -0
  33. data/lib/mutineer/rails_worker_db.rb +1 -1
  34. data/lib/mutineer/reporter.rb +288 -52
  35. data/lib/mutineer/result.rb +37 -1
  36. data/lib/mutineer/runner.rb +133 -16
  37. data/lib/mutineer/statement_lines.rb +143 -0
  38. data/lib/mutineer/test_runners/minitest.rb +4 -2
  39. data/lib/mutineer/test_runners/rspec.rb +243 -3
  40. data/lib/mutineer/test_runners.rb +5 -3
  41. data/lib/mutineer/version.rb +1 -1
  42. data/lib/mutineer/worker_pool.rb +4 -2
  43. data/lib/mutineer.rb +3 -0
  44. 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 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?
@@ -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].freeze
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&.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",
@@ -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