mutineer 1.0.2 → 1.2.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.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ class MinitestIntegration
5
+ # Stops a Minitest run at the first failing test: one failure already
6
+ # kills the mutant. Child-process only, like MinitestIntegration.
7
+ #
8
+ # It sets a flag instead of unwinding the stack, so that class-level
9
+ # wrappers (`after_all`, a block-form transaction) still finish. The
10
+ # remaining tests and classes then return before they start.
11
+ #
12
+ # Minitest 5 and 6 need different hook points. An unknown shape installs
13
+ # no hook, and the run is a full run.
14
+ module StopAtFirstFailure
15
+ # Prepended on `Minitest::CompositeReporter`.
16
+ module RecordFailure
17
+ # Records the result, then sets the stop flag on a failure or an
18
+ # error. Only the outer reporter counts: a test can build and record
19
+ # on its own reporter.
20
+ #
21
+ # @param result [Minitest::Result] the result of one test.
22
+ # @return [void]
23
+ def record(result)
24
+ super
25
+ return if result.passed? || result.skipped?
26
+ return unless equal?(StopAtFirstFailure.armed_reporter)
27
+ return unless StopAtFirstFailure.armed_here?
28
+
29
+ StopAtFirstFailure.stopped = true
30
+ end
31
+ end
32
+
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
+ # Prepended on the singleton class of each test class for Minitest 6.
61
+ module SkipAfterStop6
62
+ # Skips the test class after a stop.
63
+ #
64
+ # @param reporter [Minitest::CompositeReporter] the reporter.
65
+ # @param options [Hash] the Minitest options.
66
+ # @return [Object, nil] whatever Minitest returns, or nil when skipped.
67
+ def run_suite(reporter, options = {})
68
+ return if StopAtFirstFailure.stopped_here?
69
+
70
+ super
71
+ end
72
+
73
+ # Skips one test after a stop, without a record.
74
+ #
75
+ # @param klass [Class] the test class.
76
+ # @param method_name [String] the test method.
77
+ # @param reporter [Minitest::CompositeReporter] the reporter.
78
+ # @return [Object, nil] whatever Minitest returns, or nil when skipped.
79
+ def run(klass, method_name, reporter)
80
+ return if StopAtFirstFailure.stopped_here?
81
+
82
+ super
83
+ end
84
+ end
85
+
86
+ # Prepended on the singleton class of each test class for Minitest 5.
87
+ module SkipAfterStop5
88
+ # Skips the test class after a stop.
89
+ #
90
+ # @param reporter [Minitest::CompositeReporter] the reporter.
91
+ # @param options [Hash] the Minitest options.
92
+ # @return [Object, nil] whatever Minitest returns, or nil when skipped.
93
+ def run(reporter, options = {})
94
+ return if StopAtFirstFailure.stopped_here?
95
+
96
+ super
97
+ end
98
+
99
+ # Skips one test after a stop, without a record.
100
+ #
101
+ # @param klass [Class] the test class.
102
+ # @param method_name [String] the test method.
103
+ # @param reporter [Minitest::CompositeReporter] the reporter.
104
+ # @return [Object, nil] whatever Minitest returns, or nil when skipped.
105
+ def run_one_method(klass, method_name, reporter)
106
+ return if StopAtFirstFailure.stopped_here?
107
+
108
+ super
109
+ end
110
+ end
111
+
112
+ 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
+ # True after the outer reporter records a failure or an error.
125
+ #
126
+ # @return [Boolean, nil]
127
+ attr_accessor :stopped
128
+
129
+ # Installs the hooks and arms the stop for this process. Call it after
130
+ # the test files load, so that every test class gets its prepend.
131
+ #
132
+ # @param runnables [Array<Class>] the loaded test classes.
133
+ # @return [Boolean] false when the Minitest shape is unknown.
134
+ def arm!(runnables)
135
+ outer, skip = hooks_for_loaded_minitest
136
+ return false unless outer
137
+
138
+ prepend_once(::Minitest::CompositeReporter, RecordFailure)
139
+ prepend_once(::Minitest.singleton_class, outer)
140
+ runnables.each { |klass| prepend_once(klass.singleton_class, skip) }
141
+ self.armed_pid = Process.pid
142
+ self.armed_reporter = nil
143
+ self.stopped = false
144
+ true
145
+ end
146
+
147
+ # Clears the armed state.
148
+ #
149
+ # @return [void]
150
+ def disarm!
151
+ self.armed_pid = nil
152
+ self.armed_reporter = nil
153
+ self.stopped = false
154
+ end
155
+
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
+ # True when the armed run in this process has stopped.
164
+ #
165
+ # @return [Boolean]
166
+ def stopped_here?
167
+ stopped == true && armed_here?
168
+ end
169
+
170
+ private
171
+
172
+ # Picks the hook modules for the loaded Minitest.
173
+ #
174
+ # @return [Array(Module, Module), nil] the outer reporter hook and the
175
+ # skip hook, or nil for an unknown shape.
176
+ 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]
182
+ end
183
+ 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
+ end
197
+ end
198
+ end
199
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
3
+ require_relative "minitest_integration/stop_at_first_failure"
4
4
 
5
5
  module Mutineer
6
6
  # Child-process-only: loads a test file in the current process and runs it
@@ -16,6 +16,11 @@ module Mutineer
16
16
  # boundary for unexpected errors. Swallowing those here would create a
17
17
  # second exit-2 path and break this method's 0/1 return contract.
18
18
  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.
22
+ STOP_AT_FIRST_FAILURE_SEED = 1
23
+
19
24
  # Tested via runner_test.rb, not in isolation — a direct unit test
20
25
  # would require forking and duplicate isolation_test's coverage.
21
26
  #
@@ -24,8 +29,10 @@ module Mutineer
24
29
  # Minitest.run.
25
30
  #
26
31
  # @param test_files [String, Array<String>] one file or many files.
32
+ # @param stop_at_first_failure [Boolean] end the run at the first failing
33
+ # test. Only the mutant path passes true.
27
34
  # @return [Integer] 0 on success, 1 on failure.
28
- def self.run(test_files)
35
+ def self.run(test_files, stop_at_first_failure: false)
29
36
  begin
30
37
  require "minitest"
31
38
  rescue LoadError
@@ -47,13 +54,20 @@ module Mutineer
47
54
  Minitest::Runnable.reset
48
55
  Array(test_files).each { |f| load f }
49
56
 
50
- orig = $stdout
51
- # Silence the child's test output; the parent only cares about pass/fail.
52
- $stdout = StringIO.new
53
- passed = Minitest.run([])
54
- $stdout = orig
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
63
+ # No silencing here: the fork boundary that calls this method has already
64
+ # pointed stdout at File::NULL (see ChildStdout).
65
+ passed = Minitest.run(args)
55
66
 
56
- passed ? 0 : 1
67
+ # A plugin can replace the summary reporter, so a stop decides by itself.
68
+ passed && !StopAtFirstFailure.stopped_here? ? 0 : 1
69
+ ensure
70
+ StopAtFirstFailure.disarm!
57
71
  end
58
72
  end
59
73
  end
@@ -11,12 +11,18 @@ require_relative "mutators/condition_negation"
11
11
  require_relative "mutators/string_literal"
12
12
  require_relative "mutators/regex_literal"
13
13
  require_relative "mutators/collection_method"
14
+ require_relative "mutators/safe_navigation"
15
+ require_relative "mutators/range_literal"
16
+ require_relative "mutators/negation_removal"
17
+ require_relative "mutators/chain_link"
18
+ require_relative "mutators/operand_removal"
19
+ require_relative "mutators/array_literal"
14
20
 
15
21
  module Mutineer
16
22
  # Maps operator names to operator classes.
17
23
  #
18
24
  # DEFAULT_NAMES is the v1 default set (Tier-1 plus statement-removal).
19
- # The six Tier-2 operators live in ALL but are OFF by default — they only
25
+ # The Tier-2 operators live in ALL but are OFF by default — they only
20
26
  # run when named via `--operators` or `operators:` in `.mutineer.yml`.
21
27
  # Keeping DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps
22
28
  # the default survivor set unchanged.
@@ -33,13 +39,21 @@ module Mutineer
33
39
  "condition_negation" => Mutators::ConditionNegation,
34
40
  "string_literal" => Mutators::StringLiteral,
35
41
  "regex" => Mutators::RegexLiteral,
36
- "collection_method" => Mutators::CollectionMethod
42
+ "collection_method" => Mutators::CollectionMethod,
43
+ "safe_navigation" => Mutators::SafeNavigation,
44
+ "range" => Mutators::RangeLiteral,
45
+ "negation_removal" => Mutators::NegationRemoval,
46
+ "chain_link" => Mutators::ChainLink,
47
+ "operand_removal" => Mutators::OperandRemoval,
48
+ "array_literal" => Mutators::ArrayLiteral
37
49
  }.freeze
38
50
 
39
51
  # The default Tier-1 operator set.
40
52
  DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
41
53
  # Tier-2 operators that remain opt-in.
42
- TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method].freeze
54
+ TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
55
+ safe_navigation range negation_removal
56
+ chain_link operand_removal array_literal].freeze
43
57
 
44
58
  # Short human-readable descriptions for each operator.
45
59
  DESCRIPTIONS = {
@@ -53,7 +67,13 @@ module Mutineer
53
67
  "condition_negation" => "wrap if/unless/ternary condition in !( ... )",
54
68
  "string_literal" => "non-empty string -> \"\", empty string -> \"mutineer\"",
55
69
  "regex" => "drop leading ^ / trailing $, swap + <-> *",
56
- "collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject"
70
+ "collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject",
71
+ "safe_navigation" => "&. -> .",
72
+ "range" => ".. <-> ...",
73
+ "negation_removal" => "!x, not x -> x",
74
+ "chain_link" => "drop one call from a chain: a.b.c -> a.c",
75
+ "operand_removal" => "a && b -> a, b",
76
+ "array_literal" => "[a, b] -> []"
57
77
  }.freeze
58
78
 
59
79
  # Resolves operator names to classes.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Array-literal mutator (Tier-2).
8
+ #
9
+ # Replaces a non-empty array literal with an empty one, one mutation per
10
+ # literal: `[a, b]` and `%i[a b]` become `[]`. The mutant survives when no
11
+ # test checks the contents of the array.
12
+ class ArrayLiteral < Base
13
+ # Visits array nodes and emits array-literal mutations.
14
+ #
15
+ # @param node [Prism::ArrayNode] array node to inspect.
16
+ # @return [void]
17
+ def visit_array_node(node)
18
+ emit(node)
19
+ super # nested arrays each get their own mutation
20
+ end
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
+ private
30
+
31
+ # Emits a mutation for a non-empty array with brackets.
32
+ #
33
+ # The operator skips an implicit array (`x = 1, 2`), because it has no
34
+ # brackets. It also skips an array that holds a heredoc, because the
35
+ # heredoc body stays behind as code.
36
+ #
37
+ # @param node [Prism::ArrayNode] array node to inspect.
38
+ # @return [void]
39
+ def emit(node)
40
+ return if node.opening_loc.nil? || node.elements.empty? || heredoc?(node)
41
+
42
+ @mutations << Mutation.new(
43
+ start_offset: node.location.start_offset,
44
+ end_offset: node.location.end_offset,
45
+ replacement: "[]",
46
+ operator: :array_literal
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end
@@ -26,6 +26,21 @@ module Mutineer
26
26
  subject.def_node.body&.accept(self)
27
27
  @mutations
28
28
  end
29
+
30
+ private
31
+
32
+ # Returns whether a node is, or contains, a heredoc.
33
+ #
34
+ # A heredoc's body lies outside its node's byte range. A mutation that
35
+ # deletes the node leaves the body behind as code, so the mutant
36
+ # always raises.
37
+ #
38
+ # @param node [Prism::Node] node to inspect.
39
+ # @return [Boolean] true when a heredoc is inside the node.
40
+ def heredoc?(node)
41
+ (node.respond_to?(:heredoc?) && node.heredoc?) ||
42
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
43
+ end
29
44
  end
30
45
  end
31
46
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Chain-link mutator (Tier-2).
8
+ #
9
+ # Drops one dotted call from a chain of calls, with its arguments and block:
10
+ # `user.account.owner.name` becomes `user.owner.name` and `user.account.name`.
11
+ # The chain's receiver and its final call stay, so a chain of n dotted calls
12
+ # gives at most n - 1 mutations. A chain begins at a receiver that is not a
13
+ # dotted call: a local, a constant, `self`, `list[i]`, `(a + b)`. A call in
14
+ # an argument or a block starts a chain of its own.
15
+ #
16
+ # The mutant survives when no test tells the chain apart from the same chain
17
+ # without that step: a scope, a filter or a lookup the tests never see.
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
23
+ # raises (killed by any test that runs the line) or reaches a class method
24
+ # that builds the instance itself (equivalent); neither says anything about
25
+ # the tests.
26
+ class ChainLink < Base
27
+ # Method names whose link is never dropped: core conversions and copies,
28
+ # plus `new`.
29
+ SKIPPED = %i[
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
+ dup clone freeze itself
32
+ new
33
+ ].freeze
34
+
35
+ # Resets the per-subject record of calls already placed in a chain.
36
+ #
37
+ # @param subject [Mutineer::Subject] subject whose body is visited.
38
+ # @param source [String] full source text for byte-based slicing.
39
+ # @return [Array<Mutineer::Mutation>] collected mutations.
40
+ def mutations_for(subject, source)
41
+ @links = {}.compare_by_identity
42
+ super
43
+ end
44
+
45
+ # Visits a call. The outermost dotted call of a chain is its final call;
46
+ # the dotted calls below it in the receiver are its links.
47
+ #
48
+ # @param node [Prism::CallNode] call node to inspect.
49
+ # @return [void]
50
+ def visit_call_node(node)
51
+ chain(node) if dotted?(node) && !@links.key?(node)
52
+ super
53
+ end
54
+
55
+ # Visits `a.b.c += 1`, whose final call Prism parses as its own node.
56
+ #
57
+ # @param node [Prism::CallOperatorWriteNode] node to inspect.
58
+ # @return [void]
59
+ def visit_call_operator_write_node(node)
60
+ chain(node)
61
+ super
62
+ end
63
+
64
+ # Visits `a.b.c ||= 1`.
65
+ #
66
+ # @param node [Prism::CallOrWriteNode] node to inspect.
67
+ # @return [void]
68
+ def visit_call_or_write_node(node)
69
+ chain(node)
70
+ super
71
+ end
72
+
73
+ # Visits `a.b.c &&= 1`.
74
+ #
75
+ # @param node [Prism::CallAndWriteNode] node to inspect.
76
+ # @return [void]
77
+ def visit_call_and_write_node(node)
78
+ chain(node)
79
+ super
80
+ end
81
+
82
+ # Visits a call used as an assignment target, as in `a.b.c, d = 1, 2`.
83
+ #
84
+ # @param node [Prism::CallTargetNode] node to inspect.
85
+ # @return [void]
86
+ def visit_call_target_node(node)
87
+ chain(node)
88
+ super
89
+ end
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
+ private
99
+
100
+ # Walks a final call's receiver chain, recording each link so it is not
101
+ # taken for the final call of a chain of its own, and dropping each link
102
+ # not in {SKIPPED}.
103
+ #
104
+ # @param top [Prism::Node] the chain's final call; responds to `receiver`.
105
+ # @return [void]
106
+ def chain(top)
107
+ link = top.receiver
108
+ while dotted?(link)
109
+ @links[link] = true
110
+ drop(link) unless SKIPPED.include?(link.name)
111
+ link = link.receiver
112
+ end
113
+ end
114
+
115
+ # Emits the removal of one link: from the end of its receiver to the end
116
+ # of its arguments and block, so a chain split across lines keeps the
117
+ # layout of the lines that remain.
118
+ #
119
+ # @param link [Prism::CallNode] the dotted call to drop.
120
+ # @return [void]
121
+ def drop(link)
122
+ @mutations << Mutation.new(
123
+ start_offset: link.receiver.location.end_offset,
124
+ end_offset: link.location.end_offset,
125
+ replacement: "",
126
+ operator: :chain_link
127
+ )
128
+ end
129
+
130
+ # Returns whether a node is a call through `.`, `&.` or `::`.
131
+ #
132
+ # @param node [Prism::Node, nil] node to inspect.
133
+ # @return [Boolean] true for a call node with a call operator.
134
+ def dotted?(node)
135
+ node.is_a?(Prism::CallNode) && !node.call_operator_loc.nil?
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Negation-removal mutator (Tier-2).
8
+ #
9
+ # Removes the `!` or `not` of a negation, one mutation per occurrence:
10
+ # `!x` becomes `x` and `not x` becomes ` x`. The mutant survives when no
11
+ # test depends on the negated value.
12
+ class NegationRemoval < Base
13
+ # Visits call nodes and emits negation-removal mutations.
14
+ #
15
+ # @param node [Prism::CallNode] call node to inspect.
16
+ # @return [void]
17
+ def visit_call_node(node)
18
+ emit(node)
19
+ super # nested negations (!!x) each get their own mutation
20
+ end
21
+
22
+ private
23
+
24
+ # Emits a mutation when the node is a prefix `!` or `not`.
25
+ #
26
+ # The explicit form `x.!` is skipped: without its message, `x.` does
27
+ # not parse.
28
+ #
29
+ # @param node [Prism::CallNode] call node to inspect.
30
+ # @return [void]
31
+ def emit(node)
32
+ return unless node.name == :! && node.call_operator_loc.nil?
33
+
34
+ loc = node.message_loc
35
+ @mutations << Mutation.new(
36
+ start_offset: loc.start_offset,
37
+ end_offset: loc.end_offset,
38
+ replacement: "",
39
+ operator: :negation_removal
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Operand-removal mutator (Tier-2).
8
+ #
9
+ # Replaces a boolean expression with one of its operands, two mutations
10
+ # per `&&`, `||`, `and` or `or`: `a && b` becomes `(a)` and `(b)`. The
11
+ # parentheses keep the precedence of `and` and `or`. A surviving mutant
12
+ # shows that no test needs the operand that was removed.
13
+ class OperandRemoval < Base
14
+ # Node types for jumps. A jump in a value context does not parse, so
15
+ # the operator never keeps a jump alone.
16
+ JUMPS = [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode].freeze
17
+
18
+ # Visits `and` nodes.
19
+ #
20
+ # @param node [Prism::AndNode] node to inspect.
21
+ # @return [void]
22
+ def visit_and_node(node)
23
+ emit(node)
24
+ super # nested connectors (a && b && c) each get their own mutations
25
+ end
26
+
27
+ # Visits `or` nodes.
28
+ #
29
+ # @param node [Prism::OrNode] node to inspect.
30
+ # @return [void]
31
+ def visit_or_node(node)
32
+ emit(node)
33
+ super
34
+ end
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
+ private
44
+
45
+ # Emits one mutation that keeps the left operand, then one that keeps
46
+ # the right operand. The order is fixed, because the two mutants share
47
+ # a token and their occurrence ordinals tell them apart.
48
+ #
49
+ # @param node [Prism::AndNode, Prism::OrNode] connector node.
50
+ # @return [void]
51
+ def emit(node)
52
+ [[node.left, node.right], [node.right, node.left]].each do |kept, removed|
53
+ next if JUMPS.any? { |type| kept.is_a?(type) } || heredoc?(removed)
54
+
55
+ @mutations << Mutation.new(
56
+ start_offset: node.location.start_offset,
57
+ end_offset: node.location.end_offset,
58
+ replacement: "(#{kept.slice})",
59
+ operator: :operand_removal
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Range mutator (Tier-2).
8
+ #
9
+ # Swaps `..` with `...` and `...` with `..`, one mutation per range. The
10
+ # `..` -> `...` mutant survives when no test checks the last element.
11
+ # Flip-flops are FlipFlopNode, not RangeNode, so this mutator skips them.
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.
16
+ class RangeLiteral < Base
17
+ # Maps each range operator to its opposite.
18
+ SWAPS = { ".." => "...", "..." => ".." }.freeze
19
+
20
+ # Visits range nodes and emits range mutations.
21
+ #
22
+ # @param node [Prism::RangeNode] range node to inspect.
23
+ # @return [void]
24
+ def visit_range_node(node)
25
+ emit(node) unless endless?(node)
26
+ super # nested ranges ((1..2)...(3..4)) each get their own mutation
27
+ end
28
+
29
+ private
30
+
31
+ # Whether the range has no end: `1..` or `1..nil`.
32
+ #
33
+ # @param node [Prism::RangeNode] range node to inspect.
34
+ # @return [Boolean]
35
+ def endless?(node)
36
+ node.right.nil? || node.right.is_a?(Prism::NilNode)
37
+ end
38
+
39
+ # Emits the mutation that swaps the range operator.
40
+ #
41
+ # @param node [Prism::RangeNode] range node to mutate.
42
+ # @return [void]
43
+ def emit(node)
44
+ loc = node.operator_loc
45
+ @mutations << Mutation.new(
46
+ start_offset: loc.start_offset,
47
+ end_offset: loc.end_offset,
48
+ replacement: SWAPS.fetch(loc.slice),
49
+ operator: :range
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end