mutineer 1.0.1 → 1.1.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
@@ -10,20 +10,29 @@ module Mutineer
10
10
  # Never call this in the parent — it manipulates global Minitest state
11
11
  # (autorun, runnables) that only makes sense in a throwaway forked child.
12
12
  #
13
- # No `rescue` here: Isolation.run's fork block is the single exception
14
- # boundary (any exception there becomes exit 2). Adding a rescue would
15
- # create a second exit-2 path and break this method's 0/1 return contract.
13
+ # A missing minitest is rescued as FrameworkUnavailable (Isolation.run
14
+ # still turns that raise into exit 2). There is no rescue around the
15
+ # suite run itself: Isolation.run's fork block is the single exception
16
+ # boundary for unexpected errors. Swallowing those here would create a
17
+ # second exit-2 path and break this method's 0/1 return contract.
16
18
  class MinitestIntegration
17
- # ponytail: tested via runner_test.rb (U6), not in isolation — a direct
18
- # unit test would require forking and duplicate isolation_test's coverage.
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
+
24
+ # Tested via runner_test.rb, not in isolation — a direct unit test
25
+ # would require forking and duplicate isolation_test's coverage.
19
26
  #
20
- # `test_files` is one path or an Array of paths (M3 coverage selection
27
+ # `test_files` is one path or an Array of paths (coverage selection
21
28
  # passes the covering subset); each is loaded before the single
22
29
  # Minitest.run.
23
30
  #
24
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.
25
34
  # @return [Integer] 0 on success, 1 on failure.
26
- def self.run(test_files)
35
+ def self.run(test_files, stop_at_first_failure: false)
27
36
  begin
28
37
  require "minitest"
29
38
  rescue LoadError
@@ -45,13 +54,20 @@ module Mutineer
45
54
  Minitest::Runnable.reset
46
55
  Array(test_files).each { |f| load f }
47
56
 
48
- orig = $stdout
49
- # Silence the child's test output; the parent only cares about pass/fail.
50
- $stdout = StringIO.new
51
- passed = Minitest.run([])
52
- $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)
53
66
 
54
- 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!
55
71
  end
56
72
  end
57
73
  end
@@ -19,7 +19,7 @@ module Mutineer
19
19
  # Computes the stable id for a single mutant.
20
20
  #
21
21
  # NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
22
- # with the separator; SHA256[0,12] gives a fixed-length, copy-pasteable key.
22
+ # with the separator; `SHA256[0,12]` gives a fixed-length, copy-pasteable key.
23
23
  #
24
24
  # @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
25
25
  # its `qualified_name` anchors the id to a method rather than a byte position.
@@ -11,16 +11,19 @@ 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"
14
18
 
15
19
  module Mutineer
16
20
  # Maps operator names to operator classes.
17
21
  #
18
- # DEFAULT_NAMES is the v1 default set
19
- # (the M4 Tier-1 + statement-removal operators per locked decision #2). The
20
- # three Tier-2 operators live in ALL but are OFF by default — they only run
21
- # when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
22
- # DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
23
- # survivor set unchanged.
22
+ # DEFAULT_NAMES is the v1 default set (Tier-1 plus statement-removal).
23
+ # The Tier-2 operators live in ALL but are OFF by default — they only
24
+ # run when named via `--operators` or `operators:` in `.mutineer.yml`.
25
+ # Keeping DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps
26
+ # the default survivor set unchanged.
24
27
  class MutatorRegistry
25
28
  # All available mutator classes keyed by operator name.
26
29
  ALL = {
@@ -34,13 +37,19 @@ module Mutineer
34
37
  "condition_negation" => Mutators::ConditionNegation,
35
38
  "string_literal" => Mutators::StringLiteral,
36
39
  "regex" => Mutators::RegexLiteral,
37
- "collection_method" => Mutators::CollectionMethod
40
+ "collection_method" => Mutators::CollectionMethod,
41
+ "safe_navigation" => Mutators::SafeNavigation,
42
+ "range" => Mutators::RangeLiteral,
43
+ "negation_removal" => Mutators::NegationRemoval,
44
+ "chain_link" => Mutators::ChainLink
38
45
  }.freeze
39
46
 
40
47
  # The default Tier-1 operator set.
41
48
  DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
42
49
  # Tier-2 operators that remain opt-in.
43
- TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method].freeze
50
+ TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
51
+ safe_navigation range negation_removal
52
+ chain_link].freeze
44
53
 
45
54
  # Short human-readable descriptions for each operator.
46
55
  DESCRIPTIONS = {
@@ -54,7 +63,11 @@ module Mutineer
54
63
  "condition_negation" => "wrap if/unless/ternary condition in !( ... )",
55
64
  "string_literal" => "non-empty string -> \"\", empty string -> \"mutineer\"",
56
65
  "regex" => "drop leading ^ / trailing $, swap + <-> *",
57
- "collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject"
66
+ "collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject",
67
+ "safe_navigation" => "&. -> .",
68
+ "range" => ".. <-> ...",
69
+ "negation_removal" => "!x, not x -> x",
70
+ "chain_link" => "drop one call from a chain: a.b.c -> a.c"
58
71
  }.freeze
59
72
 
60
73
  # Resolves operator names to classes.
@@ -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,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
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Safe-navigation mutator (Tier-2).
8
+ #
9
+ # Replaces `&.` with `.`, one mutation per occurrence. The mutant raises
10
+ # NoMethodError on a nil receiver, so it survives when no test passes nil.
11
+ class SafeNavigation < Base
12
+ # Visits call nodes and emits safe-navigation mutations.
13
+ #
14
+ # @param node [Prism::CallNode] call node to inspect.
15
+ # @return [void]
16
+ def visit_call_node(node)
17
+ emit(node)
18
+ super # chained calls (a&.b&.c) each get their own mutation
19
+ end
20
+
21
+ # Visits `a&.b += 1`, which Prism parses as its own node, not a CallNode.
22
+ #
23
+ # @param node [Prism::CallOperatorWriteNode] node to inspect.
24
+ # @return [void]
25
+ def visit_call_operator_write_node(node)
26
+ emit(node)
27
+ super
28
+ end
29
+
30
+ # Visits `a&.b ||= 1`.
31
+ #
32
+ # @param node [Prism::CallOrWriteNode] node to inspect.
33
+ # @return [void]
34
+ def visit_call_or_write_node(node)
35
+ emit(node)
36
+ super
37
+ end
38
+
39
+ # Visits `a&.b &&= 1`.
40
+ #
41
+ # @param node [Prism::CallAndWriteNode] node to inspect.
42
+ # @return [void]
43
+ def visit_call_and_write_node(node)
44
+ emit(node)
45
+ super
46
+ end
47
+
48
+ # Visits a call used as an assignment target, as in `for a&.b in list`.
49
+ #
50
+ # @param node [Prism::CallTargetNode] node to inspect.
51
+ # @return [void]
52
+ def visit_call_target_node(node)
53
+ emit(node)
54
+ super
55
+ end
56
+
57
+ private
58
+
59
+ # Emits a mutation when the node's call operator is `&.`.
60
+ #
61
+ # @param node [Prism::Node] a node with `safe_navigation?` and `call_operator_loc`.
62
+ # @return [void]
63
+ def emit(node)
64
+ return unless node.safe_navigation?
65
+
66
+ loc = node.call_operator_loc
67
+ @mutations << Mutation.new(
68
+ start_offset: loc.start_offset,
69
+ end_offset: loc.end_offset,
70
+ replacement: ".",
71
+ operator: :safe_navigation
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -434,7 +434,8 @@ module Mutineer
434
434
  else
435
435
  Isolation.apply_whole_file(mutated, source_file)
436
436
  end
437
- TestRunners.for(framework).run(abs_tests)
437
+ # One failing test already kills the mutant, so the child stops there.
438
+ TestRunners.for(framework).run(abs_tests, stop_at_first_failure: true)
438
439
  end
439
440
  end
440
441
 
@@ -9,8 +9,12 @@ module Mutineer
9
9
  # Runs the given Minitest files.
10
10
  #
11
11
  # @param test_files [String, Array<String>] one file or many files.
12
+ # @param stop_at_first_failure [Boolean] when true, the run ends at the
13
+ # first failing test.
12
14
  # @return [Integer] 0 on success, 1 on failure.
13
- def self.run(test_files) = MinitestIntegration.run(test_files)
15
+ def self.run(test_files, stop_at_first_failure: false)
16
+ MinitestIntegration.run(test_files, stop_at_first_failure: stop_at_first_failure)
17
+ end
14
18
  end
15
19
  end
16
20
  end