diff --git a/dist/index.js b/dist/index.js index c10af15..2b86ffa 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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; @@ -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)) @@ -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 `` 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 = {}; diff --git a/src/__tests__/parser-factory.test.ts b/src/__tests__/parser-factory.test.ts index 8fcb662..244a7f9 100644 --- a/src/__tests__/parser-factory.test.ts +++ b/src/__tests__/parser-factory.test.ts @@ -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 = ` + + + + + + + + + + + +`; + const reportB = ` + + + + + + + + + + + +`; + + 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 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 = ` + + + + + + + + + + + + + + + + +`; + + 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); + }); }); }); diff --git a/src/parsers/parser-factory.ts b/src/parsers/parser-factory.ts index 2db184d..6a4da5d 100644 --- a/src/parsers/parser-factory.ts +++ b/src/parsers/parser-factory.ts @@ -3,6 +3,7 @@ import type { AggregatedCoverageResults, CoverageResults, FileCoverage, + LineCoverage, } from "../types/coverage.js"; import type { CoverageFormat, ICoverageParser } from "./base-parser.js"; import { CloverParser } from "./clover-parser.js"; @@ -167,9 +168,16 @@ export const CoverageParserFactory = { }, /** - * 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: CoverageResults[]): AggregatedCoverageResults { + const mergedFiles = mergeFilesByPath(results); + let totalStatements = 0; let coveredStatements = 0; let totalConditionals = 0; @@ -184,38 +192,28 @@ export const CoverageParserFactory = { let totalBranches = 0; let totalLines = 0; - const allFiles: FileCoverage[] = []; - - 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; - // Count partials from file's partialLines - if (file.partialLines) { - totalPartials += file.partialLines.length; + for (const line of file.lines) { + totalLines++; + if (line.count > 0) { + totalHits++; + } else { + totalMisses++; } + } - // Count branches - totalBranches += file.conditionals; + if (file.partialLines) { + totalPartials += file.partialLines.length; } + + totalBranches += file.conditionals; } const lineRate = @@ -240,18 +238,229 @@ export 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 `` 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: CoverageResults[]): FileCoverage[] { + const perReportCombined = results.flatMap((r) => + groupByPath(r.files, sumFileGroup), + ); + return groupByPath(perReportCombined, mergeFileGroup); +} + +function groupByPath( + files: FileCoverage[], + combine: (group: FileCoverage[]) => FileCoverage, +): FileCoverage[] { + 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: FileCoverage[] = []; + 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: FileCoverage[]): FileCoverage { + 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: FileCoverage[]): FileCoverage { + 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, + }); +} + +interface MergedFileMetrics { + statements: number; + coveredStatements: number; + conditionals: number; + coveredConditionals: number; + methods: number; + coveredMethods: number; +} + +/** + * 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: FileCoverage[], + lineMap: Map, + metrics: MergedFileMetrics, +): FileCoverage { + 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: number, total: number): number { + return total > 0 + ? Number.parseFloat(((covered / total) * 100).toFixed(2)) + : 0; +} + /** * Re-export for convenience */