Class: Mutineer::CoverageMap

Inherits:
Object
  • Object
show all
Defined in:
lib/mutineer/coverage_map.rb

Overview

Maps (source_file, line) -> [test_files] so each mutant runs only against the tests that actually exercise its line. Built once, then queried per mutant via #tests_for. Persisted to .mutineer/coverage.json with a content-based digest that rebuilds the map whenever any tracked file changes.

Keys are "file:line" strings (relative to project_root) everywhere, in memory and on disk, so load/save needs no key transformation.

Constant Summary collapse

DEFAULT_CAPTURE_TIMEOUT =

Seconds per coverage subprocess before the parent kills it.

120
RESULT_FD =

File descriptor in a capture subprocess that carries the JSON result to the parent. Stdout stays free for test output, which goes to File::NULL.

3

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source_paths:, test_paths:, cache_dir: ".mutineer", load_paths: ["lib"], project_root: Dir.pwd, capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil, framework: "minitest", verbose: false) ⇒ CoverageMap

Returns a new instance of CoverageMap.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/mutineer/coverage_map.rb', line 52

def initialize(source_paths:, test_paths:, cache_dir: ".mutineer",
               load_paths: ["lib"], project_root: Dir.pwd,
               capture_timeout: DEFAULT_CAPTURE_TIMEOUT, boot_path: nil,
               framework: "minitest", verbose: false)
  @source_paths = Array(source_paths)
  @test_paths   = Array(test_paths)
  @cache_dir    = cache_dir
  @load_paths   = Array(load_paths)
  @project_root = project_root
  @capture_timeout = capture_timeout
  @boot_path    = boot_path
  @framework    = framework || "minitest"
  @verbose      = verbose
  @map          = {}
  @failed_test_files = []
  @failed_clean_tests = []
  @loaded_dependencies = {}
  @phase_a_ran  = false
end

Instance Attribute Details

#failed_clean_tests ⇒ Object (readonly)

Returns the value of attribute failed_clean_tests.



30
31
32
# File 'lib/mutineer/coverage_map.rb', line 30

def failed_clean_tests
  @failed_clean_tests
end

#failed_test_files ⇒ Object (readonly)

Returns the value of attribute failed_test_files.



30
31
32
# File 'lib/mutineer/coverage_map.rb', line 30

def failed_test_files
  @failed_test_files
end

#map ⇒ Object (readonly)

Returns the value of attribute map.



30
31
32
# File 'lib/mutineer/coverage_map.rb', line 30

def map
  @map
end

#phase_a_ran ⇒ Object (readonly)

Returns the value of attribute phase_a_ran.



30
31
32
# File 'lib/mutineer/coverage_map.rb', line 30

def phase_a_ran
  @phase_a_ran
end

#project_root ⇒ Object (readonly)

Returns the value of attribute project_root.



30
31
32
# File 'lib/mutineer/coverage_map.rb', line 30

def project_root
  @project_root
end

Class Method Details

.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: []) ⇒ Mutineer::CoverageMap

Build a QUERY-ONLY map from data captured elsewhere (the daemon builds the map app-side and ships map + failed_test_files over IPC; the tool reconstructs it here for per-mutant selection). Skips the capture machinery entirely: only the three fields #tests_for / #method_uncapturable? read are set.

Parameters:

  • map (Hash) —

    the "file:line" => [test_files] map.

  • failed_test_files (Array<String>) —

    test files whose capture failed.

  • project_root (String) —

    project root (for path relativization).

  • failed_clean_tests (Array<String>) (defaults to: []) —

    test files whose unmutated run failed.

Returns:



43
44
45
46
47
48
49
50
# File 'lib/mutineer/coverage_map.rb', line 43

def self.from_data(map:, failed_test_files:, project_root:, failed_clean_tests: [])
  instance = allocate
  instance.instance_variable_set(:@map, map || {})
  instance.instance_variable_set(:@failed_test_files, failed_test_files || [])
  instance.instance_variable_set(:@failed_clean_tests, failed_clean_tests || [])
  instance.instance_variable_set(:@project_root, project_root)
  instance
end

Instance Method Details

#abs_load_paths ⇒ Array<String> (private)

Returns absolute load paths.

Returns:

  • (Array<String>) —

    absolute load paths.



905
# File 'lib/mutineer/coverage_map.rb', line 905

def abs_load_paths   = @load_paths.map { |p| absolute(p) }

#abs_source_paths ⇒ Array<String> (private)

Returns absolute source paths.

Returns:

  • (Array<String>) —

    absolute source paths.



900
# File 'lib/mutineer/coverage_map.rb', line 900

def abs_source_paths = @source_paths.map { |p| absolute(p) }

#absolute(path) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Expands a path relative to the project root (see ProjectPath.absolute).

Parameters:

  • path (String) —

    path to expand.

Returns:

  • (String) —

    absolute path.



919
# File 'lib/mutineer/coverage_map.rb', line 919

def absolute(path) = ProjectPath.absolute(path, @project_root)

#accept_capture_payload(test_path, payload) ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Records a wrapped capture: assertion failures go to #failed_clean_tests; successful coverage is inverted into the map. Capture crashes stay in #failed_test_files via #fail_test.

Parameters:

  • test_path (String) —

    test file path.

  • payload (Hash) —

    wrapped capture with string keys.



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/mutineer/coverage_map.rb', line 352

def accept_capture_payload(test_path, payload)
  unless wrapped_capture?(payload)
    fail_test(test_path, "invalid coverage output: missing pass/coverage payload")
    return
  end

  record_loaded(payload["loaded_files"])

  unless payload["passed"]
    @failed_clean_tests << relativize(test_path)
    return
  end

  coverage = payload["coverage"]
  record(coverage, test_path) if coverage.is_a?(Hash)
end

#boot_digest_path ⇒ Object (private)

boot_path is a require-style path (e.g. "config/environment", no extension); resolve it to the real file for reading, appending ".rb" when needed.



819
820
821
# File 'lib/mutineer/coverage_map.rb', line 819

def boot_digest_path
  File.exist?(absolute(@boot_path)) ? @boot_path : "#{@boot_path}.rb"
end

#build_or_load ⇒ Object

Standalone entry: load the cached map when the content digest matches, otherwise rebuild from subprocesses and overwrite the cache.



74
75
76
77
# File 'lib/mutineer/coverage_map.rb', line 74

def build_or_load
  warn_external_sources
  cached_or { run_phase_a }
end

#build_via_fork(after_fork: nil) ⇒ Object

Boot-mode build: Coverage is already running in the parent (started before the app booted, so booted source lines are instrumented). A clean ruby subprocess has no booted env, so per-test coverage is captured by FORKING the booted parent instead. Inverts into the same map #tests_for reads, and reuses the digest cache (the digest mixes in the boot file so a boot cache never collides with a standalone one).



85
86
87
88
# File 'lib/mutineer/coverage_map.rb', line 85

def build_via_fork(after_fork: nil)
  warn_external_sources
  cached_or(after_fork: after_fork) { run_phase_a_via_fork(after_fork: after_fork) }
end

#cache_path ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the cache path.

Returns:

  • (String) —

    cache file path.



859
# File 'lib/mutineer/coverage_map.rb', line 859

def cache_path = File.join(@cache_dir, "coverage.json")

#cached_or(after_fork: nil) { ... } ⇒ Mutineer::CoverageMap (private)

Shared cache dance for both build paths: hit the digest-keyed cache, else yield to populate @map and persist it. A digest match is not proof that today's unmutated suite still passes — re-check on a cache hit.

Parameters:

  • after_fork (Proc, nil) (defaults to: nil) —

    boot-mode fork hook forwarded to a clean re-check.

Yields:

  • when the cache is missing or stale.

Returns:



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/mutineer/coverage_map.rb', line 166

def cached_or(after_fork: nil)
  @digest = compute_digest
  cached = read_cache
  if cached && cached["digest"] == @digest && dependencies_match?(cached)
    @map = cached["map"] || {}
    @failed_test_files = cached["failed_test_files"] || []
    @failed_clean_tests = []
    @loaded_dependencies = cached["dependencies"] || {}
    retry_failed_captures(after_fork)
    warn_incomplete unless @failed_test_files.empty?
    verify_cached_clean(after_fork: after_fork)
    verify_combined_clean(after_fork: after_fork)
    save
    return self
  end

  yield
  verify_combined_clean(after_fork: after_fork)
  save
  self
end

#capture(test_path) ⇒ Object (private)

Spawns a fresh ruby reading an inline script from stdin. A fork would miss already-loaded app lines, so Coverage must start in a clean process before any source is loaded. Returns the wrapped capture payload (passed + coverage), or nil when the subprocess failed (logged + skipped).



308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/mutineer/coverage_map.rb', line 308

def capture(test_path)
  status, out = spawn_script(subprocess_script(test_path))
  return fail_test(test_path, "timed out after #{@capture_timeout}s") unless status
  return fail_test(test_path, "subprocess exited #{status.exitstatus}") unless status.success?

  parsed = JSON.parse(out)
  return fail_test(test_path, "invalid coverage output: missing pass/coverage payload") unless wrapped_capture?(parsed)

  parsed
rescue JSON::ParserError => e
  fail_test(test_path, "invalid coverage output: #{e.message}")
end

#capture_loaded_files ⇒ Array<String> (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Project-local .rb files loaded in this process at capture time.

Returns:

  • (Array<String>) —

    absolute realpaths.



732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/mutineer/coverage_map.rb', line 732

def capture_loaded_files
  prefix = project_root_real
  prefix = "#{prefix}/" unless prefix.end_with?("/")
  $LOADED_FEATURES.filter_map do |f|
    next unless f.end_with?(".rb")

    abs = File.realpath(f)
    abs if abs.start_with?(prefix)
  rescue Errno::ENOENT
    nil
  end
end

#clean_check_script(test_paths) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds a pass/fail-only subprocess script (no coverage instrumentation).

Parameters:

  • test_paths (Array<String>) —

    test file paths.

Returns:

  • (String) —

    Ruby script text.



574
575
576
# File 'lib/mutineer/coverage_map.rb', line 574

def clean_check_script(test_paths)
  @framework == "rspec" ? rspec_clean_check_script(test_paths) : minitest_clean_check_script(test_paths)
end

#compute_digest ⇒ Object (private)

Digest each file's ROLE + relative path + content length + content, plus the load_paths. Without role/path/length delimiters the digest collides (("ab","c") == ("a","bc")) and is blind to source/test role swaps, silently accepting a stale cached map.



807
808
809
810
811
812
813
814
815
# File 'lib/mutineer/coverage_map.rb', line 807

def compute_digest
  d = Digest::SHA256.new
  digest_group(d, "source", @source_paths)
  digest_group(d, "test", @test_paths)
  digest_group(d, "boot", [boot_digest_path]) if @boot_path
  @load_paths.sort.each { |lp| d.update("loadpath\0#{lp}\0") }
  d.update("framework\0#{@framework}\0")
  d.hexdigest
end

#covered_source_files ⇒ Object (private)

Source rel-paths that received coverage from any successful capture.



142
143
144
# File 'lib/mutineer/coverage_map.rb', line 142

def covered_source_files
  @map.keys.map { |k| k.rpartition(":").first }.to_set
end

#dependencies_match?(cached) ⇒ Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

True when the cached map recorded support-file fingerprints and they still match. Missing validity data is a miss (rebuild).

Parameters:

  • cached (Hash) —

    parsed coverage.json.

Returns:

  • (Boolean)


793
794
795
796
797
798
799
800
801
# File 'lib/mutineer/coverage_map.rb', line 793

def dependencies_match?(cached)
  deps = cached["dependencies"]
  return false unless deps.is_a?(Hash)

  deps.all? do |rel, fingerprint|
    abs = absolute(rel)
    File.file?(abs) && file_fingerprint(abs) == fingerprint
  end
end

#describe_status(status) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Human description of a child Process::Status for capture diagnostics.

Parameters:

  • status (Process::Status) —

    the reaped child status.

Returns:

  • (String) —

    e.g. "killed by signal 9 (SIGKILL)" or "exit status 1".



295
296
297
298
299
300
301
302
# File 'lib/mutineer/coverage_map.rb', line 295

def describe_status(status)
  if status.signaled?
    sig = status.termsig
    "killed by signal #{sig}#{Signal.signame(sig) ? " (SIG#{Signal.signame(sig)})" : ''}"
  else
    "exit status #{status.exitstatus.inspect}"
  end
end

#digest_group(digest, role, paths) ⇒ Array(String, String, Array<String>) (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Groups a digest with its role and paths.

Parameters:

  • digest (String) —

    digest string.

  • role (String) —

    digest role.

  • paths (Array<String>) —

    paths in the digest group.

Returns:

  • (Array(String, String, Array<String>)) —

    grouped digest data.



830
831
832
833
834
835
836
837
838
839
840
841
842
# File 'lib/mutineer/coverage_map.rb', line 830

def digest_group(digest, role, paths)
  paths.sort.each do |p|
    content = File.read(absolute(p))
    digest.update(role)
    digest.update("\0")
    digest.update(relativize(absolute(p)))
    digest.update("\0")
    digest.update(content.bytesize.to_s)
    digest.update("\0")
    digest.update(content)
    digest.update("\0")
  end
end

#fail_test(test_path, reason) ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Records a failed coverage capture.

Parameters:

  • test_path (String) —

    test file path.

  • reason (String) —

    failure reason.



327
328
329
330
331
332
# File 'lib/mutineer/coverage_map.rb', line 327

def fail_test(test_path, reason)
  rel = relativize(test_path)
  @failed_test_files << rel
  warn "[mutineer] coverage skipped for #{rel}: #{reason}"
  nil
end

#failed_test_targets ⇒ Object (private)

Basenames of the sources that failed test files pair with by convention: a trailing _test/_spec is stripped first, as pairing tries that form first.



148
149
150
151
152
153
154
155
156
157
# File 'lib/mutineer/coverage_map.rb', line 148

def failed_test_targets
  @failed_test_files.map do |t|
    name = File.basename(t, ".rb")
    case name
    when /_(test|spec)\z/ then name.sub(/_(test|spec)\z/, "")
    when "test_helper" then name # Minitest's support file pairs with no source
    else name.delete_prefix("test_")
    end
  end.to_set
end

#file_fingerprint(abs) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Byte fingerprint of a file for cache dependency checks.

Parameters:

  • abs (String) —

    absolute path.

Returns:

  • (String) —

    hex digest.



782
783
784
785
# File 'lib/mutineer/coverage_map.rb', line 782

def file_fingerprint(abs)
  content = File.binread(abs)
  Digest::SHA256.hexdigest("#{content.bytesize}\0#{content}")
end

#fork_capture(abs_test, abs_sources, after_fork) ⇒ Object (private)

Fork the booted parent, run one test under the inherited Coverage, and return its per-source counts hash (or nil on failure). Reuses the same fork + Marshal-over-pipe + hard-exit! discipline as WorkerPool/Isolation.



236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/mutineer/coverage_map.rb', line 236

def fork_capture(abs_test, abs_sources, after_fork)
  rd, wr = IO.pipe
  # Marshal output is binary: an un-binmoded pipe can raise
  # Encoding::UndefinedConversionError on write, which the child's rescue then
  # swallows, losing the real error and yielding a bare "no result".
  rd.binmode
  wr.binmode
  pid = fork do
    rd.close
    payload =
      begin
        ChildStdout.silence
        # Fork-safety hook: the in-process path reconnects AR; the daemon
        # routes to its worker DB. Nil (non-Rails) = no-op. Injected so this
        # file needs neither Runner (Prism) nor Rails.
        after_fork&.call
        Coverage.result(clear: true, stop: false) # discard pre-test delta
        passed = TestRunners.for(@framework).run([abs_test]).zero?
        # lines:true yields {file => {lines: [...]}}; reduce to the counts
        # array record() expects, keeping only our source files.
        coverage = Coverage.result(stop: false)
                           .select { |f, _| abs_sources.include?(f) }
                           .transform_values { |v| v.is_a?(Hash) ? v[:lines] : v }
        { "passed" => passed, "coverage" => coverage,
          "loaded_files" => capture_loaded_files }
      rescue Exception => e # rubocop:disable Lint/RescueException
        # Stringify (an arbitrary Exception may not marshal); the parent
        # surfaces this under --verbose. A String marshals safely over the pipe.
        "#{e.class}: #{e.message}#{e.backtrace&.first ? " @ #{e.backtrace.first}" : ''}"
      end
    begin
      wr.write(Marshal.dump(payload))
    rescue StandardError # rubocop:disable Lint/SuppressedException
      # pipe gone; parent records "no result"
    ensure
      wr.close
      exit!(0) # skip at_exit so the parent suite's autorun never re-fires here
    end
  end
  wr.close
  data = rd.read
  rd.close
  _, status = Process.waitpid2(pid)
  # An empty pipe means the child died before writing (e.g. a hard crash,
  # OOM, or a signal from the test's own subprocess handling). Report HOW it
  # died (exit status / signal) as a diagnostic string so --verbose has
  # something actionable instead of a silent "no result".
  return "child wrote no result (#{describe_status(status)})" if data.empty?

  Marshal.load(data)
rescue StandardError => e
  "parent could not read capture result: #{e.class}: #{e.message}"
end

#fork_clean_pass?(abs_tests, after_fork) ⇒ Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Runs test files in a fork of the booted parent and returns whether they passed. Bounded by @capture_timeout so a hung child cannot block the CLI.

Parameters:

  • abs_tests (Array<String>) —

    absolute test file paths.

  • after_fork (Proc, nil) —

    boot-mode fork hook.

Returns:

  • (Boolean)


519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# File 'lib/mutineer/coverage_map.rb', line 519

def fork_clean_pass?(abs_tests, after_fork)
  rd, wr = IO.pipe
  rd.binmode
  wr.binmode
  pid = fork do
    rd.close
    Process.setpgid(0, 0) rescue nil # rubocop:disable Style/RescueModifier
    begin
      ChildStdout.silence
      after_fork&.call
      Coverage.result(clear: true, stop: false) if Coverage.running?
      wr.write(Marshal.dump(TestRunners.for(@framework).run(abs_tests).zero?))
    rescue Exception # rubocop:disable Lint/RescueException
      wr.write(Marshal.dump(false))
    ensure
      wr.close
      exit!(0)
    end
  end
  wr.close
  readable, = IO.select([rd], nil, nil, @capture_timeout)
  unless readable
    kill_fork_clean(pid)
    rd.close
    return false
  end
  data = rd.read
  rd.close
  Process.waitpid2(pid)
  return false if data.empty?

  Marshal.load(data)
rescue StandardError
  false
end

#kill_fork_clean(pid) ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

SIGKILLs a hung clean-check child (and its group) then reaps it.

Parameters:

  • pid (Integer) —

    child pid.



560
561
562
563
564
565
566
567
# File 'lib/mutineer/coverage_map.rb', line 560

def kill_fork_clean(pid)
  begin
    Process.kill(:KILL, -pid)
  rescue Errno::ESRCH, Errno::EPERM
    Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
  end
  Process.waitpid2(pid) rescue nil # rubocop:disable Style/RescueModifier
end

#loaded_files_expression ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Ruby source of the child-side $LOADED_FEATURES filter (project .rb files).

Returns:

  • (String) —

    expression to embed in a capture subprocess script.



716
717
718
719
720
# File 'lib/mutineer/coverage_map.rb', line 716

def loaded_files_expression
  root = project_root_real
  prefix = root.end_with?("/") ? root : "#{root}/"
  "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"
end

#loaded_relative(abs) ⇒ String? (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Path of abs relative to the real project root, or nil when outside it.

Parameters:

  • abs (String) —

    absolute realpath.

Returns:

  • (String, nil)


769
770
771
772
773
774
775
# File 'lib/mutineer/coverage_map.rb', line 769

def loaded_relative(abs)
  root = project_root_real
  prefix = root.end_with?("/") ? root : "#{root}/"
  return unless abs.start_with?(prefix)

  abs.delete_prefix(prefix)
end

#method_uncapturable?(file, line_range) ⇒ Boolean

Per-method taint. A mutant on a line whose enclosing method got zero successful coverage, in a file a failed sibling test targets, is :uncapturable (the capture that would have covered it errored), NOT a genuine gap. A method with any covered line means its uncovered lines are a real :no_coverage. A failed capture emits no coverage, so per-line intent is unknowable; method-range + successful coverage is the finest derivable signal. Fully-failed files behave exactly as uncapturable_source? did (every method range has zero coverage).

Parameters:

  • file (String) —

    source file path.

  • line_range (Range) —

    1-based enclosing-method line range.

Returns:

  • (Boolean)


130
131
132
133
134
135
136
137
# File 'lib/mutineer/coverage_map.rb', line 130

def method_uncapturable?(file, line_range)
  return false if @failed_test_files.empty?

  rel = relativize(absolute(file))
  return false unless failed_test_targets.include?(File.basename(rel, ".rb"))

  line_range.none? { |ln| @map.key?("#{rel}:#{ln}") }
end

#minitest_clean_check_script(test_paths) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Minitest clean-suite check. Preloads configured sources like capture and standalone Runner.execute, so tests that rely on that preload stay green.

Parameters:

  • test_paths (Array<String>) —

    test file paths.

Returns:

  • (String) —

    Ruby script text.



584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
# File 'lib/mutineer/coverage_map.rb', line 584

def minitest_clean_check_script(test_paths)
  loads = Array(test_paths).map { |t| "load #{absolute(t).inspect}" }.join("\n")
  <<~RUBY
    require "minitest"
    require "stringio"
    def Minitest.autorun; end
    _report = StringIO.new
    Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
    Minitest.extensions << "mutineer_report"
    $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
    #{abs_source_paths.inspect}.each { |f| require f }
    #{loads}
    _passed = Minitest.run([])
    $stderr.write(_report.string) unless _passed
    exit(_passed ? 0 : 1)
  RUBY
end

#minitest_subprocess_script(test_path) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds the minitest subprocess script.

Parameters:

  • test_path (String) —

    test file path.

Returns:

  • (String) —

    Ruby script text.



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
# File 'lib/mutineer/coverage_map.rb', line 640

def minitest_subprocess_script(test_path)
  <<~RUBY
    #{result_channel_expression}
    require "coverage"
    require "json"
    require "minitest"
    require "stringio"
    def Minitest.autorun; end
    _report = StringIO.new
    Minitest.define_singleton_method(:plugin_mutineer_report_init) { |options| reporter << Minitest::SummaryReporter.new(_report, options) }
    Minitest.extensions << "mutineer_report"
    Coverage.start(lines: true)
    $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
    #{abs_source_paths.inspect}.each { |f| require f }
    load #{absolute(test_path).inspect}
    _passed = Minitest.run([])
    $stderr.write(_report.string) unless _passed
    _result.puts JSON.generate("passed" => _passed == true, "coverage" => Coverage.result,
                                "loaded_files" => #{loaded_files_expression})
    _result.close
  RUBY
end

#project_root_real ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Canonical project root for loaded-feature matching (/var vs /private/var).

Returns:

  • (String) —

    realpath of the project root when it exists.



726
# File 'lib/mutineer/coverage_map.rb', line 726

def project_root_real = ProjectPath.root_real(@project_root)

#read_cache ⇒ Hash? (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Reads the coverage cache.

Returns:

  • (Hash, nil) —

    cached payload.



865
866
867
868
869
870
871
# File 'lib/mutineer/coverage_map.rb', line 865

def read_cache
  return nil unless File.exist?(cache_path)

  JSON.parse(File.read(cache_path))
rescue JSON::ParserError
  nil # corrupt cache: rebuild from scratch
end

#record(coverage, test_path) ⇒ Object (private)

Records every source line with a non-zero execution count as covered by this test file. Coverage.result keys are absolute; relativize and drop any path outside the project (stdlib/gem files).



697
698
699
700
701
702
703
704
705
706
707
708
709
710
# File 'lib/mutineer/coverage_map.rb', line 697

def record(coverage, test_path)
  rel_test = relativize(test_path)
  coverage.each do |abs_file, data|
    rel = relativize(abs_file)
    next if rel.start_with?("/") # outside project_root: not our source

    counts = data.is_a?(Array) ? data : data["lines"]
    counts.each_with_index do |count, idx|
      next unless count&.positive?

      (@map["#{rel}:#{idx + 1}"] ||= []) << rel_test
    end
  end
end

#record_loaded(paths) ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Fingerprints project-local support files from a capture payload.

Parameters:

  • paths (Array, nil) —

    absolute loaded-file paths.



750
751
752
753
754
755
756
757
758
759
760
761
762
# File 'lib/mutineer/coverage_map.rb', line 750

def record_loaded(paths)
  Array(paths).each do |raw|
    next unless raw.is_a?(String) && File.file?(raw)

    abs = File.realpath(raw)
    rel = loaded_relative(abs)
    next unless rel
    next unless rel.end_with?(".rb")
    next if rel.start_with?("vendor/bundle/") || rel.start_with?("node_modules/")

    @loaded_dependencies[rel] = file_fingerprint(abs)
  end
end

#relativize(path) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Relativizes a path against the project root (see ProjectPath.relative).

Parameters:

  • path (String) —

    path to relativize.

Returns:

  • (String) —

    relative path, or an absolute path when outside the root.



912
# File 'lib/mutineer/coverage_map.rb', line 912

def relativize(path) = ProjectPath.relative(path, @project_root)

#remaining(deadline) ⇒ Float (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Seconds left before deadline, never negative.

Parameters:

  • deadline (Float) —

    a CLOCK_MONOTONIC time.

Returns:

  • (Float)


497
498
499
# File 'lib/mutineer/coverage_map.rb', line 497

def remaining(deadline)
  [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
end

#result_channel_expression ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Ruby source that opens the result channel in a #spawn_script child. The script runs it first, so no file that a test opens can take fd RESULT_FD. Close-on-exec keeps the fd out of the test's own subprocesses.

Returns:

  • (String) —

    Ruby script text.



508
509
510
# File 'lib/mutineer/coverage_map.rb', line 508

def result_channel_expression
  "_result = IO.new(#{RESULT_FD}, \"w\"); _result.close_on_exec = true"
end

#retry_failed_captures(after_fork) ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Re-runs tests whose previous capture crashed. A fixed helper is invisible to the source/test digest when that capture never recorded loaded_files.

Parameters:

  • after_fork (Proc, nil) —

    boot-mode fork hook.



395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/mutineer/coverage_map.rb', line 395

def retry_failed_captures(after_fork)
  pending = @failed_test_files.dup
  return if pending.empty?

  @failed_test_files = []
  abs_sources = abs_source_paths
  pending.each do |rel|
    test_path = @test_paths.find { |t| relativize(t) == rel } || rel
    if @boot_path
      payload = fork_capture(absolute(test_path), abs_sources, after_fork)
      case payload
      when Hash then accept_capture_payload(test_path, payload)
      when String
        fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
          "fork capture produced no result (re-run with --verbose for the error)")
      else fail_test(test_path, "fork capture produced no result")
      end
    else
      payload = capture(test_path)
      accept_capture_payload(test_path, payload) if payload
    end
  end
end

#rspec_clean_check_script(test_paths) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

RSpec clean-suite check. Preloads configured sources like capture.

Parameters:

  • test_paths (Array<String>) —

    spec file paths.

Returns:

  • (String) —

    Ruby script text.



607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
# File 'lib/mutineer/coverage_map.rb', line 607

def rspec_clean_check_script(test_paths)
  specs = Array(test_paths).map { |t| absolute(t).inspect }.join(", ")
  <<~RUBY
    require "stringio"
    begin
      require "rspec/core"
    rescue LoadError
      exit 3
    end
    RSpec::Core::Runner.disable_autorun!
    $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
    #{abs_source_paths.inspect}.each { |f| require f }
    _sink = StringIO.new
    status = RSpec::Core::Runner.run(["--no-color", #{specs}], _sink, _sink)
    $stderr.write(_sink.string) unless status.zero?
    exit(status.zero? ? 0 : 1)
  RUBY
end

#rspec_subprocess_script(test_path) ⇒ Object (private)

Same coverage-JSON contract as the minitest path, but driven by RSpec: require rspec/core lazily, require the sources under Coverage, then run the one spec via RSpec::Core::Runner. The JSON goes to the result channel (see #spawn_script), so spec output cannot corrupt it. A missing rspec makes the script exit non-zero -> capture() records a skipped (incomplete-map) test, with a hint.



669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
# File 'lib/mutineer/coverage_map.rb', line 669

def rspec_subprocess_script(test_path)
  <<~RUBY
    #{result_channel_expression}
    require "coverage"
    require "json"
    require "stringio"
    begin
      require "rspec/core"
    rescue LoadError
      warn "[mutineer] framework 'rspec' requested but rspec is not available in the project"
      exit 3
    end
    RSpec::Core::Runner.disable_autorun!
    Coverage.start(lines: true)
    $LOAD_PATH.unshift(*#{abs_load_paths.inspect})
    #{abs_source_paths.inspect}.each { |f| require f }
    _sink = StringIO.new
    _status = RSpec::Core::Runner.run(["--no-color", #{absolute(test_path).inspect}], _sink, _sink)
    $stderr.write(_sink.string) unless _status.zero?
    _result.puts JSON.generate("passed" => _status.zero?, "coverage" => Coverage.result,
                                "loaded_files" => #{loaded_files_expression})
    _result.close
  RUBY
end

#run_phase_a ⇒ Object (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Runs standalone coverage capture.



191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/mutineer/coverage_map.rb', line 191

def run_phase_a
  @phase_a_ran = true
  @map = {}
  @failed_test_files = []
  @failed_clean_tests = []
  @loaded_dependencies = {}

  @test_paths.each do |test_path|
    payload = capture(test_path)
    next unless payload

    accept_capture_payload(test_path, payload)
  end
end

#run_phase_a_via_fork(after_fork:) ⇒ Object (private)

Boot-mode capture. For each test file, fork the booted parent; the child resets its Coverage delta, runs that ONE test, and marshals back the raw per-source coverage counts. record() inverts them exactly as the subprocess path does. Serial fork (one test at a time): boot apps fork cheaply via COW and per-test isolation matters more than throughput here.



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/mutineer/coverage_map.rb', line 211

def run_phase_a_via_fork(after_fork:)
  @phase_a_ran = true
  @map = {}
  @failed_test_files = []
  @failed_clean_tests = []
  @loaded_dependencies = {}
  abs_sources = abs_source_paths

  @test_paths.each do |test_path|
    # Tri-state payload: Hash = capture result, String = error diagnostic from
    # the child, nil = pipe gone / empty. The String diagnostic is what
    # becomes an :uncapturable status.
    case (payload = fork_capture(absolute(test_path), abs_sources, after_fork))
    when Hash then accept_capture_payload(test_path, payload)
    when String
      fail_test(test_path, @verbose ? "fork capture failed: #{payload}" :
        "fork capture produced no result (re-run with --verbose for the error)")
    else fail_test(test_path, "fork capture produced no result")
    end
  end
end

#save ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Saves the coverage cache.



877
878
879
880
881
882
883
884
885
886
# File 'lib/mutineer/coverage_map.rb', line 877

def save
  return unless @failed_clean_tests.empty?

  FileUtils.mkdir_p(@cache_dir)
  data = { "digest" => @digest, "failed_test_files" => @failed_test_files,
           "dependencies" => @loaded_dependencies, "map" => @map }
  tmp = "#{cache_path}.tmp"
  File.write(tmp, JSON.generate(data))
  File.rename(tmp, cache_path) # atomic swap
end

#spawn_script(script, result: true) ⇒ Array(Process::Status, String) (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Runs script in a fresh ruby - that reads the script from stdin. The child's stdout goes to File::NULL, so test output never reaches the user or the result. With result: true, the child writes its result as one line to fd RESULT_FD, a pipe that only the script uses. The child's stderr is the parent's stderr, so warnings from the script reach the user. A wall clock of @capture_timeout bounds the whole call, so a hung test cannot wedge the run.

The parent reads one line, not until EOF: a process that a test leaves running can inherit fd RESULT_FD (a fork without exec keeps it despite close-on-exec) and hold the pipe open long after the child exits. A clean check reports only through its exit status, so it gets no pipe.

Parameters:

  • script (String) —

    Ruby script text.

  • result (Boolean) (defaults to: true) —

    whether to open the result pipe on fd RESULT_FD.

Returns:

  • (Array(Process::Status, String)) —

    the exit status and the line the child wrote to fd RESULT_FD ("" without one); [nil, ""] after a timeout.



467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
# File 'lib/mutineer/coverage_map.rb', line 467

def spawn_script(script, result: true)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @capture_timeout
  script_rd, script_wr = IO.pipe
  result_rd, result_wr = IO.pipe if result
  options = { in: script_rd, out: File::NULL }
  options[RESULT_FD] = result_wr if result
  pid = Process.spawn(RbConfig.ruby, "-", **options)
  waiter = Process.detach(pid)
  script_rd.close
  result_wr&.close
  reader = Thread.new { result_rd.gets.to_s } if result
  script_wr.write(script)
  script_wr.close
  unless waiter.join(remaining(deadline))
    Process.kill(:KILL, pid) rescue nil # rubocop:disable Style/RescueModifier
    waiter.join
    reader&.kill
    return [nil, ""]
  end
  [waiter.value, reader&.join(remaining(deadline))&.value.to_s]
ensure
  reader&.kill
  [script_rd, script_wr, result_rd, result_wr].compact.each { |io| io.close unless io.closed? }
end

#subprocess_clean_pass?(test_paths) ⇒ Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Runs test files in a fresh interpreter and returns whether they passed.

Parameters:

  • test_paths (Array<String>) —

    test file paths.

Returns:

  • (Boolean)


443
444
445
446
# File 'lib/mutineer/coverage_map.rb', line 443

def subprocess_clean_pass?(test_paths)
  status, = spawn_script(clean_check_script(test_paths), result: false)
  status&.success? || false
end

#subprocess_script(test_path) ⇒ String (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Builds the framework-specific subprocess script.

Parameters:

  • test_path (String) —

    test file path.

Returns:

  • (String) —

    Ruby script text.



631
632
633
# File 'lib/mutineer/coverage_map.rb', line 631

def subprocess_script(test_path)
  @framework == "rspec" ? rspec_subprocess_script(test_path) : minitest_subprocess_script(test_path)
end

#tests_for(file, line) ⇒ Object

Lookup: the test files that cover file:line, or [] when none do. Per-file granularity; upgrade to per-method when throughput warrants (requires Minitest method isolation + finer Coverage tracking).



93
94
95
# File 'lib/mutineer/coverage_map.rb', line 93

def tests_for(file, line)
  @map["#{relativize(file)}:#{line}"] || []
end

#uncapturable_source?(file) ⇒ Boolean

Is this source file's empty coverage the result of an errored capture rather than a genuine coverage gap? True iff some capture failed this run AND this file got zero coverage from any successful capture AND a failed test file maps to it by the _test/spec/test naming convention. Derived purely from already-persisted state (@map keys + @failed_test_files); no rerun, no new cached field, no digest change.

File-level, convention-based attribution. A line covered only by a failed test in an otherwise-covered file stays no_coverage (condition 2), and a source with no naming-convention test match is never tainted. Upgrade path: persist per-file coverage per successful run and diff against the failed set, or record test->source targets explicitly.

Returns:

  • (Boolean)


109
110
111
112
113
114
115
116
# File 'lib/mutineer/coverage_map.rb', line 109

def uncapturable_source?(file)
  return false if @failed_test_files.empty?

  rel = relativize(absolute(file))
  return false if covered_source_files.include?(rel)

  failed_test_targets.include?(File.basename(rel, ".rb"))
end

#verify_cached_clean(after_fork: nil) ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Re-runs each successfully captured test on a cache hit. Digest equality cannot prove the current unmutated suite still passes.

Parameters:

  • after_fork (Proc, nil) (defaults to: nil) —

    boot-mode fork hook.



375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/mutineer/coverage_map.rb', line 375

def verify_cached_clean(after_fork: nil)
  @test_paths.each do |test_path|
    rel = relativize(test_path)
    next if @failed_test_files.include?(rel)

    ok = if @boot_path
           fork_clean_pass?([absolute(test_path)], after_fork)
         else
           subprocess_clean_pass?([test_path])
         end
    @failed_clean_tests << rel unless ok
  end
end

#verify_combined_clean(after_fork: nil) ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Runs every successfully captured test together. Per-file capture can miss a failure that only appears when covering files share one process.

Parameters:

  • after_fork (Proc, nil) (defaults to: nil) —

    boot-mode fork hook.



425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/mutineer/coverage_map.rb', line 425

def verify_combined_clean(after_fork: nil)
  runnable = @test_paths.reject { |t| @failed_test_files.include?(relativize(t)) }
  return if runnable.size < 2
  return unless @failed_clean_tests.empty?

  ok = if @boot_path
         fork_clean_pass?(runnable.map { |t| absolute(t) }, after_fork)
       else
         subprocess_clean_pass?(runnable)
       end
  @failed_clean_tests << "combined suite" unless ok
end

#warn_external_sources ⇒ Object (private)

A configured source that resolves outside project_root would silently be dropped (its coverage relativizes to an absolute path). Warn instead.



846
847
848
849
850
851
852
853
# File 'lib/mutineer/coverage_map.rb', line 846

def warn_external_sources
  @source_paths.each do |p|
    next unless relativize(absolute(p)).start_with?("/")

    warn "[mutineer] source #{p} is outside project root #{@project_root}; " \
         "its coverage will be ignored"
  end
end

#warn_incomplete ⇒ void (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Warns when coverage capture was incomplete.



892
893
894
895
# File 'lib/mutineer/coverage_map.rb', line 892

def warn_incomplete
  warn "[mutineer] cached coverage map may be incomplete; these test files " \
       "failed to contribute: #{@failed_test_files.join(', ')}"
end

#wrapped_capture?(payload) ⇒ Boolean (private)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

True when payload is the wrapped capture JSON/Marshal contract (passed + coverage), not a raw Coverage.result hash.

Parameters:

  • payload (Object) —

    parsed subprocess output or forked Marshal value.

Returns:

  • (Boolean)


340
341
342
# File 'lib/mutineer/coverage_map.rb', line 340

def wrapped_capture?(payload)
  payload.is_a?(Hash) && payload.key?("passed") && payload.key?("coverage")
end