mutineer 0.7.0 → 0.9.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +39 -4
  4. data/lib/mutineer/baseline.rb +29 -13
  5. data/lib/mutineer/changed_lines.rb +29 -12
  6. data/lib/mutineer/cli.rb +88 -20
  7. data/lib/mutineer/config.rb +24 -2
  8. data/lib/mutineer/coverage_map.rb +86 -4
  9. data/lib/mutineer/isolation.rb +89 -40
  10. data/lib/mutineer/minitest_integration.rb +13 -9
  11. data/lib/mutineer/mutant_id.rb +24 -5
  12. data/lib/mutineer/mutation.rb +12 -6
  13. data/lib/mutineer/mutator_registry.rb +33 -6
  14. data/lib/mutineer/mutators/arithmetic.rb +8 -2
  15. data/lib/mutineer/mutators/base.rb +11 -3
  16. data/lib/mutineer/mutators/boolean_connector.rb +15 -5
  17. data/lib/mutineer/mutators/boolean_literal.rb +19 -6
  18. data/lib/mutineer/mutators/collection_method.rb +44 -0
  19. data/lib/mutineer/mutators/comparison.rb +7 -8
  20. data/lib/mutineer/mutators/condition_negation.rb +14 -5
  21. data/lib/mutineer/mutators/literal_mutation.rb +15 -4
  22. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  23. data/lib/mutineer/mutators/return_nil.rb +22 -7
  24. data/lib/mutineer/mutators/statement_removal.rb +6 -9
  25. data/lib/mutineer/mutators/string_literal.rb +34 -0
  26. data/lib/mutineer/pairing.rb +32 -13
  27. data/lib/mutineer/parser.rb +17 -7
  28. data/lib/mutineer/project.rb +73 -2
  29. data/lib/mutineer/reporter.rb +169 -0
  30. data/lib/mutineer/result.rb +100 -32
  31. data/lib/mutineer/runner.rb +29 -2
  32. data/lib/mutineer/subject.rb +10 -6
  33. data/lib/mutineer/test_runners/minitest.rb +5 -4
  34. data/lib/mutineer/test_runners/rspec.rb +10 -17
  35. data/lib/mutineer/test_runners.rb +9 -2
  36. data/lib/mutineer/version.rb +2 -1
  37. data/lib/mutineer/worker_pool.rb +51 -29
  38. data/lib/mutineer.rb +4 -0
  39. metadata +18 -1
@@ -117,6 +117,9 @@ module Mutineer
117
117
  self
118
118
  end
119
119
 
120
+ # Runs standalone Phase A coverage capture.
121
+ #
122
+ # @api private
120
123
  def run_phase_a
121
124
  @phase_a_ran = true
122
125
  @map = {}
@@ -160,6 +163,11 @@ module Mutineer
160
163
  # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
161
164
  def fork_capture(abs_test, abs_sources, rails)
162
165
  rd, wr = IO.pipe
166
+ # #19: Marshal output is binary — an un-binmoded pipe can raise
167
+ # Encoding::UndefinedConversionError on write, which the child's rescue then
168
+ # swallows, losing the real error and yielding a bare "no result".
169
+ rd.binmode
170
+ wr.binmode
163
171
  pid = fork do
164
172
  rd.close
165
173
  payload =
@@ -189,12 +197,30 @@ module Mutineer
189
197
  wr.close
190
198
  data = rd.read
191
199
  rd.close
192
- Process.waitpid(pid)
193
- return nil if data.empty?
200
+ _, status = Process.waitpid2(pid)
201
+ # #19: an empty pipe means the child died before writing (e.g. a hard crash,
202
+ # OOM, or a signal from the test's own subprocess handling). Report HOW it
203
+ # died (exit status / signal) as a diagnostic string so --verbose has
204
+ # something actionable instead of a silent "no result".
205
+ return "child wrote no result (#{describe_status(status)})" if data.empty?
194
206
 
195
207
  Marshal.load(data)
196
- rescue StandardError
197
- nil
208
+ rescue StandardError => e
209
+ "parent could not read capture result: #{e.class}: #{e.message}"
210
+ end
211
+
212
+ # Human description of a child Process::Status for capture diagnostics.
213
+ #
214
+ # @api private
215
+ # @param status [Process::Status] the reaped child status.
216
+ # @return [String] e.g. "killed by signal 9 (SIGKILL)" or "exit status 1".
217
+ def describe_status(status)
218
+ if status.signaled?
219
+ sig = status.termsig
220
+ "killed by signal #{sig}#{Signal.signame(sig) ? " (SIG#{Signal.signame(sig)})" : ''}"
221
+ else
222
+ "exit status #{status.exitstatus.inspect}"
223
+ end
198
224
  end
199
225
 
200
226
  # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
@@ -225,6 +251,12 @@ module Mutineer
225
251
  fail_test(test_path, "invalid coverage output: #{e.message}")
226
252
  end
227
253
 
254
+ # Records a failed coverage capture.
255
+ #
256
+ # @api private
257
+ # @param test_path [String] test file path.
258
+ # @param reason [String] failure reason.
259
+ # @return [void]
228
260
  def fail_test(test_path, reason)
229
261
  rel = relativize(test_path)
230
262
  @failed_test_files << rel
@@ -232,10 +264,20 @@ module Mutineer
232
264
  nil
233
265
  end
234
266
 
267
+ # Builds the framework-specific subprocess script.
268
+ #
269
+ # @api private
270
+ # @param test_path [String] test file path.
271
+ # @return [String] Ruby script text.
235
272
  def subprocess_script(test_path)
236
273
  @framework == "rspec" ? rspec_subprocess_script(test_path) : minitest_subprocess_script(test_path)
237
274
  end
238
275
 
276
+ # Builds the minitest subprocess script.
277
+ #
278
+ # @api private
279
+ # @param test_path [String] test file path.
280
+ # @return [String] Ruby script text.
239
281
  def minitest_subprocess_script(test_path)
240
282
  <<~RUBY
241
283
  require "coverage"
@@ -322,6 +364,13 @@ module Mutineer
322
364
  File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
323
365
  end
324
366
 
367
+ # Groups a digest with its role and paths.
368
+ #
369
+ # @api private
370
+ # @param digest [String] digest string.
371
+ # @param role [String] digest role.
372
+ # @param paths [Array<String>] paths in the digest group.
373
+ # @return [Array(String, String, Array<String>)] grouped digest data.
325
374
  def digest_group(digest, role, paths)
326
375
  paths.sort.each do |p|
327
376
  content = File.read(absolute(p))
@@ -347,8 +396,16 @@ module Mutineer
347
396
  end
348
397
  end
349
398
 
399
+ # Returns the cache path.
400
+ #
401
+ # @api private
402
+ # @return [String] cache file path.
350
403
  def cache_path = File.join(@cache_dir, "coverage.json")
351
404
 
405
+ # Reads the coverage cache.
406
+ #
407
+ # @api private
408
+ # @return [Hash, nil] cached payload.
352
409
  def read_cache
353
410
  return nil unless File.exist?(cache_path)
354
411
 
@@ -357,6 +414,10 @@ module Mutineer
357
414
  nil # corrupt cache — rebuild from scratch
358
415
  end
359
416
 
417
+ # Saves the coverage cache.
418
+ #
419
+ # @api private
420
+ # @return [void]
360
421
  def save
361
422
  FileUtils.mkdir_p(@cache_dir)
362
423
  data = { "digest" => @digest, "failed_test_files" => @failed_test_files, "map" => @map }
@@ -365,20 +426,41 @@ module Mutineer
365
426
  File.rename(tmp, cache_path) # atomic swap
366
427
  end
367
428
 
429
+ # Warns when coverage capture was incomplete.
430
+ #
431
+ # @api private
432
+ # @return [void]
368
433
  def warn_incomplete
369
434
  warn "[mutineer] cached coverage map may be incomplete; these test files " \
370
435
  "failed to contribute: #{@failed_test_files.join(', ')}"
371
436
  end
372
437
 
438
+ # Returns absolute source paths.
439
+ #
440
+ # @return [Array<String>] absolute source paths.
373
441
  def abs_source_paths = @source_paths.map { |p| absolute(p) }
442
+
443
+ # Returns absolute load paths.
444
+ #
445
+ # @return [Array<String>] absolute load paths.
374
446
  def abs_load_paths = @load_paths.map { |p| absolute(p) }
375
447
 
448
+ # Relativizes a path against the project root.
449
+ #
450
+ # @api private
451
+ # @param path [String] path to relativize.
452
+ # @return [String] relative path.
376
453
  def relativize(path)
377
454
  return path unless path.start_with?("/")
378
455
 
379
456
  path.delete_prefix("#{@project_root}/")
380
457
  end
381
458
 
459
+ # Expands a path relative to the project root.
460
+ #
461
+ # @api private
462
+ # @param path [String] path to expand.
463
+ # @return [String] absolute path.
382
464
  def absolute(path)
383
465
  File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
384
466
  end
@@ -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
@@ -8,15 +8,21 @@ require_relative "mutators/statement_removal"
8
8
  require_relative "mutators/return_nil"
9
9
  require_relative "mutators/literal_mutation"
10
10
  require_relative "mutators/condition_negation"
11
+ require_relative "mutators/string_literal"
12
+ require_relative "mutators/regex_literal"
13
+ require_relative "mutators/collection_method"
11
14
 
12
15
  module Mutineer
13
- # Maps operator name -> operator class. DEFAULT_NAMES is the v1 default set
16
+ # Maps operator names to operator classes.
17
+ #
18
+ # DEFAULT_NAMES is the v1 default set
14
19
  # (the M4 Tier-1 + statement-removal operators per locked decision #2). The
15
20
  # three Tier-2 operators live in ALL but are OFF by default — they only run
16
21
  # when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
17
22
  # DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
18
23
  # survivor set unchanged.
19
24
  class MutatorRegistry
25
+ # All available mutator classes keyed by operator name.
20
26
  ALL = {
21
27
  "arithmetic" => Mutators::Arithmetic,
22
28
  "comparison" => Mutators::Comparison,
@@ -25,12 +31,18 @@ module Mutineer
25
31
  "statement_removal" => Mutators::StatementRemoval,
26
32
  "return_nil" => Mutators::ReturnNil,
27
33
  "literal_mutation" => Mutators::LiteralMutation,
28
- "condition_negation" => Mutators::ConditionNegation
34
+ "condition_negation" => Mutators::ConditionNegation,
35
+ "string_literal" => Mutators::StringLiteral,
36
+ "regex" => Mutators::RegexLiteral,
37
+ "collection_method" => Mutators::CollectionMethod
29
38
  }.freeze
30
39
 
40
+ # The default Tier-1 operator set.
31
41
  DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
32
- TIER2_NAMES = %w[return_nil literal_mutation condition_negation].freeze
42
+ # Tier-2 operators that remain opt-in.
43
+ TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method].freeze
33
44
 
45
+ # Short human-readable descriptions for each operator.
34
46
  DESCRIPTIONS = {
35
47
  "arithmetic" => "+ <-> -, * <-> /, % -> *, ** -> *",
36
48
  "comparison" => "< <-> <=, > <-> >=, == <-> !=",
@@ -39,16 +51,31 @@ module Mutineer
39
51
  "statement_removal" => "replace a non-final statement with nil",
40
52
  "return_nil" => "replace a return / final expression with nil",
41
53
  "literal_mutation" => "integer -> 0, 1, n+1; string -> empty",
42
- "condition_negation" => "wrap if/unless/ternary condition in !( ... )"
54
+ "condition_negation" => "wrap if/unless/ternary condition in !( ... )",
55
+ "string_literal" => "non-empty string -> \"\", empty string -> \"mutineer\"",
56
+ "regex" => "drop leading ^ / trailing $, swap + <-> *",
57
+ "collection_method" => "map<->each, all?<->any?, first<->last, min<->max, select<->reject"
43
58
  }.freeze
44
59
 
45
- # Returns the operator classes for the given names. Unknown names raise
46
- # ArgumentError immediately (caught at the CLI boundary -> exit 2).
60
+ # Resolves operator names to classes.
61
+ #
62
+ # @param names [Array<String>] operator names to resolve.
63
+ # @return [Array<Class>] mutator classes in the requested order.
64
+ # @raise [ArgumentError] when a name is unknown.
47
65
  def self.resolve(names = DEFAULT_NAMES)
48
66
  names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
49
67
  end
50
68
 
69
+ # Returns whether the operator is part of the default Tier-1 set.
70
+ #
71
+ # @param name [String] operator name.
72
+ # @return [Boolean] true when the operator is default.
51
73
  def self.default?(name) = DEFAULT_NAMES.include?(name)
74
+
75
+ # Returns the tier number for an operator name.
76
+ #
77
+ # @param name [String] operator name.
78
+ # @return [Integer] 2 for Tier-2 operators, otherwise 1.
52
79
  def self.tier(name) = TIER2_NAMES.include?(name) ? 2 : 1
53
80
  end
54
81
  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 = []