Class: Mutineer::CoverageMap
- Inherits:
-
Object
- Object
- Mutineer::CoverageMap
- 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
-
#failed_clean_tests ⇒ Object
readonly
Returns the value of attribute failed_clean_tests.
-
#failed_test_files ⇒ Object
readonly
Returns the value of attribute failed_test_files.
-
#map ⇒ Object
readonly
Returns the value of attribute map.
-
#phase_a_ran ⇒ Object
readonly
Returns the value of attribute phase_a_ran.
-
#project_root ⇒ Object
readonly
Returns the value of attribute project_root.
Class Method Summary collapse
-
.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_filesover IPC; the tool reconstructs it here for per-mutant selection).
Instance Method Summary collapse
-
#abs_load_paths ⇒ Array<String>
private
Returns absolute load paths.
-
#abs_source_paths ⇒ Array<String>
private
Returns absolute source paths.
-
#absolute(path) ⇒ String
private
private
Expands a path relative to the project root (see ProjectPath.absolute).
-
#accept_capture_payload(test_path, payload) ⇒ void
private
private
Records a wrapped capture: assertion failures go to #failed_clean_tests; successful coverage is inverted into the map.
-
#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.
-
#build_or_load ⇒ Object
Standalone entry: load the cached map when the content digest matches, otherwise rebuild from subprocesses and overwrite the cache.
-
#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).
-
#cache_path ⇒ String
private
private
Returns the cache path.
-
#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.
-
#capture(test_path) ⇒ Object
private
Spawns a fresh
rubyreading an inline script from stdin. -
#capture_loaded_files ⇒ Array<String>
private
private
Project-local
.rbfiles loaded in this process at capture time. -
#clean_check_script(test_paths) ⇒ String
private
private
Builds a pass/fail-only subprocess script (no coverage instrumentation).
-
#compute_digest ⇒ Object
private
Digest each file's ROLE + relative path + content length + content, plus the load_paths.
-
#covered_source_files ⇒ Object
private
Source rel-paths that received coverage from any successful capture.
-
#dependencies_match?(cached) ⇒ Boolean
private
private
True when the cached map recorded support-file fingerprints and they still match.
-
#describe_status(status) ⇒ String
private
private
Human description of a child Process::Status for capture diagnostics.
-
#digest_group(digest, role, paths) ⇒ Array(String, String, Array<String>)
private
private
Groups a digest with its role and paths.
-
#fail_test(test_path, reason) ⇒ void
private
private
Records a failed coverage capture.
-
#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.
-
#file_fingerprint(abs) ⇒ String
private
private
Byte fingerprint of a file for cache dependency checks.
-
#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).
-
#fork_clean_pass?(abs_tests, after_fork) ⇒ Boolean
private
private
Runs test files in a fork of the booted parent and returns whether they passed.
-
#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
constructor
A new instance of CoverageMap.
-
#kill_fork_clean(pid) ⇒ void
private
private
SIGKILLs a hung clean-check child (and its group) then reaps it.
-
#loaded_files_expression ⇒ String
private
private
Ruby source of the child-side
$LOADED_FEATURESfilter (project.rbfiles). -
#loaded_relative(abs) ⇒ String?
private
private
Path of
absrelative to the real project root, or nil when outside it. -
#method_uncapturable?(file, line_range) ⇒ Boolean
Per-method taint.
-
#minitest_clean_check_script(test_paths) ⇒ String
private
private
Minitest clean-suite check.
-
#minitest_subprocess_script(test_path) ⇒ String
private
private
Builds the minitest subprocess script.
-
#project_root_real ⇒ String
private
private
Canonical project root for loaded-feature matching (
/varvs/private/var). -
#read_cache ⇒ Hash?
private
private
Reads the coverage cache.
-
#record(coverage, test_path) ⇒ Object
private
Records every source line with a non-zero execution count as covered by this test file.
-
#record_loaded(paths) ⇒ void
private
private
Fingerprints project-local support files from a capture payload.
-
#relativize(path) ⇒ String
private
private
Relativizes a path against the project root (see ProjectPath.relative).
-
#remaining(deadline) ⇒ Float
private
private
Seconds left before
deadline, never negative. -
#result_channel_expression ⇒ String
private
private
Ruby source that opens the result channel in a #spawn_script child.
-
#retry_failed_captures(after_fork) ⇒ void
private
private
Re-runs tests whose previous capture crashed.
-
#rspec_clean_check_script(test_paths) ⇒ String
private
private
RSpec clean-suite check.
-
#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.
-
#run_phase_a ⇒ Object
private
private
Runs standalone coverage capture.
-
#run_phase_a_via_fork(after_fork:) ⇒ Object
private
Boot-mode capture.
-
#save ⇒ void
private
private
Saves the coverage cache.
-
#spawn_script(script, result: true) ⇒ Array(Process::Status, String)
private
private
Runs
scriptin a freshruby -that reads the script from stdin. -
#subprocess_clean_pass?(test_paths) ⇒ Boolean
private
private
Runs test files in a fresh interpreter and returns whether they passed.
-
#subprocess_script(test_path) ⇒ String
private
private
Builds the framework-specific subprocess script.
-
#tests_for(file, line) ⇒ Object
Lookup: the test files that cover
file:line, or [] when none do. -
#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.
-
#verify_cached_clean(after_fork: nil) ⇒ void
private
private
Re-runs each successfully captured test on a cache hit.
-
#verify_combined_clean(after_fork: nil) ⇒ void
private
private
Runs every successfully captured test together.
-
#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_incomplete ⇒ void
private
private
Warns when coverage capture was incomplete.
-
#wrapped_capture?(payload) ⇒ Boolean
private
private
True when
payloadis the wrapped capture JSON/Marshal contract (passed+coverage), not a raw Coverage.result hash.
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.
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.
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.
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).
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.
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.
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.
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.}") 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.
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).
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).
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.
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.
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.
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.
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.}#{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.}" 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.
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.
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).
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.
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).
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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 = { in: script_rd, out: File::NULL } [RESULT_FD] = result_wr if result pid = Process.spawn(RbConfig.ruby, "-", **) 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.
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.
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.
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.
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.
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.
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 |