mutineer-mongoid 2.0.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. metadata +143 -0
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+
6
+ module Mutineer
7
+ # Raised when another process already holds exclusive ownership of a source
8
+ # file. Aborting beats silently restoring (or capturing) the other run's mutant.
9
+ class ConcurrentRunError < StandardError
10
+ # @param path [String] the source file the other run already owns.
11
+ # @return [ConcurrentRunError]
12
+ def initialize(path)
13
+ super("another mutineer run owns #{path} — aborting to avoid corrupting the source file.")
14
+ end
15
+ end
16
+
17
+ # Apply one whole-file mutant to the REAL source path for the external
18
+ # (`--test-command`) backend, and guarantee the original is restored on every
19
+ # exit path. A separate `bundle exec` subprocess has its own VM and cannot see
20
+ # an in-process `load`, so the mutant must live on disk while its suite runs,
21
+ # which makes leaving the file mutated the one genuinely dangerous failure mode.
22
+ #
23
+ # Defense in depth, mirroring the tempfile-orphan discipline
24
+ # (`JobPlan.sweep_orphans`, `isolation.rb` tempfiles):
25
+ # - exclusive OS ownership (flock) is acquired before swap or recovery;
26
+ # - the original bytes are held in memory AND written to a sibling backup;
27
+ # - `ensure` restores from memory around every mutant;
28
+ # - the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
29
+ # can self-heal a left-mutated tree on the next run's startup once the
30
+ # kernel has released the dead owner's lock.
31
+ # Only one mutant is in flight per file at a time (the external path is serial),
32
+ # so backups never collide.
33
+ module FileSwap
34
+ # Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
35
+ BACKUP_SUFFIX = ".mutineer-backup"
36
+
37
+ # Subdirectory beside the source that holds flock files (stable across cwd
38
+ # and `cache_dir`, so concurrent runs on one inode share one lock).
39
+ LOCK_DIR_NAME = "file-swap-locks"
40
+
41
+ # Canonical source path => open lock File held by this process.
42
+ # @api private
43
+ OWNED = {}
44
+
45
+ # Real path when the file exists, otherwise `File.expand_path`. Symlink
46
+ # aliases of one inode share this identity for locks, backups, and ownership.
47
+ #
48
+ # @param path [String] source path, relative or absolute.
49
+ # @return [String] canonical absolute path.
50
+ def self.canonical_path(path)
51
+ expanded = File.expand_path(path)
52
+ File.exist?(expanded) ? File.realpath(expanded) : expanded
53
+ end
54
+
55
+ # Holds exclusive OS ownership of each source path for the duration of the
56
+ # block. Re-entrant for paths this process already owns. Raises
57
+ # {ConcurrentRunError} when another process holds a path (non-blocking).
58
+ #
59
+ # @param paths [Array<String>] source file paths to own.
60
+ # @yield the block to run while ownership is held.
61
+ # @return [Object] the block's return value.
62
+ def self.owning(paths)
63
+ acquired = []
64
+ Array(paths).map { |p| canonical_path(p) }.uniq.sort.each do |path|
65
+ next if OWNED.key?(path)
66
+
67
+ acquire!(path)
68
+ acquired << path
69
+ end
70
+ yield
71
+ ensure
72
+ acquired.reverse_each { |path| release!(path) }
73
+ end
74
+
75
+ # Writes `mutated` to `source_file`, yields, then restores the original bytes
76
+ # on every exit path (normal return, exception, or `ensure`). Byte-exact:
77
+ # binary read/write preserves encoding, newlines, and trailing bytes.
78
+ # Acquires exclusive ownership first; a leftover backup with no live owner is
79
+ # treated as the original (a prior hard-killed run), not a concurrent run.
80
+ #
81
+ # @param source_file [String] path to the real source file.
82
+ # @param mutated [String] mutated source text to write for the duration.
83
+ # @yield the block to run while the mutant is on disk.
84
+ # @return [Object] the block's return value.
85
+ def self.with(source_file, mutated)
86
+ path = canonical_path(source_file)
87
+ created = false
88
+ original = nil
89
+ backup = path + BACKUP_SUFFIX
90
+ owning([path]) do
91
+ begin
92
+ original = File.exist?(backup) ? File.binread(backup) : File.binread(path)
93
+ File.binwrite(backup, original)
94
+ created = true
95
+ File.binwrite(path, mutated)
96
+ yield
97
+ ensure
98
+ if created
99
+ File.binwrite(path, original)
100
+ File.unlink(backup) if File.exist?(backup)
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # Startup/after-run self-heal: restore any source file left mutated by a prior
107
+ # interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
108
+ # Skips a backup whose source is owned by a live process. Prints one line to
109
+ # stderr when it actually heals something, so a developer knows their working
110
+ # tree was auto-restored (a file they did not touch).
111
+ #
112
+ # @param dirs [Array<String>] directories to sweep for orphaned backups.
113
+ # @return [void]
114
+ def self.restore_orphans(dirs)
115
+ healed = 0
116
+ dirs.uniq.each do |dir|
117
+ Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
118
+ source_file = backup.delete_suffix(BACKUP_SUFFIX)
119
+ begin
120
+ owning([source_file]) { healed += restore_one(backup, source_file) }
121
+ rescue ConcurrentRunError
122
+ next
123
+ end
124
+ end
125
+ end
126
+ return if healed.zero?
127
+
128
+ warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
129
+ end
130
+
131
+ # Exclusive non-blocking flock for canonical `path`.
132
+ #
133
+ # @api private
134
+ # @param path [String] canonical source path.
135
+ # @return [void]
136
+ # @raise [Mutineer::ConcurrentRunError] when the lock is held elsewhere.
137
+ def self.acquire!(path)
138
+ file = File.open(lock_file(path), File::RDWR | File::CREAT, 0o644)
139
+ unless file.flock(File::LOCK_EX | File::LOCK_NB)
140
+ file.close
141
+ raise ConcurrentRunError, path
142
+ end
143
+ OWNED[path] = file
144
+ end
145
+
146
+ # Flock path for a canonical source: `<dir>/.mutineer/file-swap-locks/<sha>`.
147
+ # Derived only from the source path so cwd and `cache_dir` cannot split a lock.
148
+ #
149
+ # @api private
150
+ # @param path [String] canonical source path.
151
+ # @return [String] lock file path.
152
+ def self.lock_file(path)
153
+ dir = File.join(File.dirname(path), ".mutineer", LOCK_DIR_NAME)
154
+ FileUtils.mkdir_p(dir)
155
+ File.join(dir, Digest::SHA256.hexdigest(path))
156
+ end
157
+
158
+ # Releases a lock acquired by {acquire!}.
159
+ #
160
+ # @api private
161
+ # @param path [String] expanded source path.
162
+ # @return [void]
163
+ def self.release!(path)
164
+ file = OWNED.delete(path)
165
+ return unless file
166
+
167
+ file.flock(File::LOCK_UN)
168
+ file.close
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # Restores one backup if the sibling source exists. Returns 1 when bytes
174
+ # were written back, 0 when the backup was redundant or had no sibling.
175
+ #
176
+ # @api private
177
+ # @param backup [String] path to the `*.mutineer-backup` file.
178
+ # @param source_file [String] corresponding source path.
179
+ # @return [Integer] 1 if healed, otherwise 0.
180
+ def self.restore_one(backup, source_file)
181
+ return 0 unless File.exist?(source_file)
182
+
183
+ backup_bytes = File.binread(backup)
184
+ if File.binread(source_file) == backup_bytes
185
+ File.unlink(backup)
186
+ 0
187
+ else
188
+ File.binwrite(source_file, backup_bytes)
189
+ File.unlink(backup)
190
+ 1
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require_relative "result"
5
+ require_relative "parser"
6
+ require_relative "child_stdout"
7
+ require_relative "kill_channel"
8
+
9
+ module Mutineer
10
+ # Fork-based isolation for running one mutant. The block runs in a child
11
+ # process; the parent enforces a wall-clock timeout and decodes the child's
12
+ # exit status into a Result.
13
+ #
14
+ # Exit-status contract (the block's return value, or an explicit exit, is the
15
+ # child's status): 0 => survived, 1 => killed, 2 => error. Timeout is
16
+ # detected by the parent's monitor flag, not by status.signaled? (which is
17
+ # true for ANY signal death, e.g. SIGSEGV — it cannot tell our SIGKILL apart
18
+ # from the OS's).
19
+ #
20
+ # mutineer: the reload strategy this enables (whole-file `load`) re-executes
21
+ # the entire file — any top-level code runs again. Acceptable for POROs;
22
+ # document if users hit issues with initializers/callbacks. Alternative: the
23
+ # redefine strategy (surgical single-method redefinition).
24
+ class Isolation
25
+ DEFAULT_TIMEOUT = 10 # seconds
26
+
27
+ # Seconds between the parent's checks on a running child.
28
+ POLL_INTERVAL = 0.005
29
+
30
+ # Runs the block in a forked child. The block's return value (an Integer
31
+ # exit code) or any explicit `exit` is honoured; an unhandled exception
32
+ # becomes exit 2 with the cause written to STDERR.
33
+ #
34
+ # The child silences its stdout (see {ChildStdout.silence}) before the
35
+ # block runs, so test output never reaches the user. Stderr stays open.
36
+ #
37
+ # With `channel: true` (a `--matrix` run) the block gets the write end of a
38
+ # pipe for {KillChannel} lines. The parent reads it while it waits, so a
39
+ # child with more than a pipe buffer to say never blocks, and attaches the
40
+ # lines to the Result as {Kills}. The verdict is the one a run without
41
+ # `--matrix` gives (see {.finish}).
42
+ #
43
+ # @param timeout [Integer] timeout in seconds.
44
+ # @param channel [Boolean] open a {KillChannel} pipe for the block.
45
+ # @yieldparam channel [IO, nil] the pipe's write end, or nil without `channel`.
46
+ # @yieldreturn [Integer] child exit status.
47
+ # @return [Mutineer::Result] result from the child process.
48
+ def self.run(timeout: DEFAULT_TIMEOUT, channel: false)
49
+ rd, wr = IO.pipe if channel
50
+ wr&.sync = true
51
+ pid = fork do
52
+ rd&.close
53
+ # Own process group so a timeout kill can reap grandchildren (match
54
+ # daemon/external backends). Best-effort: if setpgid fails, kill the pid.
55
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
56
+ code = 0
57
+ begin
58
+ ChildStdout.silence
59
+ # A block that takes no channel (a zero-arity lambda) still runs.
60
+ result = channel ? yield(wr) : yield
61
+ code = result.is_a?(Integer) ? result : 0
62
+ rescue SystemExit => e
63
+ code = e.status
64
+ rescue Exception => e # rubocop:disable Lint/RescueException
65
+ # STDERR, not `warn`: a test may have left `$stderr` as a StringIO.
66
+ STDERR.puts "[mutineer-child] #{e.class}: #{e.message}"
67
+ code = 2
68
+ end
69
+ STDERR.flush
70
+ # exit! skips at_exit handlers — critical, since a child forked from
71
+ # inside our own Minitest suite would otherwise re-run the parent's
72
+ # at_exit autorun hook on the way out.
73
+ exit!(code)
74
+ end
75
+
76
+ wr&.close
77
+ buffer = +"" if rd
78
+ reading = !rd.nil?
79
+
80
+ # Single-threaded deadline poll: we are the ONLY caller of waitpid on this
81
+ # pid, so we never reap-then-kill. SIGKILL the process group only after
82
+ # WNOHANG shows the child is still alive past the deadline.
83
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
84
+ loop do
85
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
86
+ return finish(decode(status), rd, buffer, finished: true) if reaped
87
+
88
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
89
+ begin
90
+ Process.kill(:KILL, -pid)
91
+ rescue Errno::ESRCH, Errno::EPERM
92
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
93
+ end
94
+ begin
95
+ _reaped, status = Process.waitpid2(pid)
96
+ # Child may have finished cleanly between WNOHANG and kill; honor it.
97
+ return finish(decode(status), rd, buffer, finished: true) if status && status.exited? && !status.signaled?
98
+ rescue Errno::ECHILD
99
+ # already reaped
100
+ end
101
+ return finish(Result.timeout, rd, buffer, finished: false)
102
+ end
103
+ if reading
104
+ IO.select([rd], nil, nil, POLL_INTERVAL)
105
+ reading = drain(rd, buffer)
106
+ else
107
+ sleep POLL_INTERVAL
108
+ end
109
+ end
110
+ ensure
111
+ rd&.close
112
+ # Closed after the fork in the normal path; still open if fork raised.
113
+ wr.close if wr && !wr.closed?
114
+ end
115
+
116
+ # Reads what the channel holds now, without blocking.
117
+ #
118
+ # @api private
119
+ # @param rd [IO] the channel's read end.
120
+ # @param buffer [String] bytes read so far; appended to.
121
+ # @return [Boolean] false once the channel is at end of file.
122
+ def self.drain(rd, buffer)
123
+ loop do
124
+ chunk = rd.read_nonblock(65_536, exception: false)
125
+ return true if chunk == :wait_readable
126
+ return false if chunk.nil?
127
+
128
+ buffer << chunk
129
+ end
130
+ end
131
+
132
+ # The Result to return. Without a channel it is `result` itself. With one,
133
+ # the rest of the channel is read, and the Result carries the {Kills} it
134
+ # names.
135
+ #
136
+ # The verdict matches a run without `--matrix`, which skips everything after
137
+ # the first failing serial test except the code around it (see
138
+ # {KillChannel}). So when a serial test has named a kill and the child then
139
+ # ended inside a region that run would skip (an exit, a crash or the
140
+ # timeout in a later test, class or group), the mutant is `killed`: the
141
+ # plain run never reached that point. An end anywhere else keeps the exit
142
+ # status, because the plain run reaches that code too: the rest of the
143
+ # failing test's class wrapper or group hooks, or the suite hooks. A kill in
144
+ # the parallel tests of a Minitest run also keeps it, because the stop
145
+ # cannot skip tests already queued.
146
+ #
147
+ # The row is complete only when the child ended before the timeout, sent a
148
+ # valid stream with both `start` and `end`, lost no line, and its kills
149
+ # agree with the verdict (a killed mutant names a killer; a survivor names
150
+ # none). An invalid stream promotes nothing, and its tests are dropped: an
151
+ # out-of-order line means the parent cannot tell which outcomes are real.
152
+ #
153
+ # @api private
154
+ # @param result [Mutineer::Result] the verdict from the exit status or the timeout.
155
+ # @param rd [IO, nil] the channel's read end.
156
+ # @param buffer [String, nil] bytes read so far.
157
+ # @param finished [Boolean] the child ended before the timeout.
158
+ # @return [Mutineer::Result]
159
+ def self.finish(result, rd, buffer, finished:)
160
+ return result unless rd
161
+
162
+ drain(rd, buffer)
163
+ report = KillChannel.parse(buffer)
164
+ return result.with(kills: Kills.new(killed_by: [], ran: [], complete: false)) if report.invalid
165
+
166
+ result = Result.killed if report.started && report.serial_kill && report.skipping.positive?
167
+ agrees = result.killed? ? report.killed.any? : result.survived? && report.killed.empty?
168
+ complete = finished && report.started && report.finished && report.lost.zero? && agrees
169
+ result.with(kills: Kills.new(killed_by: report.killed, ran: report.ran, complete: complete))
170
+ end
171
+
172
+ # Strategy 7a (default): write the whole mutated file and `load` it, which
173
+ # reopens its classes and redefines every method in place. Re-runs file-
174
+ # level side effects. Child-only — mutates the loaded program.
175
+ #
176
+ # The tempfile is created in the ORIGINAL file's directory, not the system
177
+ # temp dir, so any `require_relative` in the mutated source resolves
178
+ # against its real neighbours (e.g. a mutator's `require_relative
179
+ # "base"`). Writing it elsewhere makes those requires resolve to the temp
180
+ # dir and raise LoadError.
181
+ #
182
+ # @api private
183
+ # @param mutated [String] mutated source text.
184
+ # @param source_file [String] original source file path.
185
+ # @return [Object] whatever `load` returns.
186
+ def self.apply_whole_file(mutated, source_file)
187
+ Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
188
+ f.write(mutated)
189
+ f.flush
190
+ load f.path
191
+ end
192
+ end
193
+
194
+ # Redefine strategy: extract just the enclosing DefNode, apply the mutation
195
+ # to that snippet, wrap it in its real namespace, and `load` only that one
196
+ # method back into the running process. No file-level side effects re-run.
197
+ # Child-only.
198
+ #
199
+ # The snippet keeps its own `def self.x` for singletons, so the namespace
200
+ # wrapper redefines instance and singleton methods correctly without any
201
+ # special-casing.
202
+ #
203
+ # @api private
204
+ # @param mutation [Mutineer::Mutation] mutation to apply.
205
+ # @param subject [Mutineer::Subject] subject being mutated.
206
+ # @param source [String] full source text.
207
+ # @return [Object] whatever `load` returns.
208
+ def self.apply_surgical(mutation, subject, source)
209
+ loc = subject.def_node.location
210
+ def_start = loc.start_offset
211
+ snippet = source.byteslice(def_start...loc.end_offset)
212
+ rel_s = mutation.start_offset - def_start
213
+ rel_e = mutation.end_offset - def_start
214
+ mutated_def = snippet.byteslice(0...rel_s) + mutation.replacement + snippet.byteslice(rel_e..)
215
+
216
+ # Rebuild the FULL namespace nesting textually so unqualified enclosing-
217
+ # namespace constants resolve exactly as the reload strategy would. A
218
+ # bare redefinition on the owner would collapse Module.nesting to [owner]
219
+ # and raise NameError on such constants (C2 scope-collapse).
220
+ keywords = nesting_keywords(subject.lexical_namespace)
221
+ prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
222
+ prefix += "\n" unless prefix.empty?
223
+
224
+ # #20: a singleton method whose def has NO `self.` receiver (the
225
+ # `class << self` and `module_function` forms) would, as a bare `def foo`
226
+ # inside `module Owner`, redefine the INSTANCE method — but the call
227
+ # (`Owner.foo`) dispatches to the singleton, so the mutant never runs and
228
+ # falsely survives. Re-open the singleton class so the redefinition lands on
229
+ # the same method the test calls. `def self.foo` already carries its
230
+ # receiver, so it is left as-is (wrapping it would mis-target).
231
+ inner =
232
+ if subject.singleton && subject.def_node.receiver.nil?
233
+ "class << self\n#{mutated_def}\nend"
234
+ else
235
+ mutated_def
236
+ end
237
+ inner = "#{subject.block_owner}.class_eval do\n#{inner}\nend" if subject.block_owner
238
+ wrapped = "#{prefix}#{inner}#{"\nend" * keywords.size}"
239
+
240
+ # A snippet that fails to reparse must NOT silently fall through to
241
+ # running the ORIGINAL method (C2 false-survived). Raise -> the fork
242
+ # block aborts before any test runs -> Result.error, never a bogus
243
+ # `survived`.
244
+ raise "surgical snippet failed to reparse" if Parser.parse_string(wrapped).errors.any?
245
+
246
+ # Preserve original visibility — class/module bodies define methods
247
+ # public, but 7a's `load` would re-apply the file's private/protected
248
+ # (C2).
249
+ owner = subject.namespace.empty? ? Object : Object.const_get(subject.namespace.join("::"))
250
+ target = subject.singleton ? owner.singleton_class : owner
251
+ vis = method_visibility(target, subject.name)
252
+
253
+ # Write the wrapped snippet to a tempfile and `load` it: `load` runs it
254
+ # at top level, so the textual class/module wrappers rebuild
255
+ # Module.nesting identically, with no dynamic string execution for
256
+ # scanners to flag. The input is the project's OWN source (the enclosing
257
+ # method, textually mutated), loaded only in this forked child.
258
+ Tempfile.create(["mutineer_surgical", ".rb"]) do |f|
259
+ f.write(wrapped)
260
+ f.flush
261
+ load f.path
262
+ end
263
+
264
+ target.send(vis, subject.name) if vis && vis != :public
265
+ end
266
+
267
+ # Resolve each namespace ELEMENT to its live Module and pick the correct
268
+ # keyword (reopening a class with `module` — or vice versa — raises
269
+ # TypeError), so the textual wrapper matches the real definitions.
270
+ #
271
+ # #5: a compact element like "Foo::Bar" stays a SINGLE wrapper `class Foo::
272
+ # Bar` (nesting [Foo::Bar]), matching how a whole-file load (reload) sees
273
+ # it. Splitting it into `module Foo; class Bar` gave nesting [Foo::Bar,
274
+ # Foo], so an unqualified constant defined only in Foo would resolve under
275
+ # redefine but not reload — a strategy disagreement.
276
+ #
277
+ # A root-anchored element (`::Top`, #145) resolves from Object and keeps its
278
+ # `::` in the wrapper, so `module Outer; class ::Top` rebuilds nesting
279
+ # [Top, Outer] exactly as the source does.
280
+ #
281
+ # @api private
282
+ # @param namespace [Array<String>] class/module chain as written.
283
+ # @return [Array<[String, String]>] wrapper keywords and names.
284
+ def self.nesting_keywords(namespace)
285
+ mod = Object
286
+ namespace.map do |name|
287
+ # const_get resolves a compact "Foo::Bar" too
288
+ mod = name.start_with?("::") ? Object.const_get(name.delete_prefix("::")) : mod.const_get(name)
289
+ [mod.is_a?(Class) ? "class" : "module", name]
290
+ end
291
+ end
292
+
293
+ # Returns the visibility for a method name.
294
+ #
295
+ # @api private
296
+ # @param mod [Module] module or class being inspected.
297
+ # @param name [Symbol] method name.
298
+ # @return [Symbol, nil] `:public`, `:protected`, `:private`, or nil.
299
+ def self.method_visibility(mod, name)
300
+ return :private if mod.private_method_defined?(name)
301
+ return :protected if mod.protected_method_defined?(name)
302
+ return :public if mod.public_method_defined?(name)
303
+
304
+ nil
305
+ end
306
+
307
+ # Decodes a child status into a Result.
308
+ #
309
+ # @api private
310
+ # @param status [Process::Status] child exit status.
311
+ # @return [Mutineer::Result] decoded result.
312
+ def self.decode(status)
313
+ case status.exitstatus
314
+ when 0 then Result.survived
315
+ when 1 then Result.killed
316
+ when 2 then Result.error("child exited with status 2")
317
+ else Result.error("unexpected exit status: #{status.exitstatus.inspect}")
318
+ end
319
+ end
320
+ end
321
+ end