mutineer 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,20 +10,25 @@ module Mutineer
10
10
  # exit status into a Result.
11
11
  #
12
12
  # Exit-status contract (the block's return value, or an explicit exit, is the
13
- # child's status): 0 => survived, 1 => killed, 2 => error. Timeout is detected
14
- # by the parent's monitor flag, not by status.signaled? (which is true for ANY
15
- # signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart from the OS's).
13
+ # child's status): 0 => survived, 1 => killed, 2 => error. Timeout is
14
+ # detected by the parent's monitor flag, not by status.signaled? (which is
15
+ # true for ANY signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart
16
+ # from the OS's).
16
17
  #
17
- # mutineer: the reload strategy this enables (whole-file `load`) re-executes the
18
- # entire file — any top-level code runs again. Acceptable for POROs; document
19
- # if users hit issues with initializers/callbacks. Alternative: the redefine
20
- # strategy (surgical single-method redefinition).
18
+ # mutineer: the reload strategy this enables (whole-file `load`) re-executes
19
+ # the entire file — any top-level code runs again. Acceptable for POROs;
20
+ # document if users hit issues with initializers/callbacks. Alternative: the
21
+ # redefine strategy (surgical single-method redefinition).
21
22
  class Isolation
22
23
  DEFAULT_TIMEOUT = 10 # seconds
23
24
 
24
25
  # Runs the block in a forked child. The block's return value (an Integer
25
26
  # exit code) or any explicit `exit` is honoured; an unhandled exception
26
27
  # becomes exit 2 with the cause written to STDERR.
28
+ #
29
+ # @param timeout [Integer] timeout in seconds.
30
+ # @yieldreturn [Integer] child exit status.
31
+ # @return [Mutineer::Result] result from the child process.
27
32
  def self.run(timeout: DEFAULT_TIMEOUT)
28
33
  pid = fork do
29
34
  code = 0
@@ -43,11 +48,12 @@ module Mutineer
43
48
  exit!(code)
44
49
  end
45
50
 
46
- # Single-threaded deadline poll (R2): we are the ONLY caller of waitpid on
47
- # this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG shows
48
- # the child is still alive past the deadline — so the kill can never hit a
49
- # reaped/recycled pid. Timeout is a parent-side fact (deadline reached), not
50
- # status.signaled? (which is true for ANY signal death, e.g. SIGSEGV).
51
+ # Single-threaded deadline poll (R2): we are the ONLY caller of waitpid
52
+ # on this pid, so we never reap-then-kill. We SIGKILL only after WNOHANG
53
+ # shows the child is still alive past the deadline — so the kill can
54
+ # never hit a reaped/recycled pid. Timeout is a parent-side fact
55
+ # (deadline reached), not status.signaled? (which is true for ANY signal
56
+ # death, e.g. SIGSEGV).
51
57
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
52
58
  loop do
53
59
  reaped, status = Process.waitpid2(pid, Process::WNOHANG)
@@ -63,13 +69,19 @@ module Mutineer
63
69
  end
64
70
 
65
71
  # Strategy 7a (default): write the whole mutated file and `load` it, which
66
- # reopens its classes and redefines every method in place. Re-runs file-level
67
- # side effects. Child-only — mutates the loaded program.
72
+ # reopens its classes and redefines every method in place. Re-runs file-
73
+ # level side effects. Child-only — mutates the loaded program.
68
74
  #
69
75
  # The tempfile is created in the ORIGINAL file's directory, not the system
70
- # temp dir, so any `require_relative` in the mutated source resolves against
71
- # its real neighbours (e.g. a mutator's `require_relative "base"`). Writing it
72
- # elsewhere makes those requires resolve to the temp dir and raise LoadError.
76
+ # temp dir, so any `require_relative` in the mutated source resolves
77
+ # against its real neighbours (e.g. a mutator's `require_relative
78
+ # "base"`). Writing it elsewhere makes those requires resolve to the temp
79
+ # dir and raise LoadError.
80
+ #
81
+ # @api private
82
+ # @param mutated [String] mutated source text.
83
+ # @param source_file [String] original source file path.
84
+ # @return [Object] whatever `load` returns.
73
85
  def self.apply_whole_file(mutated, source_file)
74
86
  Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f|
75
87
  f.write(mutated)
@@ -78,47 +90,69 @@ module Mutineer
78
90
  end
79
91
  end
80
92
 
81
- # Redefine strategy: extract just the enclosing DefNode, apply the mutation to
82
- # that snippet, wrap it in its real namespace, and `load` only that one method
83
- # back into the running process. No file-level side effects re-run. Child-only.
93
+ # Redefine strategy: extract just the enclosing DefNode, apply the mutation
94
+ # to that snippet, wrap it in its real namespace, and `load` only that one
95
+ # method back into the running process. No file-level side effects re-run.
96
+ # Child-only.
84
97
  #
85
98
  # The snippet keeps its own `def self.x` for singletons, so the namespace
86
99
  # wrapper redefines instance and singleton methods correctly without any
87
100
  # special-casing.
101
+ #
102
+ # @api private
103
+ # @param mutation [Mutineer::Mutation] mutation to apply.
104
+ # @param subject [Mutineer::Subject] subject being mutated.
105
+ # @param source [String] full source text.
106
+ # @return [Object] whatever `load` returns.
88
107
  def self.apply_surgical(mutation, subject, source)
89
108
  loc = subject.def_node.location
90
109
  def_start = loc.start_offset
91
- # Byte slicing (C1): Prism offsets are byte offsets.
92
110
  snippet = source.byteslice(def_start...loc.end_offset)
93
111
  rel_s = mutation.start_offset - def_start
94
112
  rel_e = mutation.end_offset - def_start
95
113
  mutated_def = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..)
96
114
 
97
115
  # Rebuild the FULL namespace nesting textually so unqualified enclosing-
98
- # namespace constants resolve exactly as the reload strategy would. A bare
99
- # redefinition on the owner would collapse Module.nesting to [owner] and
100
- # raise NameError on such constants (C2 scope-collapse).
116
+ # namespace constants resolve exactly as the reload strategy would. A
117
+ # bare redefinition on the owner would collapse Module.nesting to [owner]
118
+ # and raise NameError on such constants (C2 scope-collapse).
101
119
  keywords = nesting_keywords(subject.namespace)
102
120
  prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
103
121
  prefix += "\n" unless prefix.empty?
104
- wrapped = "#{prefix}#{mutated_def}#{"\nend" * keywords.size}"
105
122
 
106
- # A snippet that fails to reparse must NOT silently fall through to running
107
- # the ORIGINAL method (C2 false-survived). Raise -> the fork block aborts
108
- # before any test runs -> Result.error, never a bogus `survived`.
123
+ # #20: a singleton method whose def has NO `self.` receiver (the
124
+ # `class << self` and `module_function` forms) would, as a bare `def foo`
125
+ # inside `module Owner`, redefine the INSTANCE method but the call
126
+ # (`Owner.foo`) dispatches to the singleton, so the mutant never runs and
127
+ # falsely survives. Re-open the singleton class so the redefinition lands on
128
+ # the same method the test calls. `def self.foo` already carries its
129
+ # receiver, so it is left as-is (wrapping it would mis-target).
130
+ inner =
131
+ if subject.singleton && subject.def_node.receiver.nil?
132
+ "class << self\n#{mutated_def}\nend"
133
+ else
134
+ mutated_def
135
+ end
136
+ wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
137
+
138
+ # A snippet that fails to reparse must NOT silently fall through to
139
+ # running the ORIGINAL method (C2 false-survived). Raise -> the fork
140
+ # block aborts before any test runs -> Result.error, never a bogus
141
+ # `survived`.
109
142
  raise "surgical snippet failed to reparse" if Parser.parse_string(wrapped).errors.any?
110
143
 
111
- # Preserve original visibility — class/module bodies define methods public,
112
- # but 7a's `load` would re-apply the file's private/protected (C2).
144
+ # Preserve original visibility — class/module bodies define methods
145
+ # public, but 7a's `load` would re-apply the file's private/protected
146
+ # (C2).
113
147
  owner = subject.namespace.empty? ? Object : Object.const_get(subject.namespace.join("::"))
114
148
  target = subject.singleton ? owner.singleton_class : owner
115
149
  vis = method_visibility(target, subject.name)
116
150
 
117
- # Write the wrapped snippet to a tempfile and `load` it: `load` runs it at
118
- # top level, so the textual class/module wrappers rebuild Module.nesting
119
- # identically, with no dynamic string execution for scanners to flag. The
120
- # input is the project's OWN source (the enclosing method, textually
121
- # mutated), loaded only in this forked child.
151
+ # Write the wrapped snippet to a tempfile and `load` it: `load` runs it
152
+ # at top level, so the textual class/module wrappers rebuild
153
+ # Module.nesting identically, with no dynamic string execution for
154
+ # scanners to flag. The input is the project's OWN source (the enclosing
155
+ # method, textually mutated), loaded only in this forked child.
122
156
  Tempfile.create(["mutineer_surgical", ".rb"]) do |f|
123
157
  f.write(wrapped)
124
158
  f.flush
@@ -132,11 +166,15 @@ module Mutineer
132
166
  # keyword (reopening a class with `module` — or vice versa — raises
133
167
  # TypeError), so the textual wrapper matches the real definitions.
134
168
  #
135
- # #5: a compact element like "Foo::Bar" stays a SINGLE wrapper `class Foo::Bar`
136
- # (nesting [Foo::Bar]), matching how a whole-file load (reload) sees it.
137
- # Splitting it into `module Foo; class Bar` gave nesting [Foo::Bar, Foo], so an
138
- # unqualified constant defined only in Foo would resolve under redefine but not
139
- # reload — a strategy disagreement.
169
+ # #5: a compact element like "Foo::Bar" stays a SINGLE wrapper `class Foo::
170
+ # Bar` (nesting [Foo::Bar]), matching how a whole-file load (reload) sees
171
+ # it. Splitting it into `module Foo; class Bar` gave nesting [Foo::Bar,
172
+ # Foo], so an unqualified constant defined only in Foo would resolve under
173
+ # redefine but not reload — a strategy disagreement.
174
+ #
175
+ # @api private
176
+ # @param namespace [Array<String>] namespace components.
177
+ # @return [Array<[String, String]>] wrapper keywords and names.
140
178
  def self.nesting_keywords(namespace)
141
179
  mod = Object
142
180
  namespace.map do |name|
@@ -145,6 +183,12 @@ module Mutineer
145
183
  end
146
184
  end
147
185
 
186
+ # Returns the visibility for a method name.
187
+ #
188
+ # @api private
189
+ # @param mod [Module] module or class being inspected.
190
+ # @param name [Symbol] method name.
191
+ # @return [Symbol, nil] `:public`, `:protected`, `:private`, or nil.
148
192
  def self.method_visibility(mod, name)
149
193
  return :private if mod.private_method_defined?(name)
150
194
  return :protected if mod.protected_method_defined?(name)
@@ -153,6 +197,11 @@ module Mutineer
153
197
  nil
154
198
  end
155
199
 
200
+ # Decodes a child status into a Result.
201
+ #
202
+ # @api private
203
+ # @param status [Process::Status] child exit status.
204
+ # @return [Mutineer::Result] decoded result.
156
205
  def self.decode(status)
157
206
  case status.exitstatus
158
207
  when 0 then Result.survived
@@ -11,18 +11,19 @@ module Mutineer
11
11
  # (autorun, runnables) that only makes sense in a throwaway forked child.
12
12
  #
13
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 create
15
- # a second exit-2 path and break this method's 0/1 return contract.
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.
16
16
  class MinitestIntegration
17
17
  # ponytail: tested via runner_test.rb (U6), not in isolation — a direct
18
18
  # unit test would require forking and duplicate isolation_test's coverage.
19
19
  #
20
- # `test_files` is one path or an Array of paths (M3 coverage selection passes
21
- # the covering subset); each is loaded before the single Minitest.run.
20
+ # `test_files` is one path or an Array of paths (M3 coverage selection
21
+ # passes the covering subset); each is loaded before the single
22
+ # Minitest.run.
23
+ #
24
+ # @param test_files [String, Array<String>] one file or many files.
25
+ # @return [Integer] 0 on success, 1 on failure.
22
26
  def self.run(test_files)
23
- # minitest is required LAZILY (never at load time) so Mutineer keeps zero
24
- # runtime gem deps and loads fine in an rspec-only project; a missing
25
- # minitest raises a clear Mutineer error rather than a LoadError backtrace.
26
27
  begin
27
28
  require "minitest"
28
29
  rescue LoadError
@@ -31,6 +32,10 @@ module Mutineer
31
32
  "or use --framework rspec"
32
33
  end
33
34
 
35
+ # minitest is required LAZILY (never at load time) so Mutineer keeps
36
+ # zero runtime gem deps and loads fine in an rspec-only project; a missing
37
+ # minitest raises a clear Mutineer error rather than a LoadError backtrace.
38
+
34
39
  # Neutralise autorun so a test file's `require "minitest/autorun"`
35
40
  # registers no at_exit hook.
36
41
  def Minitest.autorun; end # rubocop:disable Lint/NestedMethodDefinition
@@ -38,11 +43,10 @@ module Mutineer
38
43
  # Drop runnables inherited from the parent suite (this is the child's
39
44
  # private copy — the parent is unaffected) so only the target test runs.
40
45
  Minitest::Runnable.reset
41
-
42
46
  Array(test_files).each { |f| load f }
43
47
 
44
- # Silence the child's test output; the parent only cares about pass/fail.
45
48
  orig = $stdout
49
+ # Silence the child's test output; the parent only cares about pass/fail.
46
50
  $stdout = StringIO.new
47
51
  passed = Minitest.run([])
48
52
  $stdout = orig
@@ -16,8 +16,18 @@ module Mutineer
16
16
  module MutantId
17
17
  module_function
18
18
 
19
+ # Computes the stable id for a single mutant.
20
+ #
19
21
  # NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
20
22
  # with the separator; SHA256[0,12] gives a fixed-length, copy-pasteable key.
23
+ #
24
+ # @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
25
+ # its `qualified_name` anchors the id to a method rather than a byte position.
26
+ # @param mutation [Mutineer::Mutation] the atomic edit whose operator is hashed.
27
+ # @param source [String] the full, unmutated source the mutation indexes into.
28
+ # @param occurrence [Integer] 0-based ordinal among twins sharing the same
29
+ # (operator, token) within the subject, disambiguating otherwise-identical mutants.
30
+ # @return [String] a 12-character hex id, stable across edits outside the subject.
21
31
  def for(subject, mutation, source, occurrence = 0)
22
32
  Digest::SHA256.hexdigest(
23
33
  [subject.qualified_name, mutation.operator,
@@ -25,9 +35,14 @@ module Mutineer
25
35
  )[0, 12]
26
36
  end
27
37
 
28
- # Ids for a subject's full mutation list, in input order, assigning each its
29
- # 0-based occurrence among twins sharing the same (operator, token). This is
30
- # what disambiguates `a + b + c`'s two `+` mutants without an offset.
38
+ # Computes ids for a subject's full mutation list, in input order, assigning
39
+ # each its 0-based occurrence among twins sharing the same (operator, token).
40
+ # This is what disambiguates `a + b + c`'s two `+` mutants without an offset.
41
+ #
42
+ # @param subject [Mutineer::Subject] the subject the mutations belong to.
43
+ # @param source [String] the full, unmutated source for token normalization.
44
+ # @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
45
+ # @return [Array<String>] one 12-character id per mutation, positionally aligned.
31
46
  def for_subject(subject, source, mutations)
32
47
  seen = Hash.new(0)
33
48
  mutations.map do |m|
@@ -38,8 +53,12 @@ module Mutineer
38
53
  end
39
54
  end
40
55
 
41
- # The exact code being mutated, whitespace-collapsed — same normalization the
42
- # Reporter's diff_for uses for its token label.
56
+ # Extracts the exact code being mutated, whitespace-collapsed — the same
57
+ # normalization the Reporter's `diff_for` uses for its token label.
58
+ #
59
+ # @param mutation [Mutineer::Mutation] supplies the byte range to slice.
60
+ # @param source [String] the source to byteslice (byte offsets, never char).
61
+ # @return [String] the mutated token with runs of whitespace collapsed to one space.
43
62
  def normalized_token(mutation, source)
44
63
  source.byteslice(mutation.start_offset...mutation.end_offset).gsub(/\s+/, " ").strip
45
64
  end
@@ -3,17 +3,23 @@
3
3
  require_relative "parser"
4
4
 
5
5
  module Mutineer
6
- # One atomic byte-range edit. Immutable. One mutation per mutant — never
7
- # combine. Source is mutated textually, never regenerated from the AST.
6
+ # One atomic byte-range edit.
7
+ #
8
+ # Immutable. One mutation per mutant — never combine. Source is mutated
9
+ # textually, never regenerated from the AST.
8
10
  Mutation = Data.define(:start_offset, :end_offset, :replacement, :operator) do
9
- # Pure: returns a new string, does not mutate `source`. Prism offsets are
10
- # BYTE offsets, so all slicing is byte-based (byteslice) — char slicing would
11
- # corrupt any source containing a multibyte char before the mutation point.
11
+ # Applies the mutation to source text.
12
+ #
13
+ # @param source [String] original source text.
14
+ # @return [String] mutated source text.
12
15
  def apply(source)
13
16
  source.byteslice(0, start_offset) + replacement + source.byteslice(end_offset..)
14
17
  end
15
18
 
16
- # Validity rule: a mutation is valid iff the mutated source re-parses clean.
19
+ # Checks whether the mutated source still parses cleanly.
20
+ #
21
+ # @param source [String] original source text.
22
+ # @return [Boolean] true when the mutated source re-parses without errors.
17
23
  def valid?(source)
18
24
  Parser.parse_string(apply(source)).errors.empty?
19
25
  end
@@ -10,13 +10,16 @@ require_relative "mutators/literal_mutation"
10
10
  require_relative "mutators/condition_negation"
11
11
 
12
12
  module Mutineer
13
- # Maps operator name -> operator class. DEFAULT_NAMES is the v1 default set
13
+ # Maps operator names to operator classes.
14
+ #
15
+ # DEFAULT_NAMES is the v1 default set
14
16
  # (the M4 Tier-1 + statement-removal operators per locked decision #2). The
15
17
  # three Tier-2 operators live in ALL but are OFF by default — they only run
16
18
  # when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
17
19
  # DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
18
20
  # survivor set unchanged.
19
21
  class MutatorRegistry
22
+ # All available mutator classes keyed by operator name.
20
23
  ALL = {
21
24
  "arithmetic" => Mutators::Arithmetic,
22
25
  "comparison" => Mutators::Comparison,
@@ -28,9 +31,12 @@ module Mutineer
28
31
  "condition_negation" => Mutators::ConditionNegation
29
32
  }.freeze
30
33
 
34
+ # The default Tier-1 operator set.
31
35
  DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
36
+ # Tier-2 operators that remain opt-in.
32
37
  TIER2_NAMES = %w[return_nil literal_mutation condition_negation].freeze
33
38
 
39
+ # Short human-readable descriptions for each operator.
34
40
  DESCRIPTIONS = {
35
41
  "arithmetic" => "+ <-> -, * <-> /, % -> *, ** -> *",
36
42
  "comparison" => "< <-> <=, > <-> >=, == <-> !=",
@@ -42,13 +48,25 @@ module Mutineer
42
48
  "condition_negation" => "wrap if/unless/ternary condition in !( ... )"
43
49
  }.freeze
44
50
 
45
- # Returns the operator classes for the given names. Unknown names raise
46
- # ArgumentError immediately (caught at the CLI boundary -> exit 2).
51
+ # Resolves operator names to classes.
52
+ #
53
+ # @param names [Array<String>] operator names to resolve.
54
+ # @return [Array<Class>] mutator classes in the requested order.
55
+ # @raise [ArgumentError] when a name is unknown.
47
56
  def self.resolve(names = DEFAULT_NAMES)
48
57
  names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
49
58
  end
50
59
 
60
+ # Returns whether the operator is part of the default Tier-1 set.
61
+ #
62
+ # @param name [String] operator name.
63
+ # @return [Boolean] true when the operator is default.
51
64
  def self.default?(name) = DEFAULT_NAMES.include?(name)
65
+
66
+ # Returns the tier number for an operator name.
67
+ #
68
+ # @param name [String] operator name.
69
+ # @return [Integer] 2 for Tier-2 operators, otherwise 1.
52
70
  def self.tier(name) = TIER2_NAMES.include?(name) ? 2 : 1
53
71
  end
54
72
  end
@@ -4,13 +4,19 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Arithmetic operator: +<->-, *<->/, %->*, **->*. One mutation per
8
- # occurrence, rewriting the operator token (CallNode#message_loc).
7
+ # Arithmetic operator mutator.
8
+ #
9
+ # One mutation per occurrence, rewriting the operator token.
9
10
  class Arithmetic < Base
11
+ # Token replacements for arithmetic operators.
10
12
  REPLACEMENTS = {
11
13
  :+ => "-", :- => "+", :* => "/", :/ => "*", :% => "*", :** => "*"
12
14
  }.freeze
13
15
 
16
+ # Visits call nodes and emits arithmetic mutations.
17
+ #
18
+ # @param node [Prism::CallNode] call node to inspect.
19
+ # @return [void]
14
20
  def visit_call_node(node)
15
21
  replacement = REPLACEMENTS[node.name]
16
22
  loc = node.message_loc
@@ -4,14 +4,22 @@ require "prism"
4
4
  require_relative "../mutation"
5
5
 
6
6
  module Mutineer
7
+ # Namespace for all built-in mutator implementations.
7
8
  module Mutators
8
- # Base Prism visitor for operators. Subclasses override visit_* methods to
9
- # push Mutation objects onto @mutations. Visiting only def_node.body is the
10
- # body-only enforcement the def signature line is never touched.
9
+ # Base Prism visitor for operators.
10
+ #
11
+ # Subclasses override `visit_*` methods to push `Mutation` objects onto
12
+ # `@mutations`. Visiting only `def_node.body` is the body-only enforcement:
13
+ # the def signature line is never touched.
11
14
  #
12
15
  # ponytail: one implementor in M1; Base earns its keep at M4 when
13
16
  # comparison/boolean operators land and share this contract.
14
17
  class Base < Prism::Visitor
18
+ # Walks the subject body and collects mutations.
19
+ #
20
+ # @param subject [Mutineer::Subject] subject whose body is visited.
21
+ # @param source [String] full source text for byte-based slicing.
22
+ # @return [Array<Mutineer::Mutation>] collected mutations.
15
23
  def mutations_for(subject, source)
16
24
  @source = source
17
25
  @mutations = []
@@ -4,20 +4,26 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Boolean connector operator: && <-> ||, and <-> or. Replacement is derived
8
- # from the actual source token (operator_loc.slice) so symbolic and keyword
9
- # forms each map to their own form — never crossing && to `or`, which would
10
- # change precedence and surprise the reader (KTD-2).
7
+ # Boolean connector mutator.
11
8
  #
12
- # Clean-room: from the spec's operator table, not the mutant gem.
9
+ # Replaces symbolic and keyword connectors with their opposite form.
13
10
  class BooleanConnector < Base
11
+ # Token replacements for boolean connectors.
14
12
  REPLACEMENTS = { "&&" => "||", "||" => "&&", "and" => "or", "or" => "and" }.freeze
15
13
 
14
+ # Visits `and` nodes.
15
+ #
16
+ # @param node [Prism::AndNode] node to inspect.
17
+ # @return [void]
16
18
  def visit_and_node(node)
17
19
  emit(node)
18
20
  super
19
21
  end
20
22
 
23
+ # Visits `or` nodes.
24
+ #
25
+ # @param node [Prism::OrNode] node to inspect.
26
+ # @return [void]
21
27
  def visit_or_node(node)
22
28
  emit(node)
23
29
  super
@@ -25,6 +31,10 @@ module Mutineer
25
31
 
26
32
  private
27
33
 
34
+ # Emits a connector mutation when the token is replaceable.
35
+ #
36
+ # @param node [Prism::Node] connector node.
37
+ # @return [void]
28
38
  def emit(node)
29
39
  loc = node.operator_loc
30
40
  replacement = REPLACEMENTS[loc.slice]
@@ -4,24 +4,32 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Mutates true/false AND nil literals — "boolean_literal" is the spec's name
8
- # for the family (§4), so nil is in-scope by design even though it is not
9
- # strictly a boolean. true<->false, and nil->true (nil->true catches more
10
- # return-value gaps than nil->false). Rewrites the whole node location;
11
- # these nodes have no sub-token location.
7
+ # Boolean literal mutator.
12
8
  #
13
- # Clean-room: from the spec's operator table, not the mutant gem.
9
+ # Mutates true/false and nil literals in the boolean_literal family.
14
10
  class BooleanLiteral < Base
11
+ # Visits true literals.
12
+ #
13
+ # @param node [Prism::TrueNode] node to inspect.
14
+ # @return [void]
15
15
  def visit_true_node(node)
16
16
  emit(node, "false")
17
17
  super
18
18
  end
19
19
 
20
+ # Visits false literals.
21
+ #
22
+ # @param node [Prism::FalseNode] node to inspect.
23
+ # @return [void]
20
24
  def visit_false_node(node)
21
25
  emit(node, "true")
22
26
  super
23
27
  end
24
28
 
29
+ # Visits nil literals.
30
+ #
31
+ # @param node [Prism::NilNode] node to inspect.
32
+ # @return [void]
25
33
  def visit_nil_node(node)
26
34
  emit(node, "true")
27
35
  super
@@ -29,6 +37,11 @@ module Mutineer
29
37
 
30
38
  private
31
39
 
40
+ # Emits a boolean-literal mutation.
41
+ #
42
+ # @param node [Prism::Node] literal node.
43
+ # @param replacement [String] replacement token.
44
+ # @return [void]
32
45
  def emit(node, replacement)
33
46
  loc = node.location
34
47
  @mutations << Mutation.new(
@@ -4,23 +4,22 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Comparison / boundary operator: <->-<=, >->-=>, ==->!=, etc. The single
8
- # highest-value Tier-1 family (spec §4) — exposes off-by-one and boundary
9
- # gaps line coverage never catches. Rewrites the operator token
10
- # (CallNode#message_loc), one mutation per occurrence.
7
+ # Comparison and boundary mutator.
11
8
  #
12
- # Clean-room: implemented from the spec's operator table and public
13
- # mutation-testing literature, not the mutant gem.
9
+ # Rewrites comparison operators one occurrence at a time.
14
10
  class Comparison < Base
11
+ # Token replacements for comparison operators.
15
12
  REPLACEMENTS = {
16
13
  :< => "<=", :<= => "<", :> => ">=", :>= => ">", :== => "!=", :!= => "=="
17
14
  }.freeze
18
15
 
16
+ # Visits call nodes and emits comparison mutations.
17
+ #
18
+ # @param node [Prism::CallNode] call node to inspect.
19
+ # @return [void]
19
20
  def visit_call_node(node)
20
21
  replacement = REPLACEMENTS[node.name]
21
22
  loc = node.message_loc
22
- # receiver guard: reject any unary call accidentally named like an
23
- # operator; binary comparisons always have a receiver.
24
23
  if replacement && loc && node.receiver
25
24
  @mutations << Mutation.new(
26
25
  start_offset: loc.start_offset,
@@ -4,18 +4,23 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Condition-negation operator (Tier 2, OFF by default). Wraps an if/unless/
8
- # ternary condition in `!( ... )` textually. Ruby ternaries parse as IfNode in
9
- # Prism, so visit_if_node covers them too (R12). The standard validity re-parse
10
- # downstream discards any wrap that fails to round-trip (R14).
7
+ # Condition-negation mutator.
11
8
  #
12
- # Clean-room: from the spec's operator description, not the mutant gem.
9
+ # Wraps if/unless predicates in `!( ... )` textually.
13
10
  class ConditionNegation < Base
11
+ # Visits if nodes.
12
+ #
13
+ # @param node [Prism::IfNode] node to inspect.
14
+ # @return [void]
14
15
  def visit_if_node(node)
15
16
  wrap(node.predicate)
16
17
  super
17
18
  end
18
19
 
20
+ # Visits unless nodes.
21
+ #
22
+ # @param node [Prism::UnlessNode] node to inspect.
23
+ # @return [void]
19
24
  def visit_unless_node(node)
20
25
  wrap(node.predicate)
21
26
  super
@@ -23,6 +28,10 @@ module Mutineer
23
28
 
24
29
  private
25
30
 
31
+ # Wraps a predicate in negation.
32
+ #
33
+ # @param predicate [Prism::Node, nil] predicate node.
34
+ # @return [void]
26
35
  def wrap(predicate)
27
36
  return unless predicate
28
37
 
@@ -4,12 +4,14 @@ require_relative "base"
4
4
 
5
5
  module Mutineer
6
6
  module Mutators
7
- # Literal-fuzzing operator (Tier 2, OFF by default). Integers emit up to
8
- # three mutations (0, 1, n+1) with no-op guards for 0 and 1; strings collapse
9
- # to "" unless already empty. One mutation per emitted candidate (R11).
7
+ # Literal mutation mutator.
10
8
  #
11
- # Clean-room: from the spec's operator description, not the mutant gem.
9
+ # Mutates integers and strings with the Tier-2 literal rules.
12
10
  class LiteralMutation < Base
11
+ # Visits integer literals.
12
+ #
13
+ # @param node [Prism::IntegerNode] node to inspect.
14
+ # @return [void]
13
15
  def visit_integer_node(node)
14
16
  n = node.value
15
17
  emit(node.location, "0") unless n.zero?
@@ -18,6 +20,10 @@ module Mutineer
18
20
  super
19
21
  end
20
22
 
23
+ # Visits string literals.
24
+ #
25
+ # @param node [Prism::StringNode] node to inspect.
26
+ # @return [void]
21
27
  def visit_string_node(node)
22
28
  loc = node.location
23
29
  token = @source.byteslice(loc.start_offset...loc.end_offset)
@@ -27,6 +33,11 @@ module Mutineer
27
33
 
28
34
  private
29
35
 
36
+ # Emits a literal mutation.
37
+ #
38
+ # @param loc [Prism::Location] source location.
39
+ # @param replacement [String] replacement literal.
40
+ # @return [void]
30
41
  def emit(loc, replacement)
31
42
  @mutations << Mutation.new(
32
43
  start_offset: loc.start_offset,