Skip to content
Open
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
222 changes: 194 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,185 @@ 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:
//
// - `statements` uses max() across reports (same file ⇒ same count).
// - When statements align 1:1 with lines, derive `coveredStatements`
// from the merged line union exactly.
// - Otherwise, approximate the union by summing per-report covered
// counts and clamping to `statements`. This is more accurate than
// max() when reports exercise different blocks (the common case
// for Go, where different suites cover different paths), at the
// cost of overestimating when reports cover overlapping blocks.
// Exact reconstruction would require preserving block structure
// through FileCoverage.
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.min(statements, group.reduce((s, f) => s + f.coveredStatements, 0));
// 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
110 changes: 110 additions & 0 deletions src/__tests__/parser-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,5 +265,115 @@ 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 union coveredStatements when Go reports exercise disjoint blocks", 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,
// and when reports cover disjoint blocks (report A hits block 1 only,
// report B hits block 2 only), the union should credit both.
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);
// Union of disjoint coverage: A covers 2 stmts, B covers 2 stmts,
// overlap is 0 → 4 covered, not max(2,2)=2.
expect(aggregated.coveredStatements).toBe(4);
expect(aggregated.lineRate).toBe(100);
});
});
});
Loading
Loading