diff --git a/README.md b/README.md
index 8dd840f..8c994ad 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,10 @@ npm test -- --coverage --coverageProvider=v8 --coverageReporters=cobertura
dotcov check coverage/cobertura-coverage.xml --min-line 90 --min-branch 90
```
+Jest's and Vitest's Cobertura writer (istanbul) attaches branches only to lines that hold
+statements. A branch on any other line is missing from the report's lines, so DotCov's
+branch count can be lower than the tool's own summary. Line counts match.
+
For Python with coverage.py and pytest installed, select your package as the coverage source:
```bash
diff --git a/src/DotCov/CoberturaParser.cs b/src/DotCov/CoberturaParser.cs
index 5a554d4..9c4f59a 100644
--- a/src/DotCov/CoberturaParser.cs
+++ b/src/DotCov/CoberturaParser.cs
@@ -218,18 +218,21 @@ private static void RecordSource(string value, DocumentContext document)
///
/// Walk one <class> subtree once and feed both collectors. Lines under
- /// <methods><method> reach the file collector AND the method collector
- /// (attributed to the enclosing method); the trailing class-level <lines>
- /// summary reaches only the file collector. ReadSubtree leaves the outer reader on
- /// the closing tag when done.
+ /// <methods><method> reach the method collector (attributed to the enclosing
+ /// method) and reach the file collector only when the class has no class-level
+ /// <lines>; that summary, when present, alone decides the file's lines.
+ /// ReadSubtree leaves the outer reader on the closing tag when done.
///
private static void ConsumeClass(XmlReader reader, DocumentContext document, FileCollector? files, MethodCollector? methods)
{
if (CreateClassContext(reader, document, files, methods) is not { } context) return;
- using var sub = reader.ReadSubtree();
+ using (var sub = reader.ReadSubtree())
+ {
+ while (sub.Read())
+ context.Visit(sub);
+ }
- while (sub.Read())
- context.Visit(sub);
+ context.Complete();
}
private static ClassContext? CreateClassContext(
@@ -242,6 +245,19 @@ private sealed class ClassContext(
string file, string className, DocumentContext document, LineAccumulator? fileAcc, MethodCollector? methods)
{
private MethodCollector.Entry? _methodAcc;
+ // Method lines wait for the class end: istanbul lists each function's declaration line
+ // there, which is not a line of its own line coverage, so a class-level summary wins.
+ private readonly LineAccumulator? _methodLines = fileAcc is null ? null : new LineAccumulator();
+ private bool _inMethod;
+ private bool _hasClassLines;
+
+ private LineAccumulator? LineTarget => _inMethod ? _methodLines : fileAcc;
+
+ /// At the class end, method lines stand in for a class without class-level lines.
+ public void Complete()
+ {
+ if (!_hasClassLines) fileAcc?.AddFrom(_methodLines!);
+ }
public void Visit(XmlReader sub)
{
@@ -251,27 +267,30 @@ public void Visit(XmlReader sub)
_methodAcc = methods?.For(
new MethodKey(file, className, sub.GetAttribute("name") ?? "", sub.GetAttribute("signature") ?? ""),
DecodeComplexity(sub.GetAttribute("complexity")));
+ _inMethod = !sub.IsEmptyElement;
if (sub.IsEmptyElement) _methodAcc = null;
break;
case (XmlNodeType.EndElement, "method"):
_methodAcc = null;
+ _inMethod = false;
break;
case (XmlNodeType.Element, "line"):
+ _hasClassLines |= !_inMethod;
if (!TryDecodeLine(sub, file, document, out var line))
{
// A skipped line takes its nested s with it; they must not
// attach to the previous branched line.
- fileAcc?.SkipLine();
+ LineTarget?.SkipLine();
break;
}
- fileAcc?.AddLine(sub, line, file, document);
+ LineTarget?.AddLine(sub, line, file, document);
_methodAcc?.AddLine(line);
break;
case (XmlNodeType.Element, "condition"):
- fileAcc?.AddCondition(sub);
+ LineTarget?.AddCondition(sub);
break;
}
}
@@ -474,9 +493,7 @@ private sealed class LineAccumulator
public void AddLine(XmlReader reader, LineData line, string file, DocumentContext document)
{
_conditionLine = -1;
- _lineHits[line.Number] = _lineHits.TryGetValue(line.Number, out var existing)
- ? Math.Max(existing, line.Hits)
- : line.Hits;
+ MergeHits(line.Number, line.Hits);
// Emitters disagree on casing: original Cobertura/JaCoCo write `branch="true"`,
// Coverlet writes `branch="True"`. A literal compare would silently drop Coverlet
@@ -487,9 +504,7 @@ public void AddLine(XmlReader reader, LineData line, string file, DocumentContex
if (TryParseConditionCoverage(cond, out var covered, out var total))
{
- _branchesByLine[line.Number] = _branchesByLine.TryGetValue(line.Number, out var existingBranch)
- ? (Math.Max(existingBranch.Covered, covered), Math.Max(existingBranch.Total, total))
- : (covered, total);
+ MergeBranches(line.Number, covered, total);
_conditionLine = line.Number;
}
else
@@ -508,8 +523,30 @@ public void AddCondition(XmlReader reader)
if (!int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)) return;
if (!TryParseConditionOutcomes(reader.GetAttribute("coverage"), out var covered)) return;
- if (!_conditionsByLine.TryGetValue(_conditionLine, out var conds))
- _conditionsByLine[_conditionLine] = conds = new Dictionary();
+ MergeCondition(_conditionLine, number, covered);
+ }
+
+ /// Fold another accumulator's lines, branches and conditions into this one.
+ public void AddFrom(LineAccumulator other)
+ {
+ foreach (var (line, hits) in other._lineHits) MergeHits(line, hits);
+ foreach (var (line, (covered, total)) in other._branchesByLine) MergeBranches(line, covered, total);
+ foreach (var (line, conds) in other._conditionsByLine)
+ foreach (var (number, covered) in conds) MergeCondition(line, number, covered);
+ }
+
+ private void MergeHits(int line, int hits) =>
+ _lineHits[line] = _lineHits.TryGetValue(line, out var existing) ? Math.Max(existing, hits) : hits;
+
+ private void MergeBranches(int line, int covered, int total) =>
+ _branchesByLine[line] = _branchesByLine.TryGetValue(line, out var existing)
+ ? (Math.Max(existing.Covered, covered), Math.Max(existing.Total, total))
+ : (covered, total);
+
+ private void MergeCondition(int line, int number, int covered)
+ {
+ if (!_conditionsByLine.TryGetValue(line, out var conds))
+ _conditionsByLine[line] = conds = new Dictionary();
conds[number] = conds.TryGetValue(number, out var existing) ? Math.Max(existing, covered) : covered;
}
diff --git a/tests/DotCov.Tests/CoberturaParserTests.cs b/tests/DotCov.Tests/CoberturaParserTests.cs
index 174cf83..b7e6eb6 100644
--- a/tests/DotCov.Tests/CoberturaParserTests.cs
+++ b/tests/DotCov.Tests/CoberturaParserTests.cs
@@ -234,6 +234,68 @@ public async Task Parse_CoverletLayout_DedupesMethodsAndClassLines()
await Assert.That(file.UncoveredLines).IsEquivalentTo([20, 21], CollectionOrdering.Matching);
}
+ [Test]
+ public async Task Parse_IstanbulLayout_MethodDeclarationLinesAreNotFileLines()
+ {
+ // istanbul (Jest, Vitest) lists each function's declaration line under ; the
+ // class-level are its line coverage and need not contain that line.
+ const string xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml));
+ var file = CoberturaParser.Parse(stream).Files.Single();
+
+ await Assert.That(file.LinesTotal).IsEqualTo(2);
+ await Assert.That(file.LinesHit).IsEqualTo(1);
+ await Assert.That(file.UncoveredLines).IsEquivalentTo([5], CollectionOrdering.Matching);
+ await Assert.That(file.BranchesHit).IsEqualTo(1);
+ await Assert.That(file.BranchesTotal).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Parse_ClassWithoutClassLevelLines_CountsItsMethodLines()
+ {
+ const string xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml));
+ var file = CoberturaParser.Parse(stream).Files.Single();
+
+ await Assert.That(file.LinesTotal).IsEqualTo(2);
+ await Assert.That(file.LinesHit).IsEqualTo(2);
+ await Assert.That(file.BranchesByLine[11]).IsEqualTo((1, 2));
+ await Assert.That(file.ConditionsByLine[11][0]).IsEqualTo(1);
+ }
+
[Test]
public async Task Parse_BranchedLines_PopulatesBranchesByLineFromXml()
{