Summary
In 1.0.0, SimulateCoverage backfills branch data for tracked-but-unloaded files using StaticCoverageExtractor (Prism). For elsif, the synthesized :else arm tuple does not match the tuple Ruby's Coverage emits for the same file when it is loaded.
Because BranchesCombiner keys arms on [type, start_line, start_col, end_line, end_col], merging a simulated resultset for a file with a real one for the same file does not combine those two arms — it keeps both. The synthesized arm can never be hit by anything, so it is reported as a permanently missed branch.
This surfaces in any multi-process setup where one process loads a file and another only tracks it. Most visibly: Rails' parallelize(workers: N) (Minitest), where the parent process forks workers and never autoloads the app code itself, so the parent contributes a simulated entry and the worker contributes the real one. It also affects merging resultsets from two suites (e.g. RSpec + Minitest), where each suite simulates files the other actually loads.
Reproduction
example.rb:
def check(x)
if x == 1
:one
elsif x == 2
:two
end
end
repro.rb:
require "coverage"
require "simplecov"
require "simplecov/static_coverage_extractor"
path = File.expand_path("example.rb", __dir__)
# What Coverage reports when the file IS loaded, with every arm exercised.
Coverage.start(lines: true, branches: true)
require path
check(1); check(2); check(3)
real = Coverage.result[path][:branches]
# What SimpleCov synthesizes when the file is NOT loaded (SimulateCoverage's path).
synth = SimpleCov::StaticCoverageExtractor.call(File.read(path))["branches"]
# Merge them the way ResultMerger merges a simulated resultset with a real one.
zeroed = synth.transform_values { |a| a.transform_values { 0 } }
merged = SimpleCov::Combine::BranchesCombiner.combine(zeroed, real)
merged.flat_map { |_, a| a.to_a }.each { |k, v| puts "#{k.inspect} => #{v}" }
Output (simplecov 1.0.0, ruby 4.0.5, prism 1.9.0):
[:then, 1, 3, 4, 3, 8] => 1
[:else, 2, 5, 4, 5, 8] => 0 <== phantom, synthesized only
[:else, 2, 4, 2, 6, 5] => 2 <== the real arm
[:then, 4, 5, 4, 5, 8] => 1
[:else, 5, 4, 2, 6, 5] => 1
The file really has 4 branch arms; the merged result has 5, one of which is unhittable.
The disagreement is in the elsif else-arm of the outer if:
real (Coverage): [:else, 2, 4, 2, 6, 5] # spans the elsif, lines 4..6
synth (Prism): [:else, 2, 5, 4, 5, 8] # points at line 5, the elsif's *body*
Coverage treats the whole elsif clause as the else-arm of the outer if; the extractor appears to use the nested clause's body (statements) location instead.
Impact
On a real Rails app (Minitest, parallelize(workers: 4), eager_load = false), a file whose single test covers 100% of its branches when run alone reports a missed branch when the full suite runs, purely from this merge. Across one rails test test/domain run (1448 tests) it produced 12 phantom missed branches, all :else arms. Setting config.eager_load = true makes the parent load the files for real and the phantoms drop to 0, which confirms the simulated-vs-real merge is the trigger.
Note that # simplecov:disable / ignore_branches don't help here, since the phantom arm is structurally indistinguishable from a real missed branch by the time it reaches the report.
Expected
A file's branch arms should be identical whether the data came from Coverage or from StaticCoverageExtractor, so that merging a simulated entry with a real one is a no-op on the arm set (only hit counts should differ).
Possible fixes
- Emit the
elsif else-arm with the clause's full location (matching Coverage) rather than its body's location. Worth auditing the other constructs for the same class of mismatch.
- Defensively, have the merge reconcile a simulated entry against a real one for the same file rather than unioning arm tuples — e.g. drop synthesized arms for any file that some process actually executed, or match on
[type, start_line] when one side is synthesized.
The docstring on StaticCoverageExtractor already acknowledges that Prism's positions "don't always match Coverage's byte-for-byte" and that "downstream consumers that key off line numbers ... see the data they expect" — but BranchesCombiner keys off the full tuple including columns, and its own comment encodes the assumption this breaks: "Branches inside files are always same if they exist, the difference only in coverage count."
Environment
- simplecov 1.0.0
- ruby 4.0.5
- prism 1.9.0
- Rails 8.1.3 (Minitest
parallelize(workers: 4), SimpleCov.start 'rails', enable_coverage :branch)
Summary
In 1.0.0,
SimulateCoveragebackfills branch data for tracked-but-unloaded files usingStaticCoverageExtractor(Prism). Forelsif, the synthesized:elsearm tuple does not match the tuple Ruby'sCoverageemits for the same file when it is loaded.Because
BranchesCombinerkeys arms on[type, start_line, start_col, end_line, end_col], merging a simulated resultset for a file with a real one for the same file does not combine those two arms — it keeps both. The synthesized arm can never be hit by anything, so it is reported as a permanently missed branch.This surfaces in any multi-process setup where one process loads a file and another only tracks it. Most visibly: Rails'
parallelize(workers: N)(Minitest), where the parent process forks workers and never autoloads the app code itself, so the parent contributes a simulated entry and the worker contributes the real one. It also affects merging resultsets from two suites (e.g. RSpec + Minitest), where each suite simulates files the other actually loads.Reproduction
example.rb:repro.rb:Output (simplecov 1.0.0, ruby 4.0.5, prism 1.9.0):
The file really has 4 branch arms; the merged result has 5, one of which is unhittable.
The disagreement is in the
elsifelse-arm of the outerif:Coveragetreats the wholeelsifclause as the else-arm of the outerif; the extractor appears to use the nested clause's body (statements) location instead.Impact
On a real Rails app (Minitest,
parallelize(workers: 4),eager_load = false), a file whose single test covers 100% of its branches when run alone reports a missed branch when the full suite runs, purely from this merge. Across onerails test test/domainrun (1448 tests) it produced 12 phantom missed branches, all:elsearms. Settingconfig.eager_load = truemakes the parent load the files for real and the phantoms drop to 0, which confirms the simulated-vs-real merge is the trigger.Note that
# simplecov:disable/ignore_branchesdon't help here, since the phantom arm is structurally indistinguishable from a real missed branch by the time it reaches the report.Expected
A file's branch arms should be identical whether the data came from
Coverageor fromStaticCoverageExtractor, so that merging a simulated entry with a real one is a no-op on the arm set (only hit counts should differ).Possible fixes
elsifelse-arm with the clause's full location (matchingCoverage) rather than its body's location. Worth auditing the other constructs for the same class of mismatch.[type, start_line]when one side is synthesized.The docstring on
StaticCoverageExtractoralready acknowledges that Prism's positions "don't always matchCoverage's byte-for-byte" and that "downstream consumers that key off line numbers ... see the data they expect" — butBranchesCombinerkeys off the full tuple including columns, and its own comment encodes the assumption this breaks: "Branches inside files are always same if they exist, the difference only in coverage count."Environment
parallelize(workers: 4),SimpleCov.start 'rails',enable_coverage :branch)