From ec0bc11f433d88307d3c18a8ddb91f2e5f328b32 Mon Sep 17 00:00:00 2001 From: Alexander Nachtmann Date: Tue, 13 Oct 2026 03:45:01 +0200 Subject: [PATCH] Let class-level lines decide a file's lines istanbul (Jest, Vitest) lists each function's declaration line under , and that line is often not part of its own line coverage, the class-level . DotCov counted those declaration lines as file lines, so its line totals ran above the tool's own: on the typescript-sdk packages by up to 779 lines per report. Lines under now count toward the file only when the class has no class-level ; they still feed the method data. Coverlet repeats its method lines in the class summary, so its totals do not change. On all ten typescript-sdk Vitest reports, DotCov's line totals now equal Vitest's. Branch totals can stay lower, because the writer attaches branches only to statement lines; the README says so. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++ src/DotCov/CoberturaParser.cs | 73 ++++++++++++++++------ tests/DotCov.Tests/CoberturaParserTests.cs | 62 ++++++++++++++++++ 3 files changed, 121 insertions(+), 18 deletions(-) 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() {