mutineer 1.5.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.
@@ -9,6 +9,7 @@ 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"
13
14
  require_relative "pairing"
14
15
 
@@ -28,8 +29,36 @@ module Mutineer
28
29
  # the parent. Stdout stays free for test output, which goes to File::NULL.
29
30
  RESULT_FD = 3
30
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
+
31
37
  attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
32
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
+
33
62
  # Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
34
63
  # map app-side and ships `map` + `failed_test_files` over IPC; the tool
35
64
  # reconstructs it here for per-mutant selection). Skips the capture machinery
@@ -40,10 +69,18 @@ module Mutineer
40
69
  # @param failed_test_files [Array<String>] test files whose capture failed.
41
70
  # @param project_root [String] project root (for path relativization).
42
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}).
43
76
  # @return [Mutineer::CoverageMap] a query-only map.
44
- 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: {})
45
79
  instance = allocate
46
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 || []))
47
84
  instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
48
85
  instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
49
86
  instance.instance_variable_set(:@project_root, project_root)
@@ -53,8 +90,9 @@ module Mutineer
53
90
  def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
54
91
  load_paths: ["lib"], project_root: Dir.pwd,
55
92
  capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
56
- framework: "minitest", verbose: false)
93
+ framework: "minitest", verbose: false, require_paths: [])
57
94
  @source_paths = Array(source_paths)
95
+ @require_paths = Array(require_paths)
58
96
  @test_paths = Array(test_paths)
59
97
  @cache_dir = cache_dir
60
98
  @load_paths = Array(load_paths)
@@ -66,6 +104,9 @@ module Mutineer
66
104
  @map = {}
67
105
  @failed_test_files = []
68
106
  @failed_clean_tests = []
107
+ @load_lines = Set.new
108
+ @load_methods = Set.new
109
+ @timings = {}
69
110
  @loaded_dependencies = {}
70
111
  @phase_a_ran = false
71
112
  end
@@ -83,8 +124,15 @@ module Mutineer
83
124
  # the booted parent instead. Inverts into the same map #tests_for reads, and
84
125
  # reuses the digest cache (the digest mixes in the boot file so a boot cache
85
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.
86
130
  def build_via_fork(after_fork: nil)
87
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 })
88
136
  cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
89
137
  end
90
138
 
@@ -95,6 +143,54 @@ module Mutineer
95
143
  @map["#{relativize(file)}:#{line}"] || []
96
144
  end
97
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
+
98
194
  # Is this source file's empty coverage the result of an *errored* capture
99
195
  # rather than a genuine coverage gap? True iff some capture failed this run
100
196
  # AND this file got zero coverage from any successful capture AND a failed
@@ -272,8 +368,18 @@ module Mutineer
272
368
  def cached_or(after_fork: nil)
273
369
  @digest = compute_digest
274
370
  cached = read_cache
275
- 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")
276
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
277
383
  @failed_test_files = cached["failed_test_files"] || []
278
384
  @failed_clean_tests = []
279
385
  @loaded_dependencies = cached["dependencies"] || {}
@@ -297,12 +403,15 @@ module Mutineer
297
403
  def run_phase_a
298
404
  @phase_a_ran = true
299
405
  @map = {}
406
+ @load_lines = Set.new
407
+ @load_methods = Set.new
408
+ @timings = {}
300
409
  @failed_test_files = []
301
410
  @failed_clean_tests = []
302
411
  @loaded_dependencies = {}
303
412
 
304
413
  @test_paths.each do |test_path|
305
- payload = capture(test_path)
414
+ payload = timed(test_path) { capture(test_path) }
306
415
  next unless payload
307
416
 
308
417
  accept_capture_payload(test_path, payload)
@@ -317,51 +426,83 @@ module Mutineer
317
426
  def run_phase_a_via_fork(after_fork:)
318
427
  @phase_a_ran = true
319
428
  @map = {}
429
+ @timings = {}
320
430
  @failed_test_files = []
321
431
  @failed_clean_tests = []
322
432
  @loaded_dependencies = {}
323
433
  abs_sources = abs_source_paths
324
434
 
325
435
  @test_paths.each do |test_path|
326
- # Tri-state payload: Hash = capture result, String = error diagnostic from
327
- # the child, nil = pipe gone / empty. The String diagnostic is what
328
- # becomes an :uncapturable status.
329
- case (payload = fork_capture(absolute(test_path), abs_sources, after_fork))
330
- when Hash then accept_capture_payload(test_path, payload)
331
- when String
332
- fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
333
- "fork capture produced no result (re-run with --verbose for the error)")
334
- else fail_test(test_path, "fork capture produced no result")
335
- 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")
336
472
  end
337
473
  end
338
474
 
339
475
  # Fork the booted parent, run one test under the inherited Coverage, and
340
- # 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
341
478
  # fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.
342
479
  def fork_capture(abs_test, abs_sources, after_fork)
480
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
343
481
  rd, wr = IO.pipe
344
482
  # Marshal output is binary: an un-binmoded pipe can raise
345
483
  # Encoding::UndefinedConversionError on write, which the child's rescue then
346
484
  # swallows, losing the real error and yielding a bare "no result".
347
485
  rd.binmode
348
486
  wr.binmode
487
+ parent = Process.pid
349
488
  pid = fork do
350
489
  rd.close
490
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
491
+ OrphanGuard.start(parent)
351
492
  payload =
352
493
  begin
353
494
  ChildStdout.silence
354
495
  # Fork-safety hook: the in-process path reconnects AR; the daemon
355
- # routes to its worker DB. Nil (non-Rails) = no-op. Injected so this
356
- # 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.
357
498
  after_fork&.call
358
499
  Coverage.result(clear: true, stop: false) # discard pre-test delta
359
500
  passed = TestRunners.for(@framework).run([abs_test]).zero?
360
- # lines:true yields {file => {lines: [...]}}; reduce to the counts
501
+ # {file => {lines: [...], methods: {...}}}; reduce to the counts
361
502
  # array record() expects, keeping only our source files.
362
503
  coverage = Coverage.result(stop: false)
363
504
  .select { |f, _| abs_sources.include?(f) }
364
- .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
505
+ .transform_values { |v| v.is_a?(Hash) ? lines_with_called_defs(v) : v }
365
506
  { "passed" => passed, "coverage" => coverage,
366
507
  "loaded_files" => capture_loaded_files }
367
508
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -379,18 +520,22 @@ module Mutineer
379
520
  end
380
521
  end
381
522
  wr.close
382
- data = rd.read
383
- rd.close
384
- _, 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
+
385
528
  # An empty pipe means the child died before writing (e.g. a hard crash,
386
529
  # OOM, or a signal from the test's own subprocess handling). Report HOW it
387
530
  # died (exit status / signal) as a diagnostic string so --verbose has
388
531
  # something actionable instead of a silent "no result".
389
- return "child wrote no result (#{describe_status(status)})" if data.empty?
532
+ return "child wrote no result (#{describe_status(status)})" if payload.nil?
390
533
 
391
- Marshal.load(data)
534
+ payload
392
535
  rescue StandardError => e
393
536
  "parent could not read capture result: #{e.class}: #{e.message}"
537
+ ensure
538
+ [rd, wr].compact.each { |io| io.close unless io.closed? }
394
539
  end
395
540
 
396
541
  # Human description of a child Process::Status for capture diagnostics.
@@ -462,6 +607,7 @@ module Mutineer
462
607
  end
463
608
 
464
609
  record_loaded(payload["loaded_files"])
610
+ record_load(payload["load_coverage"])
465
611
 
466
612
  unless payload["passed"]
467
613
  @failed_clean_tests << relativize(test_path)
@@ -507,16 +653,9 @@ module Mutineer
507
653
  pending.each do |rel|
508
654
  test_path = @test_paths.find { |t| relativize(t) == rel } || rel
509
655
  if @boot_path
510
- payload = fork_capture(absolute(test_path), abs_sources, after_fork)
511
- case payload
512
- when Hash then accept_capture_payload(test_path, payload)
513
- when String
514
- fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
515
- "fork capture produced no result (re-run with --verbose for the error)")
516
- else fail_test(test_path, "fork capture produced no result")
517
- end
656
+ accept_fork_payload(test_path, timed(test_path) { fork_capture(absolute(test_path), abs_sources, after_fork) })
518
657
  else
519
- payload = capture(test_path)
658
+ payload = timed(test_path) { capture(test_path) }
520
659
  accept_capture_payload(test_path, payload) if payload
521
660
  end
522
661
  end
@@ -556,8 +695,8 @@ module Mutineer
556
695
  # or the result. With `result: true`, the child writes its result as one
557
696
  # line to fd {RESULT_FD}, a pipe that only the script uses. The child's
558
697
  # stderr is the parent's stderr, so warnings from the script reach the
559
- # user. A wall clock of `@capture_timeout` bounds the whole call, so a hung
560
- # 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.
561
700
  #
562
701
  # The parent reads one line, not until EOF: a process that a test leaves
563
702
  # running can inherit fd {RESULT_FD} (a `fork` without `exec` keeps it
@@ -574,27 +713,72 @@ module Mutineer
574
713
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
575
714
  script_rd, script_wr = IO.pipe
576
715
  result_rd, result_wr = IO.pipe if result
577
- options = { in: script_rd, out: File::NULL }
716
+ options = { in: script_rd, out: File::NULL, pgroup: true }
578
717
  options[RESULT_FD] = result_wr if result
579
718
  pid = Process.spawn(RbConfig.ruby, "-", **options)
580
- waiter = Process.detach(pid)
581
719
  script_rd.close
582
720
  result_wr&.close
583
- reader = Thread.new { result_rd.gets.to_s } if result
584
- script_wr.write(script)
585
- script_wr.close
586
- unless waiter.join(remaining(deadline))
587
- Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
588
- waiter.join
589
- reader&.kill
590
- 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.
591
725
  end
592
- [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]
593
729
  ensure
594
- reader&.kill
595
730
  [script_rd, script_wr, result_rd, result_wr].compact.each { |io| io.close unless io.closed? }
596
731
  end
597
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
+
598
782
  # Seconds left before `deadline`, never negative.
599
783
  #
600
784
  # @api private
@@ -623,12 +807,15 @@ module Mutineer
623
807
  # @param after_fork [Proc, nil] boot-mode fork hook.
624
808
  # @return [Boolean]
625
809
  def fork_clean_pass?(abs_tests, after_fork)
810
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
626
811
  rd, wr = IO.pipe
627
812
  rd.binmode
628
813
  wr.binmode
814
+ parent = Process.pid
629
815
  pid = fork do
630
816
  rd.close
631
817
  Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
818
+ OrphanGuard.start(parent)
632
819
  begin
633
820
  ChildStdout.silence
634
821
  after_fork&.call
@@ -642,34 +829,32 @@ module Mutineer
642
829
  end
643
830
  end
644
831
  wr.close
645
- readable, = IO.select([rd], nil, nil, @capture_timeout)
646
- unless readable
647
- kill_fork_clean(pid)
648
- rd.close
649
- return false
650
- end
651
- data = rd.read
652
- rd.close
653
- Process.waitpid2(pid)
654
- return false if data.empty?
655
-
656
- Marshal.load(data)
832
+ _, passed = await_child(pid, deadline) { read_marshal(rd) }
833
+ passed == true
657
834
  rescue StandardError
658
835
  false
836
+ ensure
837
+ [rd, wr].compact.each { |io| io.close unless io.closed? }
659
838
  end
660
839
 
661
- # 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.
662
843
  #
663
844
  # @api private
664
- # @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`.
665
847
  # @return [void]
666
- def kill_fork_clean(pid)
848
+ def kill_group(pid, waiter)
667
849
  begin
668
850
  Process.kill(:KILL, -pid)
669
851
  rescue Errno::ESRCH, Errno::EPERM
670
- 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
671
856
  end
672
- Process.waitpid2(pid) rescue nil # rubocop:disable Style/RescueModifier
857
+ waiter.join
673
858
  end
674
859
 
675
860
  # Builds a pass/fail-only subprocess script (no coverage instrumentation).
@@ -681,8 +866,9 @@ module Mutineer
681
866
  @framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
682
867
  end
683
868
 
684
- # Minitest clean-suite check. Preloads configured sources like capture and
685
- # 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.
686
872
  #
687
873
  # @api private
688
874
  # @param test_paths [Array<String>] test file paths.
@@ -697,7 +883,7 @@ module Mutineer
697
883
  Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
698
884
  Minitest.extensions << "mutineer_report"
699
885
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
700
- #{abs_source_paths.inspect}.each { |f| require f }
886
+ #{abs_preload_paths.inspect}.each { |f| require f }
701
887
  #{loads}
702
888
  _passed = Minitest.run([])
703
889
  $stderr.write(_report.string) unless _passed
@@ -705,7 +891,8 @@ module Mutineer
705
891
  RUBY
706
892
  end
707
893
 
708
- # RSpec clean-suite check. Preloads configured sources like capture.
894
+ # RSpec clean-suite check. Preloads configured sources and `--require`
895
+ # files like capture.
709
896
  #
710
897
  # @api private
711
898
  # @param test_paths [Array<String>] spec file paths.
@@ -721,7 +908,7 @@ module Mutineer
721
908
  end
722
909
  RSpec::Core::Runner.disable_autorun!
723
910
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
724
- #{abs_source_paths.inspect}.each { |f| require f }
911
+ #{abs_preload_paths.inspect}.each { |f| require f }
725
912
  _sink = StringIO.new
726
913
  status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
727
914
  $stderr.write(_sink.string) unless status.zero?
@@ -754,13 +941,16 @@ module Mutineer
754
941
  _report = StringIO.new
755
942
  Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
756
943
  Minitest.extensions << "mutineer_report"
757
- Coverage.start(lines: true)
944
+ Coverage.start(lines: true, methods: true)
758
945
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
759
- #{abs_source_paths.inspect}.each { |f| require f }
946
+ #{abs_preload_paths.inspect}.each { |f| require f }
947
+ _load = #{load_coverage_expression}
760
948
  load #{absolute(test_path).inspect}
761
949
  _passed = Minitest.run([])
762
950
  $stderr.write(_report.string) unless _passed
763
- _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,
764
954
  "loaded_files" => #{loaded_files_expression})
765
955
  _result.close
766
956
  RUBY
@@ -785,13 +975,16 @@ module Mutineer
785
975
  exit 3
786
976
  end
787
977
  RSpec::Core::Runner.disable_autorun!
788
- Coverage.start(lines: true)
978
+ Coverage.start(lines: true, methods: true)
789
979
  $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
790
- #{abs_source_paths.inspect}.each { |f| require f }
980
+ #{abs_preload_paths.inspect}.each { |f| require f }
981
+ _load = #{load_coverage_expression}
791
982
  _sink = StringIO.new
792
983
  _status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
793
984
  $stderr.write(_sink.string) unless _status.zero?
794
- _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,
795
988
  "loaded_files" => #{loaded_files_expression})
796
989
  _result.close
797
990
  RUBY
@@ -802,16 +995,95 @@ module Mutineer
802
995
  # path outside the project (stdlib/gem files).
803
996
  def record(coverage, test_path)
804
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)
805
1079
  coverage.each do |abs_file, data|
806
1080
  rel = relativize(abs_file)
807
1081
  next if rel.start_with?("/") # outside project_root: not our source
808
1082
 
809
- counts = data.is_a?(Array) ? data : data["lines"]
810
- counts.each_with_index do |count, idx|
811
- 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
812
1085
 
813
- (@map["#{rel}:#{idx + 1}"] ||= []) << rel_test
814
- end
1086
+ counts.each_with_index { |count, idx| yield "#{rel}:#{idx + 1}" if count&.positive? }
815
1087
  end
816
1088
  end
817
1089
 
@@ -914,7 +1186,9 @@ module Mutineer
914
1186
  d = Digest::SHA256.new
915
1187
  digest_group(d, "source", @source_paths)
916
1188
  digest_group(d, "test", @test_paths)
917
- 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
918
1192
  ownership_paths.each do |rel|
919
1193
  d.update("owner\0")
920
1194
  d.update(rel)
@@ -961,10 +1235,13 @@ module Mutineer
961
1235
  paths.uniq.sort
962
1236
  end
963
1237
 
964
- # boot_path is a require-style path (e.g. "config/environment", no extension);
965
- # resolve it to the real file for reading, appending ".rb" when needed.
966
- def boot_digest_path
967
- File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
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"
968
1245
  end
969
1246
 
970
1247
  # Groups a digest with its role and paths.
@@ -1026,7 +1303,9 @@ module Mutineer
1026
1303
 
1027
1304
  FileUtils.mkdir_p(@cache_dir)
1028
1305
  data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
1029
- "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 }
1030
1309
  tmp = "#{cache_path}.tmp"
1031
1310
  File.write(tmp, JSON.generate(data))
1032
1311
  File.rename(tmp, cache_path) # atomic swap
@@ -1046,6 +1325,13 @@ module Mutineer
1046
1325
  # @return [Array<String>] absolute source paths.
1047
1326
  def abs_source_paths = @source_paths.map { |p| absolute(p) }
1048
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
+
1049
1335
  # Returns absolute load paths.
1050
1336
  #
1051
1337
  # @return [Array<String>] absolute load paths.