mutineer 0.6.2 → 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.
@@ -24,12 +24,14 @@ module Mutineer
24
24
  :sources, :tests, :operators, :threshold, :only, :dry_run,
25
25
  :cache_dir, :project_root, :load_paths,
26
26
  :jobs, :format, :output, :strategy, :require_paths,
27
- :boot, :rails, :since, :framework,
27
+ :boot, :rails, :since, :framework, :verbose, :ignore,
28
+ :baseline, :baseline_epsilon,
28
29
  keyword_init: true
29
30
  ) do
31
+ # Config file name.
30
32
  CONFIG_FILE = ".mutineer.yml"
31
33
  # Keys accepted in .mutineer.yml (R7). `require` maps to the :require_paths field.
32
- KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework].freeze
34
+ KNOWN_KEYS = %w[operators jobs threshold only require boot rails since framework verbose ignore baseline].freeze
33
35
 
34
36
  def initialize(**kwargs)
35
37
  super
@@ -45,6 +47,9 @@ module Mutineer
45
47
  self.strategy ||= "reload"
46
48
  self.require_paths ||= []
47
49
  self.rails = false if rails.nil?
50
+ self.verbose = false if verbose.nil?
51
+ self.ignore ||= []
52
+ self.baseline_epsilon ||= 0.0
48
53
  end
49
54
 
50
55
  # Walk from `start` toward `home`, returning the first .mutineer.yml path found
@@ -124,16 +129,30 @@ module Mutineer
124
129
 
125
130
  # Pick rspec when a MAJORITY of the given test files end with _spec.rb;
126
131
  # otherwise minitest. Empty/ambiguous -> minitest (the safe default).
132
+ # Detects the test framework from the file list.
133
+ #
134
+ # @param tests [Array<String>] test file paths.
135
+ # @return [String] `"rspec"` or `"minitest"`.
127
136
  def self.detect_framework(tests)
128
137
  tests = Array(tests)
129
138
  specs = tests.count { |t| t.to_s.end_with?("_spec.rb") }
130
139
  specs > tests.length / 2.0 ? "rspec" : "minitest"
131
140
  end
132
141
 
142
+ # Maps a config key to its Struct field.
143
+ #
144
+ # @param known_key [String] config key.
145
+ # @return [Symbol] struct field name.
133
146
  def self.field_for(known_key)
134
147
  known_key == "require" ? :require_paths : known_key.to_sym
135
148
  end
136
149
 
150
+ # Coerces a config value to its target type.
151
+ #
152
+ # @param known_key [String] config key.
153
+ # @param value [Object] raw YAML value.
154
+ # @param file_name [String] config file name for warnings.
155
+ # @return [Object] coerced value.
137
156
  def self.coerce(known_key, value, file_name)
138
157
  case known_key
139
158
  when "operators" then filter_operators(Array(value).map(&:to_s), file_name)
@@ -143,6 +162,9 @@ module Mutineer
143
162
  when "boot" then value.to_s
144
163
  when "framework" then value.to_s
145
164
  when "rails" then value == true || value.to_s == "true"
165
+ when "verbose" then value == true || value.to_s == "true"
166
+ when "ignore" then Array(value).map(&:to_s)
167
+ when "baseline" then value.to_s
146
168
  else value
147
169
  end
148
170
  end
@@ -150,6 +172,12 @@ module Mutineer
150
172
  # Drop (with a warning) operator names the registry doesn't know (R7).
151
173
  # Referenced lazily so config.rb carries no load-order dependency on the
152
174
  # registry; by the time a config is parsed at runtime, it is loaded.
175
+ # Filters out unknown operator names.
176
+ #
177
+ # @api private
178
+ # @param names [Array<String>] operator names.
179
+ # @param file_name [String] config file name for warnings.
180
+ # @return [Array<String>] known operator names.
153
181
  def self.filter_operators(names, file_name)
154
182
  known = MutatorRegistry::ALL.keys
155
183
  names.select do |n|
@@ -6,6 +6,7 @@ require "digest"
6
6
  require "fileutils"
7
7
  require "rbconfig"
8
8
  require "coverage"
9
+ require "set"
9
10
  require_relative "minitest_integration"
10
11
  require_relative "test_runners"
11
12
 
@@ -25,7 +26,7 @@ module Mutineer
25
26
  def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
26
27
  load_paths: ["lib"], project_root: Dir.pwd,
27
28
  capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
28
- framework: "minitest")
29
+ framework: "minitest", verbose: false)
29
30
  @source_paths = Array(source_paths)
30
31
  @test_paths = Array(test_paths)
31
32
  @cache_dir = cache_dir
@@ -34,6 +35,7 @@ module Mutineer
34
35
  @capture_timeout = capture_timeout
35
36
  @boot_path = boot_path
36
37
  @framework = framework || "minitest"
38
+ @verbose = verbose
37
39
  @map = {}
38
40
  @failed_test_files = []
39
41
  @phase_a_ran = false
@@ -64,8 +66,40 @@ module Mutineer
64
66
  @map["#{relativize(file)}:#{line}"] || []
65
67
  end
66
68
 
69
+ # #9: is this source file's empty coverage the result of an *errored* capture
70
+ # rather than a genuine coverage gap? True iff (KTD-2) some capture failed this
71
+ # run AND this file got zero coverage from any successful capture AND a failed
72
+ # test file maps to it by the standard _test/_spec naming convention. Derived
73
+ # purely from already-persisted state (@map keys + @failed_test_files); no rerun,
74
+ # no new cached field, no digest change.
75
+ #
76
+ # ponytail: file-level, convention-based attribution. A line covered only by a
77
+ # failed test in an otherwise-covered file stays no_coverage (condition 2), and
78
+ # a source with no naming-convention test match is never tainted. Upgrade path:
79
+ # persist per-file coverage per successful run and diff against the failed set,
80
+ # or record test->source targets explicitly. Not needed for the #8/#9 cases.
81
+ def uncapturable_source?(file)
82
+ return false if @failed_test_files.empty?
83
+
84
+ rel = relativize(absolute(file))
85
+ return false if covered_source_files.include?(rel)
86
+
87
+ failed_test_targets.include?(File.basename(rel, ".rb"))
88
+ end
89
+
67
90
  private
68
91
 
92
+ # Source rel-paths that received coverage from any successful capture.
93
+ def covered_source_files
94
+ @map.keys.map { |k| k.rpartition(":").first }.to_set
95
+ end
96
+
97
+ # Basenames of failed test files with a trailing _test/_spec (and .rb) stripped,
98
+ # i.e. the source basenames they would have covered by convention.
99
+ def failed_test_targets
100
+ @failed_test_files.map { |t| File.basename(t, ".rb").sub(/_(test|spec)\z/, "") }.to_set
101
+ end
102
+
69
103
  # Shared cache dance for both build paths: hit the digest-keyed cache, else
70
104
  # yield to populate @map and persist it.
71
105
  def cached_or
@@ -83,6 +117,9 @@ module Mutineer
83
117
  self
84
118
  end
85
119
 
120
+ # Runs standalone Phase A coverage capture.
121
+ #
122
+ # @api private
86
123
  def run_phase_a
87
124
  @phase_a_ran = true
88
125
  @map = {}
@@ -108,10 +145,16 @@ module Mutineer
108
145
  abs_sources = abs_source_paths
109
146
 
110
147
  @test_paths.each do |test_path|
111
- coverage = fork_capture(absolute(test_path), abs_sources, rails)
112
- next fail_test(test_path, "fork capture produced no result") unless coverage
113
-
114
- record(coverage, test_path)
148
+ # Tri-state payload (KTD-1): Hash = coverage, String = error diagnostic
149
+ # from the child, nil = pipe gone / empty.
150
+ # ponytail/#9: this String diagnostic is what #9 turns into an :uncapturable status.
151
+ case (coverage = fork_capture(absolute(test_path), abs_sources, rails))
152
+ when Hash then record(coverage, test_path)
153
+ when String
154
+ fail_test(test_path, @verbose ? "fork capture failed: #{coverage}" :
155
+ "fork capture produced no result (re-run with --verbose for the error)")
156
+ else fail_test(test_path, "fork capture produced no result")
157
+ end
115
158
  end
116
159
  end
117
160
 
@@ -120,6 +163,11 @@ module Mutineer
120
163
  # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
121
164
  def fork_capture(abs_test, abs_sources, rails)
122
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
123
171
  pid = fork do
124
172
  rd.close
125
173
  payload =
@@ -132,8 +180,10 @@ module Mutineer
132
180
  Coverage.result(stop: false)
133
181
  .select { |f, _| abs_sources.include?(f) }
134
182
  .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
135
- rescue Exception # rubocop:disable Lint/RescueException
136
- nil
183
+ rescue Exception => e # rubocop:disable Lint/RescueException
184
+ # KTD-1: stringify (an arbitrary Exception may not marshal); the parent
185
+ # surfaces this under --verbose. A String marshals safely over the pipe.
186
+ "#{e.class}: #{e.message}#{e.backtrace&.first ? " @ #{e.backtrace.first}" : ''}"
137
187
  end
138
188
  begin
139
189
  wr.write(Marshal.dump(payload))
@@ -147,12 +197,30 @@ module Mutineer
147
197
  wr.close
148
198
  data = rd.read
149
199
  rd.close
150
- Process.waitpid(pid)
151
- 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?
152
206
 
153
207
  Marshal.load(data)
154
- rescue StandardError
155
- 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
156
224
  end
157
225
 
158
226
  # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
@@ -183,6 +251,12 @@ module Mutineer
183
251
  fail_test(test_path, "invalid coverage output: #{e.message}")
184
252
  end
185
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]
186
260
  def fail_test(test_path, reason)
187
261
  rel = relativize(test_path)
188
262
  @failed_test_files << rel
@@ -190,10 +264,20 @@ module Mutineer
190
264
  nil
191
265
  end
192
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.
193
272
  def subprocess_script(test_path)
194
273
  @framework == "rspec" ? rspec_subprocess_script(test_path) : minitest_subprocess_script(test_path)
195
274
  end
196
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.
197
281
  def minitest_subprocess_script(test_path)
198
282
  <<~RUBY
199
283
  require "coverage"
@@ -280,6 +364,13 @@ module Mutineer
280
364
  File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
281
365
  end
282
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.
283
374
  def digest_group(digest, role, paths)
284
375
  paths.sort.each do |p|
285
376
  content = File.read(absolute(p))
@@ -305,8 +396,16 @@ module Mutineer
305
396
  end
306
397
  end
307
398
 
399
+ # Returns the cache path.
400
+ #
401
+ # @api private
402
+ # @return [String] cache file path.
308
403
  def cache_path = File.join(@cache_dir, "coverage.json")
309
404
 
405
+ # Reads the coverage cache.
406
+ #
407
+ # @api private
408
+ # @return [Hash, nil] cached payload.
310
409
  def read_cache
311
410
  return nil unless File.exist?(cache_path)
312
411
 
@@ -315,6 +414,10 @@ module Mutineer
315
414
  nil # corrupt cache — rebuild from scratch
316
415
  end
317
416
 
417
+ # Saves the coverage cache.
418
+ #
419
+ # @api private
420
+ # @return [void]
318
421
  def save
319
422
  FileUtils.mkdir_p(@cache_dir)
320
423
  data = { "digest" => @digest, "failed_test_files" => @failed_test_files, "map" => @map }
@@ -323,20 +426,41 @@ module Mutineer
323
426
  File.rename(tmp, cache_path) # atomic swap
324
427
  end
325
428
 
429
+ # Warns when coverage capture was incomplete.
430
+ #
431
+ # @api private
432
+ # @return [void]
326
433
  def warn_incomplete
327
434
  warn "[mutineer] cached coverage map may be incomplete; these test files " \
328
435
  "failed to contribute: #{@failed_test_files.join(', ')}"
329
436
  end
330
437
 
438
+ # Returns absolute source paths.
439
+ #
440
+ # @return [Array<String>] absolute source paths.
331
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.
332
446
  def abs_load_paths = @load_paths.map { |p| absolute(p) }
333
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.
334
453
  def relativize(path)
335
454
  return path unless path.start_with?("/")
336
455
 
337
456
  path.delete_prefix("#{@project_root}/")
338
457
  end
339
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.
340
464
  def absolute(path)
341
465
  File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
342
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
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Mutineer
6
+ # Content-based stable id for a mutant — NOT byte offsets. Pure function, reused
7
+ # by the Runner (matching the ignore list), the Reporter (emitting a copy-
8
+ # pasteable id per survivor), and #13 baseline gating (diffing id-sets run to
9
+ # run). `digest` is stdlib, so zero new deps.
10
+ #
11
+ # Offset-free by design: keyed on the subject's qualified_name (a method, not a
12
+ # byte position) + operator + the normalized mutated token + an occurrence
13
+ # ordinal among same-(operator, token) twins WITHIN the subject. So it survives
14
+ # any edit outside the subject method — where raw start/end offsets shift on
15
+ # every edit earlier in the file and would silently stop matching.
16
+ module MutantId
17
+ module_function
18
+
19
+ # Computes the stable id for a single mutant.
20
+ #
21
+ # NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
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.
31
+ def for(subject, mutation, source, occurrence = 0)
32
+ Digest::SHA256.hexdigest(
33
+ [subject.qualified_name, mutation.operator,
34
+ normalized_token(mutation, source), occurrence].join("\x00")
35
+ )[0, 12]
36
+ end
37
+
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.
46
+ def for_subject(subject, source, mutations)
47
+ seen = Hash.new(0)
48
+ mutations.map do |m|
49
+ key = [m.operator, normalized_token(m, source)]
50
+ occ = seen[key]
51
+ seen[key] += 1
52
+ self.for(subject, m, source, occ)
53
+ end
54
+ end
55
+
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.
62
+ def normalized_token(mutation, source)
63
+ source.byteslice(mutation.start_offset...mutation.end_offset).gsub(/\s+/, " ").strip
64
+ end
65
+ end
66
+ end