C# coverage merge drops uncovered lines when reports list different lines of the same file

Version

Observed on SonarQube Community Build 25.2 with the C# plugin 10.6 (deployment type should not matter here).
Also reproduced against the current master of sonar-dotnet (Coverage.java) with the unit tests below.
The reports I used are in the OpenCover format, but the bug is in the format-independent merge.

What happens

When several coverage reports contain the same source file but do not list the same set of lines, the merged result depends on the order in which the reports are read:

  1. Lines that are executable but not covered disappear from “lines to cover”, so the coverage percentage is inflated.
  2. Lines covered by only one of the reports lose hits. A line hit once ends up with 0 hits.

I noticed it because the same commit, analyzed twice with the same reports, produced very different coverage percentages. The number of covered lines was the same in both runs, only the number of lines to cover changed.

Root cause

Coverage.mergeLineHits passes every slot of the other report’s array to addHits, including the slots that hold SPECIAL_HITS_NON_EXECUTABLE (-1):

for (int i = otherHitsByLine.length - 1; i >= 0; i--) {
  addHits(file, i + 1, otherHitsByLine[i]);
}

addHits then adds that -1 to the existing value:

existing other result expected
0 (uncovered) -1 (line not in report) -1 (not executable) 0
1 -1 0 1
N -1 N - 1 N
-1 0 or N 0 or N same (correct)

So a.mergeWith(b) and b.mergeWith(a) give different results as soon as one report lists a line that the other one does not.

How to reproduce

No project is needed, three unit tests on Coverage are enough (code at the end). With the current master:

full.mergeWith(partial)    -> {10=1, 11=1}                expected {10=1, 11=1, 12=0, 13=0}
partial.mergeWith(full)    -> {10=1, 11=1, 12=0, 13=0}    correct
partial.mergeWith(other)   -> {10=0, 11=0, 12=1, 13=1}    expected {10=1, 11=1, 12=1, 13=1}

When does this happen in practice

Any setup where two reports describe the same file with different line sets. In my case it is the Unity Code Coverage package: for each test mode it writes two OpenCover files, one listing every sequence point with zero visits and one containing only the methods that were actually called. Both files match sonar.cs.opencover.reportsPaths, and the result depends on which one the scanner reads first.

Fix

Skip the non-executable slots when merging:

for (int i = otherHitsByLine.length - 1; i >= 0; i--) {
  if (otherHitsByLine[i] != SPECIAL_HITS_NON_EXECUTABLE) {
    addHits(file, i + 1, otherHitsByLine[i]);
  }
}

With this change the three tests below pass, and the existing CoverageTest tests still pass.
I have opened a pull request with the fix and the tests: Fix coverage merge dropping uncovered lines when reports list different lines of the same file by jh11240 · Pull Request #9856 · SonarSource/sonar-dotnet · GitHub

Unit tests (to add to CoverageTest.java)

private static final String FILE = "Foo.cs";

@Test
public void mergeWith_otherHasFewerLines_keepsUncoveredLines() {
  Coverage sut = allLinesUncovered();

  sut.mergeWith(onlyFirstMethodCovered());

  // Lines 12 and 13 are executable and uncovered. They must stay in the lines to cover.
  assertThat(sut.hits(FILE)).isEqualTo(Map.of(10, 1, 11, 1, 12, 0, 13, 0));
}

@Test
public void mergeWith_isOrderIndependent() {
  Coverage fullFirst = allLinesUncovered();
  fullFirst.mergeWith(onlyFirstMethodCovered());

  Coverage partialFirst = onlyFirstMethodCovered();
  partialFirst.mergeWith(allLinesUncovered());

  assertThat(fullFirst.hits(FILE)).isEqualTo(partialFirst.hits(FILE));
}

@Test
public void mergeWith_disjointLines_keepsHits() {
  Coverage sut = onlyFirstMethodCovered();

  sut.mergeWith(onlySecondMethodCovered());

  // Each report covers a different method. No hit may be lost.
  assertThat(sut.hits(FILE)).isEqualTo(Map.of(10, 1, 11, 1, 12, 1, 13, 1));
}

// A report listing every sequence point of the file (lines 10 to 13), none of them executed.
private static Coverage allLinesUncovered() {
  Coverage coverage = new Coverage();
  for (int line = 10; line <= 13; line++) {
    coverage.addHits(FILE, line, 0);
  }
  return coverage;
}

// A report listing only the first method (lines 10 and 11), executed once.
private static Coverage onlyFirstMethodCovered() {
  Coverage coverage = new Coverage();
  coverage.addHits(FILE, 10, 1);
  coverage.addHits(FILE, 11, 1);
  return coverage;
}

// A report listing only the second method (lines 12 and 13), executed once.
private static Coverage onlySecondMethodCovered() {
  Coverage coverage = new Coverage();
  coverage.addHits(FILE, 12, 1);
  coverage.addHits(FILE, 13, 1);
  return coverage;
}

Hey @jh11240, welcome to the Community!

25.2 is quite an old version of SonarQube Community Build, so normally I would ask you to retry against the latest version (26.10). However, since you have already proposed a fix in the code, I guess that you have verified that the logic in the current version of the code is still flawed, so I’m going to flag this for the team to have a look directly. In the meantime, I encourage you to try it on 26.10 anyway.

We hit the same bug on SonarQube Server 2026.5.2 with the csharpenterprise plugin 10.34.0.3385 (SonarScanner for .NET 11.0), using Microsoft Code Coverage XML (sonar.cs.vscoveragexml.reportsPaths). So it is present in the enterprise plugin on a current Server version and is not specific to OpenCover.

Our setup

dotnet test --collect "Code Coverage;Format=xml" on two test projects yields three reports matching our glob: one per test run and one merged report. They are read in varying order because WildcardPatternFileProvider.listFiles returns a HashSet<File> and the paths contain a GUID and timestamps.

Impact

For identical code we got these results (lines to cover / uncovered), depending on the position of the merged report (G):

Order lines_to_cover / uncovered coverage
G last 1313 / 18 98.6%
G in the middle 1311 / 83 93.7%
G first 1244 / 281 77.4%

Every analysis where G was read first (11 of 36) failed our quality gate (new coverage < 80%); none of the other 25 did. Here the bug lowers coverage (lines covered by one report lose hits), so it fails builds intermittently.

Verification

I reimplemented the parser, addHits and mergeWith and ran them on the real reports (51 main files, matching the scanner log). It reproduces exactly the three results above for the six possible orders. Skipping -1 as proposed in PR #9856 gives 1313 / 18 for all six.

This confirms the root cause and the proposed fix. Is there a target release for it?

Hi @Bulgur6664, there’s target release yet. This is flagged for the team to look into, for now we’re waiting for them to investigate.