mutineer 1.4.0 → 1.6.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +475 -0
  3. data/README.md +224 -28
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +117 -20
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +533 -100
  8. data/lib/mutineer/daemon_backend.rb +51 -33
  9. data/lib/mutineer/daemon_client.rb +212 -22
  10. data/lib/mutineer/daemon_server.rb +74 -27
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/file_swap.rb +1 -1
  13. data/lib/mutineer/isolation.rb +92 -6
  14. data/lib/mutineer/job_plan.rb +352 -0
  15. data/lib/mutineer/kill_channel.rb +284 -0
  16. data/lib/mutineer/kill_matrix.rb +88 -0
  17. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  18. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  19. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  20. data/lib/mutineer/minitest_integration.rb +75 -11
  21. data/lib/mutineer/mutant_id.rb +5 -4
  22. data/lib/mutineer/mutators/array_literal.rb +0 -7
  23. data/lib/mutineer/mutators/base.rb +63 -4
  24. data/lib/mutineer/mutators/chain_link.rb +0 -7
  25. data/lib/mutineer/mutators/collection_method.rb +1 -1
  26. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  27. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  28. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  29. data/lib/mutineer/mutators/return_nil.rb +5 -11
  30. data/lib/mutineer/mutators/string_literal.rb +1 -1
  31. data/lib/mutineer/orphan_guard.rb +28 -0
  32. data/lib/mutineer/pairing.rb +222 -15
  33. data/lib/mutineer/parser.rb +12 -3
  34. data/lib/mutineer/project.rb +344 -21
  35. data/lib/mutineer/rails_worker_db.rb +83 -9
  36. data/lib/mutineer/reporter.rb +306 -53
  37. data/lib/mutineer/result.rb +73 -5
  38. data/lib/mutineer/runner.rb +123 -242
  39. data/lib/mutineer/statement_lines.rb +183 -0
  40. data/lib/mutineer/subject.rb +6 -2
  41. data/lib/mutineer/test_runners/minitest.rb +4 -2
  42. data/lib/mutineer/test_runners/rspec.rb +282 -3
  43. data/lib/mutineer/test_runners.rb +5 -3
  44. data/lib/mutineer/version.rb +1 -1
  45. data/lib/mutineer/worker_pool.rb +4 -2
  46. data/lib/mutineer.rb +2 -0
  47. metadata +10 -3
@@ -9,7 +9,9 @@ require "set"
9
9
  require_relative "minitest_integration"
10
10
  require_relative "test_runners"
11
11
  require_relative "child_stdout"
12
+ require_relative "orphan_guard"
12
13
  require_relative "project_path"
14
+ require_relative "pairing"
13
15
 
14
16
  module Mutineer
15
17
  # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
@@ -27,8 +29,36 @@ module Mutineer
27
29
  # the parent. Stdout stays free for test output, which goes to File::NULL.
28
30
  RESULT_FD = 3
29
31
 
32
+ # Seconds a capture waits for its result after the child exits in time.
33
+ # The result is already in the pipe then; the wait only lets the reader
34
+ # thread run, even when the capture deadline has passed (#129).
35
+ RESULT_GRACE = 1
36
+
30
37
  attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
31
38
 
39
+ # The source lines that ran while the app booted or the sources and the
40
+ # `--require` files loaded, before any test ran, as a Set of "file:line"
41
+ # keys like {#map}'s (#187, #217).
42
+ # A mutant on such a line can change a value computed before the mutant was
43
+ # applied, so its verdict is not trusted (`ran_at_load`).
44
+ #
45
+ # @return [Set<String>]
46
+ attr_reader :load_lines
47
+
48
+ # The source methods that were called while the app booted or the sources
49
+ # and the `--require` files loaded, as a Set of "file:line:column" keys of
50
+ # their `def` (#209). Line coverage cannot tell this for a one-line or
51
+ # endless method: its `def` line counts when the method is defined.
52
+ #
53
+ # @return [Set<String>]
54
+ attr_reader :load_methods
55
+
56
+ # Seconds each test file took to capture, keyed by project-relative path
57
+ # (#203). {#order_tests} runs the cheaper files first.
58
+ #
59
+ # @return [Hash{String => Float}]
60
+ attr_reader :timings
61
+
32
62
  # Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
33
63
  # map app-side and ships `map` + `failed_test_files` over IPC; the tool
34
64
  # reconstructs it here for per-mutant selection). Skips the capture machinery
@@ -39,10 +69,18 @@ module Mutineer
39
69
  # @param failed_test_files [Array<String>] test files whose capture failed.
40
70
  # @param project_root [String] project root (for path relativization).
41
71
  # @param failed_clean_tests [Array<String>] test files whose unmutated run failed.
72
+ # @param load_lines [Array<String>, Set<String>] "file:line" keys that ran at load ({#load_lines}).
73
+ # @param load_methods [Array<String>, Set<String>] "file:line:column" keys of methods called at load
74
+ # ({#load_methods}).
75
+ # @param timings [Hash{String => Float}] capture seconds per test file ({#timings}).
42
76
  # @return [Mutineer::CoverageMap] a query-only map.
43
- def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [])
77
+ def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [], load_lines: [],
78
+ load_methods: [], timings: {})
44
79
  instance = allocate
45
80
  instance.instance_variable_set(:@map, map || {})
81
+ instance.instance_variable_set(:@timings, timings || {})
82
+ instance.instance_variable_set(:@load_lines, Set.new(load_lines || []))
83
+ instance.instance_variable_set(:@load_methods, Set.new(load_methods || []))
46
84
  instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
47
85
  instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
48
86
  instance.instance_variable_set(:@project_root, project_root)
@@ -52,8 +90,9 @@ module Mutineer
52
90
  def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
53
91
  load_paths: ["lib"], project_root: Dir.pwd,
54
92
  capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
55
- framework: "minitest", verbose: false)
93
+ framework: "minitest", verbose: false, require_paths: [])
56
94
  @source_paths = Array(source_paths)
95
+ @require_paths = Array(require_paths)
57
96
  @test_paths = Array(test_paths)
58
97
  @cache_dir = cache_dir
59
98
  @load_paths = Array(load_paths)
@@ -65,6 +104,9 @@ module Mutineer
65
104
  @map = {}
66
105
  @failed_test_files = []
67
106
  @failed_clean_tests = []
107
+ @load_lines = Set.new
108
+ @load_methods = Set.new
109
+ @timings = {}
68
110
  @loaded_dependencies = {}
69
111
  @phase_a_ran = false
70
112
  end
@@ -82,8 +124,15 @@ module Mutineer
82
124
  # the booted parent instead. Inverts into the same map #tests_for reads, and
83
125
  # reuses the digest cache (the digest mixes in the boot file so a boot cache
84
126
  # never collides with a standalone one).
127
+ #
128
+ # The lines that ran during boot are read here on every run, cache or not:
129
+ # the boot runs every time, and no test is credited with those lines.
85
130
  def build_via_fork(after_fork: nil)
86
131
  warn_external_sources
132
+ # Matched by real path in #record_load: a Coverage key is the path as
133
+ # required, which can differ from the configured one (/var, /private/var).
134
+ booted = Coverage.running? ? Coverage.peek_result : {}
135
+ record_load(booted.transform_values { |v| v.is_a?(Hash) ? load_entry(v[:lines], v[:methods]) : v })
87
136
  cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
88
137
  end
89
138
 
@@ -94,12 +143,63 @@ module Mutineer
94
143
  @map["#{relativize(file)}:#{line}"] || []
95
144
  end
96
145
 
146
+ # The covering `tests` of a mutant in `file`, in the order a mutant run
147
+ # loads them (#203): the files {Pairing.infer_tests} pairs with `file`
148
+ # first, then the cheapest by {#timings}. A file with no timing comes
149
+ # after the timed ones, and the path breaks a tie, so the same cache gives
150
+ # the same order on every run. Costs compare in doubling buckets
151
+ # (`log2(seconds - fastest + 1)`), so files of close cost keep their path
152
+ # order when a rebuild measures them again. Taking off the fastest
153
+ # captured file's time removes the startup cost every standalone capture
154
+ # pays; a failed capture is timed again on each run, so it does not count.
155
+ # A run that stops at the first failure then reaches a fast killing test
156
+ # before a slow file uses up the timeout.
157
+ #
158
+ # @param file [String] the mutated source file path.
159
+ # @param tests [Array<String>] project-relative test paths from {#tests_for}.
160
+ # @return [Array<String>] the same paths, reordered.
161
+ def order_tests(file, tests)
162
+ rel = relativize(absolute(file))
163
+ @paired ||= {}
164
+ paired = @paired[rel] ||= Pairing.infer_tests(rel, project_root: @project_root,
165
+ prefer: @framework || "minitest")
166
+ base = @timings.except(*@failed_test_files).values.min || 0
167
+ tests.sort_by do |t|
168
+ cost = @timings[t]
169
+ [paired.include?(t) ? 0 : 1, cost ? Math.log2(cost - base + 1).floor : Float::INFINITY, t]
170
+ end
171
+ end
172
+
173
+ # True when `file:line` ran while the app booted or the sources loaded
174
+ # (see {#load_lines}).
175
+ #
176
+ # @param file [String] source file path.
177
+ # @param line [Integer] 1-based line.
178
+ # @return [Boolean]
179
+ def ran_at_load?(file, line)
180
+ @load_lines.include?("#{relativize(file)}:#{line}")
181
+ end
182
+
183
+ # True when the method whose `def` starts at `line` and `column` of `file`
184
+ # was called while the app booted or the sources loaded (see {#load_methods}).
185
+ #
186
+ # @param file [String] source file path.
187
+ # @param line [Integer] 1-based line of the `def`.
188
+ # @param column [Integer] 0-based byte column of the `def`.
189
+ # @return [Boolean]
190
+ def method_ran_at_load?(file, line, column)
191
+ @load_methods.include?("#{relativize(file)}:#{line}:#{column}")
192
+ end
193
+
97
194
  # Is this source file's empty coverage the result of an *errored* capture
98
195
  # rather than a genuine coverage gap? True iff some capture failed this run
99
196
  # AND this file got zero coverage from any successful capture AND a failed
100
197
  # test file maps to it by the _test/_spec/test_ naming convention. Derived
101
- # purely from already-persisted state (@map keys + @failed_test_files); no
102
- # rerun, no new cached field, no digest change.
198
+ # from already-persisted state (@map keys + @failed_test_files). A split
199
+ # name checks the longer source files that existed when that check first
200
+ # ran for this map. Adding or deleting one of those files changes the
201
+ # coverage digest, so the next run captures again instead of retargeting
202
+ # a stored failure.
103
203
  #
104
204
  # File-level, convention-based attribution. A line covered only by a failed
105
205
  # test in an otherwise-covered file stays no_coverage (condition 2), and a
@@ -112,7 +212,7 @@ module Mutineer
112
212
  rel = relativize(absolute(file))
113
213
  return false if covered_source_files.include?(rel)
114
214
 
115
- failed_test_targets.include?(File.basename(rel, ".rb"))
215
+ failed_test_blames?(rel)
116
216
  end
117
217
 
118
218
  # Per-method taint. A mutant on a line whose enclosing method got zero
@@ -131,7 +231,7 @@ module Mutineer
131
231
  return false if @failed_test_files.empty?
132
232
 
133
233
  rel = relativize(absolute(file))
134
- return false unless failed_test_targets.include?(File.basename(rel, ".rb"))
234
+ return false unless failed_test_blames?(rel)
135
235
 
136
236
  line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
137
237
  end
@@ -143,17 +243,119 @@ module Mutineer
143
243
  @map.keys.map { |k| k.rpartition(":").first }.to_set
144
244
  end
145
245
 
146
- # Basenames of the sources that failed test files pair with by convention:
147
- # a trailing _test/_spec is stripped first, as pairing tries that form first.
148
- def failed_test_targets
149
- @failed_test_files.map do |t|
150
- name = File.basename(t, ".rb")
151
- case name
152
- when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
153
- when "test_helper" then name # Minitest's support file pairs with no source
154
- else name.delete_prefix("test_")
246
+ # True when a failed test file pairs with `source_rel` by convention.
247
+ # Exact `_test` / `_spec` / `test_` names match by basename, as before.
248
+ # A split `<name>_*_test.rb` matches only in the mirrored test directory,
249
+ # and only when no longer source file owns that name (#87). The answer
250
+ # is kept for this map, so a later delete does not move the failure.
251
+ #
252
+ # @param source_rel [String] project-relative source path.
253
+ # @return [Boolean]
254
+ def failed_test_blames?(source_rel)
255
+ @blame_for ||= {}
256
+ return @blame_for[source_rel] if @blame_for.key?(source_rel)
257
+
258
+ @blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
259
+ end
260
+
261
+ # True when a failed exact `_test` / `_spec` / `test_` file pairs with
262
+ # this source. An `app/` or `lib/` source only accepts a `test/` or
263
+ # `spec/` file from its mirrored directory, so
264
+ # `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
265
+ # Any other source still matches by basename. A fixture under
266
+ # `test/fixtures/` and an explicit test both name their source that way.
267
+ #
268
+ # @param source_rel [String] project-relative source path.
269
+ # @return [Boolean]
270
+ def exact_failed_test?(source_rel)
271
+ name = File.basename(source_rel, ".rb")
272
+ base, lib = Pairing.logical_path(source_rel)
273
+ @failed_test_files.any? do |test_path|
274
+ next false unless failed_test_target_name(test_path) == name
275
+
276
+ rel = relativize(test_path)
277
+ dir = File.dirname(rel)
278
+ if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
279
+ mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
280
+ else
281
+ true
155
282
  end
156
- end.to_set
283
+ end
284
+ end
285
+
286
+ # True when pairing gives this source a mirrored test directory.
287
+ #
288
+ # @param source_rel [String] project-relative source path.
289
+ # @return [Boolean]
290
+ def app_or_lib_source?(source_rel)
291
+ source_rel.start_with?("app/", "lib/")
292
+ end
293
+
294
+ # Basename a failed test file would pair with, before the directory check.
295
+ #
296
+ # @param test_path [String]
297
+ # @return [String]
298
+ def failed_test_target_name(test_path)
299
+ name = File.basename(test_path, ".rb")
300
+ case name
301
+ when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
302
+ when "test_helper" then name
303
+ else name.delete_prefix("test_")
304
+ end
305
+ end
306
+
307
+ # True when `dir` is `test`, `spec`, or a directory under one of them.
308
+ #
309
+ # @param dir [String] project-relative directory.
310
+ # @return [Boolean]
311
+ def conventional_test_dir?(dir)
312
+ dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
313
+ end
314
+
315
+ # True when a failed split test file pairs with this source. Directory and
316
+ # the longer-source check match {Pairing}, so a test outside the mirror, or
317
+ # one owned by `user_session.rb`, does not taint `user.rb`.
318
+ #
319
+ # @param source_rel [String] project-relative source path.
320
+ # @return [Boolean]
321
+ def split_failed_test?(source_rel)
322
+ base, lib = Pairing.logical_path(source_rel)
323
+ name = File.basename(base)
324
+ @failed_test_files.any? do |t|
325
+ test_rel = relativize(t)
326
+ entry = File.basename(test_rel)
327
+ next false unless Pairing.split_entry?(entry, name)
328
+ next false unless mirrored_test_dir?(test_rel, base, lib)
329
+ next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
330
+
331
+ true
332
+ end
333
+ end
334
+
335
+ # True when `test_rel` sits in a mirrored test directory for `base`.
336
+ #
337
+ # @param test_rel [String] project-relative test path.
338
+ # @param base [String] logical source path without extension.
339
+ # @param lib [Boolean] whether the source originated from lib/.
340
+ # @return [Boolean]
341
+ def mirrored_test_dir?(test_rel, base, lib)
342
+ dirs = [Pairing.mirror_dir("test", base)]
343
+ dirs << Pairing.mirror_dir("test/lib", base) if lib
344
+ dirs.include?(File.dirname(test_rel))
345
+ end
346
+
347
+ # True when `test_rel` sits in a mirrored spec directory for `base`.
348
+ # Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
349
+ # file uses this directory.
350
+ #
351
+ # @param test_rel [String] project-relative test path.
352
+ # @param base [String] logical source path without extension.
353
+ # @param lib [Boolean] whether the source originated from lib/.
354
+ # @return [Boolean]
355
+ def spec_mirror?(test_rel, base, lib)
356
+ dirs = [Pairing.mirror_dir("spec", base)]
357
+ dirs << Pairing.mirror_dir("spec/lib", base) if lib
358
+ dirs.include?(File.dirname(test_rel))
157
359
  end
158
360
 
159
361
  # Shared cache dance for both build paths: hit the digest-keyed cache, else
@@ -166,8 +368,18 @@ module Mutineer
166
368
  def cached_or(after_fork: nil)
167
369
  @digest = compute_digest
168
370
  cached = read_cache
169
- if cached && cached["digest"] == @digest && dependencies_match?(cached)
371
+ # A cache from before load methods (#209) or test timings (#203) were
372
+ # saved rebuilds once. Boot mode reads its load lines and methods from
373
+ # the live boot instead (#build_via_fork), but its map from before #209
374
+ # lacks the `def` lines of the methods each test called.
375
+ if cached && cached["digest"] == @digest && dependencies_match?(cached) &&
376
+ cached.key?("load_methods") && cached.key?("timings")
170
377
  @map = cached["map"] || {}
378
+ @timings = cached["timings"] || {}
379
+ unless @boot_path
380
+ @load_lines = Set.new(cached["load_lines"])
381
+ @load_methods = Set.new(cached["load_methods"])
382
+ end
171
383
  @failed_test_files = cached["failed_test_files"] || []
172
384
  @failed_clean_tests = []
173
385
  @loaded_dependencies = cached["dependencies"] || {}
@@ -191,12 +403,15 @@ module Mutineer
191
403
  def run_phase_a
192
404
  @phase_a_ran = true
193
405
  @map = {}
406
+ @load_lines = Set.new
407
+ @load_methods = Set.new
408
+ @timings = {}
194
409
  @failed_test_files = []
195
410
  @failed_clean_tests = []
196
411
  @loaded_dependencies = {}
197
412
 
198
413
  @test_paths.each do |test_path|
199
- payload = capture(test_path)
414
+ payload = timed(test_path) { capture(test_path) }
200
415
  next unless payload
201
416
 
202
417
  accept_capture_payload(test_path, payload)
@@ -211,51 +426,83 @@ module Mutineer
211
426
  def run_phase_a_via_fork(after_fork:)
212
427
  @phase_a_ran = true
213
428
  @map = {}
429
+ @timings = {}
214
430
  @failed_test_files = []
215
431
  @failed_clean_tests = []
216
432
  @loaded_dependencies = {}
217
433
  abs_sources = abs_source_paths
218
434
 
219
435
  @test_paths.each do |test_path|
220
- # Tri-state payload: Hash = capture result, String = error diagnostic from
221
- # the child, nil = pipe gone / empty. The String diagnostic is what
222
- # becomes an :uncapturable status.
223
- case (payload = fork_capture(absolute(test_path), abs_sources, after_fork))
224
- when Hash then accept_capture_payload(test_path, payload)
225
- when String
226
- fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
227
- "fork capture produced no result (re-run with --verbose for the error)")
228
- else fail_test(test_path, "fork capture produced no result")
229
- end
436
+ accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
437
+ end
438
+ end
439
+
440
+ # Runs the block, a capture of `test_path`, and records its wall-clock
441
+ # seconds in {#timings}. The parent times the whole capture, so the
442
+ # numbers compare files, not exact test cost.
443
+ #
444
+ # @api private
445
+ # @param test_path [String] test file path.
446
+ # @yieldreturn [Object] the capture result.
447
+ # @return [Object] the block's value.
448
+ def timed(test_path)
449
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
450
+ value = yield
451
+ @timings[relativize(test_path)] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
452
+ value
453
+ end
454
+
455
+ # Records a {#fork_capture} result. Hash = capture result, String = error
456
+ # diagnostic from the child, :timeout = the capture deadline passed, nil =
457
+ # pipe gone / empty. The String diagnostic is what becomes an
458
+ # :uncapturable status.
459
+ #
460
+ # @api private
461
+ # @param test_path [String] test file path.
462
+ # @param payload [Hash, String, Symbol, nil] what {#fork_capture} returned.
463
+ # @return [void]
464
+ def accept_fork_payload(test_path, payload)
465
+ case payload
466
+ when Hash then accept_capture_payload(test_path, payload)
467
+ when :timeout then fail_test(test_path, "timed out after #{@capture_timeout}s")
468
+ when String
469
+ fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
470
+ "fork capture produced no result (re-run with --verbose for the error)")
471
+ else fail_test(test_path, "fork capture produced no result")
230
472
  end
231
473
  end
232
474
 
233
475
  # Fork the booted parent, run one test under the inherited Coverage, and
234
- # return its per-source counts hash (or nil on failure). Reuses the same
476
+ # return its per-source counts hash, a String diagnostic on failure, or
477
+ # :timeout after `@capture_timeout` (see {#await_child}). Reuses the same
235
478
  # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
236
479
  def fork_capture(abs_test, abs_sources, after_fork)
480
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
237
481
  rd, wr = IO.pipe
238
482
  # Marshal output is binary: an un-binmoded pipe can raise
239
483
  # Encoding::UndefinedConversionError on write, which the child's rescue then
240
484
  # swallows, losing the real error and yielding a bare "no result".
241
485
  rd.binmode
242
486
  wr.binmode
487
+ parent = Process.pid
243
488
  pid = fork do
244
489
  rd.close
490
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
491
+ OrphanGuard.start(parent)
245
492
  payload =
246
493
  begin
247
494
  ChildStdout.silence
248
495
  # Fork-safety hook: the in-process path reconnects AR; the daemon
249
- # routes to its worker DB. Nil (non-Rails) = no-op. Injected so this
250
- # file needs neither Runner (Prism) nor Rails.
496
+ # drops its protocol channel and routes to its worker DB. Nil =
497
+ # no-op. Injected so this file needs neither Runner (Prism) nor Rails.
251
498
  after_fork&.call
252
499
  Coverage.result(clear: true, stop: false) # discard pre-test delta
253
500
  passed = TestRunners.for(@framework).run([abs_test]).zero?
254
- # lines:true yields {file => {lines: [...]}}; reduce to the counts
501
+ # {file => {lines: [...], methods: {...}}}; reduce to the counts
255
502
  # array record() expects, keeping only our source files.
256
503
  coverage = Coverage.result(stop: false)
257
504
  .select { |f, _| abs_sources.include?(f) }
258
- .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
505
+ .transform_values { |v| v.is_a?(Hash) ? lines_with_called_defs(v) : v }
259
506
  { "passed" => passed, "coverage" => coverage,
260
507
  "loaded_files" => capture_loaded_files }
261
508
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -273,18 +520,22 @@ module Mutineer
273
520
  end
274
521
  end
275
522
  wr.close
276
- data = rd.read
277
- rd.close
278
- _, status = Process.waitpid2(pid)
523
+ # Marshal.load reads exactly one object, not until EOF: a process that the
524
+ # test leaves running (even outside the group) can hold the pipe open.
525
+ status, payload = await_child(pid, deadline) { read_marshal(rd) }
526
+ return :timeout unless status
527
+
279
528
  # An empty pipe means the child died before writing (e.g. a hard crash,
280
529
  # OOM, or a signal from the test's own subprocess handling). Report HOW it
281
530
  # died (exit status / signal) as a diagnostic string so --verbose has
282
531
  # something actionable instead of a silent "no result".
283
- return "child wrote no result (#{describe_status(status)})" if data.empty?
532
+ return "child wrote no result (#{describe_status(status)})" if payload.nil?
284
533
 
285
- Marshal.load(data)
534
+ payload
286
535
  rescue StandardError => e
287
536
  "parent could not read capture result: #{e.class}: #{e.message}"
537
+ ensure
538
+ [rd, wr].compact.each { |io| io.close unless io.closed? }
288
539
  end
289
540
 
290
541
  # Human description of a child Process::Status for capture diagnostics.
@@ -356,6 +607,7 @@ module Mutineer
356
607
  end
357
608
 
358
609
  record_loaded(payload["loaded_files"])
610
+ record_load(payload["load_coverage"])
359
611
 
360
612
  unless payload["passed"]
361
613
  @failed_clean_tests << relativize(test_path)
@@ -401,16 +653,9 @@ module Mutineer
401
653
  pending.each do |rel|
402
654
  test_path = @test_paths.find { |t| relativize(t) == rel } || rel
403
655
  if @boot_path
404
- payload = fork_capture(absolute(test_path), abs_sources, after_fork)
405
- case payload
406
- when Hash then accept_capture_payload(test_path, payload)
407
- when String
408
- fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
409
- "fork capture produced no result (re-run with --verbose for the error)")
410
- else fail_test(test_path, "fork capture produced no result")
411
- end
656
+ accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
412
657
  else
413
- payload = capture(test_path)
658
+ payload = timed(test_path) { capture(test_path) }
414
659
  accept_capture_payload(test_path, payload) if payload
415
660
  end
416
661
  end
@@ -450,8 +695,8 @@ module Mutineer
450
695
  # or the result. With `result: true`, the child writes its result as one
451
696
  # line to fd {RESULT_FD}, a pipe that only the script uses. The child's
452
697
  # stderr is the parent's stderr, so warnings from the script reach the
453
- # user. A wall clock of `@capture_timeout` bounds the whole call, so a hung
454
- # test cannot wedge the run.
698
+ # user. A wall clock of `@capture_timeout` bounds the whole call (see
699
+ # {#await_child}), so a hung test cannot wedge the run.
455
700
  #
456
701
  # The parent reads one line, not until EOF: a process that a test leaves
457
702
  # running can inherit fd {RESULT_FD} (a `fork` without `exec` keeps it
@@ -468,27 +713,72 @@ module Mutineer
468
713
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
469
714
  script_rd, script_wr = IO.pipe
470
715
  result_rd, result_wr = IO.pipe if result
471
- options = { in: script_rd, out: File::NULL }
716
+ options = { in: script_rd, out: File::NULL, pgroup: true }
472
717
  options[RESULT_FD] = result_wr if result
473
718
  pid = Process.spawn(RbConfig.ruby, "-", **options)
474
- waiter = Process.detach(pid)
475
719
  script_rd.close
476
720
  result_wr&.close
477
- reader = Thread.new { result_rd.gets.to_s } if result
478
- script_wr.write(script)
479
- script_wr.close
480
- unless waiter.join(remaining(deadline))
481
- Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
482
- waiter.join
483
- reader&.kill
484
- return [nil, ""]
721
+ begin
722
+ script_wr.write(script)
723
+ rescue Errno::EPIPE
724
+ # The child exited before reading its script; await_child still reaps it.
485
725
  end
486
- [waiter.value, reader&.join(remaining(deadline))&.value.to_s]
726
+ script_wr.close
727
+ status, out = await_child(pid, deadline) { read_result_line(result_rd) if result }
728
+ [status, out.to_s]
487
729
  ensure
488
- reader&.kill
489
730
  [script_rd, script_wr, result_rd, result_wr].compact.each { |io| io.close unless io.closed? }
490
731
  end
491
732
 
733
+ # Reads the one result line a {#spawn_script} child writes to fd
734
+ # {RESULT_FD}.
735
+ #
736
+ # @api private
737
+ # @param io [IO] the parent end of the result pipe.
738
+ # @return [String] the line, or `""` at end of file.
739
+ def read_result_line(io) = io.gets.to_s
740
+
741
+ # Reads the one Marshal object a forked capture child writes, without
742
+ # waiting for end of file.
743
+ #
744
+ # @api private
745
+ # @param io [IO] the parent end of the binary result pipe.
746
+ # @return [Object, nil] the object, or nil when the child wrote none.
747
+ def read_marshal(io)
748
+ Marshal.load(io)
749
+ rescue EOFError
750
+ nil
751
+ end
752
+
753
+ # Waits for capture child `pid`, which leads its own process group, while
754
+ # a thread runs the block to read the child's result. One `deadline`
755
+ # bounds the run, the read, and the reap. Past it, the whole group gets
756
+ # SIGKILL, so a process the test started does not outlive a hung capture.
757
+ # Process.detach owns reaping. The same deadline and group kill as
758
+ # DaemonServer#wait_verdict, plus a reader, because the child reports
759
+ # through a pipe.
760
+ #
761
+ # A child that exits before the deadline has written its result, so the
762
+ # reader gets the time left, and at least {RESULT_GRACE} seconds, to finish.
763
+ #
764
+ # @api private
765
+ # @param pid [Integer] child pid, also its process group id.
766
+ # @param deadline [Float] a CLOCK_MONOTONIC time.
767
+ # @yieldreturn [Object] what the child wrote.
768
+ # @return [Array(Process::Status, Object)] the exit status and the block's
769
+ # value (nil when the reader did not finish); `[nil, nil]` after a timeout.
770
+ def await_child(pid, deadline, &read)
771
+ waiter = Process.detach(pid)
772
+ reader = Thread.new(&read)
773
+ reader.report_on_exception = false
774
+ return [nil, nil] unless waiter.join(remaining(deadline))
775
+
776
+ [waiter.value, reader.join([remaining(deadline), RESULT_GRACE].max)&.value]
777
+ ensure
778
+ kill_group(pid, waiter) if waiter&.alive?
779
+ reader&.kill
780
+ end
781
+
492
782
  # Seconds left before `deadline`, never negative.
493
783
  #
494
784
  # @api private
@@ -517,12 +807,15 @@ module Mutineer
517
807
  # @param after_fork [Proc, nil] boot-mode fork hook.
518
808
  # @return [Boolean]
519
809
  def fork_clean_pass?(abs_tests, after_fork)
810
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
520
811
  rd, wr = IO.pipe
521
812
  rd.binmode
522
813
  wr.binmode
814
+ parent = Process.pid
523
815
  pid = fork do
524
816
  rd.close
525
817
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
818
+ OrphanGuard.start(parent)
526
819
  begin
527
820
  ChildStdout.silence
528
821
  after_fork&.call
@@ -536,34 +829,32 @@ module Mutineer
536
829
  end
537
830
  end
538
831
  wr.close
539
- readable, = IO.select([rd], nil, nil, @capture_timeout)
540
- unless readable
541
- kill_fork_clean(pid)
542
- rd.close
543
- return false
544
- end
545
- data = rd.read
546
- rd.close
547
- Process.waitpid2(pid)
548
- return false if data.empty?
549
-
550
- Marshal.load(data)
832
+ _, passed = await_child(pid, deadline) { read_marshal(rd) }
833
+ passed == true
551
834
  rescue StandardError
552
835
  false
836
+ ensure
837
+ [rd, wr].compact.each { |io| io.close unless io.closed? }
553
838
  end
554
839
 
555
- # SIGKILLs a hung clean-check child (and its group) then reaps it.
840
+ # SIGKILLs capture child `pid` and its process group, then waits for
841
+ # `waiter` to reap the child. Falls back to the pid alone when the group
842
+ # does not exist yet.
556
843
  #
557
844
  # @api private
558
- # @param pid [Integer] child pid.
845
+ # @param pid [Integer] child pid, also its process group id.
846
+ # @param waiter [Thread] the Process.detach thread for `pid`.
559
847
  # @return [void]
560
- def kill_fork_clean(pid)
848
+ def kill_group(pid, waiter)
561
849
  begin
562
850
  Process.kill(:KILL, -pid)
563
851
  rescue Errno::ESRCH, Errno::EPERM
564
- Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
852
+ # No group yet: the child has not run setpgid. Never signal a reaped pid.
853
+ if waiter.alive?
854
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
855
+ end
565
856
  end
566
- Process.waitpid2(pid) rescue nil # rubocop:disable Style/RescueModifier
857
+ waiter.join
567
858
  end
568
859
 
569
860
  # Builds a pass/fail-only subprocess script (no coverage instrumentation).
@@ -575,8 +866,9 @@ module Mutineer
575
866
  @framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
576
867
  end
577
868
 
578
- # Minitest clean-suite check. Preloads configured sources like capture and
579
- # standalone {Runner.execute}, so tests that rely on that preload stay green.
869
+ # Minitest clean-suite check. Preloads configured sources and `--require`
870
+ # files like capture and standalone {Runner.execute}, so tests that rely on
871
+ # that preload stay green.
580
872
  #
581
873
  # @api private
582
874
  # @param test_paths [Array<String>] test file paths.
@@ -591,7 +883,7 @@ module Mutineer
591
883
  Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
592
884
  Minitest.extensions << "mutineer_report"
593
885
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
594
- #{abs_source_paths.inspect}.each { |f| require f }
886
+ #{abs_preload_paths.inspect}.each { |f| require f }
595
887
  #{loads}
596
888
  _passed = Minitest.run([])
597
889
  $stderr.write(_report.string) unless _passed
@@ -599,7 +891,8 @@ module Mutineer
599
891
  RUBY
600
892
  end
601
893
 
602
- # RSpec clean-suite check. Preloads configured sources like capture.
894
+ # RSpec clean-suite check. Preloads configured sources and `--require`
895
+ # files like capture.
603
896
  #
604
897
  # @api private
605
898
  # @param test_paths [Array<String>] spec file paths.
@@ -615,7 +908,7 @@ module Mutineer
615
908
  end
616
909
  RSpec::Core::Runner.disable_autorun!
617
910
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
618
- #{abs_source_paths.inspect}.each { |f| require f }
911
+ #{abs_preload_paths.inspect}.each { |f| require f }
619
912
  _sink = StringIO.new
620
913
  status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
621
914
  $stderr.write(_sink.string) unless status.zero?
@@ -648,13 +941,16 @@ module Mutineer
648
941
  _report = StringIO.new
649
942
  Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
650
943
  Minitest.extensions << "mutineer_report"
651
- Coverage.start(lines: true)
944
+ Coverage.start(lines: true, methods: true)
652
945
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
653
- #{abs_source_paths.inspect}.each { |f| require f }
946
+ #{abs_preload_paths.inspect}.each { |f| require f }
947
+ _load = #{load_coverage_expression}
654
948
  load #{absolute(test_path).inspect}
655
949
  _passed = Minitest.run([])
656
950
  $stderr.write(_report.string) unless _passed
657
- _result.puts JSON.generate("passed" => _passed == true, "coverage" => Coverage.result,
951
+ _coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
952
+ _result.puts JSON.generate("passed" => _passed == true, "coverage" => _coverage,
953
+ "load_coverage" => _load,
658
954
  "loaded_files" => #{loaded_files_expression})
659
955
  _result.close
660
956
  RUBY
@@ -679,13 +975,16 @@ module Mutineer
679
975
  exit 3
680
976
  end
681
977
  RSpec::Core::Runner.disable_autorun!
682
- Coverage.start(lines: true)
978
+ Coverage.start(lines: true, methods: true)
683
979
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
684
- #{abs_source_paths.inspect}.each { |f| require f }
980
+ #{abs_preload_paths.inspect}.each { |f| require f }
981
+ _load = #{load_coverage_expression}
685
982
  _sink = StringIO.new
686
983
  _status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
687
984
  $stderr.write(_sink.string) unless _status.zero?
688
- _result.puts JSON.generate("passed" => _status.zero?, "coverage" => Coverage.result,
985
+ _coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
986
+ _result.puts JSON.generate("passed" => _status.zero?, "coverage" => _coverage,
987
+ "load_coverage" => _load,
689
988
  "loaded_files" => #{loaded_files_expression})
690
989
  _result.close
691
990
  RUBY
@@ -696,16 +995,95 @@ module Mutineer
696
995
  # path outside the project (stdlib/gem files).
697
996
  def record(coverage, test_path)
698
997
  rel_test = relativize(test_path)
998
+ each_covered_key(coverage) { |key| (@map[key] ||= []) << rel_test }
999
+ end
1000
+
1001
+ # Adds the configured source lines that ran at load to {#load_lines}, and
1002
+ # the methods called at load to {#load_methods}. Lines of other files
1003
+ # (tests, helpers, gems) are dropped.
1004
+ #
1005
+ # @api private
1006
+ # @param coverage [Hash, nil] counts per absolute file, as {#record} reads
1007
+ # them; a Hash entry can also hold "methods", as {#load_entry} builds it.
1008
+ # @return [void]
1009
+ def record_load(coverage)
1010
+ return unless coverage.is_a?(Hash)
1011
+
1012
+ @source_rels ||= @source_paths.to_set { |p| relativize(absolute(p)) }
1013
+ ours = coverage.select { |abs_file, _| @source_rels.include?(relativize(abs_file)) }
1014
+ each_covered_key(ours) { |key| @load_lines << key }
1015
+ ours.each do |abs_file, data|
1016
+ next unless data.is_a?(Hash) && data["methods"].is_a?(Array)
1017
+
1018
+ data["methods"].each { |line, column| @load_methods << "#{relativize(abs_file)}:#{line}:#{column}" }
1019
+ end
1020
+ end
1021
+
1022
+ # One file's line counts, with the `def` line of each method the test
1023
+ # called counted too (#209). A forked capture does not see the `def` lines
1024
+ # run, since the boot defined the methods before the test. Ruby counts no
1025
+ # line when an endless method runs, so without this its mutants would be
1026
+ # `no_coverage` even when a test calls it. A line where more than one
1027
+ # method starts is not counted: the map keys tests by line, so the test
1028
+ # would also be credited with the methods it did not call.
1029
+ #
1030
+ # @api private
1031
+ # @param data [Hash] `Coverage` result for one file, with `:lines` and `:methods`.
1032
+ # @return [Array, nil] line counts.
1033
+ def lines_with_called_defs(data)
1034
+ lines = data[:lines]
1035
+ return lines unless lines && data[:methods]
1036
+
1037
+ lines = lines.dup
1038
+ starts = data[:methods].keys.map { |key| key[2] }.tally
1039
+ data[:methods].each do |key, count|
1040
+ line = key[2]
1041
+ lines[line - 1] = [lines[line - 1].to_i, count].max if count.positive? && starts[line] == 1
1042
+ end
1043
+ lines
1044
+ end
1045
+
1046
+ # One file's load-time coverage in the shape {#record_load} reads: the line
1047
+ # counts, and the `[line, column]` of each method called at least once.
1048
+ # {#load_coverage_expression} builds the same shape in a capture child.
1049
+ #
1050
+ # @api private
1051
+ # @param lines [Array, nil] line counts.
1052
+ # @param methods [Hash, nil] `Coverage` method counts, keyed by
1053
+ # `[owner, name, start_line, start_column, end_line, end_column]`.
1054
+ # @return [Hash{String => Array}]
1055
+ def load_entry(lines, methods)
1056
+ { "lines" => lines, "methods" => (methods || {}).filter_map { |key, count| key[2, 2] if count.positive? } }
1057
+ end
1058
+
1059
+ # Ruby source of the capture child's load-time coverage: `Coverage.peek_result`
1060
+ # for the configured sources only, matched by real path (a Coverage key is
1061
+ # the path as required), so the payload does not carry every loaded gem.
1062
+ # Each entry has the shape {#load_entry} builds.
1063
+ #
1064
+ # @api private
1065
+ # @return [String] expression to embed in a capture subprocess script.
1066
+ def load_coverage_expression
1067
+ "Coverage.peek_result.select { |f, _| #{abs_source_paths.inspect}.include?((File.realpath(f) rescue f)) }" \
1068
+ ".transform_values { |v| { \"lines\" => v[:lines], " \
1069
+ "\"methods\" => v[:methods].filter_map { |k, n| k[2, 2] if n.positive? } } }"
1070
+ end
1071
+
1072
+ # Yields a "file:line" key for every project line with a non-zero count.
1073
+ #
1074
+ # @api private
1075
+ # @param coverage [Hash] counts per absolute file: an Array, or a Hash with "lines".
1076
+ # @yieldparam key [String] the "file:line" key.
1077
+ # @return [void]
1078
+ def each_covered_key(coverage)
699
1079
  coverage.each do |abs_file, data|
700
1080
  rel = relativize(abs_file)
701
1081
  next if rel.start_with?("/") # outside project_root: not our source
702
1082
 
703
- counts = data.is_a?(Array) ? data : data["lines"]
704
- counts.each_with_index do |count, idx|
705
- next unless count&.positive?
1083
+ counts = data.is_a?(Hash) ? data["lines"] : data
1084
+ next unless counts.is_a?(Array) # a Coverage mode without line counts
706
1085
 
707
- (@map["#{rel}:#{idx + 1}"] ||= []) << rel_test
708
- end
1086
+ counts.each_with_index { |count, idx| yield "#{rel}:#{idx + 1}" if count&.positive? }
709
1087
  end
710
1088
  end
711
1089
 
@@ -808,16 +1186,62 @@ module Mutineer
808
1186
  d = Digest::SHA256.new
809
1187
  digest_group(d, "source", @source_paths)
810
1188
  digest_group(d, "test", @test_paths)
811
- digest_group(d, "boot", [boot_digest_path]) if @boot_path
1189
+ # One group per file: digest_group sorts, and the load order matters.
1190
+ @require_paths.each { |p| digest_group(d, "require", [digest_path(p)]) }
1191
+ digest_group(d, "boot", [digest_path(@boot_path)]) if @boot_path
1192
+ ownership_paths.each do |rel|
1193
+ d.update("owner\0")
1194
+ d.update(rel)
1195
+ d.update("\0")
1196
+ end
812
1197
  @load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
813
1198
  d.update("framework\0#{@framework}\0")
814
1199
  d.hexdigest
815
1200
  end
816
1201
 
817
- # boot_path is a require-style path (e.g. "config/environment", no extension);
818
- # resolve it to the real file for reading, appending ".rb" when needed.
819
- def boot_digest_path
820
- File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
1202
+ # Longer source files that can take a split test from a configured source.
1203
+ # Only their presence is digested. A content edit of a sibling does not
1204
+ # change pairing, and it does not rebuild coverage. Each directory is
1205
+ # listed once, even when many sources share it.
1206
+ #
1207
+ # @return [Array<String>] project-relative paths, sorted.
1208
+ def ownership_paths
1209
+ root = File.expand_path(@project_root)
1210
+ listings = {}
1211
+ paths = @source_paths.flat_map do |source|
1212
+ rel = relativize(absolute(source))
1213
+ next [] if rel.start_with?("/")
1214
+
1215
+ base, = Pairing.logical_path(rel)
1216
+ name = File.basename(base)
1217
+ dir = File.dirname(base)
1218
+ folders = [dir == "." ? nil : dir]
1219
+ %w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
1220
+ folders.uniq.flat_map do |folder|
1221
+ abs = folder ? File.join(root, folder) : root
1222
+ entries = listings.fetch(abs) do
1223
+ listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
1224
+ end
1225
+ entries.filter_map do |entry|
1226
+ next unless entry.end_with?(".rb")
1227
+
1228
+ stem = entry.delete_suffix(".rb")
1229
+ next unless stem.start_with?("#{name}_") && stem.length > name.length
1230
+
1231
+ folder ? File.join(folder, entry) : entry
1232
+ end
1233
+ end
1234
+ end
1235
+ paths.uniq.sort
1236
+ end
1237
+
1238
+ # boot_path and require_paths are require-style paths (e.g. "config/environment",
1239
+ # no extension); resolve one to the file `require` loads: the path itself, then
1240
+ # ".rb", then a native extension. A folder of the same name (lib/my_gem/ next to
1241
+ # lib/my_gem.rb) is not the file.
1242
+ def digest_path(path)
1243
+ ["", ".rb", ".#{RbConfig::CONFIG['DLEXT']}"].map { |ext| "#{path}#{ext}" }
1244
+ .find { |p| File.file?(absolute(p)) } || "#{path}.rb"
821
1245
  end
822
1246
 
823
1247
  # Groups a digest with its role and paths.
@@ -879,7 +1303,9 @@ module Mutineer
879
1303
 
880
1304
  FileUtils.mkdir_p(@cache_dir)
881
1305
  data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
882
- "dependencies" => @loaded_dependencies, "map" => @map }
1306
+ "dependencies" => @loaded_dependencies, "map" => @map,
1307
+ "load_lines" => @load_lines.to_a.sort, "load_methods" => @load_methods.to_a.sort,
1308
+ "timings" => @timings }
883
1309
  tmp = "#{cache_path}.tmp"
884
1310
  File.write(tmp, JSON.generate(data))
885
1311
  File.rename(tmp, cache_path) # atomic swap
@@ -899,6 +1325,13 @@ module Mutineer
899
1325
  # @return [Array<String>] absolute source paths.
900
1326
  def abs_source_paths = @source_paths.map { |p| absolute(p) }
901
1327
 
1328
+ # The sources, then the `--require` files, in the order standalone
1329
+ # {Runner.execute} requires them before it forks the mutants (#217).
1330
+ #
1331
+ # @api private
1332
+ # @return [Array<String>] absolute paths.
1333
+ def abs_preload_paths = abs_source_paths + @require_paths.map { |p| absolute(p) }
1334
+
902
1335
  # Returns absolute load paths.
903
1336
  #
904
1337
  # @return [Array<String>] absolute load paths.