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,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+ require_relative "child_stdout"
6
+ require_relative "orphan_guard"
7
+
8
+ module Mutineer
9
+ # App-side daemon (persistent worker).
10
+ #
11
+ # Runs UNDER THE APP'S OWN BUNDLE/RUBY (the tool's DaemonClient spawns it via
12
+ # `bundle exec ruby`). It boots the app ONCE, then serves per-mutant test-run
13
+ # requests over stdin/stdout as newline-delimited JSON (the protocol keeps a
14
+ # private copy of the original stdout; the app's own stdout goes to stderr).
15
+ # For each request it FORKS a child that loads the mutated source text the
16
+ # tool sent, runs the covering tests, and exits with a status the parent
17
+ # decodes into a verdict.
18
+ #
19
+ # HARD CONSTRAINT: this file must be loadable WITHOUT Prism or the rest of
20
+ # mutineer. The app's Ruby may be < 3.4 (no stdlib Prism) and its bundle has no
21
+ # mutineer. So it requires ONLY stdlib + the app's own boot file; it
22
+ # re-implements the fork/timeout/decode loop rather than requiring
23
+ # `isolation.rb` (which pulls in Prism). All parsing/mutation happened
24
+ # tool-side; the daemon only `load`s text.
25
+ #
26
+ # Protocol (one JSON object per line, both directions):
27
+ # boot in : {"cmd":"boot","project_root":"...","boot":"config/environment","require_paths":[...],
28
+ # "load_paths":["test"],"framework":"minitest","rails":true,"schema":"db/schema.rb"}
29
+ # ready out: {"ready":true,"ruby":"3.3.6"} (or {"ready":false,"error":"..."} then exit)
30
+ # run in : {"id":N,"worker":I,"payload":{"code":"<ruby>","source_file":"app/models/order.rb"},
31
+ # "tests":["test/models/order_test.rb"],"timeout":30}
32
+ # verdict : {"id":N,"verdict":"survived"|"killed"|"error"|"timeout"}
33
+ # quit in : {"cmd":"quit"}
34
+ #
35
+ # Worker isolation: when the app is Rails, each fork is routed to its own
36
+ # database `<db>-<worker>` via {RailsWorkerDb} BEFORE any test loads, so
37
+ # concurrent workers cannot clobber each other's transactional fixtures.
38
+ # `worker` defaults to 0 (serial). SQLite this pass; Postgres provisioning is
39
+ # not yet implemented.
40
+ #
41
+ # Verdict mapping: child exit 0=survived (suite passed), 1=killed (suite
42
+ # failed), 2=error (child raised AROUND the test: load, boot, or worker-DB
43
+ # routing failure); parent-detected timeout. Tagging an in-test DB error (one
44
+ # fired inside a test body, vs at routing time) as `error` rather than
45
+ # `killed` is only observable under the concurrent gate and is not yet
46
+ # implemented.
47
+ module DaemonServer
48
+ # Poll interval (seconds) for the per-fork deadline wait loop.
49
+ POLL = 0.02
50
+
51
+ class << self
52
+ # Serve the protocol on the given IO pair (defaults to stdio). Returns on quit.
53
+ #
54
+ # @param input [IO] request stream.
55
+ # @param output [IO, nil] verdict stream. nil (the default) reserves the
56
+ # process's original stdout for the protocol and points fd 1 at stderr.
57
+ # @param errio [IO] diagnostics stream (never the IPC channel).
58
+ # @return [void]
59
+ def run(input: $stdin, output: nil, errio: $stderr)
60
+ # Only a stream `run` reserved itself is the daemon's to close in a fork.
61
+ @protocol = output ? nil : reserve_protocol_output
62
+ output ||= @protocol
63
+ @errio = errio
64
+ @output = output
65
+ boot_line = input.gets
66
+ return if boot_line.nil? # client vanished before boot
67
+
68
+ boot!(JSON.parse(boot_line.strip))
69
+ output.puts(JSON.generate("ready" => true, "ruby" => RUBY_VERSION))
70
+ output.flush
71
+
72
+ input.each_line do |line|
73
+ line = line.strip
74
+ next if line.empty?
75
+
76
+ begin
77
+ req = JSON.parse(line)
78
+ rescue JSON::ParserError => e
79
+ # A corrupt line has no id to address a reply to (and the client only
80
+ # ever sends valid JSON, so it cannot be a pending request). Log and
81
+ # read on rather than write an unaddressable verdict onto the channel.
82
+ @errio.puts("[daemon] dropped unparseable line: #{e.message}")
83
+ next
84
+ end
85
+ break if req["cmd"] == "quit"
86
+
87
+ # Build the coverage map app-side and ship it to the tool, which then
88
+ # selects covering tests per mutant. One-shot control message.
89
+ if req["cmd"] == "coverage"
90
+ output.puts(JSON.generate(build_coverage_map))
91
+ output.flush
92
+ next
93
+ end
94
+
95
+ output.puts(JSON.generate(run_mutant(req)))
96
+ output.flush
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # Move the protocol channel off fd 1 before the app boots. The app may
103
+ # print through `puts`, `STDOUT`, or a raw fd 1 write (a logger, a
104
+ # subprocess). A line like that ahead of the ready message would break
105
+ # the client's JSON read. The protocol keeps a private dup of the
106
+ # original stdout. Fd 1 then points at stderr, so app output stays
107
+ # visible in the tool's diagnostics.
108
+ #
109
+ # @return [IO] the reserved protocol stream.
110
+ def reserve_protocol_output
111
+ protocol = STDOUT.dup
112
+ STDOUT.reopen(STDERR)
113
+ $stdout = STDOUT
114
+ protocol
115
+ end
116
+
117
+ # BOOT ONCE. chdir + require the app's boot file so the whole app is loaded
118
+ # and inherited by every fork. Never requires mutineer.
119
+ def boot!(cfg)
120
+ @cfg = cfg
121
+ @framework = cfg.fetch("framework", "minitest")
122
+ @source_dirs = Array(cfg["source_dirs"]).map { |d| File.expand_path(d) }
123
+ Dir.chdir(cfg["project_root"]) if cfg["project_root"]
124
+ ENV["RAILS_ENV"] ||= "test" if cfg["rails"]
125
+ Array(cfg["load_paths"]).each { |d| $LOAD_PATH.unshift(File.expand_path(d)) }
126
+ # Start Coverage BEFORE the app loads, so booted source lines are
127
+ # instrumented. The map build (build_via_fork) forks this booted parent.
128
+ if cfg["coverage"]
129
+ require "coverage"
130
+ Coverage.start(lines: true, methods: true)
131
+ end
132
+ # Clear any mutant tempfile a prior SIGKILLed timeout child orphaned in a
133
+ # source dir BEFORE the app boots. Zeitwerk would otherwise choke on the
134
+ # tempfile's non-constant name during autoload setup.
135
+ sweep_temps
136
+ require File.expand_path(cfg["boot"]) if cfg["boot"]
137
+ # The --require files, after the boot as in-process (Runner.execute), so
138
+ # the coverage peek sees what they ran at load.
139
+ Array(cfg["require_paths"]).each { |f| require File.expand_path(f) }
140
+ setup_worker_db(cfg) if cfg["rails"] || defined?(Mongoid::Clients)
141
+ if @framework == "rspec" && !mongoid_worker_db
142
+ raise "RSpec daemon execution currently requires Mongoid"
143
+ end
144
+ rescue Exception => e # rubocop:disable Lint/RescueException
145
+ # Boot failed (bad boot path, app error). Tell the client and exit so it
146
+ # can surface a clean error rather than hang on the handshake.
147
+ @output.puts(JSON.generate("ready" => false, "error" => "#{e.class}: #{e.message}"))
148
+ @output.flush
149
+ exit!(1)
150
+ end
151
+
152
+ # Load the per-worker DB adapter app-side (sibling gem file, by relative path
153
+ # so it bypasses the app bundle, like this daemon itself). Mongoid gets
154
+ # disposable child databases. ActiveRecord retains its SQLite slot adapter.
155
+ # A Mongoid setup failure aborts boot before any specs can run.
156
+ def setup_worker_db(cfg)
157
+ require_relative "rails_worker_db"
158
+ require_relative "mongoid_worker_db"
159
+ @worker_db = if MongoidWorkerDb.available?
160
+ MongoidWorkerDb.new
161
+ elsif RailsWorkerDb.available?
162
+ RailsWorkerDb
163
+ end
164
+ schema = cfg["schema"] && File.expand_path(cfg["schema"])
165
+ @schema_path = schema if schema && File.exist?(schema)
166
+ # Each worker slot is seeded once, on first use (not every mutant fork).
167
+ @slot_ready = {}
168
+ rescue LoadError => e
169
+ @errio.puts("[daemon] worker-DB routing unavailable: #{e.message}")
170
+ @worker_db = nil
171
+ end
172
+
173
+ # Build the coverage map app-side (Coverage was started at boot) and return
174
+ # it as `{map, failed_test_files, load_lines, load_methods}` for the tool to select covering tests.
175
+ # Capture forks route to worker 0's DB (isolated, serial). On any failure
176
+ # return an empty map + an error string. The tool then falls back to the
177
+ # full test set rather than mis-scoring everything as no_coverage.
178
+ def build_coverage_map
179
+ require_relative "coverage_map"
180
+ root = @cfg["project_root"] || Dir.pwd
181
+ cmap = CoverageMap.new(
182
+ source_paths: Array(@cfg["sources"]), test_paths: Array(@cfg["tests"]),
183
+ load_paths: Array(@cfg["load_paths"]), project_root: root,
184
+ boot_path: @cfg["boot"], require_paths: Array(@cfg["require_paths"]),
185
+ framework: @framework, cache_dir: @cfg["cache_dir"] || File.join(root, ".mutineer"),
186
+ capture_timeout: @cfg["capture_timeout"] || CoverageMap::DEFAULT_CAPTURE_TIMEOUT
187
+ ).build_via_fork(after_fork: coverage_after_fork,
188
+ before_fork: mongoid_worker_db&.method(:before_fork),
189
+ cleanup: mongoid_worker_db&.method(:cleanup),
190
+ cache_key: mongoid_worker_db&.coverage_cache_key)
191
+ # Nothing reads Coverage after the map, so a mutant forked from this
192
+ # daemon does not pay for it (#228).
193
+ Coverage.suspend if Coverage.running?
194
+ { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
195
+ "failed_clean_tests" => cmap.failed_clean_tests, "load_lines" => cmap.load_lines.to_a,
196
+ "load_methods" => cmap.load_methods.to_a }
197
+ rescue Exception => e # rubocop:disable Lint/RescueException
198
+ @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
199
+ { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }
200
+ end
201
+
202
+ # Fork-safety hook for coverage capture. Each capture fork drops its copy
203
+ # of the protocol channel (see close_protocol) and, when the app has a
204
+ # worker-DB adapter, routes to a fresh copy of the base DB in worker 0's
205
+ # slot (captures run serially, so one worker is enough).
206
+ def coverage_after_fork
207
+ worker_db = @worker_db
208
+ schema = @schema_path
209
+ lambda do
210
+ close_protocol
211
+ worker_db&.after_fork(0, schema, seed: true)
212
+ end
213
+ end
214
+
215
+ # A forked child never answers on the protocol channel. Closing its
216
+ # inherited copy lets a daemon crash read as EOF on the client at once,
217
+ # not only after a slow or hung child exits. A caller-supplied output
218
+ # (for example STDOUT) is left open.
219
+ def close_protocol
220
+ @protocol&.close rescue nil # rubocop:disable Style/RescueModifier
221
+ end
222
+
223
+ # Fork a child to run one mutant in isolation; decode its exit into a verdict.
224
+ def run_mutant(req)
225
+ timeout = req.fetch("timeout", 30)
226
+ worker = req.fetch("worker", 0)
227
+ # Seed the slot's DB until the first killed/survived fork for this worker slot.
228
+ first_use = @worker_db && !@slot_ready[worker]
229
+ daemon = Process.pid
230
+ mongoid_worker_db&.before_fork
231
+ pid = fork do
232
+ # New process group so a per-fork timeout can SIGKILL the whole subtree,
233
+ # and silence the child's stdout so test output stays out of the diagnostics.
234
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
235
+ OrphanGuard.start(daemon)
236
+ code =
237
+ begin
238
+ ChildStdout.silence
239
+ close_protocol
240
+ # Route THIS fork at its own worker database before any test loads.
241
+ # A routing failure raises here and is scored `error`, never a false verdict.
242
+ @worker_db&.after_fork(worker, first_use ? @schema_path : nil, seed: first_use)
243
+ apply_payload(req["payload"])
244
+ run_tests(Array(req["tests"]))
245
+ rescue Exception => e # rubocop:disable Lint/RescueException
246
+ @errio.puts("[daemon-child] #{e.class}: #{e.message}")
247
+ 2
248
+ end
249
+ exit!(code)
250
+ end
251
+ verdict = wait_verdict(pid, timeout)
252
+ # Mark ready only when the child finished cleanly after seeding
253
+ # (killed/survived). Timeout can interrupt mid-copy; error is a routing
254
+ # failure. Both leave the slot unready so the next fork seeds again.
255
+ @slot_ready[worker] = true if first_use && %w[killed survived].include?(verdict)
256
+ # A SIGKILLed timeout child skipped its Tempfile unlink. Sweep the orphan
257
+ # so it cannot outlive the run or trip Zeitwerk on a later fork.
258
+ sweep_temps if verdict == "timeout"
259
+ { "id" => req["id"], "verdict" => verdict }
260
+ ensure
261
+ mongoid_worker_db&.cleanup(pid) if pid
262
+ end
263
+
264
+ # Mongoid owns disposable child databases; ActiveRecord keeps its existing
265
+ # slot lifecycle. Do not invoke Mongoid cleanup on an ActiveRecord adapter.
266
+ # @return [MongoidWorkerDb, nil]
267
+ def mongoid_worker_db
268
+ @worker_db if defined?(MongoidWorkerDb) && @worker_db.is_a?(MongoidWorkerDb)
269
+ end
270
+
271
+ # Remove orphaned mutant tempfiles from the source dirs (parent-side; the
272
+ # SIGKILL path cannot run the child's ensure). Mirrors JobPlan.sweep_orphans.
273
+ def sweep_temps
274
+ @source_dirs.to_a.each do |dir|
275
+ Dir.glob(File.join(dir, "mutineer_daemon*.rb")).each do |f|
276
+ File.unlink(f) rescue nil # rubocop:disable Style/RescueModifier
277
+ end
278
+ end
279
+ end
280
+
281
+ # Single-waiter deadline loop (mirrors Isolation.run and
282
+ # ExternalBackend.wait_with_timeout, re-implemented here because Isolation
283
+ # pulls in Prism which is forbidden app-side). NOTE: this is the 3rd copy of
284
+ # the waitpid2(WNOHANG)+deadline+pgroup-SIGKILL+decode discipline. A fix to
285
+ # the kill/reap/decode logic must be applied to all three in lockstep.
286
+ # CoverageMap#await_child applies the same deadline and group kill to
287
+ # coverage capture.
288
+ # SIGKILL the child's process group past the deadline; a signalled child
289
+ # (nil exitstatus) is `error`.
290
+ def wait_verdict(pid, timeout)
291
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
292
+ loop do
293
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
294
+ if reaped
295
+ return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
296
+ end
297
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
298
+ begin
299
+ Process.kill(:KILL, -pid)
300
+ rescue Errno::ESRCH, Errno::EPERM
301
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
302
+ end
303
+ begin
304
+ _reaped, status = Process.waitpid2(pid)
305
+ if status && status.exited? && !status.signaled?
306
+ return { 0 => "survived", 1 => "killed" }.fetch(status.exitstatus, "error")
307
+ end
308
+ rescue Errno::ECHILD
309
+ # already reaped
310
+ end
311
+ return "timeout"
312
+ end
313
+ sleep POLL
314
+ end
315
+ end
316
+
317
+ # Write the tool-built mutated text beside the real source and `load` it,
318
+ # reopening the mutated class/method in THIS child only. It goes in the
319
+ # source file's directory (like Isolation.apply_whole_file) so a
320
+ # `require_relative` in the mutated source resolves against its real
321
+ # neighbours. Writing it to the tmpdir would LoadError on such files and
322
+ # score a spurious `error` that diverges from the in-process path. The
323
+ # Zeitwerk hazard (a stray `.rb` in an autoload dir) is handled by the
324
+ # boot/timeout `sweep_temps`, not by relocating the file. Same path for
325
+ # reload (whole file) and redefine (wrapped snippet).
326
+ def apply_payload(payload)
327
+ dir = File.dirname(File.expand_path(payload.fetch("source_file")))
328
+ Tempfile.create(["mutineer_daemon", ".rb"], dir) do |f|
329
+ f.write(payload.fetch("code"))
330
+ f.flush
331
+ load f.path
332
+ end
333
+ end
334
+
335
+ # Load the covering test files and run them; 0 = all passed (survived),
336
+ # 1 = a failure/error (killed). RSpec uses the existing fork-safe runner.
337
+ def run_tests(tests)
338
+ if @framework == "rspec"
339
+ require_relative "mongoid_failures"
340
+ return MongoidFailures.run(tests)
341
+ end
342
+ raise "unsupported framework #{@framework.inspect}" unless @framework == "minitest"
343
+
344
+ require "minitest"
345
+ require "rails/test_help" if defined?(Rails)
346
+ tests.each { |t| load File.expand_path(t) }
347
+ Minitest.run([]) ? 0 : 1
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,288 @@
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
+ # External execution backend. Runs the user's `--test-command` as a subprocess
14
+ # in the app's own runtime (whatever Ruby its bundle resolves to), so mutineer
15
+ # (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.
24
+ #
25
+ # Child environment: bundler/gem env injected by Mutineer's Ruby is stripped, and
26
+ # version-manager concrete version bins (e.g. `~/.rbenv/versions/3.4.9/bin`) are
27
+ # removed from PATH so shims / `.ruby-version` can select the app's Ruby (#32).
28
+ module ExternalBackend
29
+ # Generous ceiling for the one-off smoke/calibration run (a cold app boot plus
30
+ # the full suite). The per-mutant timeout is derived from how long this took.
31
+ SMOKE_TIMEOUT = 900
32
+ # Poll interval for the deadline wait loop. Independent of Isolation's loop —
33
+ # this backend waits on an external process TREE, not an in-process fork.
34
+ POLL = 0.02
35
+
36
+ # PATH entries that pin a concrete Ruby under a version manager (ahead of
37
+ # shims). Optional trailing slash; normal bins are left alone.
38
+ VERSION_BIN_PATH = %r{
39
+ (?:
40
+ /\.rbenv/versions/[^/]+/bin
41
+ |/\.asdf/installs/ruby/[^/]+/bin
42
+ |/\.rubies/[^/]+/bin
43
+ |/rubies/[^/]+/bin
44
+ )/?\z
45
+ }x
46
+
47
+ # Env keys Process.spawn must unset (nil value) so Mutineer's Ruby/bundler
48
+ # context does not leak. Spawn merges env onto the parent; omitting a key
49
+ # does NOT clear it.
50
+ CLEAR_ENV_KEYS = %w[
51
+ BUNDLER_VERSION RBENV_VERSION ASDF_RUBY_VERSION RBENV_DIR
52
+ ].freeze
53
+
54
+ # Turn a command template into an argv array (no shell → no eval, no
55
+ # injection). The <tt>%{files}</tt> token expands IN PLACE to N separate argv
56
+ # elements — one per path, unescaped — so a path containing a space stays a
57
+ # single argument. It is not a space-joined string.
58
+ #
59
+ # @param command [String] the --test-command template (contains <tt>%{files}</tt>).
60
+ # @param files [Array<String>] test file paths to substitute.
61
+ # @return [Array<String>] argv.
62
+ def self.build_argv(command, files)
63
+ Shellwords.split(command).flat_map { |tok| tok == "%{files}" ? files : [tok] }
64
+ end
65
+
66
+ # Environment delta for the test-command child. Process.spawn merges this
67
+ # onto the parent env: set a key to +nil+ to unset it. Scrubs Mutineer/bundler
68
+ # injection and version-manager PATH pins so shims / `.ruby-version` can win.
69
+ # Keeps other vars (e.g. `RAILS_ENV`) so setting them on the Mutineer command
70
+ # still reaches the suite.
71
+ #
72
+ # @api private
73
+ # @return [Hash{String => String, nil}] env for Process.spawn.
74
+ def self.child_env
75
+ env = {}
76
+ cleared_pin = false
77
+ ENV.each_key do |k|
78
+ next unless clear_env_key?(k)
79
+
80
+ env[k] = nil
81
+ cleared_pin = true
82
+ end
83
+ path = ENV["PATH"].to_s.split(File::PATH_SEPARATOR)
84
+ kept = path.reject { |p| p.match?(VERSION_BIN_PATH) }
85
+ scrubbed_bin = kept.size != path.size
86
+ # Only reorder PATH when we scrubbed a pin; otherwise leave the user's PATH alone.
87
+ path = if cleared_pin || scrubbed_bin
88
+ (version_manager_shim_dirs + kept).uniq
89
+ else
90
+ kept
91
+ end
92
+ env["PATH"] = path.join(File::PATH_SEPARATOR)
93
+ env
94
+ end
95
+
96
+ # True when the key must be unset in the child (bundler/gem/version-manager).
97
+ #
98
+ # @api private
99
+ # @param key [String] environment variable name.
100
+ # @return [Boolean]
101
+ def self.clear_env_key?(key)
102
+ key.start_with?("BUNDLE_", "RUBY", "GEM_") || CLEAR_ENV_KEYS.include?(key)
103
+ end
104
+
105
+ # Existing shim directories for rbenv / asdf (chruby uses PATH without shims).
106
+ #
107
+ # @api private
108
+ # @return [Array<String>] absolute shim paths that exist.
109
+ def self.version_manager_shim_dirs
110
+ home = ENV["HOME"]
111
+ return [] if home.nil? || home.empty?
112
+
113
+ [
114
+ File.join(home, ".rbenv", "shims"),
115
+ File.join(home, ".asdf", "shims")
116
+ ].select { |d| File.directory?(d) }
117
+ end
118
+
119
+ # Runs the command for ONE mutant against whatever is currently on disk (the
120
+ # caller has already swapped the mutant in via FileSwap). Maps the outcome to a
121
+ # Result. Uses {child_env} so version-manager PATH pins from Mutineer's Ruby do
122
+ # not force the suite onto the wrong interpreter.
123
+ #
124
+ # @param command [String] the --test-command template.
125
+ # @param files [Array<String>] test file paths.
126
+ # @param timeout [Numeric] per-mutant wall-clock timeout in seconds.
127
+ # @param verbose [Boolean] print the child's captured output on a non-pass.
128
+ # @return [Mutineer::Result]
129
+ def self.run(command, files, timeout:, verbose: false)
130
+ kind, code, output, = spawn_capture(command, files, timeout)
131
+ case kind
132
+ when :timeout
133
+ # A timeout is the one non-pass we flag by default — a normal kill is also
134
+ # a non-zero exit, so notifying on every non-zero would spam every kill.
135
+ warn "[mutineer] test-command exceeded #{timeout}s and was killed (scored timeout)."
136
+ warn output if verbose && !output.empty?
137
+ Result.timeout
138
+ else # :exited
139
+ return Result.survived if code&.zero?
140
+ # Signal death (nil exitstatus) is infrastructure, not a suite assertion
141
+ # failure — match Isolation/daemon (error), do not inflate kill rate.
142
+ if code.nil?
143
+ warn output if verbose && !output.empty?
144
+ return Result.error("test-command terminated by signal")
145
+ end
146
+
147
+ warn output if verbose && !output.empty?
148
+ Result.killed
149
+ end
150
+ end
151
+
152
+ # Pre-flight: run the command once against the UNMUTATED tree. Green (exit 0)
153
+ # returns the elapsed seconds (used to calibrate the per-mutant timeout);
154
+ # anything else raises SmokeCheckError so the run aborts before scoring.
155
+ #
156
+ # @param command [String] the --test-command template.
157
+ # @param files [Array<String>] test file paths.
158
+ # @param timeout [Numeric] ceiling for the calibration run.
159
+ # @return [Float] elapsed seconds of the clean run.
160
+ # @raise [Mutineer::SmokeCheckError] when the clean suite is not green.
161
+ def self.smoke_check!(command, files, timeout: SMOKE_TIMEOUT)
162
+ kind, code, output, elapsed = spawn_capture(command, files, timeout)
163
+ return elapsed if kind == :exited && code&.zero?
164
+
165
+ reason =
166
+ if kind == :timeout then "did not finish within #{timeout}s"
167
+ elsif code.nil? then "was terminated by a signal"
168
+ else "exited #{code}"
169
+ end
170
+ detail = output.empty? ? "" : "\n--- last output ---\n#{tail(output)}"
171
+ hint = ruby_version_mismatch_hint(output)
172
+ raise SmokeCheckError,
173
+ "the test command #{reason} against the UNMUTATED source — " \
174
+ "#{hint || generic_env_hint}.#{detail}"
175
+ end
176
+
177
+ # Generic smoke-failure framing when no Ruby-version mismatch is detected.
178
+ #
179
+ # @api private
180
+ # @return [String]
181
+ def self.generic_env_hint
182
+ "the unmutated suite is not green (failing tests, or a broken environment: " \
183
+ "DB, RAILS_ENV, migrations) — fix that before scoring"
184
+ end
185
+
186
+ # Targeted hint when Bundler reports a RubyVersionMismatch (common under
187
+ # rbenv/asdf/chruby when Mutineer's version bin sits ahead of shims on PATH).
188
+ #
189
+ # @api private
190
+ # @param output [String] captured child stdout+stderr.
191
+ # @return [String, nil] hint text, or nil when not a version mismatch.
192
+ def self.ruby_version_mismatch_hint(output)
193
+ return nil if output.nil? || output.empty?
194
+ return nil unless output.match?(/RubyVersionMismatch|Your Ruby version is .* but your Gemfile specified/i)
195
+
196
+ ran = output[/Your Ruby version is ([0-9.]+)/i, 1]
197
+ want = output[/Gemfile specified ([0-9.]+)/i, 1]
198
+ parts = +"Detected a Ruby version mismatch"
199
+ parts << ": the test command ran under #{ran}" if ran
200
+ parts << " but the app expects #{want}" if want
201
+ parts << ". Mutineer already scrubbed version-manager bins and RBENV_VERSION/" \
202
+ "ASDF_RUBY_VERSION from the child env; the suite still picked the wrong " \
203
+ "Ruby. Use a wrapper that re-selects the app Ruby and ensure " \
204
+ ".ruby-version / .tool-versions is present " \
205
+ "(see README: Apps on Ruby < 3.4 → Under a version manager)"
206
+ parts
207
+ end
208
+
209
+ # Spawns the command to a captured combined-output tempfile, enforces a
210
+ # wall-clock timeout (SIGKILL past the deadline), and returns
211
+ # [kind, exit_code, output, elapsed]. Mirrors Isolation's single-waiter loop:
212
+ # we are the only caller of waitpid on this pid, so the kill can never hit a
213
+ # reaped/recycled pid.
214
+ #
215
+ # @api private
216
+ def self.spawn_capture(command, files, timeout)
217
+ argv = build_argv(command, files)
218
+ # Unreachable in practice (validate_test_command! guarantees a non-empty
219
+ # command with %{files}); a neutral ArgumentError, not the smoke-specific
220
+ # SmokeCheckError, since this is not a smoke-check failure.
221
+ raise ArgumentError, "--test-command produced an empty command" if argv.empty?
222
+
223
+ out = Tempfile.create("mutineer_ext")
224
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
+ # `pgroup: true` puts the child in its OWN process group so a timeout can
226
+ # kill the whole tree (`bundle exec rails test` forks parallel workers;
227
+ # spring/bundler add more) — killing only the leader would orphan workers
228
+ # that keep holding the shared DB, corrupting later serial mutants. The
229
+ # explicit [program, argv0] form guarantees the no-shell exec path even for a
230
+ # degenerate single-element argv (Process.spawn(*argv) would route a lone
231
+ # metachar-bearing string through /bin/sh, breaking the argv-only invariant).
232
+ pid = Process.spawn(child_env, [argv.first, argv.first], *argv[1..],
233
+ out: out, err: %i[child out], pgroup: true)
234
+ kind, code = wait_with_timeout(pid, timeout)
235
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
236
+ out.rewind
237
+ [kind, code, out.read, elapsed]
238
+ ensure
239
+ if out
240
+ out.close
241
+ File.unlink(out.path) rescue nil # rubocop:disable Style/RescueModifier
242
+ end
243
+ end
244
+
245
+ # Waits for the spawned pid, SIGKILLing its process group past the deadline.
246
+ # Single-waiter deadline loop (mirrors Isolation), so the kill can never hit a
247
+ # reaped/recycled pid.
248
+ #
249
+ # @api private
250
+ # @param pid [Integer] the spawned child pid.
251
+ # @param timeout [Numeric] wall-clock deadline in seconds.
252
+ # @return [Array(Symbol, Integer, nil)] `[:exited, code]` or `[:timeout, nil]`.
253
+ def self.wait_with_timeout(pid, timeout)
254
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
255
+ loop do
256
+ reaped, status = Process.waitpid2(pid, Process::WNOHANG)
257
+ return [:exited, status.exitstatus] if reaped
258
+
259
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
260
+ # Kill the whole process GROUP (negative pid) so forked test workers die
261
+ # with the leader; fall back to the leader alone if the group is already
262
+ # gone. The child led its group (pgroup: true at spawn).
263
+ begin
264
+ Process.kill(:KILL, -pid)
265
+ rescue Errno::ESRCH, Errno::EPERM
266
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
267
+ end
268
+ begin
269
+ _reaped, status = Process.waitpid2(pid)
270
+ # Finished cleanly between poll and kill — honor real exit code.
271
+ return [:exited, status.exitstatus] if status && status.exited? && !status.signaled?
272
+ rescue Errno::ECHILD
273
+ # already reaped
274
+ end
275
+ return [:timeout, nil]
276
+ end
277
+ sleep POLL
278
+ end
279
+ end
280
+
281
+ # Last ~40 lines of captured output, for a smoke-failure message.
282
+ #
283
+ # @api private
284
+ def self.tail(output, lines = 40)
285
+ output.lines.last(lines).join
286
+ end
287
+ end
288
+ end