mutineer 0.11.2 → 0.11.3

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.
@@ -12,21 +12,23 @@ require_relative "test_runners"
12
12
 
13
13
  module Mutineer
14
14
  # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
15
- # the tests that actually exercise its line. Built once (Phase A), then queried
16
- # per mutant (Phase B via #tests_for). Persisted to .mutineer/coverage.json with
17
- # a content-based digest that rebuilds the map whenever any tracked file changes.
15
+ # the tests that actually exercise its line. Built once, then queried per
16
+ # mutant via #tests_for. Persisted to .mutineer/coverage.json with a
17
+ # content-based digest that rebuilds the map whenever any tracked file changes.
18
18
  #
19
- # Keys are "file:line" strings (relative to project_root) everywhere in
20
- # memory and on disk so load/save needs no key transformation (KTD4).
19
+ # Keys are "file:line" strings (relative to project_root) everywhere, in
20
+ # memory and on disk, so load/save needs no key transformation.
21
21
  class CoverageMap
22
- DEFAULT_CAPTURE_TIMEOUT = 120 # seconds, per coverage subprocess (R3)
22
+ # Seconds per coverage subprocess before the parent kills it.
23
+ DEFAULT_CAPTURE_TIMEOUT = 120
23
24
 
24
25
  attr_reader :project_root, :failed_test_files, :phase_a_ran, :map
25
26
 
26
- # Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the map
27
- # app-side and ships `map` + `failed_test_files` over IPC; the tool reconstructs it
28
- # here for per-mutant selection). Skips the capture machinery entirely — only the
29
- # three fields #tests_for / #method_uncapturable? read are set (U7).
27
+ # Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
28
+ # map app-side and ships `map` + `failed_test_files` over IPC; the tool
29
+ # reconstructs it here for per-mutant selection). Skips the capture machinery
30
+ # entirely: only the three fields #tests_for / #method_uncapturable? read are
31
+ # set.
30
32
  #
31
33
  # @param map [Hash] the "file:line" => [test_files] map.
32
34
  # @param failed_test_files [Array<String>] test files whose capture failed.
@@ -58,14 +60,14 @@ module Mutineer
58
60
  @phase_a_ran = false
59
61
  end
60
62
 
61
- # Phase A entry point (standalone): load the cached map when the content
62
- # digest matches, otherwise rebuild from subprocesses and overwrite the cache.
63
+ # Standalone entry: load the cached map when the content digest matches,
64
+ # otherwise rebuild from subprocesses and overwrite the cache.
63
65
  def build_or_load
64
66
  warn_external_sources
65
67
  cached_or { run_phase_a }
66
68
  end
67
69
 
68
- # Boot-mode Phase A: Coverage is already running in the parent (started before
70
+ # Boot-mode build: Coverage is already running in the parent (started before
69
71
  # the app booted, so booted source lines are instrumented). A clean `ruby`
70
72
  # subprocess has no booted env, so per-test coverage is captured by FORKING
71
73
  # the booted parent instead. Inverts into the same map #tests_for reads, and
@@ -76,25 +78,25 @@ module Mutineer
76
78
  cached_or { run_phase_a_via_fork(after_fork: after_fork) }
77
79
  end
78
80
 
79
- # Phase B lookup: the test files that cover `file:line`, or [] when none do.
80
- # ponytail: per-file granularity; upgrade to per-method when throughput
81
- # warrants (requires Minitest method isolation + finer Coverage tracking).
81
+ # Lookup: the test files that cover `file:line`, or [] when none do.
82
+ # Per-file granularity; upgrade to per-method when throughput warrants
83
+ # (requires Minitest method isolation + finer Coverage tracking).
82
84
  def tests_for(file, line)
83
85
  @map["#{relativize(file)}:#{line}"] || []
84
86
  end
85
87
 
86
- # #9: is this source file's empty coverage the result of an *errored* capture
87
- # rather than a genuine coverage gap? True iff (KTD-2) some capture failed this
88
- # run AND this file got zero coverage from any successful capture AND a failed
88
+ # Is this source file's empty coverage the result of an *errored* capture
89
+ # rather than a genuine coverage gap? True iff some capture failed this run
90
+ # AND this file got zero coverage from any successful capture AND a failed
89
91
  # test file maps to it by the standard _test/_spec naming convention. Derived
90
- # purely from already-persisted state (@map keys + @failed_test_files); no rerun,
91
- # no new cached field, no digest change.
92
+ # purely from already-persisted state (@map keys + @failed_test_files); no
93
+ # rerun, no new cached field, no digest change.
92
94
  #
93
- # ponytail: file-level, convention-based attribution. A line covered only by a
94
- # failed test in an otherwise-covered file stays no_coverage (condition 2), and
95
- # a source with no naming-convention test match is never tainted. Upgrade path:
96
- # persist per-file coverage per successful run and diff against the failed set,
97
- # or record test->source targets explicitly. Not needed for the #8/#9 cases.
95
+ # File-level, convention-based attribution. A line covered only by a failed
96
+ # test in an otherwise-covered file stays no_coverage (condition 2), and a
97
+ # source with no naming-convention test match is never tainted. Upgrade path:
98
+ # persist per-file coverage per successful run and diff against the failed
99
+ # set, or record test->source targets explicitly.
98
100
  def uncapturable_source?(file)
99
101
  return false if @failed_test_files.empty?
100
102
 
@@ -104,14 +106,14 @@ module Mutineer
104
106
  failed_test_targets.include?(File.basename(rel, ".rb"))
105
107
  end
106
108
 
107
- # #25: per-METHOD taint. A mutant on a line whose enclosing method got zero
109
+ # Per-method taint. A mutant on a line whose enclosing method got zero
108
110
  # successful coverage, in a file a failed sibling test targets, is
109
- # :uncapturable (the capture that would have covered it errored) NOT a
111
+ # :uncapturable (the capture that would have covered it errored), NOT a
110
112
  # genuine gap. A method with any covered line means its uncovered lines are a
111
113
  # real :no_coverage. A failed capture emits no coverage, so per-line intent is
112
- # unknowable; method-range + successful coverage is the finest derivable signal.
113
- # Fully-failed files behave exactly as uncapturable_source? did (every method
114
- # range has zero coverage), so #8/#9/#19 behavior is unchanged.
114
+ # unknowable; method-range + successful coverage is the finest derivable
115
+ # signal. Fully-failed files behave exactly as uncapturable_source? did
116
+ # (every method range has zero coverage).
115
117
  #
116
118
  # @param file [String] source file path.
117
119
  # @param line_range [Range] 1-based enclosing-method line range.
@@ -155,7 +157,7 @@ module Mutineer
155
157
  self
156
158
  end
157
159
 
158
- # Runs standalone Phase A coverage capture.
160
+ # Runs standalone coverage capture.
159
161
  #
160
162
  # @api private
161
163
  def run_phase_a
@@ -171,11 +173,11 @@ module Mutineer
171
173
  end
172
174
  end
173
175
 
174
- # Boot-mode Phase A. For each test file, fork the booted parent; the child
176
+ # Boot-mode capture. For each test file, fork the booted parent; the child
175
177
  # resets its Coverage delta, runs that ONE test, and marshals back the raw
176
178
  # per-source coverage counts. record() inverts them exactly as the subprocess
177
- # path does. ponytail: serial fork (one test at a time) boot apps fork
178
- # cheaply via COW and per-test isolation matters more than throughput here.
179
+ # path does. Serial fork (one test at a time): boot apps fork cheaply via COW
180
+ # and per-test isolation matters more than throughput here.
179
181
  def run_phase_a_via_fork(after_fork:)
180
182
  @phase_a_ran = true
181
183
  @map = {}
@@ -183,9 +185,9 @@ module Mutineer
183
185
  abs_sources = abs_source_paths
184
186
 
185
187
  @test_paths.each do |test_path|
186
- # Tri-state payload (KTD-1): Hash = coverage, String = error diagnostic
187
- # from the child, nil = pipe gone / empty.
188
- # ponytail/#9: this String diagnostic is what #9 turns into an :uncapturable status.
188
+ # Tri-state payload: Hash = coverage, String = error diagnostic from the
189
+ # child, nil = pipe gone / empty. The String diagnostic is what becomes
190
+ # an :uncapturable status.
189
191
  case (coverage = fork_capture(absolute(test_path), abs_sources, after_fork))
190
192
  when Hash then record(coverage, test_path)
191
193
  when String
@@ -201,7 +203,7 @@ module Mutineer
201
203
  # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
202
204
  def fork_capture(abs_test, abs_sources, after_fork)
203
205
  rd, wr = IO.pipe
204
- # #19: Marshal output is binary an un-binmoded pipe can raise
206
+ # Marshal output is binary: an un-binmoded pipe can raise
205
207
  # Encoding::UndefinedConversionError on write, which the child's rescue then
206
208
  # swallows, losing the real error and yielding a bare "no result".
207
209
  rd.binmode
@@ -210,9 +212,9 @@ module Mutineer
210
212
  rd.close
211
213
  payload =
212
214
  begin
213
- # Fork-safety hook: the in-process path reconnects AR; the daemon routes
214
- # to its worker DB. Nil (non-Rails) = no-op. Injected so this file needs
215
- # neither Runner (Prism) nor Rails.
215
+ # Fork-safety hook: the in-process path reconnects AR; the daemon
216
+ # routes to its worker DB. Nil (non-Rails) = no-op. Injected so this
217
+ # file needs neither Runner (Prism) nor Rails.
216
218
  after_fork&.call
217
219
  Coverage.result(clear: true, stop: false) # discard pre-test delta
218
220
  TestRunners.for(@framework).run([abs_test])
@@ -222,7 +224,7 @@ module Mutineer
222
224
  .select { |f, _| abs_sources.include?(f) }
223
225
  .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
224
226
  rescue Exception => e # rubocop:disable Lint/RescueException
225
- # KTD-1: stringify (an arbitrary Exception may not marshal); the parent
227
+ # Stringify (an arbitrary Exception may not marshal); the parent
226
228
  # surfaces this under --verbose. A String marshals safely over the pipe.
227
229
  "#{e.class}: #{e.message}#{e.backtrace&.first ? " @ #{e.backtrace.first}" : ''}"
228
230
  end
@@ -239,7 +241,7 @@ module Mutineer
239
241
  data = rd.read
240
242
  rd.close
241
243
  _, status = Process.waitpid2(pid)
242
- # #19: an empty pipe means the child died before writing (e.g. a hard crash,
244
+ # An empty pipe means the child died before writing (e.g. a hard crash,
243
245
  # OOM, or a signal from the test's own subprocess handling). Report HOW it
244
246
  # died (exit status / signal) as a diagnostic string so --verbose has
245
247
  # something actionable instead of a silent "no result".
@@ -266,8 +268,8 @@ module Mutineer
266
268
 
267
269
  # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
268
270
  # miss already-loaded app lines, so Coverage must start in a clean process
269
- # before any source is loaded (KTD1/KTD2). Returns the parsed Coverage.result
270
- # hash, or nil when the subprocess failed (logged + skipped per R6).
271
+ # before any source is loaded. Returns the parsed Coverage.result hash, or
272
+ # nil when the subprocess failed (logged + skipped).
271
273
  def capture(test_path)
272
274
  out = +""
273
275
  status = nil
@@ -275,8 +277,8 @@ module Mutineer
275
277
  stdin.write(subprocess_script(test_path))
276
278
  stdin.close
277
279
  reader = Thread.new { out << stdout.read }
278
- # R3: bound the subprocess with a wall clock a hanging test file must
279
- # not wedge the whole run before any per-mutant timeout.
280
+ # Bound the subprocess with a wall clock: a hanging test file must not
281
+ # wedge the whole run before any per-mutant timeout.
280
282
  unless wait_thr.join(@capture_timeout)
281
283
  Process.kill(:KILL, wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
282
284
  reader.kill
@@ -374,7 +376,7 @@ module Mutineer
374
376
  rel_test = relativize(test_path)
375
377
  coverage.each do |abs_file, data|
376
378
  rel = relativize(abs_file)
377
- next if rel.start_with?("/") # outside project_root not our source
379
+ next if rel.start_with?("/") # outside project_root: not our source
378
380
 
379
381
  counts = data.is_a?(Array) ? data : data["lines"]
380
382
  counts.each_with_index do |count, idx|
@@ -385,7 +387,7 @@ module Mutineer
385
387
  end
386
388
  end
387
389
 
388
- # R4: digest each file's ROLE + relative path + content length + content, plus
390
+ # Digest each file's ROLE + relative path + content length + content, plus
389
391
  # the load_paths. Without role/path/length delimiters the digest collides
390
392
  # (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
391
393
  # accepting a stale cached map.
@@ -426,7 +428,7 @@ module Mutineer
426
428
  end
427
429
  end
428
430
 
429
- # R7: a configured source that resolves outside project_root would silently be
431
+ # A configured source that resolves outside project_root would silently be
430
432
  # dropped (its coverage relativizes to an absolute path). Warn instead.
431
433
  def warn_external_sources
432
434
  @source_paths.each do |p|
@@ -452,7 +454,7 @@ module Mutineer
452
454
 
453
455
  JSON.parse(File.read(cache_path))
454
456
  rescue JSON::ParserError
455
- nil # corrupt cache rebuild from scratch
457
+ nil # corrupt cache: rebuild from scratch
456
458
  end
457
459
 
458
460
  # Saves the coverage cache.
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+ require_relative "result"
5
+ require_relative "coverage_map"
6
+ require_relative "daemon_client"
7
+ # No require_relative "runner" on purpose: runner.rb requires this file, and the
8
+ # reverse edge makes Ruby warn "circular require considered harmful" on every -w
9
+ # load. Runner is loaded first on every real path; requiring this file alone leaves
10
+ # it undefined. Rationale and the real fix: #75.
11
+
12
+ module Mutineer
13
+ # Daemon execution backend. Boots the app ONCE in a persistent subprocess under
14
+ # the app's own bundle and forks per mutant, so a Rails run pays the boot cost
15
+ # once instead of per mutant. Tool-side this only discovers jobs and builds the
16
+ # ready-to-`load` payload (Prism); the daemon needs no Prism/mutineer.
17
+ #
18
+ # When jobs > 1 each worker runs against its OWN database, which is what makes
19
+ # `--jobs N` safe under Rails (#26): parallel verdicts are identical to serial.
20
+ #
21
+ # Job collection, `--since` filtering and coverage selection stay on {Runner} and
22
+ # are called from here, so the daemon path can never drift from the in-process
23
+ # path on which mutants run or which tests narrow a mutant (score parity).
24
+ #
25
+ # Unlike {ExternalBackend}, which is a leaf {Runner} calls into, this module owns
26
+ # its orchestration and calls back for that shared vocabulary.
27
+ module DaemonBackend
28
+ # Default per-mutant timeout on the daemon path (seconds), overridden by
29
+ # config.daemon_timeout. Coverage narrowing usually keeps each job short; this
30
+ # still covers a slow suite or full-suite fallback when the map is unavailable.
31
+ # Named like its in-process counterpart {Isolation::DEFAULT_TIMEOUT}, not like
32
+ # {ExternalBackend::SMOKE_TIMEOUT}, which bounds a different thing.
33
+ DEFAULT_TIMEOUT = 60
34
+
35
+ # The daemon's per-mutant tempfile, written into the source dir so
36
+ # require_relative resolves. Kept in step with DaemonServer#sweep_temps.
37
+ DAEMON_TEMP_GLOB = "mutineer_daemon*.rb"
38
+
39
+ # Full daemon run: collect jobs, build the coverage map once, then execute
40
+ # serially or across N worker daemons. Fail-fast forces serial so the survivor
41
+ # set matches jobs 1.
42
+ #
43
+ # @param config [Mutineer::Config] run configuration (daemon set).
44
+ # @param operator_classes [Array<Class>] resolved operators.
45
+ # @return [Array(Mutineer::AggregateResult, Hash<String,String>)] aggregate and source map.
46
+ def self.execute(config, operator_classes)
47
+ jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
48
+ jobs = Runner.filter_since(jobs, source_map, config) if config.since
49
+ abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
50
+
51
+ # Nothing to mutate (`--since` matched no changed line, or every mutant is
52
+ # suppressed). Return before booting anything: the coverage daemon and the
53
+ # worker daemons below each boot the whole app, and README documents
54
+ # `--since origin/<base>` for PR CI, where a docs-only PR is routine.
55
+ if jobs.empty?
56
+ # The daemon sweeps orphaned temps at boot and nothing boots here, so sweep
57
+ # tool-side. A file a hard-killed run left in app/models breaks the app's own
58
+ # Zeitwerk boot, not just Mutineer's next run.
59
+ Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB)
60
+ return [AggregateResult.new(ignored_results), source_map]
61
+ end
62
+
63
+ # Build the coverage map once (app-side). nil when the build fails: runners
64
+ # fall back to the full --test set (and emit a stderr warning) rather than
65
+ # mis-scoring everything as no_coverage.
66
+ coverage_map = build_coverage_map(config, abs_tests)
67
+
68
+ # Worker count = resolved --jobs, capped at the job count (no idle daemons).
69
+ # >1 → N concurrent daemon handles, each on its OWN worker DB (N-handles, the
70
+ # spike-proven shape). 1 → the serial single-daemon path. --fail-fast forces
71
+ # serial: parallel's stop flag fires on the first survivor by WALL-CLOCK, not
72
+ # input index, so the verdict set would diverge from serial (a different,
73
+ # non-deterministic survivor set/score). The "identical to --jobs 1" guarantee
74
+ # below only holds when fail-fast cannot race.
75
+ worker_count = [config.jobs || 1, 1].max
76
+ worker_count = 1 if config.fail_fast
77
+ worker_count = [worker_count, jobs.size].min
78
+
79
+ results =
80
+ if worker_count > 1
81
+ run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
82
+ else
83
+ run_serial(jobs, config, abs_tests, coverage_map, source_map)
84
+ end
85
+
86
+ [AggregateResult.new(results + ignored_results), source_map]
87
+ end
88
+
89
+ # Build the coverage map via a short-lived daemon (boots the app once, captures
90
+ # per-test coverage app-side, ships the map back). Returns a query-only
91
+ # CoverageMap, or nil when the build fails / returns empty. Callers then run the
92
+ # full --test set. Coverage-build IPC has no wall-clock (same limitation as
93
+ # in-process build_via_fork). A normal nonempty map scores like in-process;
94
+ # nil falls back to the full suite (more testing, not comparable).
95
+ #
96
+ # @param config [Mutineer::Config] the run config.
97
+ # @param abs_tests [Array<String>] absolute --test paths.
98
+ # @return [Mutineer::CoverageMap, nil]
99
+ def self.build_coverage_map(config, abs_tests)
100
+ client = DaemonClient.new(boot: boot_config(config, abs_tests, coverage: true),
101
+ app_root: config.project_root).start
102
+ data = begin
103
+ client.coverage
104
+ ensure
105
+ client.quit
106
+ end
107
+ unless data && !(data["map"] || {}).empty?
108
+ reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
109
+ warn_coverage_fallback(reason)
110
+ return nil
111
+ end
112
+
113
+ CoverageMap.from_data(map: data["map"], failed_test_files: data["failed_test_files"] || [],
114
+ project_root: config.project_root)
115
+ rescue DaemonBootError => e
116
+ warn_coverage_fallback("#{e.class}: #{e.message}")
117
+ nil
118
+ end
119
+
120
+ # Stderr note when daemon coverage is unavailable (full --test set per mutant).
121
+ #
122
+ # @api private
123
+ # @param reason [String] short cause (boot error message, empty map, …).
124
+ # @return [void]
125
+ def self.warn_coverage_fallback(reason = "unknown")
126
+ warn "[mutineer] daemon coverage map unavailable (#{reason}); running every " \
127
+ "mutant against the full --test set (score not comparable to an in-process run)."
128
+ end
129
+ private_class_method :warn_coverage_fallback
130
+
131
+ # Serial path: one daemon (worker 0), one mutant at a time. Honors --fail-fast
132
+ # (stop at the first survivor).
133
+ #
134
+ # @api private
135
+ # @return [Array<Mutineer::Result>] results in input order.
136
+ def self.run_serial(jobs, config, abs_tests, coverage_map, source_map)
137
+ client = DaemonClient.new(boot: boot_config(config, abs_tests),
138
+ app_root: config.project_root).start
139
+ results = []
140
+ begin
141
+ jobs.each_with_index do |job, i|
142
+ r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
143
+ results << r
144
+ break if config.fail_fast && r.survived?
145
+ end
146
+ ensure
147
+ client.quit
148
+ end
149
+ results
150
+ end
151
+
152
+ # Parallel path: N daemon handles, each pinned to its own worker slot (own DB).
153
+ # A shared queue of job indices feeds N tool-side threads; results are placed by
154
+ # input index so the verdict set matches serial. Callers must not pass fail_fast
155
+ # here ({execute} forces serial for fail-fast). Per-mutant crashes are classified
156
+ # in {job_result}, shared with the serial path; a {DaemonBootError} ends the run
157
+ # here rather than scoring the remainder against a daemon that has given up.
158
+ #
159
+ # @api private
160
+ # @return [Array<Mutineer::Result>] one result per input job, in input order.
161
+ def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
162
+ results = Array.new(jobs.size)
163
+ queue = Queue.new
164
+ jobs.each_index { |i| queue << i }
165
+
166
+ # Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
167
+ # can still quit the daemons already up. Array.new would lose every reference.
168
+ clients = []
169
+ begin
170
+ worker_count.times do
171
+ clients << DaemonClient.new(boot: boot_config(config, abs_tests),
172
+ app_root: config.project_root).start
173
+ end
174
+ rescue StandardError
175
+ clients.each(&:quit)
176
+ raise
177
+ end
178
+
179
+ clients.each_with_index.map do |client, worker|
180
+ Thread.new do
181
+ # The abort below is re-raised by join and reported once there; without
182
+ # this Ruby also dumps the thread's backtrace, which the serial path never
183
+ # does. Same fault, same output, whatever --jobs is set to.
184
+ Thread.current.report_on_exception = false
185
+ loop do
186
+ i = begin
187
+ queue.pop(true)
188
+ rescue ThreadError
189
+ break
190
+ end
191
+ results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
192
+ end
193
+ rescue DaemonBootError
194
+ # The daemon gave up for good. Stop feeding the other workers rather
195
+ # than letting them score the rest of the run against a dead client;
196
+ # Thread#join re-raises this and ends the run.
197
+ queue.clear
198
+ raise
199
+ ensure
200
+ client.quit
201
+ end
202
+ end.each(&:join)
203
+
204
+ # Every job was popped by some worker and every pop assigns, so no slot can
205
+ # be nil here: an escaping exception aborts the run via join instead.
206
+ results
207
+ end
208
+
209
+ # Build the payload for one job, run it on the given daemon/worker, and attach
210
+ # the subject/mutation/id. Shared body of both daemon paths, so `--jobs 1` and
211
+ # `--jobs N` classify an identical fault identically.
212
+ #
213
+ # Error model, in one place because both paths call this: a crash while running
214
+ # ONE mutant is {DaemonClient}'s business: it respawns and answers `"error"`.
215
+ # Nothing is caught here on purpose — anything reaching this far is either
216
+ # {DaemonBootError}, which must end the run, or a defect, which must stay visible.
217
+ #
218
+ # @param job [Array(Mutineer::Subject, Mutineer::Mutation, String)] the work item.
219
+ # @param req_id [Integer] request id (echoed back for IPC ordering safety).
220
+ # @param client [Mutineer::DaemonClient] the daemon handle to run on.
221
+ # @param worker [Integer] the worker slot (→ worker DB) this daemon routes to.
222
+ # @api private
223
+ # @raise [Mutineer::DaemonBootError] when the daemon has given up; ends the run.
224
+ # @return [Mutineer::Result] the decorated result.
225
+ def self.job_result(job, req_id, client, worker, config, coverage_map, abs_tests, source_map)
226
+ subject, mutation, id = job
227
+ source = source_map[subject.file]
228
+ mutated = mutation.apply(source)
229
+ # Skip an invalid mutant tool-side: never ship a payload that would fail to
230
+ # load and read as a false `killed`.
231
+ # Narrow to covering tests (shared with the in-process path via
232
+ # Runner.coverage_selection, so scores match). :verdict = no_coverage/uncapturable,
233
+ # no fork. No map (build failed) → run the full --test set (fallback, not
234
+ # narrowed).
235
+ sel = coverage_map && Runner.coverage_selection(subject.file, mutation, subject, source, coverage_map)
236
+ r =
237
+ if Parser.parse_string(mutated).errors.any?
238
+ Result.skipped
239
+ elsif sel && sel[0] == :verdict
240
+ sel[1]
241
+ else
242
+ verdict = client.request(
243
+ id: req_id, worker: worker, timeout: config.daemon_timeout || DEFAULT_TIMEOUT,
244
+ payload: { "code" => mutated, "source_file" => File.expand_path(subject.file, config.project_root) },
245
+ tests: sel ? sel[1] : abs_tests
246
+ )
247
+ result_for(verdict)
248
+ end
249
+ r.with(subject: subject, mutation: mutation, id: id)
250
+ end
251
+
252
+ # The boot config the daemon needs to boot the app once: where to boot, the test
253
+ # load roots (so `require "test_helper"` resolves in every fork), framework, and
254
+ # whether this is Rails.
255
+ #
256
+ # @param config [Mutineer::Config] the run config.
257
+ # @param abs_tests [Array<String>] absolute --test paths.
258
+ # @param coverage [Boolean] whether this daemon builds the coverage map.
259
+ # @return [Hash] the boot config shipped to the daemon.
260
+ def self.boot_config(config, abs_tests, coverage: false)
261
+ {
262
+ project_root: config.project_root,
263
+ boot: File.expand_path(config.boot || "config/environment", config.project_root),
264
+ load_paths: Runner.test_load_roots(abs_tests),
265
+ source_dirs: Runner.source_dirs(config), # so the daemon can sweep orphan mutant temps
266
+ framework: config.framework,
267
+ rails: config.rails,
268
+ # Schema for per-worker DB isolation. Sent when present; the daemon
269
+ # skips worker-DB schema loading if the path is absent (e.g. structure.sql apps).
270
+ schema: schema_path(config),
271
+ # Coverage narrowing. Only the short-lived map-building daemon starts
272
+ # Coverage (before boot); worker daemons boot with it OFF (no wasted
273
+ # instrumentation/memory across every mutant fork). `sources`/`tests` are the
274
+ # map-build inputs.
275
+ coverage: coverage,
276
+ sources: config.sources.map { |s| File.expand_path(s, config.project_root) },
277
+ tests: abs_tests
278
+ }
279
+ end
280
+
281
+ # Absolute path to the app's `db/schema.rb` if it exists, else nil. Used by the
282
+ # daemon to schema-load each fork's isolated worker database. Only `schema.rb`
283
+ # is supported this pass; `structure.sql` apps get nil and fall back to
284
+ # whatever the worker DB already holds.
285
+ #
286
+ # @param config [Mutineer::Config] the run config.
287
+ # @api private
288
+ # @return [String, nil] absolute schema path or nil.
289
+ def self.schema_path(config)
290
+ path = File.expand_path("db/schema.rb", config.project_root)
291
+ File.exist?(path) ? path : nil
292
+ end
293
+
294
+ # Map a daemon verdict string to a Result. The daemon reports the four
295
+ # run-time states it can decide; pre-fork states (skipped/no_coverage/…) are
296
+ # resolved tool-side before a request is ever sent.
297
+ #
298
+ # @param verdict [String] the daemon's verdict word.
299
+ # @api private
300
+ # @return [Mutineer::Result] the matching result.
301
+ def self.result_for(verdict)
302
+ case verdict
303
+ when "survived" then Result.survived
304
+ when "killed" then Result.killed
305
+ when "timeout" then Result.timeout
306
+ else Result.error("daemon verdict: #{verdict}")
307
+ end
308
+ end
309
+
310
+ # The module's contract is {execute} (the backend entry point) plus the two the
311
+ # tests drive directly: {boot_config} from the zero-dep suite and
312
+ # {build_coverage_map} from the daemon suite. Everything else is daemon-pipeline
313
+ # internals with no caller outside this file.
314
+ private_class_method :run_serial, :run_parallel, :job_result, :schema_path, :result_for
315
+ end
316
+ end