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,1366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "rbconfig"
7
+ require "coverage"
8
+ require "set"
9
+ require_relative "minitest_integration"
10
+ require_relative "test_runners"
11
+ require_relative "child_stdout"
12
+ require_relative "orphan_guard"
13
+ require_relative "project_path"
14
+ require_relative "pairing"
15
+
16
+ module Mutineer
17
+ # Maps `(source_file, line) -> [test_files]` so each mutant runs only against
18
+ # the tests that actually exercise its line. Built once, then queried per
19
+ # mutant via #tests_for. Persisted to .mutineer/coverage.json with a
20
+ # content-based digest that rebuilds the map whenever any tracked file changes.
21
+ #
22
+ # Keys are "file:line" strings (relative to project_root) everywhere, in
23
+ # memory and on disk, so load/save needs no key transformation.
24
+ class CoverageMap
25
+ # Seconds per coverage subprocess before the parent kills it.
26
+ DEFAULT_CAPTURE_TIMEOUT = 120
27
+
28
+ # File descriptor in a capture subprocess that carries the JSON result to
29
+ # the parent. Stdout stays free for test output, which goes to File::NULL.
30
+ RESULT_FD = 3
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
+
37
+ attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
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
+
62
+ # Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
63
+ # map app-side and ships `map` + `failed_test_files` over IPC; the tool
64
+ # reconstructs it here for per-mutant selection). Skips the capture machinery
65
+ # entirely: only the three fields #tests_for / #method_uncapturable? read are
66
+ # set.
67
+ #
68
+ # @param map [Hash] the "file:line" => [test_files] map.
69
+ # @param failed_test_files [Array<String>] test files whose capture failed.
70
+ # @param project_root [String] project root (for path relativization).
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}).
76
+ # @return [Mutineer::CoverageMap] a query-only map.
77
+ def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [], load_lines: [],
78
+ load_methods: [], timings: {})
79
+ instance = allocate
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 || []))
84
+ instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
85
+ instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
86
+ instance.instance_variable_set(:@project_root, project_root)
87
+ instance
88
+ end
89
+
90
+ def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
91
+ load_paths: ["lib"], project_root: Dir.pwd,
92
+ capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
93
+ framework: "minitest", verbose: false, require_paths: [])
94
+ @source_paths = Array(source_paths)
95
+ @require_paths = Array(require_paths)
96
+ @test_paths = Array(test_paths)
97
+ @cache_dir = cache_dir
98
+ @load_paths = Array(load_paths)
99
+ @project_root = project_root
100
+ @capture_timeout = capture_timeout
101
+ @boot_path = boot_path
102
+ @framework = framework || "minitest"
103
+ @verbose = verbose
104
+ @map = {}
105
+ @failed_test_files = []
106
+ @failed_clean_tests = []
107
+ @load_lines = Set.new
108
+ @load_methods = Set.new
109
+ @timings = {}
110
+ @loaded_dependencies = {}
111
+ @phase_a_ran = false
112
+ end
113
+
114
+ # Standalone entry: load the cached map when the content digest matches,
115
+ # otherwise rebuild from subprocesses and overwrite the cache.
116
+ def build_or_load
117
+ warn_external_sources
118
+ cached_or { run_phase_a }
119
+ end
120
+
121
+ # Boot-mode build: Coverage is already running in the parent (started before
122
+ # the app booted, so booted source lines are instrumented). A clean `ruby`
123
+ # subprocess has no booted env, so per-test coverage is captured by FORKING
124
+ # the booted parent instead. Inverts into the same map #tests_for reads, and
125
+ # reuses the digest cache (the digest mixes in the boot file so a boot cache
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.
130
+ # @param after_fork [Proc, nil] child-side database routing.
131
+ # @param before_fork [Proc, nil] parent-side socket disconnection.
132
+ # @param cleanup [Proc, nil] parent-side cleanup, passed the reaped child pid.
133
+ # @param cache_key [String, nil] additional state affecting coverage (MongoDB snapshot).
134
+ def build_via_fork(after_fork: nil, before_fork: nil, cleanup: nil, cache_key: nil)
135
+ @cache_key = cache_key
136
+ @before_fork = before_fork
137
+ @fork_cleanup = cleanup
138
+ warn_external_sources
139
+ # Matched by real path in #record_load: a Coverage key is the path as
140
+ # required, which can differ from the configured one (/var, /private/var).
141
+ booted = Coverage.running? ? Coverage.peek_result : {}
142
+ record_load(booted.transform_values { |v| v.is_a?(Hash) ? load_entry(v[:lines], v[:methods]) : v })
143
+ cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
144
+ end
145
+
146
+ # Lookup: the test files that cover `file:line`, or [] when none do.
147
+ # Per-file granularity; upgrade to per-method when throughput warrants
148
+ # (requires Minitest method isolation + finer Coverage tracking).
149
+ def tests_for(file, line)
150
+ @map["#{relativize(file)}:#{line}"] || []
151
+ end
152
+
153
+ # The covering `tests` of a mutant in `file`, in the order a mutant run
154
+ # loads them (#203): the files {Pairing.infer_tests} pairs with `file`
155
+ # first, then the cheapest by {#timings}. A file with no timing comes
156
+ # after the timed ones, and the path breaks a tie, so the same cache gives
157
+ # the same order on every run. Costs compare in doubling buckets
158
+ # (`log2(seconds - fastest + 1)`), so files of close cost keep their path
159
+ # order when a rebuild measures them again. Taking off the fastest
160
+ # captured file's time removes the startup cost every standalone capture
161
+ # pays; a failed capture is timed again on each run, so it does not count.
162
+ # A run that stops at the first failure then reaches a fast killing test
163
+ # before a slow file uses up the timeout.
164
+ #
165
+ # @param file [String] the mutated source file path.
166
+ # @param tests [Array<String>] project-relative test paths from {#tests_for}.
167
+ # @return [Array<String>] the same paths, reordered.
168
+ def order_tests(file, tests)
169
+ rel = relativize(absolute(file))
170
+ @paired ||= {}
171
+ paired = @paired[rel] ||= Pairing.infer_tests(rel, project_root: @project_root,
172
+ prefer: @framework || "minitest")
173
+ base = @timings.except(*@failed_test_files).values.min || 0
174
+ tests.sort_by do |t|
175
+ cost = @timings[t]
176
+ [paired.include?(t) ? 0 : 1, cost ? Math.log2(cost - base + 1).floor : Float::INFINITY, t]
177
+ end
178
+ end
179
+
180
+ # True when `file:line` ran while the app booted or the sources loaded
181
+ # (see {#load_lines}).
182
+ #
183
+ # @param file [String] source file path.
184
+ # @param line [Integer] 1-based line.
185
+ # @return [Boolean]
186
+ def ran_at_load?(file, line)
187
+ @load_lines.include?("#{relativize(file)}:#{line}")
188
+ end
189
+
190
+ # True when the method whose `def` starts at `line` and `column` of `file`
191
+ # was called while the app booted or the sources loaded (see {#load_methods}).
192
+ #
193
+ # @param file [String] source file path.
194
+ # @param line [Integer] 1-based line of the `def`.
195
+ # @param column [Integer] 0-based byte column of the `def`.
196
+ # @return [Boolean]
197
+ def method_ran_at_load?(file, line, column)
198
+ @load_methods.include?("#{relativize(file)}:#{line}:#{column}")
199
+ end
200
+
201
+ # Is this source file's empty coverage the result of an *errored* capture
202
+ # rather than a genuine coverage gap? True iff some capture failed this run
203
+ # AND this file got zero coverage from any successful capture AND a failed
204
+ # test file maps to it by the _test/_spec/test_ naming convention. Derived
205
+ # from already-persisted state (@map keys + @failed_test_files). A split
206
+ # name checks the longer source files that existed when that check first
207
+ # ran for this map. Adding or deleting one of those files changes the
208
+ # coverage digest, so the next run captures again instead of retargeting
209
+ # a stored failure.
210
+ #
211
+ # File-level, convention-based attribution. A line covered only by a failed
212
+ # test in an otherwise-covered file stays no_coverage (condition 2), and a
213
+ # source with no naming-convention test match is never tainted. Upgrade path:
214
+ # persist per-file coverage per successful run and diff against the failed
215
+ # set, or record test->source targets explicitly.
216
+ def uncapturable_source?(file)
217
+ return false if @failed_test_files.empty?
218
+
219
+ rel = relativize(absolute(file))
220
+ return false if covered_source_files.include?(rel)
221
+
222
+ failed_test_blames?(rel)
223
+ end
224
+
225
+ # Per-method taint. A mutant on a line whose enclosing method got zero
226
+ # successful coverage, in a file a failed sibling test targets, is
227
+ # :uncapturable (the capture that would have covered it errored), NOT a
228
+ # genuine gap. A method with any covered line means its uncovered lines are a
229
+ # real :no_coverage. A failed capture emits no coverage, so per-line intent is
230
+ # unknowable; method-range + successful coverage is the finest derivable
231
+ # signal. Fully-failed files behave exactly as uncapturable_source? did
232
+ # (every method range has zero coverage).
233
+ #
234
+ # @param file [String] source file path.
235
+ # @param line_range [Range] 1-based enclosing-method line range.
236
+ # @return [Boolean]
237
+ def method_uncapturable?(file, line_range)
238
+ return false if @failed_test_files.empty?
239
+
240
+ rel = relativize(absolute(file))
241
+ return false unless failed_test_blames?(rel)
242
+
243
+ line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
244
+ end
245
+
246
+ private
247
+
248
+ # Source rel-paths that received coverage from any successful capture.
249
+ def covered_source_files
250
+ @map.keys.map { |k| k.rpartition(":").first }.to_set
251
+ end
252
+
253
+ # True when a failed test file pairs with `source_rel` by convention.
254
+ # Exact `_test` / `_spec` / `test_` names match by basename, as before.
255
+ # A split `<name>_*_test.rb` matches only in the mirrored test directory,
256
+ # and only when no longer source file owns that name (#87). The answer
257
+ # is kept for this map, so a later delete does not move the failure.
258
+ #
259
+ # @param source_rel [String] project-relative source path.
260
+ # @return [Boolean]
261
+ def failed_test_blames?(source_rel)
262
+ @blame_for ||= {}
263
+ return @blame_for[source_rel] if @blame_for.key?(source_rel)
264
+
265
+ @blame_for[source_rel] = exact_failed_test?(source_rel) || split_failed_test?(source_rel)
266
+ end
267
+
268
+ # True when a failed exact `_test` / `_spec` / `test_` file pairs with
269
+ # this source. An `app/` or `lib/` source only accepts a `test/` or
270
+ # `spec/` file from its mirrored directory, so
271
+ # `test/foo/bar_upsert_test.rb` does not taint `app/other/bar_upsert.rb`.
272
+ # Any other source still matches by basename. A fixture under
273
+ # `test/fixtures/` and an explicit test both name their source that way.
274
+ #
275
+ # @param source_rel [String] project-relative source path.
276
+ # @return [Boolean]
277
+ def exact_failed_test?(source_rel)
278
+ name = File.basename(source_rel, ".rb")
279
+ base, lib = Pairing.logical_path(source_rel)
280
+ @failed_test_files.any? do |test_path|
281
+ next false unless failed_test_target_name(test_path) == name
282
+
283
+ rel = relativize(test_path)
284
+ dir = File.dirname(rel)
285
+ if app_or_lib_source?(source_rel) && conventional_test_dir?(dir)
286
+ mirrored_test_dir?(rel, base, lib) || spec_mirror?(rel, base, lib)
287
+ else
288
+ true
289
+ end
290
+ end
291
+ end
292
+
293
+ # True when pairing gives this source a mirrored test directory.
294
+ #
295
+ # @param source_rel [String] project-relative source path.
296
+ # @return [Boolean]
297
+ def app_or_lib_source?(source_rel)
298
+ source_rel.start_with?("app/", "lib/")
299
+ end
300
+
301
+ # Basename a failed test file would pair with, before the directory check.
302
+ #
303
+ # @param test_path [String]
304
+ # @return [String]
305
+ def failed_test_target_name(test_path)
306
+ name = File.basename(test_path, ".rb")
307
+ case name
308
+ when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
309
+ when "test_helper" then name
310
+ else name.delete_prefix("test_")
311
+ end
312
+ end
313
+
314
+ # True when `dir` is `test`, `spec`, or a directory under one of them.
315
+ #
316
+ # @param dir [String] project-relative directory.
317
+ # @return [Boolean]
318
+ def conventional_test_dir?(dir)
319
+ dir == "test" || dir == "spec" || dir.start_with?("test/", "spec/")
320
+ end
321
+
322
+ # True when a failed split test file pairs with this source. Directory and
323
+ # the longer-source check match {Pairing}, so a test outside the mirror, or
324
+ # one owned by `user_session.rb`, does not taint `user.rb`.
325
+ #
326
+ # @param source_rel [String] project-relative source path.
327
+ # @return [Boolean]
328
+ def split_failed_test?(source_rel)
329
+ base, lib = Pairing.logical_path(source_rel)
330
+ name = File.basename(base)
331
+ @failed_test_files.any? do |t|
332
+ test_rel = relativize(t)
333
+ entry = File.basename(test_rel)
334
+ next false unless Pairing.split_entry?(entry, name)
335
+ next false unless mirrored_test_dir?(test_rel, base, lib)
336
+ next false if Pairing.claimed_by_longer_source?(@project_root, base, entry, File.dirname(test_rel))
337
+
338
+ true
339
+ end
340
+ end
341
+
342
+ # True when `test_rel` sits in a mirrored test directory for `base`.
343
+ #
344
+ # @param test_rel [String] project-relative test path.
345
+ # @param base [String] logical source path without extension.
346
+ # @param lib [Boolean] whether the source originated from lib/.
347
+ # @return [Boolean]
348
+ def mirrored_test_dir?(test_rel, base, lib)
349
+ dirs = [Pairing.mirror_dir("test", base)]
350
+ dirs << Pairing.mirror_dir("test/lib", base) if lib
351
+ dirs.include?(File.dirname(test_rel))
352
+ end
353
+
354
+ # True when `test_rel` sits in a mirrored spec directory for `base`.
355
+ # Split Minitest names stay in {#mirrored_test_dir?}. An exact `_spec`
356
+ # file uses this directory.
357
+ #
358
+ # @param test_rel [String] project-relative test path.
359
+ # @param base [String] logical source path without extension.
360
+ # @param lib [Boolean] whether the source originated from lib/.
361
+ # @return [Boolean]
362
+ def spec_mirror?(test_rel, base, lib)
363
+ dirs = [Pairing.mirror_dir("spec", base)]
364
+ dirs << Pairing.mirror_dir("spec/lib", base) if lib
365
+ dirs.include?(File.dirname(test_rel))
366
+ end
367
+
368
+ # Shared cache dance for both build paths: hit the digest-keyed cache, else
369
+ # yield to populate @map and persist it. A digest match is not proof that
370
+ # today's unmutated suite still passes — re-check on a cache hit.
371
+ #
372
+ # @param after_fork [Proc, nil] boot-mode fork hook forwarded to a clean re-check.
373
+ # @yield when the cache is missing or stale.
374
+ # @return [Mutineer::CoverageMap] self.
375
+ def cached_or(after_fork: nil)
376
+ @digest = compute_digest
377
+ @digest = Digest::SHA256.hexdigest("#{@digest}\0#{@cache_key}") if @cache_key
378
+ cached = read_cache
379
+ # A cache from before load methods (#209) or test timings (#203) were
380
+ # saved rebuilds once. Boot mode reads its load lines and methods from
381
+ # the live boot instead (#build_via_fork), but its map from before #209
382
+ # lacks the `def` lines of the methods each test called.
383
+ if cached && cached["digest"] == @digest && dependencies_match?(cached) &&
384
+ cached.key?("load_methods") && cached.key?("timings")
385
+ @map = cached["map"] || {}
386
+ @timings = cached["timings"] || {}
387
+ unless @boot_path
388
+ @load_lines = Set.new(cached["load_lines"])
389
+ @load_methods = Set.new(cached["load_methods"])
390
+ end
391
+ @failed_test_files = cached["failed_test_files"] || []
392
+ @failed_clean_tests = []
393
+ @loaded_dependencies = cached["dependencies"] || {}
394
+ retry_failed_captures(after_fork)
395
+ warn_incomplete unless @failed_test_files.empty?
396
+ verify_cached_clean(after_fork: after_fork)
397
+ verify_combined_clean(after_fork: after_fork)
398
+ save
399
+ return self
400
+ end
401
+
402
+ yield
403
+ verify_combined_clean(after_fork: after_fork)
404
+ save
405
+ self
406
+ end
407
+
408
+ # Runs standalone coverage capture.
409
+ #
410
+ # @api private
411
+ def run_phase_a
412
+ @phase_a_ran = true
413
+ @map = {}
414
+ @load_lines = Set.new
415
+ @load_methods = Set.new
416
+ @timings = {}
417
+ @failed_test_files = []
418
+ @failed_clean_tests = []
419
+ @loaded_dependencies = {}
420
+
421
+ @test_paths.each do |test_path|
422
+ payload = timed(test_path) { capture(test_path) }
423
+ next unless payload
424
+
425
+ accept_capture_payload(test_path, payload)
426
+ end
427
+ end
428
+
429
+ # Boot-mode capture. For each test file, fork the booted parent; the child
430
+ # resets its Coverage delta, runs that ONE test, and marshals back the raw
431
+ # per-source coverage counts. record() inverts them exactly as the subprocess
432
+ # path does. Serial fork (one test at a time): boot apps fork cheaply via COW
433
+ # and per-test isolation matters more than throughput here.
434
+ def run_phase_a_via_fork(after_fork:)
435
+ @phase_a_ran = true
436
+ @map = {}
437
+ @timings = {}
438
+ @failed_test_files = []
439
+ @failed_clean_tests = []
440
+ @loaded_dependencies = {}
441
+ abs_sources = abs_source_paths
442
+
443
+ @test_paths.each do |test_path|
444
+ accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
445
+ end
446
+ end
447
+
448
+ # Runs the block, a capture of `test_path`, and records its wall-clock
449
+ # seconds in {#timings}. The parent times the whole capture, so the
450
+ # numbers compare files, not exact test cost.
451
+ #
452
+ # @api private
453
+ # @param test_path [String] test file path.
454
+ # @yieldreturn [Object] the capture result.
455
+ # @return [Object] the block's value.
456
+ def timed(test_path)
457
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
458
+ value = yield
459
+ @timings[relativize(test_path)] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
460
+ value
461
+ end
462
+
463
+ # Records a {#fork_capture} result. Hash = capture result, String = error
464
+ # diagnostic from the child, :timeout = the capture deadline passed, nil =
465
+ # pipe gone / empty. The String diagnostic is what becomes an
466
+ # :uncapturable status.
467
+ #
468
+ # @api private
469
+ # @param test_path [String] test file path.
470
+ # @param payload [Hash, String, Symbol, nil] what {#fork_capture} returned.
471
+ # @return [void]
472
+ def accept_fork_payload(test_path, payload)
473
+ case payload
474
+ when Hash then accept_capture_payload(test_path, payload)
475
+ when :timeout then fail_test(test_path, "timed out after #{@capture_timeout}s")
476
+ when String
477
+ fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
478
+ "fork capture produced no result (re-run with --verbose for the error)")
479
+ else fail_test(test_path, "fork capture produced no result")
480
+ end
481
+ end
482
+
483
+ # Fork the booted parent, run one test under the inherited Coverage, and
484
+ # return its per-source counts hash, a String diagnostic on failure, or
485
+ # :timeout after `@capture_timeout` (see {#await_child}). Reuses the same
486
+ # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
487
+ def fork_capture(abs_test, abs_sources, after_fork)
488
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
489
+ rd, wr = IO.pipe
490
+ # Marshal output is binary: an un-binmoded pipe can raise
491
+ # Encoding::UndefinedConversionError on write, which the child's rescue then
492
+ # swallows, losing the real error and yielding a bare "no result".
493
+ rd.binmode
494
+ wr.binmode
495
+ parent = Process.pid
496
+ @before_fork&.call
497
+ pid = fork do
498
+ rd.close
499
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
500
+ OrphanGuard.start(parent)
501
+ payload =
502
+ begin
503
+ ChildStdout.silence
504
+ # Fork-safety hook: the in-process path reconnects AR; the daemon
505
+ # drops its protocol channel and routes to its worker DB. Nil =
506
+ # no-op. Injected so this file needs neither Runner (Prism) nor Rails.
507
+ after_fork&.call
508
+ Coverage.result(clear: true, stop: false) # discard pre-test delta
509
+ passed = TestRunners.for(@framework).run([abs_test]).zero?
510
+ # {file => {lines: [...], methods: {...}}}; reduce to the counts
511
+ # array record() expects, keeping only our source files.
512
+ coverage = Coverage.result(stop: false)
513
+ .select { |f, _| abs_sources.include?(f) }
514
+ .transform_values { |v| v.is_a?(Hash) ? lines_with_called_defs(v) : v }
515
+ { "passed" => passed, "coverage" => coverage,
516
+ "loaded_files" => capture_loaded_files }
517
+ rescue Exception => e # rubocop:disable Lint/RescueException
518
+ # Stringify (an arbitrary Exception may not marshal); the parent
519
+ # surfaces this under --verbose. A String marshals safely over the pipe.
520
+ "#{e.class}: #{e.message}#{e.backtrace&.first ? " @ #{e.backtrace.first}" : ''}"
521
+ end
522
+ begin
523
+ wr.write(Marshal.dump(payload))
524
+ rescue StandardError # rubocop:disable Lint/SuppressedException
525
+ # pipe gone; parent records "no result"
526
+ ensure
527
+ wr.close
528
+ exit!(0) # skip at_exit so the parent suite's autorun never re-fires here
529
+ end
530
+ end
531
+ wr.close
532
+ # Marshal.load reads exactly one object, not until EOF: a process that the
533
+ # test leaves running (even outside the group) can hold the pipe open.
534
+ status, payload = await_child(pid, deadline) { read_marshal(rd) }
535
+ return :timeout unless status
536
+
537
+ # An empty pipe means the child died before writing (e.g. a hard crash,
538
+ # OOM, or a signal from the test's own subprocess handling). Report HOW it
539
+ # died (exit status / signal) as a diagnostic string so --verbose has
540
+ # something actionable instead of a silent "no result".
541
+ return "child wrote no result (#{describe_status(status)})" if payload.nil?
542
+
543
+ payload
544
+ rescue StandardError => e
545
+ "parent could not read capture result: #{e.class}: #{e.message}"
546
+ ensure
547
+ @fork_cleanup&.call(pid) if pid
548
+ [rd, wr].compact.each { |io| io.close unless io.closed? }
549
+ end
550
+
551
+ # Human description of a child Process::Status for capture diagnostics.
552
+ #
553
+ # @api private
554
+ # @param status [Process::Status] the reaped child status.
555
+ # @return [String] e.g. "killed by signal 9 (SIGKILL)" or "exit status 1".
556
+ def describe_status(status)
557
+ if status.signaled?
558
+ sig = status.termsig
559
+ "killed by signal #{sig}#{Signal.signame(sig) ? " (SIG#{Signal.signame(sig)})" : ''}"
560
+ else
561
+ "exit status #{status.exitstatus.inspect}"
562
+ end
563
+ end
564
+
565
+ # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
566
+ # miss already-loaded app lines, so Coverage must start in a clean process
567
+ # before any source is loaded. Returns the wrapped capture payload
568
+ # (`passed` + `coverage`), or nil when the subprocess failed (logged + skipped).
569
+ def capture(test_path)
570
+ status, out = spawn_script(subprocess_script(test_path))
571
+ return fail_test(test_path, "timed out after #{@capture_timeout}s") unless status
572
+ return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?
573
+
574
+ parsed = JSON.parse(out)
575
+ return fail_test(test_path, "invalid coverage output: missing pass/coverage payload") unless wrapped_capture?(parsed)
576
+
577
+ parsed
578
+ rescue JSON::ParserError => e
579
+ fail_test(test_path, "invalid coverage output: #{e.message}")
580
+ end
581
+
582
+ # Records a failed coverage capture.
583
+ #
584
+ # @api private
585
+ # @param test_path [String] test file path.
586
+ # @param reason [String] failure reason.
587
+ # @return [void]
588
+ def fail_test(test_path, reason)
589
+ rel = relativize(test_path)
590
+ @failed_test_files << rel
591
+ warn "[mutineer] coverage skipped for #{rel}: #{reason}"
592
+ nil
593
+ end
594
+
595
+ # True when `payload` is the wrapped capture JSON/Marshal contract
596
+ # (`passed` + `coverage`), not a raw Coverage.result hash.
597
+ #
598
+ # @api private
599
+ # @param payload [Object] parsed subprocess output or forked Marshal value.
600
+ # @return [Boolean]
601
+ def wrapped_capture?(payload)
602
+ payload.is_a?(Hash) && payload.key?("passed") && payload.key?("coverage")
603
+ end
604
+
605
+ # Records a wrapped capture: assertion failures go to {#failed_clean_tests};
606
+ # successful coverage is inverted into the map. Capture crashes stay in
607
+ # {#failed_test_files} via {#fail_test}.
608
+ #
609
+ # @api private
610
+ # @param test_path [String] test file path.
611
+ # @param payload [Hash] wrapped capture with string keys.
612
+ # @return [void]
613
+ def accept_capture_payload(test_path, payload)
614
+ unless wrapped_capture?(payload)
615
+ fail_test(test_path, "invalid coverage output: missing pass/coverage payload")
616
+ return
617
+ end
618
+
619
+ record_loaded(payload["loaded_files"])
620
+ record_load(payload["load_coverage"])
621
+
622
+ unless payload["passed"]
623
+ @failed_clean_tests << relativize(test_path)
624
+ return
625
+ end
626
+
627
+ coverage = payload["coverage"]
628
+ record(coverage, test_path) if coverage.is_a?(Hash)
629
+ end
630
+
631
+ # Re-runs each successfully captured test on a cache hit. Digest equality
632
+ # cannot prove the current unmutated suite still passes.
633
+ #
634
+ # @api private
635
+ # @param after_fork [Proc, nil] boot-mode fork hook.
636
+ # @return [void]
637
+ def verify_cached_clean(after_fork: nil)
638
+ @test_paths.each do |test_path|
639
+ rel = relativize(test_path)
640
+ next if @failed_test_files.include?(rel)
641
+
642
+ ok = if @boot_path
643
+ fork_clean_pass?([absolute(test_path)], after_fork)
644
+ else
645
+ subprocess_clean_pass?([test_path])
646
+ end
647
+ @failed_clean_tests << rel unless ok
648
+ end
649
+ end
650
+
651
+ # Re-runs tests whose previous capture crashed. A fixed helper is invisible
652
+ # to the source/test digest when that capture never recorded `loaded_files`.
653
+ #
654
+ # @api private
655
+ # @param after_fork [Proc, nil] boot-mode fork hook.
656
+ # @return [void]
657
+ def retry_failed_captures(after_fork)
658
+ pending = @failed_test_files.dup
659
+ return if pending.empty?
660
+
661
+ @failed_test_files = []
662
+ abs_sources = abs_source_paths
663
+ pending.each do |rel|
664
+ test_path = @test_paths.find { |t| relativize(t) == rel } || rel
665
+ if @boot_path
666
+ accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
667
+ else
668
+ payload = timed(test_path) { capture(test_path) }
669
+ accept_capture_payload(test_path, payload) if payload
670
+ end
671
+ end
672
+ end
673
+
674
+ # Runs every successfully captured test together. Per-file capture can miss a
675
+ # failure that only appears when covering files share one process.
676
+ #
677
+ # @api private
678
+ # @param after_fork [Proc, nil] boot-mode fork hook.
679
+ # @return [void]
680
+ def verify_combined_clean(after_fork: nil)
681
+ runnable = @test_paths.reject { |t| @failed_test_files.include?(relativize(t)) }
682
+ return if runnable.size < 2
683
+ return unless @failed_clean_tests.empty?
684
+
685
+ ok = if @boot_path
686
+ fork_clean_pass?(runnable.map { |t| absolute(t) }, after_fork)
687
+ else
688
+ subprocess_clean_pass?(runnable)
689
+ end
690
+ @failed_clean_tests << "combined suite" unless ok
691
+ end
692
+
693
+ # Runs test files in a fresh interpreter and returns whether they passed.
694
+ #
695
+ # @api private
696
+ # @param test_paths [Array<String>] test file paths.
697
+ # @return [Boolean]
698
+ def subprocess_clean_pass?(test_paths)
699
+ status, = spawn_script(clean_check_script(test_paths), result: false)
700
+ status&.success? || false
701
+ end
702
+
703
+ # Runs `script` in a fresh `ruby -` that reads the script from stdin. The
704
+ # child's stdout goes to File::NULL, so test output never reaches the user
705
+ # or the result. With `result: true`, the child writes its result as one
706
+ # line to fd {RESULT_FD}, a pipe that only the script uses. The child's
707
+ # stderr is the parent's stderr, so warnings from the script reach the
708
+ # user. A wall clock of `@capture_timeout` bounds the whole call (see
709
+ # {#await_child}), so a hung test cannot wedge the run.
710
+ #
711
+ # The parent reads one line, not until EOF: a process that a test leaves
712
+ # running can inherit fd {RESULT_FD} (a `fork` without `exec` keeps it
713
+ # despite close-on-exec) and hold the pipe open long after the child exits.
714
+ # A clean check reports only through its exit status, so it gets no pipe.
715
+ #
716
+ # @api private
717
+ # @param script [String] Ruby script text.
718
+ # @param result [Boolean] whether to open the result pipe on fd {RESULT_FD}.
719
+ # @return [Array(Process::Status, String)] the exit status and the line the
720
+ # child wrote to fd {RESULT_FD} (`""` without one); `[nil, ""]` after a
721
+ # timeout.
722
+ def spawn_script(script, result: true)
723
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
724
+ script_rd, script_wr = IO.pipe
725
+ result_rd, result_wr = IO.pipe if result
726
+ options = { in: script_rd, out: File::NULL, pgroup: true }
727
+ options[RESULT_FD] = result_wr if result
728
+ pid = Process.spawn(RbConfig.ruby, "-", **options)
729
+ script_rd.close
730
+ result_wr&.close
731
+ begin
732
+ script_wr.write(script)
733
+ rescue Errno::EPIPE
734
+ # The child exited before reading its script; await_child still reaps it.
735
+ end
736
+ script_wr.close
737
+ status, out = await_child(pid, deadline) { read_result_line(result_rd) if result }
738
+ [status, out.to_s]
739
+ ensure
740
+ [script_rd, script_wr, result_rd, result_wr].compact.each { |io| io.close unless io.closed? }
741
+ end
742
+
743
+ # Reads the one result line a {#spawn_script} child writes to fd
744
+ # {RESULT_FD}.
745
+ #
746
+ # @api private
747
+ # @param io [IO] the parent end of the result pipe.
748
+ # @return [String] the line, or `""` at end of file.
749
+ def read_result_line(io) = io.gets.to_s
750
+
751
+ # Reads the one Marshal object a forked capture child writes, without
752
+ # waiting for end of file.
753
+ #
754
+ # @api private
755
+ # @param io [IO] the parent end of the binary result pipe.
756
+ # @return [Object, nil] the object, or nil when the child wrote none.
757
+ def read_marshal(io)
758
+ Marshal.load(io)
759
+ rescue EOFError
760
+ nil
761
+ end
762
+
763
+ # Waits for capture child `pid`, which leads its own process group, while
764
+ # a thread runs the block to read the child's result. One `deadline`
765
+ # bounds the run, the read, and the reap. Past it, the whole group gets
766
+ # SIGKILL, so a process the test started does not outlive a hung capture.
767
+ # Process.detach owns reaping. The same deadline and group kill as
768
+ # DaemonServer#wait_verdict, plus a reader, because the child reports
769
+ # through a pipe.
770
+ #
771
+ # A child that exits before the deadline has written its result, so the
772
+ # reader gets the time left, and at least {RESULT_GRACE} seconds, to finish.
773
+ #
774
+ # @api private
775
+ # @param pid [Integer] child pid, also its process group id.
776
+ # @param deadline [Float] a CLOCK_MONOTONIC time.
777
+ # @yieldreturn [Object] what the child wrote.
778
+ # @return [Array(Process::Status, Object)] the exit status and the block's
779
+ # value (nil when the reader did not finish); `[nil, nil]` after a timeout.
780
+ def await_child(pid, deadline, &read)
781
+ waiter = Process.detach(pid)
782
+ reader = Thread.new(&read)
783
+ reader.report_on_exception = false
784
+ return [nil, nil] unless waiter.join(remaining(deadline))
785
+
786
+ [waiter.value, reader.join([remaining(deadline), RESULT_GRACE].max)&.value]
787
+ ensure
788
+ kill_group(pid, waiter) if waiter&.alive?
789
+ reader&.kill
790
+ end
791
+
792
+ # Seconds left before `deadline`, never negative.
793
+ #
794
+ # @api private
795
+ # @param deadline [Float] a CLOCK_MONOTONIC time.
796
+ # @return [Float]
797
+ def remaining(deadline)
798
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
799
+ end
800
+
801
+ # Ruby source that opens the result channel in a {#spawn_script} child. The
802
+ # script runs it first, so no file that a test opens can take fd
803
+ # {RESULT_FD}. Close-on-exec keeps the fd out of the test's own
804
+ # subprocesses.
805
+ #
806
+ # @api private
807
+ # @return [String] Ruby script text.
808
+ def result_channel_expression
809
+ "_result = IO.new(#{RESULT_FD}, \"w\"); _result.close_on_exec = true"
810
+ end
811
+
812
+ # Runs test files in a fork of the booted parent and returns whether they passed.
813
+ # Bounded by `@capture_timeout` so a hung child cannot block the CLI.
814
+ #
815
+ # @api private
816
+ # @param abs_tests [Array<String>] absolute test file paths.
817
+ # @param after_fork [Proc, nil] boot-mode fork hook.
818
+ # @return [Boolean]
819
+ def fork_clean_pass?(abs_tests, after_fork)
820
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
821
+ rd, wr = IO.pipe
822
+ rd.binmode
823
+ wr.binmode
824
+ parent = Process.pid
825
+ @before_fork&.call
826
+ pid = fork do
827
+ rd.close
828
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
829
+ OrphanGuard.start(parent)
830
+ begin
831
+ ChildStdout.silence
832
+ after_fork&.call
833
+ Coverage.result(clear: true, stop: false) if Coverage.running?
834
+ wr.write(Marshal.dump(TestRunners.for(@framework).run(abs_tests).zero?))
835
+ rescue Exception # rubocop:disable Lint/RescueException
836
+ wr.write(Marshal.dump(false))
837
+ ensure
838
+ wr.close
839
+ exit!(0)
840
+ end
841
+ end
842
+ wr.close
843
+ _, passed = await_child(pid, deadline) { read_marshal(rd) }
844
+ passed == true
845
+ rescue StandardError
846
+ false
847
+ ensure
848
+ @fork_cleanup&.call(pid) if pid
849
+ [rd, wr].compact.each { |io| io.close unless io.closed? }
850
+ end
851
+
852
+ # SIGKILLs capture child `pid` and its process group, then waits for
853
+ # `waiter` to reap the child. Falls back to the pid alone when the group
854
+ # does not exist yet.
855
+ #
856
+ # @api private
857
+ # @param pid [Integer] child pid, also its process group id.
858
+ # @param waiter [Thread] the Process.detach thread for `pid`.
859
+ # @return [void]
860
+ def kill_group(pid, waiter)
861
+ begin
862
+ Process.kill(:KILL, -pid)
863
+ rescue Errno::ESRCH, Errno::EPERM
864
+ # No group yet: the child has not run setpgid. Never signal a reaped pid.
865
+ if waiter.alive?
866
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
867
+ end
868
+ end
869
+ waiter.join
870
+ end
871
+
872
+ # Builds a pass/fail-only subprocess script (no coverage instrumentation).
873
+ #
874
+ # @api private
875
+ # @param test_paths [Array<String>] test file paths.
876
+ # @return [String] Ruby script text.
877
+ def clean_check_script(test_paths)
878
+ @framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
879
+ end
880
+
881
+ # Minitest clean-suite check. Preloads configured sources and `--require`
882
+ # files like capture and standalone {Runner.execute}, so tests that rely on
883
+ # that preload stay green.
884
+ #
885
+ # @api private
886
+ # @param test_paths [Array<String>] test file paths.
887
+ # @return [String] Ruby script text.
888
+ def minitest_clean_check_script(test_paths)
889
+ loads = Array(test_paths).map { |t| "load #{absolute(t).inspect}" }.join("\n")
890
+ <<~RUBY
891
+ require "minitest"
892
+ require "stringio"
893
+ def Minitest.autorun; end
894
+ _report = StringIO.new
895
+ Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
896
+ Minitest.extensions << "mutineer_report"
897
+ $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
898
+ #{abs_preload_paths.inspect}.each { |f| require f }
899
+ #{loads}
900
+ _passed = Minitest.run([])
901
+ $stderr.write(_report.string) unless _passed
902
+ exit(_passed ? 0 : 1)
903
+ RUBY
904
+ end
905
+
906
+ # RSpec clean-suite check. Preloads configured sources and `--require`
907
+ # files like capture.
908
+ #
909
+ # @api private
910
+ # @param test_paths [Array<String>] spec file paths.
911
+ # @return [String] Ruby script text.
912
+ def rspec_clean_check_script(test_paths)
913
+ specs = Array(test_paths).map { |t| absolute(t).inspect }.join(", ")
914
+ <<~RUBY
915
+ require "stringio"
916
+ begin
917
+ require "rspec/core"
918
+ rescue LoadError
919
+ exit 3
920
+ end
921
+ RSpec::Core::Runner.disable_autorun!
922
+ $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
923
+ #{abs_preload_paths.inspect}.each { |f| require f }
924
+ _sink = StringIO.new
925
+ status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
926
+ $stderr.write(_sink.string) unless status.zero?
927
+ exit(status.zero? ? 0 : 1)
928
+ RUBY
929
+ end
930
+
931
+ # Builds the framework-specific subprocess script.
932
+ #
933
+ # @api private
934
+ # @param test_path [String] test file path.
935
+ # @return [String] Ruby script text.
936
+ def subprocess_script(test_path)
937
+ @framework == "rspec" ? rspec_subprocess_script(test_path) : minitest_subprocess_script(test_path)
938
+ end
939
+
940
+ # Builds the minitest subprocess script.
941
+ #
942
+ # @api private
943
+ # @param test_path [String] test file path.
944
+ # @return [String] Ruby script text.
945
+ def minitest_subprocess_script(test_path)
946
+ <<~RUBY
947
+ #{result_channel_expression}
948
+ require "coverage"
949
+ require "json"
950
+ require "minitest"
951
+ require "stringio"
952
+ def Minitest.autorun; end
953
+ _report = StringIO.new
954
+ Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
955
+ Minitest.extensions << "mutineer_report"
956
+ Coverage.start(lines: true, methods: true)
957
+ $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
958
+ #{abs_preload_paths.inspect}.each { |f| require f }
959
+ _load = #{load_coverage_expression}
960
+ load #{absolute(test_path).inspect}
961
+ _passed = Minitest.run([])
962
+ $stderr.write(_report.string) unless _passed
963
+ _coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
964
+ _result.puts JSON.generate("passed" => _passed == true, "coverage" => _coverage,
965
+ "load_coverage" => _load,
966
+ "loaded_files" => #{loaded_files_expression})
967
+ _result.close
968
+ RUBY
969
+ end
970
+
971
+ # Same coverage-JSON contract as the minitest path, but driven by RSpec:
972
+ # require rspec/core lazily, require the sources under Coverage, then run the
973
+ # one spec via RSpec::Core::Runner. The JSON goes to the result channel (see
974
+ # {#spawn_script}), so spec output cannot corrupt it. A missing rspec makes
975
+ # the script exit non-zero -> capture() records a skipped (incomplete-map)
976
+ # test, with a hint.
977
+ def rspec_subprocess_script(test_path)
978
+ <<~RUBY
979
+ #{result_channel_expression}
980
+ require "coverage"
981
+ require "json"
982
+ require "stringio"
983
+ begin
984
+ require "rspec/core"
985
+ rescue LoadError
986
+ warn "[mutineer] framework 'rspec' requested but rspec is not available in the project"
987
+ exit 3
988
+ end
989
+ RSpec::Core::Runner.disable_autorun!
990
+ Coverage.start(lines: true, methods: true)
991
+ $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
992
+ #{abs_preload_paths.inspect}.each { |f| require f }
993
+ _load = #{load_coverage_expression}
994
+ _sink = StringIO.new
995
+ _status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
996
+ $stderr.write(_sink.string) unless _status.zero?
997
+ _coverage = Coverage.result.transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
998
+ _result.puts JSON.generate("passed" => _status.zero?, "coverage" => _coverage,
999
+ "load_coverage" => _load,
1000
+ "loaded_files" => #{loaded_files_expression})
1001
+ _result.close
1002
+ RUBY
1003
+ end
1004
+
1005
+ # Records every source line with a non-zero execution count as covered by
1006
+ # this test file. Coverage.result keys are absolute; relativize and drop any
1007
+ # path outside the project (stdlib/gem files).
1008
+ def record(coverage, test_path)
1009
+ rel_test = relativize(test_path)
1010
+ each_covered_key(coverage) { |key| (@map[key] ||= []) << rel_test }
1011
+ end
1012
+
1013
+ # Adds the configured source lines that ran at load to {#load_lines}, and
1014
+ # the methods called at load to {#load_methods}. Lines of other files
1015
+ # (tests, helpers, gems) are dropped.
1016
+ #
1017
+ # @api private
1018
+ # @param coverage [Hash, nil] counts per absolute file, as {#record} reads
1019
+ # them; a Hash entry can also hold "methods", as {#load_entry} builds it.
1020
+ # @return [void]
1021
+ def record_load(coverage)
1022
+ return unless coverage.is_a?(Hash)
1023
+
1024
+ @source_rels ||= @source_paths.to_set { |p| relativize(absolute(p)) }
1025
+ ours = coverage.select { |abs_file, _| @source_rels.include?(relativize(abs_file)) }
1026
+ each_covered_key(ours) { |key| @load_lines << key }
1027
+ ours.each do |abs_file, data|
1028
+ next unless data.is_a?(Hash) && data["methods"].is_a?(Array)
1029
+
1030
+ data["methods"].each { |line, column| @load_methods << "#{relativize(abs_file)}:#{line}:#{column}" }
1031
+ end
1032
+ end
1033
+
1034
+ # One file's line counts, with the `def` line of each method the test
1035
+ # called counted too (#209). A forked capture does not see the `def` lines
1036
+ # run, since the boot defined the methods before the test. Ruby counts no
1037
+ # line when an endless method runs, so without this its mutants would be
1038
+ # `no_coverage` even when a test calls it. A line where more than one
1039
+ # method starts is not counted: the map keys tests by line, so the test
1040
+ # would also be credited with the methods it did not call.
1041
+ #
1042
+ # @api private
1043
+ # @param data [Hash] `Coverage` result for one file, with `:lines` and `:methods`.
1044
+ # @return [Array, nil] line counts.
1045
+ def lines_with_called_defs(data)
1046
+ lines = data[:lines]
1047
+ return lines unless lines && data[:methods]
1048
+
1049
+ lines = lines.dup
1050
+ starts = data[:methods].keys.map { |key| key[2] }.tally
1051
+ data[:methods].each do |key, count|
1052
+ line = key[2]
1053
+ lines[line - 1] = [lines[line - 1].to_i, count].max if count.positive? && starts[line] == 1
1054
+ end
1055
+ lines
1056
+ end
1057
+
1058
+ # One file's load-time coverage in the shape {#record_load} reads: the line
1059
+ # counts, and the `[line, column]` of each method called at least once.
1060
+ # {#load_coverage_expression} builds the same shape in a capture child.
1061
+ #
1062
+ # @api private
1063
+ # @param lines [Array, nil] line counts.
1064
+ # @param methods [Hash, nil] `Coverage` method counts, keyed by
1065
+ # `[owner, name, start_line, start_column, end_line, end_column]`.
1066
+ # @return [Hash{String => Array}]
1067
+ def load_entry(lines, methods)
1068
+ { "lines" => lines, "methods" => (methods || {}).filter_map { |key, count| key[2, 2] if count.positive? } }
1069
+ end
1070
+
1071
+ # Ruby source of the capture child's load-time coverage: `Coverage.peek_result`
1072
+ # for the configured sources only, matched by real path (a Coverage key is
1073
+ # the path as required), so the payload does not carry every loaded gem.
1074
+ # Each entry has the shape {#load_entry} builds.
1075
+ #
1076
+ # @api private
1077
+ # @return [String] expression to embed in a capture subprocess script.
1078
+ def load_coverage_expression
1079
+ "Coverage.peek_result.select { |f, _| #{abs_source_paths.inspect}.include?((File.realpath(f) rescue f)) }" \
1080
+ ".transform_values { |v| { \"lines\" => v[:lines], " \
1081
+ "\"methods\" => v[:methods].filter_map { |k, n| k[2, 2] if n.positive? } } }"
1082
+ end
1083
+
1084
+ # Yields a "file:line" key for every project line with a non-zero count.
1085
+ #
1086
+ # @api private
1087
+ # @param coverage [Hash] counts per absolute file: an Array, or a Hash with "lines".
1088
+ # @yieldparam key [String] the "file:line" key.
1089
+ # @return [void]
1090
+ def each_covered_key(coverage)
1091
+ coverage.each do |abs_file, data|
1092
+ rel = relativize(abs_file)
1093
+ next if rel.start_with?("/") # outside project_root: not our source
1094
+
1095
+ counts = data.is_a?(Hash) ? data["lines"] : data
1096
+ next unless counts.is_a?(Array) # a Coverage mode without line counts
1097
+
1098
+ counts.each_with_index { |count, idx| yield "#{rel}:#{idx + 1}" if count&.positive? }
1099
+ end
1100
+ end
1101
+
1102
+ # Ruby source of the child-side `$LOADED_FEATURES` filter (project `.rb` files).
1103
+ #
1104
+ # @api private
1105
+ # @return [String] expression to embed in a capture subprocess script.
1106
+ def loaded_files_expression
1107
+ root = project_root_real
1108
+ prefix = root.end_with?("/") ? root : "#{root}/"
1109
+ "begin; _root = #{prefix.inspect}; $LOADED_FEATURES.filter_map { |f| next unless f.end_with?(\".rb\"); abs = (File.realpath(f) rescue next); abs if abs.start_with?(_root) }; rescue StandardError; []; end"
1110
+ end
1111
+
1112
+ # Canonical project root for loaded-feature matching (`/var` vs `/private/var`).
1113
+ #
1114
+ # @api private
1115
+ # @return [String] realpath of the project root when it exists.
1116
+ def project_root_real = ProjectPath.root_real(@project_root)
1117
+
1118
+ # Project-local `.rb` files loaded in this process at capture time.
1119
+ #
1120
+ # @api private
1121
+ # @return [Array<String>] absolute realpaths.
1122
+ def capture_loaded_files
1123
+ prefix = project_root_real
1124
+ prefix = "#{prefix}/" unless prefix.end_with?("/")
1125
+ $LOADED_FEATURES.filter_map do |f|
1126
+ next unless f.end_with?(".rb")
1127
+
1128
+ abs = File.realpath(f)
1129
+ abs if abs.start_with?(prefix)
1130
+ rescue Errno::ENOENT
1131
+ nil
1132
+ end
1133
+ end
1134
+
1135
+ # Fingerprints project-local support files from a capture payload.
1136
+ #
1137
+ # @api private
1138
+ # @param paths [Array, nil] absolute loaded-file paths.
1139
+ # @return [void]
1140
+ def record_loaded(paths)
1141
+ Array(paths).each do |raw|
1142
+ next unless raw.is_a?(String) && File.file?(raw)
1143
+
1144
+ abs = File.realpath(raw)
1145
+ rel = loaded_relative(abs)
1146
+ next unless rel
1147
+ next unless rel.end_with?(".rb")
1148
+ next if rel.start_with?("vendor/bundle/") || rel.start_with?("node_modules/")
1149
+
1150
+ @loaded_dependencies[rel] = file_fingerprint(abs)
1151
+ end
1152
+ end
1153
+
1154
+ # Path of `abs` relative to the real project root, or nil when outside it.
1155
+ #
1156
+ # @api private
1157
+ # @param abs [String] absolute realpath.
1158
+ # @return [String, nil]
1159
+ def loaded_relative(abs)
1160
+ root = project_root_real
1161
+ prefix = root.end_with?("/") ? root : "#{root}/"
1162
+ return unless abs.start_with?(prefix)
1163
+
1164
+ abs.delete_prefix(prefix)
1165
+ end
1166
+
1167
+ # Byte fingerprint of a file for cache dependency checks.
1168
+ #
1169
+ # @api private
1170
+ # @param abs [String] absolute path.
1171
+ # @return [String] hex digest.
1172
+ def file_fingerprint(abs)
1173
+ content = File.binread(abs)
1174
+ Digest::SHA256.hexdigest("#{content.bytesize}\0#{content}")
1175
+ end
1176
+
1177
+ # True when the cached map recorded support-file fingerprints and they still
1178
+ # match. Missing validity data is a miss (rebuild).
1179
+ #
1180
+ # @api private
1181
+ # @param cached [Hash] parsed coverage.json.
1182
+ # @return [Boolean]
1183
+ def dependencies_match?(cached)
1184
+ deps = cached["dependencies"]
1185
+ return false unless deps.is_a?(Hash)
1186
+
1187
+ deps.all? do |rel, fingerprint|
1188
+ abs = absolute(rel)
1189
+ File.file?(abs) && file_fingerprint(abs) == fingerprint
1190
+ end
1191
+ end
1192
+
1193
+ # Digest each file's ROLE + relative path + content length + content, plus
1194
+ # the load_paths. Without role/path/length delimiters the digest collides
1195
+ # (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
1196
+ # accepting a stale cached map.
1197
+ def compute_digest
1198
+ d = Digest::SHA256.new
1199
+ digest_group(d, "source", @source_paths)
1200
+ digest_group(d, "test", @test_paths)
1201
+ # One group per file: digest_group sorts, and the load order matters.
1202
+ @require_paths.each { |p| digest_group(d, "require", [digest_path(p)]) }
1203
+ digest_group(d, "boot", [digest_path(@boot_path)]) if @boot_path
1204
+ ownership_paths.each do |rel|
1205
+ d.update("owner\0")
1206
+ d.update(rel)
1207
+ d.update("\0")
1208
+ end
1209
+ @load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
1210
+ d.update("framework\0#{@framework}\0")
1211
+ d.hexdigest
1212
+ end
1213
+
1214
+ # Longer source files that can take a split test from a configured source.
1215
+ # Only their presence is digested. A content edit of a sibling does not
1216
+ # change pairing, and it does not rebuild coverage. Each directory is
1217
+ # listed once, even when many sources share it.
1218
+ #
1219
+ # @return [Array<String>] project-relative paths, sorted.
1220
+ def ownership_paths
1221
+ root = File.expand_path(@project_root)
1222
+ listings = {}
1223
+ paths = @source_paths.flat_map do |source|
1224
+ rel = relativize(absolute(source))
1225
+ next [] if rel.start_with?("/")
1226
+
1227
+ base, = Pairing.logical_path(rel)
1228
+ name = File.basename(base)
1229
+ dir = File.dirname(base)
1230
+ folders = [dir == "." ? nil : dir]
1231
+ %w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
1232
+ folders.uniq.flat_map do |folder|
1233
+ abs = folder ? File.join(root, folder) : root
1234
+ entries = listings.fetch(abs) do
1235
+ listings[abs] = File.directory?(abs) ? Dir.children(abs) : []
1236
+ end
1237
+ entries.filter_map do |entry|
1238
+ next unless entry.end_with?(".rb")
1239
+
1240
+ stem = entry.delete_suffix(".rb")
1241
+ next unless stem.start_with?("#{name}_") && stem.length > name.length
1242
+
1243
+ folder ? File.join(folder, entry) : entry
1244
+ end
1245
+ end
1246
+ end
1247
+ paths.uniq.sort
1248
+ end
1249
+
1250
+ # boot_path and require_paths are require-style paths (e.g. "config/environment",
1251
+ # no extension); resolve one to the file `require` loads: the path itself, then
1252
+ # ".rb", then a native extension. A folder of the same name (lib/my_gem/ next to
1253
+ # lib/my_gem.rb) is not the file.
1254
+ def digest_path(path)
1255
+ ["", ".rb", ".#{RbConfig::CONFIG['DLEXT']}"].map { |ext| "#{path}#{ext}" }
1256
+ .find { |p| File.file?(absolute(p)) } || "#{path}.rb"
1257
+ end
1258
+
1259
+ # Groups a digest with its role and paths.
1260
+ #
1261
+ # @api private
1262
+ # @param digest [String] digest string.
1263
+ # @param role [String] digest role.
1264
+ # @param paths [Array<String>] paths in the digest group.
1265
+ # @return [Array(String, String, Array<String>)] grouped digest data.
1266
+ def digest_group(digest, role, paths)
1267
+ paths.sort.each do |p|
1268
+ content = File.read(absolute(p))
1269
+ digest.update(role)
1270
+ digest.update("\0")
1271
+ digest.update(relativize(absolute(p)))
1272
+ digest.update("\0")
1273
+ digest.update(content.bytesize.to_s)
1274
+ digest.update("\0")
1275
+ digest.update(content)
1276
+ digest.update("\0")
1277
+ end
1278
+ end
1279
+
1280
+ # A configured source that resolves outside project_root would silently be
1281
+ # dropped (its coverage relativizes to an absolute path). Warn instead.
1282
+ def warn_external_sources
1283
+ @source_paths.each do |p|
1284
+ next unless relativize(absolute(p)).start_with?("/")
1285
+
1286
+ warn "[mutineer] source #{p} is outside project root #{@project_root}; " \
1287
+ "its coverage will be ignored"
1288
+ end
1289
+ end
1290
+
1291
+ # Returns the cache path.
1292
+ #
1293
+ # @api private
1294
+ # @return [String] cache file path.
1295
+ def cache_path = File.join(@cache_dir, "coverage.json")
1296
+
1297
+ # Reads the coverage cache.
1298
+ #
1299
+ # @api private
1300
+ # @return [Hash, nil] cached payload.
1301
+ def read_cache
1302
+ return nil unless File.exist?(cache_path)
1303
+
1304
+ JSON.parse(File.read(cache_path))
1305
+ rescue JSON::ParserError
1306
+ nil # corrupt cache: rebuild from scratch
1307
+ end
1308
+
1309
+ # Saves the coverage cache.
1310
+ #
1311
+ # @api private
1312
+ # @return [void]
1313
+ def save
1314
+ return unless @failed_clean_tests.empty?
1315
+
1316
+ FileUtils.mkdir_p(@cache_dir)
1317
+ data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
1318
+ "dependencies" => @loaded_dependencies, "map" => @map,
1319
+ "load_lines" => @load_lines.to_a.sort, "load_methods" => @load_methods.to_a.sort,
1320
+ "timings" => @timings }
1321
+ tmp = "#{cache_path}.tmp"
1322
+ File.write(tmp, JSON.generate(data))
1323
+ File.rename(tmp, cache_path) # atomic swap
1324
+ end
1325
+
1326
+ # Warns when coverage capture was incomplete.
1327
+ #
1328
+ # @api private
1329
+ # @return [void]
1330
+ def warn_incomplete
1331
+ warn "[mutineer] cached coverage map may be incomplete; these test files " \
1332
+ "failed to contribute: #{@failed_test_files.join(', ')}"
1333
+ end
1334
+
1335
+ # Returns absolute source paths.
1336
+ #
1337
+ # @return [Array<String>] absolute source paths.
1338
+ def abs_source_paths = @source_paths.map { |p| absolute(p) }
1339
+
1340
+ # The sources, then the `--require` files, in the order standalone
1341
+ # {Runner.execute} requires them before it forks the mutants (#217).
1342
+ #
1343
+ # @api private
1344
+ # @return [Array<String>] absolute paths.
1345
+ def abs_preload_paths = abs_source_paths + @require_paths.map { |p| absolute(p) }
1346
+
1347
+ # Returns absolute load paths.
1348
+ #
1349
+ # @return [Array<String>] absolute load paths.
1350
+ def abs_load_paths = @load_paths.map { |p| absolute(p) }
1351
+
1352
+ # Relativizes a path against the project root (see {ProjectPath.relative}).
1353
+ #
1354
+ # @api private
1355
+ # @param path [String] path to relativize.
1356
+ # @return [String] relative path, or an absolute path when outside the root.
1357
+ def relativize(path) = ProjectPath.relative(path, @project_root)
1358
+
1359
+ # Expands a path relative to the project root (see {ProjectPath.absolute}).
1360
+ #
1361
+ # @api private
1362
+ # @param path [String] path to expand.
1363
+ # @return [String] absolute path.
1364
+ def absolute(path) = ProjectPath.absolute(path, @project_root)
1365
+ end
1366
+ end