Skip to content

Simulated (unloaded) files emit elsif else-branch tuples that don't match Coverage, creating phantom missed branches on merge #1226

Description

@hasghari

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

  1. 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.
  2. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions