Skip to content

Compute branch reachability from the control-flow graph (supersedes #2047) - #2056

Open
pcshrosbree wants to merge 2 commits into
coverlet-coverage:masterfrom
pcshrosbree:fix/branch-reachability-cfg
Open

pcshrosbree wants to merge 2 commits into
coverlet-coverage:masterfrom
pcshrosbree:fix/branch-reachability-cfg

Conversation

@pcshrosbree

@pcshrosbree pcshrosbree commented Oct 19, 2026 •

Copy link
Copy Markdown

TL;DR: The original check in #2047 marked reachable code as unreachable. This PR fixes the check, carries the IsReachable flag into reports, and adds regression tests. It changes 10 source files in coverlet.core, 7 test files and the changelog. Coverage figures are unchanged on the guard-clause sample from my review.

This PR supersedes #2047, as @Bertk asked in #2047 (comment). It keeps Bertk's IsReachable property on BranchPoint and BranchInfo, with a default of true. It adds the changes from my review of #2047 and regression tests. Refs #2036, but this PR is unlikely to change the partial coverage reported there (see "What to expect in reports").

What changes

  • Analysis. CecilSymbolHelper.ComputeReachableInstructions walks each method's control-flow graph once, from the method entry. The walk follows branch targets, stops at return, throw and jmp, and enters the filter and handler of each exception handler whose try entry is reached. Every path of a branch gets the reachability of its branch instruction. This replaces the first Introduce BranchPoint property IsReachable #2047 version's check of the instructions laid out before each path. That check marked the normal path of a guard clause (if (s == null) throw ...;) as unreachable.
  • Pass-through. Branch in InstrumenterResult.cs gets [DataMember] public bool IsReachable = true;. Both places in Instrumenter that create a Branch copy the flag, and so do the four BranchInfo initializers in Coverage.
  • State files. DataContractSerializer does not run field initializers. Without help, a state file written before this change has no IsReachable element, and every branch reads back as unreachable. Branch therefore sets the field to true in an [OnDeserializing] callback. In coverlet.msbuild.tasks.tests, Execute_StateUnderTest_WithInstrumentationState_ThresholdType reads such a file from TestAssets. It fails without the callback, including at Introduce BranchPoint property IsReachable #2047's current head, c415187.
  • Reports. CoverageSummary.CalculateBranchCoverage and the Cobertura <conditions> count reachable branches only, as in Introduce BranchPoint property IsReachable #2047. LCOV branch records and OpenCover branch points also exclude unreachable branches so that report details agree with their totals. A Cobertura line whose branches are all unreachable is no longer marked branch="True"; before, it showed 100% (0/0) with empty conditions. The JSON report keeps every branch and carries the flag. TeamCity output already used the filtered totals.
  • Documentation/Changelog.md has an entry under Unreleased.

How this relates to the commits on #2047

Bertk's c415187 already adopts the graph walk and the pass-through. When I use c415187's CecilSymbolHelper.cs in this PR, all my tests pass except the new jmp test. Differences from c415187:

  • the [OnDeserializing] callback above, the jmp case, and the LCOV, OpenCover and Cobertura line changes;
  • CoverageResult.Merge still matches branches as it did before. c415187 adds IsReachable to the match. When the flag is the same on both sides, the extra condition changes nothing. When the flags disagree, c415187 keeps two entries for one branch. That can happen with a MergeWith report written before this change: such a report has no IsReachable field, so its branches read back as reachable;
  • the tests (below) replace the synthetic-method tests in BranchPointReachabilityTests.cs. From Introduce BranchPoint property IsReachable #2047 I kept, with small changes, the CoverageSummary and Cobertura filter tests;
  • no unrelated reformatting, and BuildPointsForBranch and BuildPointsForSwitchCases keep their signatures. Path 1 and the switch cases copy the flag from path 0.

Tests

The C# samples in the new BranchPointReachabilityTests are compiled at test time with Roslyn, in Debug and in Release. The branch points therefore come from real IL with real offsets and sequence points. Every test that reads branch points asserts the exact list of paths and flags, with no Count >= 2 guards. The tests cover:

  • a guard clause with throw, if (x) return, a plain if/else and a switch, each in Debug and Release;
  • a branch inside a catch block, and one inside a catch nested in another catch;
  • methods built with Cecil and read back from a MemoryStream: a reachable branch, a branch and a switch that nothing reaches, and a branch after jmp;
  • an end-to-end test in coverlet.core.coverage.tests: it compiles a module with a PDB, appends a branch that nothing reaches, runs Coverage.PrepareModules and GetCoverageResult, and checks IsReachable on both the Branch and the BranchInfo objects;
  • the four BranchInfo initializers in Coverage;
  • CoveragePrepareResult serialization, with and without the IsReachable element;
  • LCOV, OpenCover and Cobertura output for a method with an unreachable branch that has hits;
  • Cobertura output for a line whose branches are all unreachable.

The end-to-end test writes a compiled module and its PDB to a temporary directory. .github/copilot-instructions.md asks unit tests not to use the file system, so the second commit moves this test from coverlet.core.tests to coverlet.core.coverage.tests, as the Copilot review suggested. It does not run the instrumented code, so it runs in the test process.

Results on Linux with SDK 10.0.402, Debug:

master b5ba919 this PR
coverlet.core.tests net10.0 337 passed, 13 skipped, 0 failed 361 passed, 13 skipped, 0 failed
coverlet.core.tests net8.0 337 passed, 13 skipped, 0 failed 361 passed, 13 skipped, 0 failed
coverlet.core.coverage.tests net10.0 and net8.0, the end-to-end test only passed
coverlet.collector.tests net10.0 and net8.0 48 passed per target 48 passed per target
coverlet.msbuild.tasks.tests net10.0 14 passed 14 passed

coverlet.msbuild.tasks.tests on net8.0 fails all 14 tests on master and on this PR alike, because MSBuildLocator finds no .NET 8 SDK on my machine.

To check that the tests can fail, I broke the code one way at a time and, on net10.0, ran the whole coverlet.core.tests suite and the end-to-end test. Each of these changes turns at least one new test red:

Change to the code New tests that fail
analysis always returns reachable 3 (dead code, jmp, end-to-end)
analysis always returns unreachable 14
skip exception handlers in the walk 4 (both catch tests, Debug and Release)
walk exception handlers only twice, without repeating to a fixed point 2 (nested catch)
fall through after jmp 1
first #2047 look-back instead of the graph walk 7
drop the copy in either Instrumenter site 1 each (end-to-end)
drop the copy in any one of the four Coverage sites 1 or 2 each
path 1 does not copy the flag from path 0 3
switch cases do not copy the flag from path 0 1 (dead code)
remove [DataMember] from Branch.IsReachable 2 (serialization)
remove the [OnDeserializing] callback 1 (state file without the field)
CoverageSummary counts unreachable branches 5
Cobertura <conditions> include unreachable branches 1
Cobertura marks a line with only unreachable branches as a branch line 1
LCOV writes BRDA records for unreachable branches 1
OpenCover writes branch points for unreachable branches 1

Three changes leave every test green: removing the filter entry from the walk, setting the default of Branch.IsReachable to false, and setting the default of BranchPoint.IsReachable to false. In the current production paths, branches inside filters are skipped and handlers are entered independently. Every production site that constructs a Branch or BranchPoint assigns the flag explicitly. The Branch deserialization callback also supplies the missing-field default. These mutations caused no failures in the exercised suite.

What to expect in reports

In the compiled C# assemblies surveyed here, no branch point was unreachable under the graph walk. With this PR, GetBranchPoints marks every branch point in this PR's coverlet.core.dll as reachable: 2,358 in Debug and 2,348 in Release. With the original look-back check from #2047, the same survey marks 52 branch points unreachable in Debug and 368 in Release. On the small guard-clause sample from my review, the Cobertura report from coverlet.console is identical to master's apart from its timestamp, and the JSON report gains "IsReachable": true on each branch. So this PR is unlikely to change the partial coverage reported in #2036. As I wrote in the review, that issue looks like a hit-counting problem, and I have not run the #2036 project.

Not tested

Add the IsReachable property introduced by Bertk in coverlet-coverage#2047 to BranchPoint
and BranchInfo. Compute branch reachability by walking each method's
control-flow graph once, from the method entry. The walk follows branch
targets, stops at return, throw and jmp, and enters the filter and
handler of each exception handler whose try entry is reached. Every
path of a branch gets the reachability of its branch instruction.

Carry the flag through Instrumentation.Branch ([DataMember], default
true), both places in Instrumenter that create a Branch, and the four
BranchInfo initializers in Coverage. DataContractSerializer does not run
field initializers, so Branch sets IsReachable to true in an
OnDeserializing callback; otherwise an instrumentation state file
without the field reads back with every branch unreachable.

Branch coverage totals leave out unreachable branches, as in coverlet-coverage#2047. So
do the per-branch details: Cobertura conditions, LCOV BRDA records and
OpenCover branch points, so that each report's details agree with its
totals. A Cobertura line whose branches are all unreachable is no longer
marked as a branch line.

The tests compile C# samples with Roslyn in Debug and in Release, so
the branch points come from real IL with real offsets. Methods built
with Cecil cover a branch and a switch that nothing reaches, and a
branch after jmp. An end-to-end test in CoverageTests checks that the
flag survives Instrumenter and Coverage.

Refs coverlet-coverage#2036. Supersedes coverlet-coverage#2047.

Co-authored-by: Bert <bert.k@hotmail.com>
@Bertk Bertk added coverlet-core regression software bug of before working feature labels Oct 19, 2026
@Bertk
Bertk requested a balanced review from Copilot October 19, 2026 20:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The disk-backed end-to-end test should move to the coverage-test project to satisfy mandatory unit-test filesystem isolation.

1 open issue
What changed in this PR

Adds control-flow-based branch reachability to Coverlet’s analysis and reporting, superseding #2047 without claiming to resolve #2036.

Changes:

  • Computes reachability and propagates it through instrumentation and coverage results.
  • Excludes unreachable branches from report totals and details while preserving older state-file compatibility.
  • Adds analysis, serialization, propagation, and reporting regression tests.
File Description
test/​coverlet.core.tests/​Symbols/​BranchPointReachabilityTests.cs Tests reachability and state serialization.
test/​coverlet.core.tests/​Reporters/​OpenCoverReporterTests.cs Checks branch details against totals.
test/​coverlet.core.tests/​Reporters/​LcovReporterTests.cs Checks filtered branch records.
test/​coverlet.core.tests/​Reporters/​CoberturaReporterTests.cs Tests unreachable-branch reporting.
test/​coverlet.core.tests/​Coverage/​CoverageTests.IsReachable.cs Tests end-to-end flag propagation.
test/​coverlet.core.tests/​Coverage/​CoverageSummaryTests.cs Tests reachable-only totals.
src/​coverlet.core/​Symbols/​CecilSymbolHelper.cs Computes control-flow reachability.
src/​coverlet.core/​Symbols/​BranchPoint.cs Adds the reachability flag.
src/​coverlet.core/​Reporters/​OpenCoverReporter.cs Filters unreachable branch points.
src/​coverlet.core/​Reporters/​LcovReporter.cs Filters unreachable branch records.
src/​coverlet.core/​Reporters/​CoberturaReporter.cs Filters branch lines and conditions.
src/​coverlet.core/​Instrumentation/​InstrumenterResult.cs Serializes reachability with compatible defaults.
src/​coverlet.core/​Instrumentation/​Instrumenter.cs Copies reachability into instrumentation data.
src/​coverlet.core/​CoverageSummary.cs Counts reachable branches only.
src/​coverlet.core/​CoverageResult.cs Adds reachability to result branches.
src/​coverlet.core/​Coverage.cs Preserves reachability during result construction.
Documentation/​Changelog.md Documents the reporting improvement.

Review effort: Balanced

Comment on lines +44 to +45
string directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(directory);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved in 7bebd95. The end-to-end test and the helper that builds its module are now in coverlet.core.coverage.tests. Coverage_BranchWithoutLines_IsReachableCopiedToBranchInfo stays in coverlet.core.tests.

Coverage_DeadBranch_IsReachableFalseSurvivesInstrumenterAndCoverage
writes a compiled module and its PDB to a temporary directory.
.github/copilot-instructions.md asks unit tests not to use the file
system, so move the test and the helper that builds its module to
coverlet.core.coverage.tests, as the Copilot review on coverlet-coverage#2056 suggested.
Coverage_BranchWithoutLines_IsReachableCopiedToBranchInfo uses mocks and
stays in coverlet.core.tests.

coverlet.core.coverage.tests does not reference coverlet.core.tests, so
the moved test compiles its sample with its own copy of the Roslyn
step, and BranchPointReachabilityTests.Compile is private again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

coverlet-core regression software bug of before working feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants