mutineer 0.11.4 → 1.0.1

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.
@@ -22,7 +22,7 @@ module Mutineer
22
22
  # Seconds per coverage subprocess before the parent kills it.
23
23
  DEFAULT_CAPTURE_TIMEOUT = 120
24
24
 
25
- attr_reader :project_root, :failed_test_files, :phase_a_ran, :map
25
+ attr_reader :project_root, :failed_test_files, :failed_clean_tests, :phase_a_ran, :map
26
26
 
27
27
  # Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the
28
28
  # map app-side and ships `map` + `failed_test_files` over IPC; the tool
@@ -33,11 +33,13 @@ module Mutineer
33
33
  # @param map [Hash] the "file:line" => [test_files] map.
34
34
  # @param failed_test_files [Array<String>] test files whose capture failed.
35
35
  # @param project_root [String] project root (for path relativization).
36
+ # @param failed_clean_tests [Array<String>] test files whose unmutated run failed.
36
37
  # @return [Mutineer::CoverageMap] a query-only map.
37
- def self.from_data(map:, failed_test_files:, project_root:)
38
+ def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [])
38
39
  instance = allocate
39
40
  instance.instance_variable_set(:@map, map || {})
40
41
  instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
42
+ instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
41
43
  instance.instance_variable_set(:@project_root, project_root)
42
44
  instance
43
45
  end
@@ -57,6 +59,8 @@ module Mutineer
57
59
  @verbose = verbose
58
60
  @map = {}
59
61
  @failed_test_files = []
62
+ @failed_clean_tests = []
63
+ @loaded_dependencies = {}
60
64
  @phase_a_ran = false
61
65
  end
62
66
 
@@ -75,7 +79,7 @@ module Mutineer
75
79
  # never collides with a standalone one).
76
80
  def build_via_fork(after_fork: nil)
77
81
  warn_external_sources
78
- cached_or { run_phase_a_via_fork(after_fork: after_fork) }
82
+ cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
79
83
  end
80
84
 
81
85
  # Lookup: the test files that cover `file:line`, or [] when none do.
@@ -141,18 +145,30 @@ module Mutineer
141
145
  end
142
146
 
143
147
  # Shared cache dance for both build paths: hit the digest-keyed cache, else
144
- # yield to populate @map and persist it.
145
- def cached_or
148
+ # yield to populate @map and persist it. A digest match is not proof that
149
+ # today's unmutated suite still passes — re-check on a cache hit.
150
+ #
151
+ # @param after_fork [Proc, nil] boot-mode fork hook forwarded to a clean re-check.
152
+ # @yield when the cache is missing or stale.
153
+ # @return [Mutineer::CoverageMap] self.
154
+ def cached_or(after_fork: nil)
146
155
  @digest = compute_digest
147
156
  cached = read_cache
148
- if cached && cached["digest"] == @digest
157
+ if cached && cached["digest"] == @digest && dependencies_match?(cached)
149
158
  @map = cached["map"] || {}
150
159
  @failed_test_files = cached["failed_test_files"] || []
160
+ @failed_clean_tests = []
161
+ @loaded_dependencies = cached["dependencies"] || {}
162
+ retry_failed_captures(after_fork)
151
163
  warn_incomplete unless @failed_test_files.empty?
164
+ verify_cached_clean(after_fork: after_fork)
165
+ verify_combined_clean(after_fork: after_fork)
166
+ save
152
167
  return self
153
168
  end
154
169
 
155
170
  yield
171
+ verify_combined_clean(after_fork: after_fork)
156
172
  save
157
173
  self
158
174
  end
@@ -164,12 +180,14 @@ module Mutineer
164
180
  @phase_a_ran = true
165
181
  @map = {}
166
182
  @failed_test_files = []
183
+ @failed_clean_tests = []
184
+ @loaded_dependencies = {}
167
185
 
168
186
  @test_paths.each do |test_path|
169
- coverage = capture(test_path)
170
- next unless coverage
187
+ payload = capture(test_path)
188
+ next unless payload
171
189
 
172
- record(coverage, test_path)
190
+ accept_capture_payload(test_path, payload)
173
191
  end
174
192
  end
175
193
 
@@ -182,16 +200,18 @@ module Mutineer
182
200
  @phase_a_ran = true
183
201
  @map = {}
184
202
  @failed_test_files = []
203
+ @failed_clean_tests = []
204
+ @loaded_dependencies = {}
185
205
  abs_sources = abs_source_paths
186
206
 
187
207
  @test_paths.each do |test_path|
188
- # Tri-state payload: Hash = coverage, String = error diagnostic from the
189
- # child, nil = pipe gone / empty. The String diagnostic is what becomes
190
- # an :uncapturable status.
191
- case (coverage = fork_capture(absolute(test_path), abs_sources, after_fork))
192
- when Hash then record(coverage, test_path)
208
+ # Tri-state payload: Hash = capture result, String = error diagnostic from
209
+ # the child, nil = pipe gone / empty. The String diagnostic is what
210
+ # becomes an :uncapturable status.
211
+ case (payload = fork_capture(absolute(test_path), abs_sources, after_fork))
212
+ when Hash then accept_capture_payload(test_path, payload)
193
213
  when String
194
- fail_test(test_path, @verbose ? "fork capture failed: #{coverage}" :
214
+ fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
195
215
  "fork capture produced no result (re-run with --verbose for the error)")
196
216
  else fail_test(test_path, "fork capture produced no result")
197
217
  end
@@ -217,12 +237,14 @@ module Mutineer
217
237
  # file needs neither Runner (Prism) nor Rails.
218
238
  after_fork&.call
219
239
  Coverage.result(clear: true, stop: false) # discard pre-test delta
220
- TestRunners.for(@framework).run([abs_test])
240
+ passed = TestRunners.for(@framework).run([abs_test]).zero?
221
241
  # lines:true yields {file => {lines: [...]}}; reduce to the counts
222
242
  # array record() expects, keeping only our source files.
223
- Coverage.result(stop: false)
224
- .select { |f, _| abs_sources.include?(f) }
225
- .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
243
+ coverage = Coverage.result(stop: false)
244
+ .select { |f, _| abs_sources.include?(f) }
245
+ .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
246
+ { "passed" => passed, "coverage" => coverage,
247
+ "loaded_files" => capture_loaded_files }
226
248
  rescue Exception => e # rubocop:disable Lint/RescueException
227
249
  # Stringify (an arbitrary Exception may not marshal); the parent
228
250
  # surfaces this under --verbose. A String marshals safely over the pipe.
@@ -268,8 +290,8 @@ module Mutineer
268
290
 
269
291
  # Spawns a fresh `ruby` reading an inline script from stdin. A fork would
270
292
  # miss already-loaded app lines, so Coverage must start in a clean process
271
- # before any source is loaded. Returns the parsed Coverage.result hash, or
272
- # nil when the subprocess failed (logged + skipped).
293
+ # before any source is loaded. Returns the wrapped capture payload
294
+ # (`passed` + `coverage`), or nil when the subprocess failed (logged + skipped).
273
295
  def capture(test_path)
274
296
  out = +""
275
297
  status = nil
@@ -289,7 +311,10 @@ module Mutineer
289
311
  end
290
312
  return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?
291
313
 
292
- JSON.parse(out)
314
+ parsed = JSON.parse(out)
315
+ return fail_test(test_path, "invalid coverage output: missing pass/coverage payload") unless wrapped_capture?(parsed)
316
+
317
+ parsed
293
318
  rescue JSON::ParserError => e
294
319
  fail_test(test_path, "invalid coverage output: #{e.message}")
295
320
  end
@@ -307,6 +332,241 @@ module Mutineer
307
332
  nil
308
333
  end
309
334
 
335
+ # True when `payload` is the wrapped capture JSON/Marshal contract
336
+ # (`passed` + `coverage`), not a raw Coverage.result hash.
337
+ #
338
+ # @api private
339
+ # @param payload [Object] parsed subprocess output or forked Marshal value.
340
+ # @return [Boolean]
341
+ def wrapped_capture?(payload)
342
+ payload.is_a?(Hash) && payload.key?("passed") && payload.key?("coverage")
343
+ end
344
+
345
+ # Records a wrapped capture: assertion failures go to {#failed_clean_tests};
346
+ # successful coverage is inverted into the map. Capture crashes stay in
347
+ # {#failed_test_files} via {#fail_test}.
348
+ #
349
+ # @api private
350
+ # @param test_path [String] test file path.
351
+ # @param payload [Hash] wrapped capture with string keys.
352
+ # @return [void]
353
+ def accept_capture_payload(test_path, payload)
354
+ unless wrapped_capture?(payload)
355
+ fail_test(test_path, "invalid coverage output: missing pass/coverage payload")
356
+ return
357
+ end
358
+
359
+ record_loaded(payload["loaded_files"])
360
+
361
+ unless payload["passed"]
362
+ @failed_clean_tests << relativize(test_path)
363
+ return
364
+ end
365
+
366
+ coverage = payload["coverage"]
367
+ record(coverage, test_path) if coverage.is_a?(Hash)
368
+ end
369
+
370
+ # Re-runs each successfully captured test on a cache hit. Digest equality
371
+ # cannot prove the current unmutated suite still passes.
372
+ #
373
+ # @api private
374
+ # @param after_fork [Proc, nil] boot-mode fork hook.
375
+ # @return [void]
376
+ def verify_cached_clean(after_fork: nil)
377
+ @test_paths.each do |test_path|
378
+ rel = relativize(test_path)
379
+ next if @failed_test_files.include?(rel)
380
+
381
+ ok = if @boot_path
382
+ fork_clean_pass?([absolute(test_path)], after_fork)
383
+ else
384
+ subprocess_clean_pass?([test_path])
385
+ end
386
+ @failed_clean_tests << rel unless ok
387
+ end
388
+ end
389
+
390
+ # Re-runs tests whose previous capture crashed. A fixed helper is invisible
391
+ # to the source/test digest when that capture never recorded `loaded_files`.
392
+ #
393
+ # @api private
394
+ # @param after_fork [Proc, nil] boot-mode fork hook.
395
+ # @return [void]
396
+ def retry_failed_captures(after_fork)
397
+ pending = @failed_test_files.dup
398
+ return if pending.empty?
399
+
400
+ @failed_test_files = []
401
+ abs_sources = abs_source_paths
402
+ pending.each do |rel|
403
+ test_path = @test_paths.find { |t| relativize(t) == rel } || rel
404
+ if @boot_path
405
+ payload = fork_capture(absolute(test_path), abs_sources, after_fork)
406
+ case payload
407
+ when Hash then accept_capture_payload(test_path, payload)
408
+ when String
409
+ fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
410
+ "fork capture produced no result (re-run with --verbose for the error)")
411
+ else fail_test(test_path, "fork capture produced no result")
412
+ end
413
+ else
414
+ payload = capture(test_path)
415
+ accept_capture_payload(test_path, payload) if payload
416
+ end
417
+ end
418
+ end
419
+
420
+ # Runs every successfully captured test together. Per-file capture can miss a
421
+ # failure that only appears when covering files share one process.
422
+ #
423
+ # @api private
424
+ # @param after_fork [Proc, nil] boot-mode fork hook.
425
+ # @return [void]
426
+ def verify_combined_clean(after_fork: nil)
427
+ runnable = @test_paths.reject { |t| @failed_test_files.include?(relativize(t)) }
428
+ return if runnable.size < 2
429
+ return unless @failed_clean_tests.empty?
430
+
431
+ ok = if @boot_path
432
+ fork_clean_pass?(runnable.map { |t| absolute(t) }, after_fork)
433
+ else
434
+ subprocess_clean_pass?(runnable)
435
+ end
436
+ @failed_clean_tests << "combined suite" unless ok
437
+ end
438
+
439
+ # Runs test files in a fresh interpreter and returns whether they passed.
440
+ #
441
+ # @api private
442
+ # @param test_paths [Array<String>] test file paths.
443
+ # @return [Boolean]
444
+ def subprocess_clean_pass?(test_paths)
445
+ status = nil
446
+ Open3.popen2(RbConfig.ruby, "-") do |stdin, stdout, wait_thr|
447
+ stdin.write(clean_check_script(test_paths))
448
+ stdin.close
449
+ reader = Thread.new { stdout.read }
450
+ unless wait_thr.join(@capture_timeout)
451
+ Process.kill(:KILL, wait_thr.pid) rescue nil # rubocop:disable Style/RescueModifier
452
+ reader.kill
453
+ return false
454
+ end
455
+ reader.join
456
+ status = wait_thr.value
457
+ end
458
+ status&.success?
459
+ end
460
+
461
+ # Runs test files in a fork of the booted parent and returns whether they passed.
462
+ # Bounded by `@capture_timeout` so a hung child cannot block the CLI.
463
+ #
464
+ # @api private
465
+ # @param abs_tests [Array<String>] absolute test file paths.
466
+ # @param after_fork [Proc, nil] boot-mode fork hook.
467
+ # @return [Boolean]
468
+ def fork_clean_pass?(abs_tests, after_fork)
469
+ rd, wr = IO.pipe
470
+ rd.binmode
471
+ wr.binmode
472
+ pid = fork do
473
+ rd.close
474
+ Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
475
+ begin
476
+ after_fork&.call
477
+ Coverage.result(clear: true, stop: false) if Coverage.running?
478
+ wr.write(Marshal.dump(TestRunners.for(@framework).run(abs_tests).zero?))
479
+ rescue Exception # rubocop:disable Lint/RescueException
480
+ wr.write(Marshal.dump(false))
481
+ ensure
482
+ wr.close
483
+ exit!(0)
484
+ end
485
+ end
486
+ wr.close
487
+ readable, = IO.select([rd], nil, nil, @capture_timeout)
488
+ unless readable
489
+ kill_fork_clean(pid)
490
+ rd.close
491
+ return false
492
+ end
493
+ data = rd.read
494
+ rd.close
495
+ Process.waitpid2(pid)
496
+ return false if data.empty?
497
+
498
+ Marshal.load(data)
499
+ rescue StandardError
500
+ false
501
+ end
502
+
503
+ # SIGKILLs a hung clean-check child (and its group) then reaps it.
504
+ #
505
+ # @api private
506
+ # @param pid [Integer] child pid.
507
+ # @return [void]
508
+ def kill_fork_clean(pid)
509
+ begin
510
+ Process.kill(:KILL, -pid)
511
+ rescue Errno::ESRCH, Errno::EPERM
512
+ Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
513
+ end
514
+ Process.waitpid2(pid) rescue nil # rubocop:disable Style/RescueModifier
515
+ end
516
+
517
+ # Builds a pass/fail-only subprocess script (no coverage instrumentation).
518
+ #
519
+ # @api private
520
+ # @param test_paths [Array<String>] test file paths.
521
+ # @return [String] Ruby script text.
522
+ def clean_check_script(test_paths)
523
+ @framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
524
+ end
525
+
526
+ # Minitest clean-suite check. Preloads configured sources like capture and
527
+ # standalone {Runner.execute}, so tests that rely on that preload stay green.
528
+ #
529
+ # @api private
530
+ # @param test_paths [Array<String>] test file paths.
531
+ # @return [String] Ruby script text.
532
+ def minitest_clean_check_script(test_paths)
533
+ loads = Array(test_paths).map { |t| "load #{absolute(t).inspect}" }.join("\n")
534
+ <<~RUBY
535
+ require "minitest"
536
+ require "stringio"
537
+ def Minitest.autorun; end
538
+ $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
539
+ #{abs_source_paths.inspect}.each { |f| load f }
540
+ #{loads}
541
+ $stdout = StringIO.new
542
+ exit(Minitest.run([]) ? 0 : 1)
543
+ RUBY
544
+ end
545
+
546
+ # RSpec clean-suite check. Preloads configured sources like capture.
547
+ #
548
+ # @api private
549
+ # @param test_paths [Array<String>] spec file paths.
550
+ # @return [String] Ruby script text.
551
+ def rspec_clean_check_script(test_paths)
552
+ specs = Array(test_paths).map { |t| absolute(t).inspect }.join(", ")
553
+ <<~RUBY
554
+ require "stringio"
555
+ begin
556
+ require "rspec/core"
557
+ rescue LoadError
558
+ exit 3
559
+ end
560
+ RSpec::Core::Runner.disable_autorun!
561
+ $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
562
+ #{abs_source_paths.inspect}.each { |f| load f }
563
+ _sink = StringIO.new
564
+ $stdout = _sink
565
+ status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
566
+ exit(status.zero? ? 0 : 1)
567
+ RUBY
568
+ end
569
+
310
570
  # Builds the framework-specific subprocess script.
311
571
  #
312
572
  # @api private
@@ -334,9 +594,10 @@ module Mutineer
334
594
  load #{absolute(test_path).inspect}
335
595
  _orig = $stdout
336
596
  $stdout = StringIO.new
337
- Minitest.run([])
597
+ _passed = Minitest.run([])
338
598
  $stdout = _orig
339
- puts Coverage.result.to_json
599
+ puts JSON.generate("passed" => _passed == true, "coverage" => Coverage.result,
600
+ "loaded_files" => #{loaded_files_expression})
340
601
  RUBY
341
602
  end
342
603
 
@@ -363,9 +624,10 @@ module Mutineer
363
624
  _orig = $stdout
364
625
  _sink = StringIO.new
365
626
  $stdout = _sink
366
- RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
627
+ _status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
367
628
  $stdout = _orig
368
- puts Coverage.result.to_json
629
+ puts JSON.generate("passed" => _status.zero?, "coverage" => Coverage.result,
630
+ "loaded_files" => #{loaded_files_expression})
369
631
  RUBY
370
632
  end
371
633
 
@@ -387,6 +649,101 @@ module Mutineer
387
649
  end
388
650
  end
389
651
 
652
+ # Ruby source of the child-side `$LOADED_FEATURES` filter (project `.rb` files).
653
+ #
654
+ # @api private
655
+ # @return [String] expression to embed in a capture subprocess script.
656
+ def loaded_files_expression
657
+ root = project_root_real
658
+ prefix = root.end_with?("/") ? root : "#{root}/"
659
+ "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"
660
+ end
661
+
662
+ # Canonical project root for loaded-feature matching (`/var` vs `/private/var`).
663
+ #
664
+ # @api private
665
+ # @return [String] realpath of the project root when it exists.
666
+ def project_root_real
667
+ File.realpath(File.expand_path(@project_root))
668
+ rescue Errno::ENOENT
669
+ File.expand_path(@project_root)
670
+ end
671
+
672
+ # Project-local `.rb` files loaded in this process at capture time.
673
+ #
674
+ # @api private
675
+ # @return [Array<String>] absolute realpaths.
676
+ def capture_loaded_files
677
+ prefix = project_root_real
678
+ prefix = "#{prefix}/" unless prefix.end_with?("/")
679
+ $LOADED_FEATURES.filter_map do |f|
680
+ next unless f.end_with?(".rb")
681
+
682
+ abs = File.realpath(f)
683
+ abs if abs.start_with?(prefix)
684
+ rescue Errno::ENOENT
685
+ nil
686
+ end
687
+ end
688
+
689
+ # Fingerprints project-local support files from a capture payload.
690
+ #
691
+ # @api private
692
+ # @param paths [Array, nil] absolute loaded-file paths.
693
+ # @return [void]
694
+ def record_loaded(paths)
695
+ Array(paths).each do |raw|
696
+ next unless raw.is_a?(String) && File.file?(raw)
697
+
698
+ abs = File.realpath(raw)
699
+ rel = loaded_relative(abs)
700
+ next unless rel
701
+ next unless rel.end_with?(".rb")
702
+ next if rel.start_with?("vendor/bundle/") || rel.start_with?("node_modules/")
703
+
704
+ @loaded_dependencies[rel] = file_fingerprint(abs)
705
+ end
706
+ end
707
+
708
+ # Path of `abs` relative to the real project root, or nil when outside it.
709
+ #
710
+ # @api private
711
+ # @param abs [String] absolute realpath.
712
+ # @return [String, nil]
713
+ def loaded_relative(abs)
714
+ root = project_root_real
715
+ prefix = root.end_with?("/") ? root : "#{root}/"
716
+ return unless abs.start_with?(prefix)
717
+
718
+ abs.delete_prefix(prefix)
719
+ end
720
+
721
+ # Byte fingerprint of a file for cache dependency checks.
722
+ #
723
+ # @api private
724
+ # @param abs [String] absolute path.
725
+ # @return [String] hex digest.
726
+ def file_fingerprint(abs)
727
+ content = File.binread(abs)
728
+ Digest::SHA256.hexdigest("#{content.bytesize}\0#{content}")
729
+ end
730
+
731
+ # True when the cached map recorded support-file fingerprints and they still
732
+ # match. Missing validity data is a miss (rebuild).
733
+ #
734
+ # @api private
735
+ # @param cached [Hash] parsed coverage.json.
736
+ # @return [Boolean]
737
+ def dependencies_match?(cached)
738
+ deps = cached["dependencies"]
739
+ return false unless deps.is_a?(Hash)
740
+
741
+ deps.all? do |rel, fingerprint|
742
+ abs = absolute(rel)
743
+ File.file?(abs) && file_fingerprint(abs) == fingerprint
744
+ end
745
+ end
746
+
390
747
  # Digest each file's ROLE + relative path + content length + content, plus
391
748
  # the load_paths. Without role/path/length delimiters the digest collides
392
749
  # (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently
@@ -462,8 +819,11 @@ module Mutineer
462
819
  # @api private
463
820
  # @return [void]
464
821
  def save
822
+ return unless @failed_clean_tests.empty?
823
+
465
824
  FileUtils.mkdir_p(@cache_dir)
466
- data = { "digest" => @digest, "failed_test_files" => @failed_test_files, "map" => @map }
825
+ data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
826
+ "dependencies" => @loaded_dependencies, "map" => @map }
467
827
  tmp = "#{cache_path}.tmp"
468
828
  File.write(tmp, JSON.generate(data))
469
829
  File.rename(tmp, cache_path) # atomic swap
@@ -494,9 +854,13 @@ module Mutineer
494
854
  # @param path [String] path to relativize.
495
855
  # @return [String] relative path.
496
856
  def relativize(path)
497
- return path unless path.start_with?("/")
857
+ abs = path.start_with?("/") ? path : absolute(path)
858
+ abs = realpath_if_exists(abs)
859
+ root = project_root_real
860
+ prefix = root.end_with?("/") ? root : "#{root}/"
861
+ return abs unless abs.start_with?(prefix)
498
862
 
499
- path.delete_prefix("#{@project_root}/")
863
+ abs.delete_prefix(prefix)
500
864
  end
501
865
 
502
866
  # Expands a path relative to the project root.
@@ -505,7 +869,17 @@ module Mutineer
505
869
  # @param path [String] path to expand.
506
870
  # @return [String] absolute path.
507
871
  def absolute(path)
508
- File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
872
+ raw = File.absolute_path?(path) ? path : File.expand_path(path, @project_root)
873
+ realpath_if_exists(raw)
874
+ end
875
+
876
+ # Real path when the file exists, otherwise `path` unchanged.
877
+ #
878
+ # @api private
879
+ # @param path [String] absolute or relative path.
880
+ # @return [String]
881
+ def realpath_if_exists(path)
882
+ File.exist?(path) ? File.realpath(path) : path
509
883
  end
510
884
  end
511
885
  end
@@ -4,6 +4,7 @@ require_relative "parser"
4
4
  require_relative "result"
5
5
  require_relative "coverage_map"
6
6
  require_relative "daemon_client"
7
+ require_relative "progress"
7
8
  # No require_relative "runner" on purpose: runner.rb requires this file, and the
8
9
  # reverse edge makes Ruby warn "circular require considered harmful" on every -w
9
10
  # load. Runner is loaded first on every real path; requiring this file alone leaves
@@ -104,6 +105,17 @@ module Mutineer
104
105
  ensure
105
106
  client.quit
106
107
  end
108
+ # A red unmutated suite must abort, even when the shipped map is empty.
109
+ # Falling back to the full --test set would treat those failures as kills.
110
+ if data.is_a?(Hash) && Array(data["failed_clean_tests"]).any?
111
+ Runner.abort_if_unclean!(CoverageMap.from_data(
112
+ map: data["map"] || {},
113
+ failed_test_files: data["failed_test_files"] || [],
114
+ project_root: config.project_root,
115
+ failed_clean_tests: data["failed_clean_tests"]
116
+ ))
117
+ end
118
+
107
119
  unless data && !(data["map"] || {}).empty?
108
120
  reason = data.is_a?(Hash) && data["error"] ? data["error"] : "empty map"
109
121
  warn_coverage_fallback(reason)
@@ -137,10 +149,12 @@ module Mutineer
137
149
  client = DaemonClient.new(boot: boot_config(config, abs_tests),
138
150
  app_root: config.project_root).start
139
151
  results = []
152
+ progress = Progress.new(jobs.size)
140
153
  begin
141
154
  jobs.each_with_index do |job, i|
142
155
  r = job_result(job, i, client, 0, config, coverage_map, abs_tests, source_map)
143
156
  results << r
157
+ progress.tick
144
158
  break if config.fail_fast && r.survived?
145
159
  end
146
160
  ensure
@@ -159,8 +173,9 @@ module Mutineer
159
173
  # @api private
160
174
  # @return [Array<Mutineer::Result>] one result per input job, in input order.
161
175
  def self.run_parallel(jobs, worker_count, config, abs_tests, coverage_map, source_map)
162
- results = Array.new(jobs.size)
163
- queue = Queue.new
176
+ results = Array.new(jobs.size)
177
+ progress = Progress.new(jobs.size)
178
+ queue = Queue.new
164
179
  jobs.each_index { |i| queue << i }
165
180
 
166
181
  # Built one at a time so a refused spawn part-way (EMFILE under a high --jobs)
@@ -189,6 +204,7 @@ module Mutineer
189
204
  break
190
205
  end
191
206
  results[i] = job_result(jobs[i], i, client, worker, config, coverage_map, abs_tests, source_map)
207
+ progress.tick
192
208
  end
193
209
  rescue DaemonBootError
194
210
  # The daemon gave up for good. Stop feeding the other workers rather
@@ -87,10 +87,13 @@ module Mutineer
87
87
  end
88
88
 
89
89
  # Ask the daemon to build the coverage map app-side and return it. One-shot
90
- # control message (no id). Returns `{"map"=>..., "failed_test_files"=>...}`
91
- # (possibly with an `"error"`), or nil if the daemon vanished. The caller then
92
- # falls back to running the full test set (no narrowing) rather than
93
- # mis-scoring.
90
+ # control message (no id). On success, returns
91
+ # `{"map"=>..., "failed_test_files"=>..., "failed_clean_tests"=>...}`.
92
+ # On coverage-build failure, returns
93
+ # `{"map"=>{}, "failed_test_files"=>[], "error"=>...}`.
94
+ # Returns nil if the daemon vanished. The caller then falls back to running
95
+ # the full test set (no narrowing) rather than mis-scoring, except a red
96
+ # unmutated suite which aborts.
94
97
  #
95
98
  # @return [Hash, nil] the coverage payload, or nil on a dead pipe.
96
99
  def coverage
@@ -150,7 +150,8 @@ module Mutineer
150
150
  load_paths: Array(@cfg["load_paths"]), project_root: root,
151
151
  boot_path: @cfg["boot"], framework: @framework, cache_dir: File.join(root, ".mutineer")
152
152
  ).build_via_fork(after_fork: coverage_after_fork)
153
- { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files }
153
+ { "map" => cmap.map, "failed_test_files" => cmap.failed_test_files,
154
+ "failed_clean_tests" => cmap.failed_clean_tests }
154
155
  rescue Exception => e # rubocop:disable Lint/RescueException
155
156
  @errio.puts("[daemon] coverage build failed: #{e.class}: #{e.message}")
156
157
  { "map" => {}, "failed_test_files" => [], "error" => "#{e.class}: #{e.message}" }