Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 55 additions & 18 deletions src/DotCov/CoberturaParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,18 +218,21 @@ private static void RecordSource(string value, DocumentContext document)

/// <summary>
/// Walk one <c>&lt;class&gt;</c> subtree once and feed both collectors. Lines under
/// <c>&lt;methods&gt;&lt;method&gt;</c> reach the file collector AND the method collector
/// (attributed to the enclosing method); the trailing class-level <c>&lt;lines&gt;</c>
/// summary reaches only the file collector. <c>ReadSubtree</c> leaves the outer reader on
/// the closing tag when done.
/// <c>&lt;methods&gt;&lt;method&gt;</c> reach the method collector (attributed to the enclosing
/// method) and reach the file collector only when the class has no class-level
/// <c>&lt;lines&gt;</c>; that summary, when present, alone decides the file's lines.
/// <c>ReadSubtree</c> leaves the outer reader on the closing tag when done.
/// </summary>
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Finalize method-only classes on the async path

When a report contains a class whose coverage exists only under <method><lines>—the fallback this change explicitly supports—ParseAsync buffers those records in _methodLines but never invokes context.Complete() after its class loop, unlike this synchronous path. The async report therefore materializes the file with zero lines and branches rather than its method coverage; invoke Complete() from the async class case as well and cover that entrypoint.

Useful? React with 👍 / 👎.

}

private static ClassContext? CreateClassContext(
Expand All @@ -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;

/// <summary>At the class end, method lines stand in for a class without class-level lines.</summary>
public void Complete()
{
if (!_hasClassLines) fileAcc?.AddFrom(_methodLines!);
}

public void Visit(XmlReader sub)
{
Expand All @@ -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 <condition>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;
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<int, int>();
MergeCondition(_conditionLine, number, covered);
}

/// <summary>Fold another accumulator's lines, branches and conditions into this one.</summary>
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<int, int>();
conds[number] = conds.TryGetValue(number, out var existing) ? Math.Max(existing, covered) : covered;
}

Expand Down
62 changes: 62 additions & 0 deletions tests/DotCov.Tests/CoberturaParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <methods>; the
// class-level <lines> are its line coverage and need not contain that line.
const string xml = """
<?xml version="1.0"?>
<coverage><packages><package><classes>
<class name="x.ts" filename="x.ts">
<methods>
<method name="run" hits="2" signature="()V">
<lines><line number="3" hits="2" /></lines>
</method>
</methods>
<lines>
<line number="4" hits="2" branch="false" />
<line number="5" hits="0" branch="true" condition-coverage="50% (1/2)" />
</lines>
</class>
</classes></package></packages></coverage>
""";

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 = """
<?xml version="1.0"?>
<coverage><packages><package><classes>
<class name="X" filename="x.cs">
<methods>
<method name="A" signature="()">
<lines>
<line number="10" hits="1" branch="False" />
<line number="11" hits="1" branch="True" condition-coverage="50% (1/2)">
<conditions><condition number="0" type="jump" coverage="50%" /></conditions>
</line>
</lines>
</method>
</methods>
</class>
</classes></package></packages></coverage>
""";

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()
{
Expand Down
Loading