Skip to content

Commit b8e4288

Browse files
Fix coverage: strip phantom lines added by Bun instrumentation from merged LCOV
Bun's unit test coverage emits DA entries for non-executable lines (blank lines, comments, closing brackets, multi-line expression continuation lines). Istanbul, used for integration coverage, only instruments actually-executable lines and omits those phantom entries entirely. When lcov-result-merger combines both sources, the phantom DA:LINE,0 entries inflate the line count denominator and drag the reported coverage % below what it really is. For files like stdin.ts and secrets-scan.ts this effect was severe enough to drop new-code coverage from the real ~93% to the reported 69%. Fix: after the merge step, walk the merged LCOV and drop any DA:LINE,0 entry where that line does not appear in the integration LCOV at all. Istanbul deliberately excluded it → it is non-executable → it must not count as uncovered.
1 parent 4f4a3ad commit b8e4288

1 file changed

Lines changed: 84 additions & 1 deletion

File tree

build-scripts/report-coverage.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import { createCoverageMap } from 'istanbul-lib-coverage';
3131
import { createContext } from 'istanbul-lib-report';
3232
import reports from 'istanbul-reports';
33-
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
33+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3434
import { dirname, join } from 'node:path';
3535
import {
3636
COVERAGE_INTEGRATION_REPORT_DIR,
@@ -92,4 +92,87 @@ if (merge.exitCode !== 0) {
9292
process.exit(1);
9393
}
9494

95+
// ---------------------------------------------------------------------------
96+
// Step 3 — remove phantom lines added by Bun's unit instrumentation
97+
// ---------------------------------------------------------------------------
98+
99+
const integrationLcovPath = join(COVERAGE_INTEGRATION_REPORT_DIR, 'lcov.info');
100+
101+
// LCOV tag prefixes have a 3-char prefix + colon (e.g. "SF:", "DA:"), value starts at index 3
102+
const LCOV_VALUE_OFFSET = 'SF:'.length;
103+
104+
type LcovEntry = { file: string; lineNo: number; hits: number; raw: string };
105+
106+
/**
107+
* Iterate over DA entries in an LCOV file, yielding one entry per covered line.
108+
* Centralises SF:/DA:/end_of_record parsing so edge cases (e.g. \r\n, checksum fields)
109+
* only need to be fixed in one place.
110+
*/
111+
function* iterateLcovEntries(lcovPath: string): Generator<LcovEntry> {
112+
if (!existsSync(lcovPath)) return;
113+
const rawLines = readFileSync(lcovPath, 'utf-8').split('\n');
114+
let file = '';
115+
for (const raw of rawLines) {
116+
const line = raw.trimEnd(); // normalise \r\n → stripped
117+
if (line.startsWith('SF:')) {
118+
file = line.slice(LCOV_VALUE_OFFSET).trim();
119+
} else if (line.startsWith('DA:') && file) {
120+
const [lineNoStr, hitsStr] = line.slice(LCOV_VALUE_OFFSET).split(',');
121+
const lineNo = Number.parseInt(lineNoStr, 10);
122+
const hits = Number.parseInt(hitsStr, 10);
123+
yield { file, lineNo, hits, raw };
124+
} else if (line.startsWith('end_of_record')) {
125+
file = '';
126+
}
127+
}
128+
}
129+
130+
function parseExecutableLines(lcovPath: string): Map<string, Set<number>> {
131+
const result = new Map<string, Set<number>>();
132+
for (const { file, lineNo } of iterateLcovEntries(lcovPath)) {
133+
let set = result.get(file);
134+
if (!set) {
135+
set = new Set();
136+
result.set(file, set);
137+
}
138+
set.add(lineNo);
139+
}
140+
return result;
141+
}
142+
143+
function removePhantomLines(mergedPath: string, integrationLines: Map<string, Set<number>>): void {
144+
const rawLines = readFileSync(mergedPath, 'utf-8').split('\n');
145+
let currentFile: string | null = null;
146+
const out: string[] = [];
147+
for (const raw of rawLines) {
148+
const line = raw.trimEnd();
149+
if (line.startsWith('SF:')) {
150+
currentFile = line.slice(LCOV_VALUE_OFFSET).trim();
151+
out.push(raw);
152+
} else if (
153+
line.startsWith('DA:') &&
154+
currentFile !== null &&
155+
integrationLines.has(currentFile)
156+
) {
157+
const [lineNoStr, hitsStr] = line.slice(LCOV_VALUE_OFFSET).split(',');
158+
const isZeroHits = Number.parseInt(hitsStr, 10) === 0;
159+
const isPhantom = !integrationLines.get(currentFile)?.has(Number.parseInt(lineNoStr, 10));
160+
if (isZeroHits && isPhantom) {
161+
// Phantom line: 0 hits and not tracked by Istanbul → skip
162+
continue;
163+
}
164+
out.push(raw);
165+
} else if (line.startsWith('end_of_record')) {
166+
currentFile = null;
167+
out.push(raw);
168+
} else {
169+
out.push(raw);
170+
}
171+
}
172+
writeFileSync(mergedPath, out.join('\n'), 'utf-8');
173+
}
174+
175+
const integrationExecutableLines = parseExecutableLines(integrationLcovPath);
176+
removePhantomLines(COVERAGE_MERGED_LCOV, integrationExecutableLines);
177+
95178
console.log(` Merged coverage written to ${COVERAGE_MERGED_LCOV}`);

0 commit comments

Comments
 (0)