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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +1194 -0
- data/LICENSE +21 -0
- data/README.md +659 -0
- data/bin/mutineer +6 -0
- data/lib/mutineer/baseline.rb +185 -0
- data/lib/mutineer/changed_lines.rb +136 -0
- data/lib/mutineer/child_stdout.rb +35 -0
- data/lib/mutineer/cli.rb +681 -0
- data/lib/mutineer/config.rb +372 -0
- data/lib/mutineer/coverage_map.rb +1366 -0
- data/lib/mutineer/daemon_backend.rb +351 -0
- data/lib/mutineer/daemon_client.rb +407 -0
- data/lib/mutineer/daemon_server.rb +351 -0
- data/lib/mutineer/external_backend.rb +288 -0
- data/lib/mutineer/file_swap.rb +194 -0
- data/lib/mutineer/isolation.rb +321 -0
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
- data/lib/mutineer/minitest_integration.rb +137 -0
- data/lib/mutineer/mongoid_failures.rb +82 -0
- data/lib/mutineer/mongoid_worker_db.rb +97 -0
- data/lib/mutineer/mutant_id.rb +126 -0
- data/lib/mutineer/mutation.rb +27 -0
- data/lib/mutineer/mutator_registry.rb +109 -0
- data/lib/mutineer/mutators/arithmetic.rb +35 -0
- data/lib/mutineer/mutators/array_literal.rb +44 -0
- data/lib/mutineer/mutators/base.rb +105 -0
- data/lib/mutineer/mutators/boolean_connector.rb +52 -0
- data/lib/mutineer/mutators/boolean_literal.rb +56 -0
- data/lib/mutineer/mutators/chain_link.rb +132 -0
- data/lib/mutineer/mutators/collection_method.rb +44 -0
- data/lib/mutineer/mutators/comparison.rb +35 -0
- data/lib/mutineer/mutators/condition_forcing.rb +134 -0
- data/lib/mutineer/mutators/condition_negation.rb +49 -0
- data/lib/mutineer/mutators/literal_mutation.rb +51 -0
- data/lib/mutineer/mutators/negation_removal.rb +44 -0
- data/lib/mutineer/mutators/operand_removal.rb +58 -0
- data/lib/mutineer/mutators/operator_assignment.rb +113 -0
- data/lib/mutineer/mutators/range_literal.rb +54 -0
- data/lib/mutineer/mutators/regex_literal.rb +72 -0
- data/lib/mutineer/mutators/return_nil.rb +72 -0
- data/lib/mutineer/mutators/safe_navigation.rb +76 -0
- data/lib/mutineer/mutators/statement_removal.rb +33 -0
- data/lib/mutineer/mutators/string_literal.rb +34 -0
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +298 -0
- data/lib/mutineer/parser.rb +45 -0
- data/lib/mutineer/progress.rb +46 -0
- data/lib/mutineer/project.rb +506 -0
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/rails_worker_db.rb +212 -0
- data/lib/mutineer/reporter.rb +926 -0
- data/lib/mutineer/result.rb +240 -0
- data/lib/mutineer/runner.rb +418 -0
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +38 -0
- data/lib/mutineer/test_runners/minitest.rb +22 -0
- data/lib/mutineer/test_runners/rspec.rb +330 -0
- data/lib/mutineer/test_runners.rb +30 -0
- data/lib/mutineer/version.rb +6 -0
- data/lib/mutineer/worker_pool.rb +147 -0
- data/lib/mutineer.rb +48 -0
- 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
|