Skip to content

Commit 9599343

Browse files
committed
Drop a simulated file's branches when a real run covered it
SimulateCoverage backfills branches and methods for tracked-but-unloaded files by static analysis, and the merge unions those tuples into the real ones by position. If a synthesized tuple's location drifts from what Coverage emits (the class of bug behind the elsif, safe-navigation, and Ruby 3.3 fixes), the drifted tuple has no real counterpart to combine with and survives the merge as a phantom, permanently-missed branch on a file that was actually fully covered. Reconcile at the point two file coverages combine: when exactly one side was actually executed (has a covered line, which a simulated file never does), treat its branch and method tuples as authoritative and drop the other side's. This contains any future extractor drift to denominator inflation for files no process loaded, rather than a false miss on a covered file. Lines still combine from both sides, so a simulated file keeps contributing the unloaded-file line denominator. When both sides ran, or neither did, nothing changes: the tuples combine by union as before.
1 parent 25d1ab5 commit 9599343

3 files changed

Lines changed: 180 additions & 2 deletions

File tree

lib/simplecov/combine/files_combiner.rb

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,57 @@ module Combine
99
module FilesCombiner
1010
module_function
1111

12+
# Branch/method tuples drawn from a simulated (never-loaded) file
13+
# when the other side of the merge was actually executed.
14+
NO_SYNTHESIZED = {"branches" => {}.freeze, "methods" => {}.freeze}.freeze
15+
1216
#
1317
# Combines the results for 2 coverages of a file.
1418
#
1519
# @return [Hash]
1620
#
1721
def combine(coverage_a, coverage_b)
22+
source_a, source_b = reconcile_synthesized(coverage_a, coverage_b)
23+
1824
combination = {"lines" => Combine.combine(LinesCombiner, coverage_a["lines"], coverage_b["lines"])}
1925
if SimpleCov.branch_coverage?
20-
combined_branches = Combine.combine(BranchesCombiner, coverage_a["branches"], coverage_b["branches"])
26+
combined_branches = Combine.combine(BranchesCombiner, source_a["branches"], source_b["branches"])
2127
combination["branches"] = combined_branches || {}
2228
end
2329
if SimpleCov.method_coverage?
24-
combination["methods"] = Combine.combine(MethodsCombiner, coverage_a["methods"], coverage_b["methods"])
30+
combination["methods"] = Combine.combine(MethodsCombiner, source_a["methods"], source_b["methods"])
2531
end
2632
combination
2733
end
34+
35+
# When exactly one side of the merge was actually executed, its branch
36+
# and method tuples are authoritative and the other side's are dropped.
37+
# A simulated entry (SimulateCoverage backfills tracked-but-unloaded
38+
# files) synthesizes those tuples statically, so a location that drifts
39+
# from what Coverage emits would otherwise be unioned in by position
40+
# and survive as a phantom, permanently-missed branch (see #1233). This
41+
# contains any such drift to denominator inflation for files no process
42+
# loaded, rather than a false miss on a covered file. Lines are never
43+
# dropped: a simulated file's line shape is correct and carries the
44+
# unloaded-file denominator (#1059).
45+
#
46+
# Returns the two coverages to draw branch/method tuples from, blanking
47+
# the non-executed side only when the other side was executed. When
48+
# both sides were executed (two real runs) or neither was (all
49+
# simulated), both are returned unchanged and combine normally.
50+
def reconcile_synthesized(coverage_a, coverage_b)
51+
executed_a = executed?(coverage_a)
52+
executed_b = executed?(coverage_b)
53+
return [coverage_a, coverage_b] if executed_a == executed_b
54+
55+
executed_a ? [coverage_a, NO_SYNTHESIZED] : [NO_SYNTHESIZED, coverage_b]
56+
end
57+
58+
# A file some process actually loaded has at least one executed line;
59+
# a simulated (never-loaded) file's lines are all `nil` or `0`.
60+
def executed?(coverage)
61+
Array(coverage["lines"]).any? { |count| count&.positive? }
62+
end
2863
end
2964
end
3065
end
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# frozen_string_literal: true
2+
3+
require "helper"
4+
5+
RSpec.describe SimpleCov::Combine::FilesCombiner do
6+
# A real, executed file: some line ran, and its branch tuples come from
7+
# Coverage, so they sit at the exact positions Coverage reports.
8+
let(:executed) do
9+
{
10+
"lines" => [nil, 1, 1, 0, nil],
11+
"branches" => {
12+
[:if, 0, 2, 2, 4, 10] => {[:then, 1, 3, 4, 3, 10] => 1, [:else, 2, 4, 4, 4, 10] => 0}
13+
}
14+
}
15+
end
16+
17+
# A simulated (tracked-but-never-loaded) file: every line is nil / 0, and
18+
# its branch tuples are synthesized. Here the `if` condition's end column
19+
# has drifted (…4, 12] vs …4, 10]), the exact failure mode of #1233.
20+
let(:simulated_drifted) do
21+
{
22+
"lines" => [nil, 0, 0, 0, nil],
23+
"branches" => {
24+
[:if, 0, 2, 2, 4, 12] => {[:then, 1, 3, 4, 3, 10] => 0, [:else, 2, 4, 4, 4, 12] => 0}
25+
}
26+
}
27+
end
28+
29+
around do |example|
30+
SimpleCov.enable_coverage(:branch)
31+
example.run
32+
SimpleCov.clear_coverage_criteria
33+
end
34+
35+
describe ".combine", if: SimpleCov.branch_coverage_supported? do
36+
it "drops a simulated file's branches when the other side was executed" do
37+
combined = described_class.combine(executed, simulated_drifted)
38+
39+
# Only the executed side's real tuple survives — the drifted one would
40+
# otherwise be a phantom, permanently-missed branch after merge.
41+
expect(combined["branches"].keys).to eq([[:if, 0, 2, 2, 4, 10]])
42+
end
43+
44+
it "is order-independent (simulated first)" do
45+
combined = described_class.combine(simulated_drifted, executed)
46+
47+
expect(combined["branches"].keys).to eq([[:if, 0, 2, 2, 4, 10]])
48+
end
49+
50+
it "still merges the lines from the simulated side" do
51+
combined = described_class.combine(executed, simulated_drifted)
52+
53+
# Line shape is authoritative on both sides, so lines combine as usual
54+
# (the simulated side contributes its zeros / relevance).
55+
expect(combined["lines"]).to eq([nil, 1, 1, 0, nil])
56+
end
57+
58+
it "keeps both branch sets when neither side was executed" do
59+
# Two simulated copies of a never-loaded file: no real data exists, so
60+
# its branches still count toward the denominator (#1059). If their
61+
# tuples happen to disagree, both survive — denominator inflation, the
62+
# acceptable fallback, rather than a false miss on a covered file.
63+
other = {
64+
"lines" => [nil, 0, 0, 0, nil],
65+
"branches" => {[:if, 0, 2, 2, 4, 20] => {[:then, 1, 3, 4, 3, 10] => 0, [:else, 2, 4, 4, 4, 20] => 0}}
66+
}
67+
68+
combined = described_class.combine(simulated_drifted, other)
69+
70+
expect(combined["branches"].keys).to contain_exactly([:if, 0, 2, 2, 4, 12], [:if, 0, 2, 2, 4, 20])
71+
end
72+
73+
it "unions two executed runs of the same file normally" do
74+
other_run = {
75+
"lines" => [nil, 1, 1, 1, nil],
76+
"branches" => {
77+
[:if, 0, 2, 2, 4, 10] => {[:then, 1, 3, 4, 3, 10] => 4, [:else, 2, 4, 4, 4, 10] => 5}
78+
}
79+
}
80+
81+
combined = described_class.combine(executed, other_run)
82+
arms = combined["branches"][[:if, 0, 2, 2, 4, 10]]
83+
84+
expect(arms[[:then, 1, 3, 4, 3, 10]]).to eq(5)
85+
expect(arms[[:else, 2, 4, 4, 4, 10]]).to eq(5)
86+
end
87+
end
88+
89+
describe ".combine method coverage", if: SimpleCov.method_coverage_supported? do
90+
around do |example|
91+
SimpleCov.enable_coverage(:method)
92+
example.run
93+
SimpleCov.clear_coverage_criteria
94+
end
95+
96+
it "drops a simulated file's methods when the other side was executed" do
97+
executed_methods = {
98+
"lines" => [nil, 1, 1],
99+
"methods" => {["Foo", :bar, 2, 2, 3, 5] => 1}
100+
}
101+
simulated_methods = {
102+
"lines" => [nil, 0, 0],
103+
"methods" => {["Foo", :bar, 2, 2, 3, 7] => 0} # drifted end column
104+
}
105+
106+
combined = described_class.combine(executed_methods, simulated_methods)
107+
108+
expect(combined["methods"].keys).to eq([["Foo", :bar, 2, 2, 3, 5]])
109+
end
110+
end
111+
end

spec/combine/results_combiner_spec.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,36 @@
158158
expect(merged_result[source_fixture("sample.rb")]["lines"]).to eq([1, 1, 2, 2, nil, nil, 2, 2, nil, nil])
159159
expect(merged_result[source_fixture("app/models/user.rb")]["lines"]).to eq([nil, 1, 1, 1, nil, nil, 1, 0, nil, nil])
160160
end
161+
162+
# End-to-end analogue of the #1233 collate scenario: one worker executed
163+
# the file (real branches), another only tracked it (simulated branches
164+
# whose tuple has drifted). The merge must keep the executed worker's
165+
# tuple and drop the simulated one rather than carry a phantom miss.
166+
describe "reconciling a simulated file against an executed one",
167+
if: SimpleCov.branch_coverage_supported? do
168+
around do |example|
169+
SimpleCov.enable_coverage(:branch)
170+
example.run
171+
SimpleCov.clear_coverage_criteria
172+
end
173+
174+
it "keeps only the executed worker's branch tuple" do
175+
executed_worker = {
176+
source_fixture("sample.rb") => {
177+
"lines" => [nil, 1, 1, 0, nil],
178+
"branches" => {[:if, 0, 2, 2, 4, 10] => {[:then, 1, 3, 4, 3, 10] => 1, [:else, 2, 4, 4, 4, 10] => 0}}
179+
}
180+
}
181+
tracking_worker = {
182+
source_fixture("sample.rb") => {
183+
"lines" => [nil, 0, 0, 0, nil],
184+
"branches" => {[:if, 0, 2, 2, 4, 12] => {[:then, 1, 3, 4, 3, 10] => 0, [:else, 2, 4, 4, 4, 12] => 0}}
185+
}
186+
}
187+
188+
combined = described_class.combine(executed_worker, tracking_worker)
189+
190+
expect(combined[source_fixture("sample.rb")]["branches"].keys).to eq([[:if, 0, 2, 2, 4, 10]])
191+
end
192+
end
161193
end

0 commit comments

Comments
 (0)