Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
215 changes: 187 additions & 28 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45548,9 +45548,15 @@ const CoverageParserFactory = {
return PARSERS.map((p) => p.format);
},
/**
* Aggregate multiple coverage results into a single result
* Aggregate multiple coverage results into a single result.
*
* Multiple reports covering the same file are merged by path with union
* semantics: per-line hit counts are combined via max(), so a line hit
* by any report counts as covered. Without this, overlapping files
* would be double-counted in the denominator, deflating the rate.
*/
aggregateResults(results) {
const mergedFiles = mergeFilesByPath(results);
let totalStatements = 0;
let coveredStatements = 0;
let totalConditionals = 0;
Expand All @@ -45563,34 +45569,26 @@ const CoverageParserFactory = {
let totalPartials = 0;
let totalBranches = 0;
let totalLines = 0;
const allFiles = [];
for (const result of results) {
totalStatements += result.metrics.statements;
coveredStatements += result.metrics.coveredStatements;
totalConditionals += result.metrics.conditionals;
coveredConditionals += result.metrics.coveredConditionals;
totalMethods += result.metrics.methods;
coveredMethods += result.metrics.coveredMethods;
// Aggregate file-level detailed metrics
for (const file of result.files) {
allFiles.push(file);
// Count hits and misses from line data
for (const line of file.lines) {
totalLines++;
if (line.count > 0) {
totalHits++;
}
else {
totalMisses++;
}
for (const file of mergedFiles) {
totalStatements += file.statements;
coveredStatements += file.coveredStatements;
totalConditionals += file.conditionals;
coveredConditionals += file.coveredConditionals;
totalMethods += file.methods;
coveredMethods += file.coveredMethods;
for (const line of file.lines) {
totalLines++;
if (line.count > 0) {
totalHits++;
}
// Count partials from file's partialLines
if (file.partialLines) {
totalPartials += file.partialLines.length;
else {
totalMisses++;
}
// Count branches
totalBranches += file.conditionals;
}
if (file.partialLines) {
totalPartials += file.partialLines.length;
}
totalBranches += file.conditionals;
}
const lineRate = totalStatements > 0
? Number.parseFloat(((coveredStatements / totalStatements) * 100).toFixed(2))
Expand All @@ -45607,17 +45605,178 @@ const CoverageParserFactory = {
coveredMethods,
lineRate,
branchRate,
files: allFiles,
files: mergedFiles,
// Detailed metrics
totalHits,
totalMisses,
totalPartials,
totalBranches,
totalFiles: allFiles.length,
totalFiles: mergedFiles.length,
totalLines,
};
},
};
/**
* Merge FileCoverage entries that share a path in two phases:
*
* 1. Within each report, sum entries sharing a path. Some parsers emit
* multiple FileCoverage entries per source file (e.g., cobertura
* produces one `<class>` per Java/C# type, and a single source file
* can contain multiple types or inner classes). These are disjoint
* parts of one file and must be summed, not unioned.
* 2. Across reports, union entries sharing a path — a line hit by any
* report counts as covered.
*/
function mergeFilesByPath(results) {
const perReportCombined = results.flatMap((r) => groupByPath(r.files, sumFileGroup));
return groupByPath(perReportCombined, mergeFileGroup);
}
function groupByPath(files, combine) {
const byPath = new Map();
for (const file of files) {
const key = file.path || file.name;
const group = byPath.get(key);
if (group) {
group.push(file);
}
else {
byPath.set(key, [file]);
}
}
const result = [];
for (const group of byPath.values()) {
result.push(group.length === 1 ? group[0] : combine(group));
}
return result;
}
/**
* Sum disjoint FileCoverage entries (e.g., inner classes within the same
* source file). Counts are additive; line data is concatenated.
*/
function sumFileGroup(group) {
const lineMap = new Map();
for (const file of group) {
for (const line of file.lines) {
// Distinct classes in the same file normally occupy non-overlapping
// line ranges, but if they do overlap, take the max hit count.
const existing = lineMap.get(line.lineNumber);
if (!existing || line.count > existing.count) {
lineMap.set(line.lineNumber, { ...line });
}
}
}
let statements = 0;
let coveredStatements = 0;
let conditionals = 0;
let coveredConditionals = 0;
let methods = 0;
let coveredMethods = 0;
for (const f of group) {
statements += f.statements;
coveredStatements += f.coveredStatements;
conditionals += f.conditionals;
coveredConditionals += f.coveredConditionals;
methods += f.methods;
coveredMethods += f.coveredMethods;
}
return finalizeMergedFile(group, lineMap, {
statements,
coveredStatements,
conditionals,
coveredConditionals,
methods,
coveredMethods,
});
}
function mergeFileGroup(group) {
const lineMap = new Map();
for (const file of group) {
for (const line of file.lines) {
const existing = lineMap.get(line.lineNumber);
if (!existing) {
lineMap.set(line.lineNumber, { ...line });
continue;
}
if (line.count > existing.count) {
existing.count = line.count;
}
if (line.type === "cond" || existing.type === "cond") {
existing.type = "cond";
}
else if (line.type === "method" || existing.type === "method") {
existing.type = "method";
}
if (line.trueCount !== undefined) {
existing.trueCount = Math.max(existing.trueCount ?? 0, line.trueCount);
}
if (line.falseCount !== undefined) {
existing.falseCount = Math.max(existing.falseCount ?? 0, line.falseCount);
}
}
}
// Parsers differ on how statements map to lines: cobertura/lcov/jacoco/
// istanbul emit one "statement" per line entry, but Go counts semantic
// blocks where one block can span many lines. To stay correct for both,
// use max() across reports (same file ⇒ same statement count). When
// statements align 1:1 with lines we can derive coveredStatements from
// the merged line union exactly; otherwise fall back to max() across
// reports (a safe underestimate of the true union).
const statements = Math.max(...group.map((f) => f.statements));
const lineAligned = group.every((f) => f.statements === f.lines.length);
const coveredStatements = lineAligned
? [...lineMap.values()].filter((l) => l.count > 0).length
: Math.max(...group.map((f) => f.coveredStatements));
// LineCoverage doesn't carry per-branch hit state, so we can't reliably
// union branch hits across reports. Same file ⇒ same branch count across
// reports, so take the max as a best-effort union.
const conditionals = Math.max(...group.map((f) => f.conditionals));
const coveredConditionals = Math.max(...group.map((f) => f.coveredConditionals));
const methods = Math.max(...group.map((f) => f.methods));
const coveredMethods = Math.max(...group.map((f) => f.coveredMethods));
return finalizeMergedFile(group, lineMap, {
statements,
coveredStatements,
conditionals,
coveredConditionals,
methods,
coveredMethods,
});
}
/**
* Assemble the final FileCoverage from a pre-populated lineMap and the
* aggregation strategy's numeric metrics. Shared by `sumFileGroup` and
* `mergeFileGroup` — they differ in how the map and metrics are built,
* but the downstream derivation (sort, missing/partial lines, rates,
* output shape) is identical.
*/
function finalizeMergedFile(group, lineMap, metrics) {
const mergedLines = [...lineMap.values()].sort((a, b) => a.lineNumber - b.lineNumber);
const missingLines = mergedLines
.filter((l) => l.count === 0)
.map((l) => l.lineNumber);
// A partial line remains partial only if it still has hits after merge;
// lines that ended up fully missed are no longer partial.
const partialLines = [
...new Set(group.flatMap((f) => f.partialLines ?? [])),
]
.filter((ln) => (lineMap.get(ln)?.count ?? 0) > 0)
.sort((a, b) => a - b);
return {
name: group[0].name,
path: group[0].path,
...metrics,
lineRate: calculateRate(metrics.coveredStatements, metrics.statements),
branchRate: calculateRate(metrics.coveredConditionals, metrics.conditionals),
lines: mergedLines,
missingLines,
partialLines,
};
}
function calculateRate(covered, total) {
return total > 0
? Number.parseFloat(((covered / total) * 100).toFixed(2))
: 0;
}

var github$1 = {};

Expand Down
104 changes: 104 additions & 0 deletions src/__tests__/parser-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,5 +265,109 @@ github.com/user/project/file.go:1.1,3.2 1 1
expect(aggregated.lineRate).toBe(0);
expect(aggregated.files).toHaveLength(0);
});

it("should merge same file across reports with union of line hits", async () => {
// Multiple reports covering the same file. Lines 1,2 hit by report
// A, lines 2,3 hit by report B. Union should report 3/4 covered,
// not 3/8.
const reportA = `<?xml version="1.0"?>
<coverage line-rate="0.5">
<packages><package name="p"><classes>
<class filename="src/shared.cs">
<lines>
<line number="1" hits="5"/>
<line number="2" hits="2"/>
<line number="3" hits="0"/>
<line number="4" hits="0"/>
</lines>
</class>
</classes></package></packages>
</coverage>`;
const reportB = `<?xml version="1.0"?>
<coverage line-rate="0.5">
<packages><package name="p"><classes>
<class filename="src/shared.cs">
<lines>
<line number="1" hits="0"/>
<line number="2" hits="3"/>
<line number="3" hits="1"/>
<line number="4" hits="0"/>
</lines>
</class>
</classes></package></packages>
</coverage>`;

const a = await CoverageParserFactory.parseContent(reportA);
const b = await CoverageParserFactory.parseContent(reportB);
const aggregated = CoverageParserFactory.aggregateResults([a, b]);

expect(aggregated.files).toHaveLength(1);
expect(aggregated.totalStatements).toBe(4);
expect(aggregated.coveredStatements).toBe(3);
expect(aggregated.lineRate).toBe(75);

const merged = aggregated.files[0];
expect(merged.lines.find((l) => l.lineNumber === 1)?.count).toBe(5);
expect(merged.lines.find((l) => l.lineNumber === 2)?.count).toBe(3);
expect(merged.lines.find((l) => l.lineNumber === 3)?.count).toBe(1);
expect(merged.lines.find((l) => l.lineNumber === 4)?.count).toBe(0);
expect(merged.missingLines).toEqual([4]);
});

it("should sum disjoint classes sharing a path within one report", async () => {
// Cobertura emits one <class> per type; a single source file with
// multiple types (inner classes, partial classes) produces several
// FileCoverage entries sharing a path. These are disjoint parts
// of the file and must be summed, not unioned (which would both
// undercount the denominator and let coveredStatements exceed it).
const report = `<?xml version="1.0"?>
<coverage line-rate="0.5">
<packages><package name="p"><classes>
<class filename="src/multi.py">
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="0"/>
</lines>
</class>
<class filename="src/multi.py">
<lines>
<line number="10" hits="1"/>
<line number="11" hits="0"/>
</lines>
</class>
</classes></package></packages>
</coverage>`;

const result = await CoverageParserFactory.parseContent(report);
const aggregated = CoverageParserFactory.aggregateResults([result]);

expect(aggregated.files).toHaveLength(1);
expect(aggregated.totalStatements).toBe(5);
expect(aggregated.coveredStatements).toBe(3);
expect(aggregated.lineRate).toBe(60);
});

it("should preserve block-level statement counts when merging Go reports", async () => {
// Go reports statement count as semantic blocks, not physical lines:
// `file.go:1.1,3.2 2 1` is one block covering lines 1-3 with 2
// statements. Merging must not collapse statements to lines.length.
const reportA = `mode: set
github.com/x/p/f.go:1.1,3.2 2 1
github.com/x/p/f.go:5.1,7.2 2 0
`;
const reportB = `mode: set
github.com/x/p/f.go:1.1,3.2 2 0
github.com/x/p/f.go:5.1,7.2 2 1
`;

const a = await CoverageParserFactory.parseContent(reportA);
const b = await CoverageParserFactory.parseContent(reportB);
const aggregated = CoverageParserFactory.aggregateResults([a, b]);

expect(aggregated.files).toHaveLength(1);
// 2 blocks × 2 statements = 4 statements total, not 6 physical lines.
expect(aggregated.totalStatements).toBe(4);
});
});
});
Loading
Loading