Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
160 changes: 132 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.flatMap((r) => r.files));
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,123 @@ 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, unioning their line hits.
* Files with unique paths are returned unchanged.
*/
function mergeFilesByPath(files) {
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] : mergeFileGroup(group));
}
return result;
}
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);
}
}
}
const mergedLines = [...lineMap.values()].sort((a, b) => a.lineNumber - b.lineNumber);
// 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
? mergedLines.filter((l) => l.count > 0).length
: Math.max(...group.map((f) => f.coveredStatements));
const missingLines = mergedLines
.filter((l) => l.count === 0)
.map((l) => l.lineNumber);
// 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));
// A partial line remains partial only if it still has hits after merge;
// lines that ended up fully missed are no longer partial.
const partialSet = new Set();
for (const file of group) {
for (const ln of file.partialLines ?? [])
partialSet.add(ln);
}
const partialLines = [...partialSet]
.filter((ln) => {
const l = lineMap.get(ln);
return l !== undefined && l.count > 0;
})
.sort((a, b) => a - b);
const lineRate = statements > 0
? Number.parseFloat(((coveredStatements / statements) * 100).toFixed(2))
: 0;
const branchRate = conditionals > 0
? Number.parseFloat(((coveredConditionals / conditionals) * 100).toFixed(2))
: 0;
return {
name: group[0].name,
path: group[0].path,
statements,
coveredStatements,
conditionals,
coveredConditionals,
methods,
coveredMethods,
lineRate,
branchRate,
lines: mergedLines,
missingLines,
partialLines,
};
}

var github$1 = {};

Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/parser-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,5 +265,75 @@ 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 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