mutineer 0.9.1 → 0.11.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.
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+
6
+ module Mutineer
7
+ # #26/#27 Phase 2a — the app-side daemon (persistent worker).
8
+ #
9
+ # Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
10
+ # `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
11
+ # requests over stdin/stdout as newline-delimited JSON. For each request it FORKS
12
+ # a child that loads the mutated source text the tool sent, runs the covering
13
+ # tests, and exits with a status the parent decodes into a verdict.
14
+ #
15
+ # HARD CONSTRAINT (KTD-2/R4): this file must be loadable WITHOUT Prism or the rest
16
+ # of mutineer — the app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
17
+ # mutineer. So it requires ONLY stdlib + the app's own boot file; it re-implements
18
+ # the fork/timeout/decode loop rather than requiring `isolation.rb` (which pulls in
19
+ # Prism). All parsing/mutation happened tool-side; the daemon only `load`s text.
20
+ #
21
+ # Protocol (one JSON object per line, both directions):
22
+ # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment",
23
+ # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
24
+ # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
25
+ # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
26
+ # "tests":["test/models/order_test.rb"],"timeout":30}
27
+ # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
28
+ # quit in : {"cmd":"quit"}
29
+ #
30
+ # Worker isolation (#26/U5): when the app is Rails, each fork is routed to its own
31
+ # database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so concurrent
32
+ # workers can't clobber each other's transactional fixtures. `worker` defaults to 0
33
+ # (serial). SQLite this pass; Postgres provisioning is U10.
34
+ #
35
+ # Verdict mapping (KTD-5, Phase-2a honest limit): child exit 0=survived (suite
36
+ # passed), 1=killed (suite failed), 2=error (child raised AROUND the test — load,
37
+ # boot, or worker-DB routing failure); parent-detected timeout. Tagging an in-TEST DB
38
+ # error (one fired inside a test body, vs at routing time) as `error` rather than
39
+ # `killed` is a U6 concern — only observable under the concurrent gate.
40
+ module DaemonServer
41
+ # Poll interval (seconds) for the per-fork deadline wait loop.
42
+ POLL = 0.02
43
+
44
+ class << self
45
+ # Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
46
+ #
47
+ # @param input [IO] request stream.
48
+ # @param output [IO] verdict stream.
49
+ # @param errio [IO] diagnostics stream (never the IPC channel).
50
+ # @return [void]
51
+ def run(input: $stdin, output: $stdout, errio: $stderr)
52
+ @errio = errio
53
+ @output = output
54
+ boot_line = input.gets
55
+ return if boot_line.nil? # client vanished before boot
56
+
57
+ boot!(JSON.parse(boot_line.strip))
58
+ output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
59
+ output.flush
60
+
61
+ input.each_line do |line|
62
+ line = line.strip
63
+ next if line.empty?
64
+
65
+ begin
66
+ req = JSON.parse(line)
67
+ rescue JSON::ParserError => e
68
+ # A corrupt line has no id to address a reply to (and the client only ever
69
+ # sends valid JSON, so it can't be a pending request) — log and read on
70
+ # rather than write an unaddressable verdict onto the channel.
71
+ @errio.puts("[daemon] dropped unparseable line: #{e.message}")
72
+ next
73
+ end
74
+ break if req["cmd"] == "quit"
75
+
76
+ # #26/U7: build the coverage map app-side and ship it to the tool, which
77
+ # then selects covering tests per mutant. One-shot control message.
78
+ if req["cmd"] == "coverage"
79
+ output.puts(JSON.generate(build_coverage_map))
80
+ output.flush
81
+ next
82
+ end
83
+
84
+ output.puts(JSON.generate(run_mutant(req)))
85
+ output.flush
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded and
92
+ # inherited by every fork. Never requires mutineer.
93
+ def boot!(cfg)
94
+ @cfg = cfg
95
+ @framework = cfg.fetch("framework", "minitest")
96
+ @source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
97
+ Dir.chdir(cfg["project_root"]) if cfg["project_root"]
98
+ ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
99
+ Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
100
+ # #26/U7: start Coverage BEFORE the app loads, so booted source lines are
101
+ # instrumented — the map build (build_via_fork) forks this booted parent.
102
+ if cfg["coverage"]
103
+ require "coverage"
104
+ Coverage.start(lines: true)
105
+ end
106
+ # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
107
+ # source dir BEFORE the app boots — Zeitwerk would otherwise choke on the
108
+ # tempfile's non-constant name during autoload setup.
109
+ sweep_temps
110
+ require File.expand_path(cfg["boot"]) if cfg["boot"]
111
+ setup_worker_db(cfg) if cfg["rails"]
112
+ rescue Exception => e # rubocop:disable Lint/RescueException
113
+ # Boot failed (bad boot path, app error) — tell the client and exit so it can
114
+ # surface a clean error rather than hang on the handshake.
115
+ @output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
116
+ @output.flush
117
+ exit!(1)
118
+ end
119
+
120
+ # Load the per-worker DB adapter app-side (sibling gem file, by relative path so
121
+ # it bypasses the app bundle — like this daemon itself). No-op unless the app has
122
+ # ActiveRecord. Records the adapter + schema path so each fork can route to its
123
+ # own database (#26 isolation, U5). SQLite-only this pass; a non-SQLite config
124
+ # raises in the fork and reads as `error`, never a mis-routed verdict.
125
+ def setup_worker_db(cfg)
126
+ require_relative "rails_worker_db"
127
+ @worker_db = RailsWorkerDb.available? ? RailsWorkerDb : nil
128
+ schema = cfg["schema"] && File.expand_path(cfg["schema"])
129
+ @schema_path = schema if schema && File.exist?(schema)
130
+ rescue LoadError => e
131
+ @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
132
+ @worker_db = nil
133
+ end
134
+
135
+ # #26/U7: build the coverage map app-side (Coverage was started at boot) and
136
+ # return it as `{map, failed_test_files}` for the tool to select covering tests.
137
+ # Capture forks route to worker 0's DB (isolated, serial). On any failure return
138
+ # an empty map + an error string — the tool then falls back to the full test set
139
+ # rather than mis-scoring everything as no_coverage.
140
+ def build_coverage_map
141
+ require_relative "coverage_map"
142
+ root = @cfg["project_root"] || Dir.pwd
143
+ cmap = CoverageMap.new(
144
+ source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
145
+ load_paths: Array(@cfg["load_paths"]), project_root: root,
146
+ boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
147
+ ).build_via_fork(after_fork: coverage_after_fork)
148
+ { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files }
149
+ rescue Exception => e # rubocop:disable Lint/RescueException
150
+ @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
151
+ { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
152
+ end
153
+
154
+ # Fork-safety hook for coverage capture: route each capture fork to worker 0's
155
+ # isolated DB (captures run serially, so one worker is enough). Nil when the app
156
+ # has no worker-DB adapter (non-Rails) — capture then runs as before.
157
+ def coverage_after_fork
158
+ return nil unless @worker_db
159
+
160
+ schema = @schema_path
161
+ -> { @worker_db.after_fork(0, schema) }
162
+ end
163
+
164
+ # Fork a child to run one mutant in isolation; decode its exit into a verdict.
165
+ def run_mutant(req)
166
+ timeout = req.fetch("timeout", 30)
167
+ worker = req.fetch("worker", 0)
168
+ pid = fork do
169
+ # New process group so a per-fork timeout can SIGKILL the whole subtree
170
+ # (carries the Phase-1 pgroup discipline), and silence the child's stdout so
171
+ # test-framework output can never corrupt the IPC pipe (KTD-6).
172
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
173
+ $stdout.reopen(File::NULL, "w")
174
+ code =
175
+ begin
176
+ # Route THIS fork at its own worker database before any test loads, so
177
+ # concurrent workers can't clobber each other's fixtures (#26). A routing
178
+ # failure raises here and is scored `error` below, never a false verdict.
179
+ @worker_db&.after_fork(worker, @schema_path)
180
+ apply_payload(req["payload"])
181
+ run_tests(Array(req["tests"]))
182
+ rescue Exception => e # rubocop:disable Lint/RescueException
183
+ @errio.puts("[daemon-child] #{e.class}: #{e.message}")
184
+ 2
185
+ end
186
+ exit!(code)
187
+ end
188
+ verdict = wait_verdict(pid, timeout)
189
+ # A SIGKILLed timeout child skipped its Tempfile unlink — sweep the orphan so
190
+ # it can't outlive the run or trip Zeitwerk on a later fork.
191
+ sweep_temps if verdict == "timeout"
192
+ { "id" => req["id"], "verdict" => verdict }
193
+ end
194
+
195
+ # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
196
+ # SIGKILL path can't run the child's ensure). Mirrors Runner.sweep_orphans.
197
+ def sweep_temps
198
+ @source_dirs.to_a.each do |dir|
199
+ Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
200
+ File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
201
+ end
202
+ end
203
+ end
204
+
205
+ # Single-waiter deadline loop (mirrors Isolation.run and
206
+ # ExternalBackend.wait_with_timeout, re-implemented here because Isolation pulls
207
+ # in Prism which is forbidden app-side). NOTE: this is the 3rd copy of the
208
+ # waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline — a fix to the
209
+ # kill/reap/decode logic must be applied to all three in lockstep. SIGKILL the
210
+ # child's process group past the deadline; a signalled child (nil exitstatus) is
211
+ # `error`.
212
+ def wait_verdict(pid, timeout)
213
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
214
+ loop do
215
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
216
+ if reaped
217
+ return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
218
+ end
219
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
220
+ begin
221
+ Process.kill(:KILL, -pid)
222
+ rescue Errno::ESRCH, Errno::EPERM
223
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
224
+ end
225
+ Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
226
+ return "timeout"
227
+ end
228
+ sleep POLL
229
+ end
230
+ end
231
+
232
+ # Write the tool-built mutated text beside the real source and `load` it —
233
+ # reopening the mutated class/method in THIS child only. It goes in the source
234
+ # file's directory (like Isolation.apply_whole_file) so a `require_relative` in
235
+ # the mutated source resolves against its real neighbours — writing it to the
236
+ # tmpdir would LoadError on such files and score a spurious `error` that
237
+ # diverges from the in-process path. The Zeitwerk hazard (a stray `.rb` in an
238
+ # autoload dir) is handled by the boot/timeout `sweep_temps`, not by relocating
239
+ # the file. Same path for reload (whole file) and redefine (wrapped snippet).
240
+ def apply_payload(payload)
241
+ dir = File.dirname(File.expand_path(payload.fetch("source_file")))
242
+ Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
243
+ f.write(payload.fetch("code"))
244
+ f.flush
245
+ load f.path
246
+ end
247
+ end
248
+
249
+ # Load the covering test files and run them; 0 = all passed (survived),
250
+ # 1 = a failure/error (killed). Minitest only in 2a (rspec is a later unit).
251
+ def run_tests(tests)
252
+ raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"
253
+
254
+ require "minitest"
255
+ require "rails/test_help" if defined?(Rails)
256
+ tests.each { |t| load File.expand_path(t) }
257
+ Minitest.run([]) ? 0 : 1
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "tempfile"
5
+ require_relative "result"
6
+
7
+ module Mutineer
8
+ # Raised when the smoke check (the unmutated suite) is not green, so the run
9
+ # aborts before scoring — a broken environment must never be reported as strong
10
+ # tests. The CLI maps this to a runtime error (exit 1), not a usage error.
11
+ class SmokeCheckError < StandardError; end
12
+
13
+ # #27 (U3): the external execution backend. Runs the user's `--test-command` as a
14
+ # subprocess in the app's OWN runtime (whatever Ruby its bundle resolves to), so
15
+ # mutineer (Ruby >= 3.4) can mutation-test apps pinned to an older Ruby.
16
+ #
17
+ # This is deliberately NOT a `TestRunners` framework adapter: those return an
18
+ # Integer 0/1 from inside a fork and are dispatched by framework name. This is a
19
+ # whole backend — it spawns a process, enforces a wall-clock timeout, and maps
20
+ # the exit status to a Result. The mapping is the SAME direction as in-process
21
+ # (suite passes => survived, suite fails => killed) but coarser: it cannot tell an
22
+ # infrastructure error from a genuine kill, so the smoke check (below) guards the
23
+ # persistent case and the score is disclosed as an upper bound (KTD-3/KTD-6).
24
+ module ExternalBackend
25
+ # Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
26
+ # the full suite). The per-mutant timeout is derived from how long this took.
27
+ SMOKE_TIMEOUT = 900
28
+ # Poll interval for the deadline wait loop. Independent of Isolation's loop —
29
+ # this backend waits on an external process TREE, not an in-process fork.
30
+ POLL = 0.02
31
+
32
+ # Turn a command template into an argv array (no shell → no eval, no
33
+ # injection). The `%{files}` token expands IN PLACE to N separate argv
34
+ # elements — one per path, unescaped — so a path containing a space stays a
35
+ # single argument. It is not a space-joined string.
36
+ #
37
+ # @param command [String] the --test-command template (contains %{files}).
38
+ # @param files [Array<String>] test file paths to substitute.
39
+ # @return [Array<String>] argv.
40
+ def self.build_argv(command, files)
41
+ Shellwords.split(command).flat_map { |tok| tok == "%{files}" ? files : [tok] }
42
+ end
43
+
44
+ # Runs the command for ONE mutant against whatever is currently on disk (the
45
+ # caller has already swapped the mutant in via FileSwap). Maps the outcome to a
46
+ # Result. Env is inherited by the subprocess, so `RAILS_ENV=test mutineer …`
47
+ # reaches the child with no parsing here.
48
+ #
49
+ # @param command [String] the --test-command template.
50
+ # @param files [Array<String>] test file paths.
51
+ # @param timeout [Numeric] per-mutant wall-clock timeout in seconds.
52
+ # @param verbose [Boolean] print the child's captured output on a non-pass.
53
+ # @return [Mutineer::Result]
54
+ def self.run(command, files, timeout:, verbose: false)
55
+ kind, code, output, = spawn_capture(command, files, timeout)
56
+ case kind
57
+ when :timeout
58
+ # A timeout is the one non-pass we flag by default — a normal kill is also
59
+ # a non-zero exit, so notifying on every non-zero would spam every kill.
60
+ warn "[mutineer] test-command exceeded #{timeout}s and was killed (scored timeout)."
61
+ warn output if verbose && !output.empty?
62
+ Result.timeout
63
+ else # :exited
64
+ return Result.survived if code&.zero?
65
+
66
+ warn output if verbose && !output.empty?
67
+ Result.killed
68
+ end
69
+ end
70
+
71
+ # Pre-flight: run the command once against the UNMUTATED tree. Green (exit 0)
72
+ # returns the elapsed seconds (used to calibrate the per-mutant timeout);
73
+ # anything else raises SmokeCheckError so the run aborts before scoring.
74
+ #
75
+ # @param command [String] the --test-command template.
76
+ # @param files [Array<String>] test file paths.
77
+ # @param timeout [Numeric] ceiling for the calibration run.
78
+ # @return [Float] elapsed seconds of the clean run.
79
+ # @raise [Mutineer::SmokeCheckError] when the clean suite is not green.
80
+ def self.smoke_check!(command, files, timeout: SMOKE_TIMEOUT)
81
+ kind, code, output, elapsed = spawn_capture(command, files, timeout)
82
+ return elapsed if kind == :exited && code&.zero?
83
+
84
+ reason =
85
+ if kind == :timeout then "did not finish within #{timeout}s"
86
+ elsif code.nil? then "was terminated by a signal"
87
+ else "exited #{code}"
88
+ end
89
+ detail = output.empty? ? "" : "\n--- last output ---\n#{tail(output)}"
90
+ raise SmokeCheckError,
91
+ "the test command #{reason} against the UNMUTATED source — the " \
92
+ "environment looks broken (check DB, RAILS_ENV, migrations), not the " \
93
+ "tests weak.#{detail}"
94
+ end
95
+
96
+ # Spawns the command to a captured combined-output tempfile, enforces a
97
+ # wall-clock timeout (SIGKILL past the deadline), and returns
98
+ # [kind, exit_code, output, elapsed]. Mirrors Isolation's single-waiter loop:
99
+ # we are the only caller of waitpid on this pid, so the kill can never hit a
100
+ # reaped/recycled pid.
101
+ #
102
+ # @api private
103
+ def self.spawn_capture(command, files, timeout)
104
+ argv = build_argv(command, files)
105
+ # Unreachable in practice (validate_test_command! guarantees a non-empty
106
+ # command with %{files}); a neutral ArgumentError, not the smoke-specific
107
+ # SmokeCheckError, since this is not a smoke-check failure.
108
+ raise ArgumentError, "--test-command produced an empty command" if argv.empty?
109
+
110
+ out = Tempfile.create("mutineer_ext")
111
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
112
+ # `pgroup: true` puts the child in its OWN process group so a timeout can
113
+ # kill the whole tree (`bundle exec rails test` forks parallel workers;
114
+ # spring/bundler add more) — killing only the leader would orphan workers
115
+ # that keep holding the shared DB, corrupting later serial mutants. The
116
+ # explicit [program, argv0] form guarantees the no-shell exec path even for a
117
+ # degenerate single-element argv (Process.spawn(*argv) would route a lone
118
+ # metachar-bearing string through /bin/sh, breaking the argv-only invariant).
119
+ pid = Process.spawn([argv.first, argv.first], *argv[1..], out: out, err: %i[child out], pgroup: true)
120
+ kind, code = wait_with_timeout(pid, timeout)
121
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
122
+ out.rewind
123
+ [kind, code, out.read, elapsed]
124
+ ensure
125
+ if out
126
+ out.close
127
+ File.unlink(out.path) rescue nil # rubocop:disable Style/RescueModifier
128
+ end
129
+ end
130
+
131
+ # Waits for the spawned pid, SIGKILLing its process group past the deadline.
132
+ # Single-waiter deadline loop (mirrors Isolation), so the kill can never hit a
133
+ # reaped/recycled pid.
134
+ #
135
+ # @api private
136
+ # @param pid [Integer] the spawned child pid.
137
+ # @param timeout [Numeric] wall-clock deadline in seconds.
138
+ # @return [Array(Symbol, Integer, nil)] `[:exited, code]` or `[:timeout, nil]`.
139
+ def self.wait_with_timeout(pid, timeout)
140
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
141
+ loop do
142
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
143
+ return [:exited, status.exitstatus] if reaped
144
+
145
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
146
+ # Kill the whole process GROUP (negative pid) so forked test workers die
147
+ # with the leader; fall back to the leader alone if the group is already
148
+ # gone. The child led its group (pgroup: true at spawn).
149
+ begin
150
+ Process.kill(:KILL, -pid)
151
+ rescue Errno::ESRCH, Errno::EPERM
152
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
153
+ end
154
+ Process.waitpid(pid) rescue nil # rubocop:disable Style/RescueModifier
155
+ return [:timeout, nil]
156
+ end
157
+ sleep POLL
158
+ end
159
+ end
160
+
161
+ # Last ~40 lines of captured output, for a smoke-failure message.
162
+ #
163
+ # @api private
164
+ def self.tail(output, lines = 40)
165
+ output.lines.last(lines).join
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mutineer
4
+ # Raised when a source file's backup already exists as FileSwap.with begins —
5
+ # a second mutineer run is racing on the same file (the backup path is shared
6
+ # and unlocked). Aborting beats silently leaving the tree mutated.
7
+ class ConcurrentRunError < StandardError
8
+ def initialize(backup)
9
+ super("a backup already exists at #{backup} — is another mutineer run active " \
10
+ "in this directory? Aborting to avoid corrupting the source file.")
11
+ end
12
+ end
13
+
14
+ # #27 (U2): apply one whole-file mutant to the REAL source path for the external
15
+ # (`--test-command`) backend, and guarantee the original is restored on every
16
+ # exit path. A separate `bundle exec` subprocess has its own VM and cannot see an
17
+ # in-process `load`, so the mutant must live on disk while its suite runs — which
18
+ # makes leaving the file mutated the one genuinely dangerous failure mode.
19
+ #
20
+ # Defense in depth, mirroring the tempfile-orphan discipline
21
+ # (`Runner.sweep_orphans`, `isolation.rb` tempfiles):
22
+ # - the original bytes are held in memory AND written to a sibling backup;
23
+ # - `ensure` restores from memory around every mutant;
24
+ # - the backup survives a SIGKILL (which skips `ensure`), so `restore_orphans`
25
+ # can self-heal a left-mutated tree on the next run's startup.
26
+ # Only one mutant is in flight per file at a time (the external path is serial),
27
+ # so backups never collide.
28
+ module FileSwap
29
+ # Suffix for the on-disk backup; fixed so `restore_orphans` finds it.
30
+ BACKUP_SUFFIX = ".mutineer-backup"
31
+
32
+ # Writes `mutated` to `source_file`, yields, then restores the original bytes
33
+ # on every exit path (normal return, exception, or `ensure`). Byte-exact:
34
+ # binary read/write preserves encoding, newlines, and trailing bytes.
35
+ #
36
+ # @param source_file [String] path to the real source file.
37
+ # @param mutated [String] mutated source text to write for the duration.
38
+ # @yield the block to run while the mutant is on disk.
39
+ # @return [Object] the block's return value.
40
+ def self.with(source_file, mutated)
41
+ backup = source_file + BACKUP_SUFFIX
42
+ # A backup already on disk means either a prior hard-killed run (restore_orphans
43
+ # should have healed it at startup) or a SECOND mutineer run racing us on the
44
+ # same file. The backup path is shared and unlocked, so proceeding would let
45
+ # us capture the other run's mutant AS the "original" and permanently mutate
46
+ # the tree. Refuse loudly rather than silently corrupt — and do it BEFORE
47
+ # `created` is set, so the ensure below never touches a backup we don't own.
48
+ raise ConcurrentRunError, backup if File.exist?(backup)
49
+
50
+ original = File.binread(source_file)
51
+ File.binwrite(backup, original)
52
+ created = true
53
+ File.binwrite(source_file, mutated)
54
+ yield
55
+ ensure
56
+ if created
57
+ File.binwrite(source_file, original)
58
+ File.unlink(backup) if File.exist?(backup)
59
+ end
60
+ end
61
+
62
+ # Startup/after-run self-heal: restore any source file left mutated by a prior
63
+ # interrupted run (a leftover `*.mutineer-backup`), then remove the backup.
64
+ # Prints one line to stderr when it actually heals something, so a developer
65
+ # knows their working tree was auto-restored (a file they did not touch).
66
+ #
67
+ # @param dirs [Array<String>] directories to sweep for orphaned backups.
68
+ # @return [void]
69
+ def self.restore_orphans(dirs)
70
+ healed = 0
71
+ dirs.uniq.each do |dir|
72
+ Dir.glob(File.join(dir, "*#{BACKUP_SUFFIX}")).each do |backup|
73
+ source_file = backup.delete_suffix(BACKUP_SUFFIX)
74
+ backup_bytes = File.binread(backup)
75
+ if !File.exist?(source_file)
76
+ # A real user file that merely ends in our suffix, with no sibling to
77
+ # restore — leave it untouched (never create a file from it).
78
+ next
79
+ elsif File.binread(source_file) == backup_bytes
80
+ # Redundant backup (e.g. a crash between restore and unlink): nothing to
81
+ # heal, just clear the orphan so the next run doesn't see a false race.
82
+ File.unlink(backup)
83
+ else
84
+ File.binwrite(source_file, backup_bytes)
85
+ File.unlink(backup)
86
+ healed += 1
87
+ end
88
+ end
89
+ end
90
+ return if healed.zero?
91
+
92
+ warn "[mutineer] restored #{healed} source file(s) left mutated by a previous interrupted run."
93
+ end
94
+ end
95
+ end