diff --git a/scripts/lcov-to-istanbul.test.ts b/scripts/lcov-to-istanbul.test.ts new file mode 100644 index 000000000..6e0599028 --- /dev/null +++ b/scripts/lcov-to-istanbul.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test' +import { + extractSourceFunctions, + parseLcov, + toIstanbul, +} from './lcov-to-istanbul' + +describe('lcov-to-istanbul', () => { + test('preserves native LCOV function records when the producer provides them', async () => { + const records = parseLcov([ + 'SF:src/example.ts', + 'FN:4,kept', + 'FNDA:7,kept', + 'DA:4,7', + 'end_of_record', + ].join('\n')) + let sourceRead = false + + const coverage = await toIstanbul(records, async () => { + sourceRead = true + return '' + }) + const file = Object.values(coverage)[0] + + expect(sourceRead).toBe(false) + expect(file?.fnMap['0']).toMatchObject({ + name: 'kept', + line: 4, + }) + expect(file?.f['0']).toBe(7) + }) + + test('derives runtime function ranges and hits from source when Bun omits FN records', async () => { + const source = [ + 'export function called() {', + ' return true', + '}', + '', + 'export const uncalled = () => {', + ' return false', + '}', + '', + 'export const object = {', + ' method() {', + ' return true', + ' },', + '}', + '', + 'declare function signatureOnly(): void', + ].join('\n') + const lineHits = new Map([ + [1, 3], + [2, 3], + [5, 0], + [6, 0], + [10, 2], + [11, 2], + ]) + + const functions = extractSourceFunctions(source, 'src/example.ts', lineHits) + + expect(functions.map(({ name, line, endLine, hits }) => ({ + name, + line, + endLine, + hits, + }))).toEqual([ + { name: 'called', line: 1, endLine: 3, hits: 3 }, + { name: '', line: 5, endLine: 7, hits: 0 }, + { name: 'method', line: 10, endLine: 12, hits: 2 }, + ]) + }) + + test('emits inferred functions in Istanbul fnMap and f fields', async () => { + const records = parseLcov([ + 'SF:src/example.ts', + 'FNF:1', + 'FNH:1', + 'DA:1,5', + 'DA:2,5', + 'end_of_record', + ].join('\n')) + const coverage = await toIstanbul( + records, + async () => 'export function example() {\n return true\n}\n', + ) + const file = Object.values(coverage)[0] + + expect(file?.fnMap['0']).toMatchObject({ + name: 'example', + line: 1, + decl: { + start: { line: 1, column: 0 }, + end: { line: 3, column: 1 }, + }, + }) + expect(file?.f['0']).toBe(5) + expect(file?.s).toEqual({ '0': 5, '1': 5 }) + }) +}) diff --git a/scripts/lcov-to-istanbul.ts b/scripts/lcov-to-istanbul.ts index 8c637207c..9d2b73223 100644 --- a/scripts/lcov-to-istanbul.ts +++ b/scripts/lcov-to-istanbul.ts @@ -5,11 +5,12 @@ * `coverage-final.json` format that `fallow health --coverage ` expects. * * Bun emits LCOV via `bun test --coverage --coverage-reporter=lcov`, but - * fallow's CRAP scorer wants Istanbul JSON. The two formats overlap enough - * that a focused converter (function map + statement map keyed by line) - * gives fallow a strict superset of what it needs to compute coverage: - * - `f` / `fnMap` from LCOV's `FN` / `FNDA` records — fallow's primary - * signal for per-function coverage. + * fallow's CRAP scorer wants Istanbul JSON. Bun's LCOV currently includes + * aggregate `FNF` / `FNH` function totals but omits the per-function `FN` / + * `FNDA` records Istanbul needs. This converter therefore combines the LCOV + * line hits with TypeScript's source AST: + * - `f` / `fnMap` from native `FN` / `FNDA` records when present, otherwise + * from source function ranges with Bun's declaration-line hit count. * - `s` / `statementMap` from LCOV's `DA` records — used as a fallback * line-coverage signal. * - `b` / `branchMap` left empty: LCOV's `BRDA` data uses block IDs that @@ -26,6 +27,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises' import { dirname, isAbsolute, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import * as ts from 'typescript' const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -55,8 +57,10 @@ type IstanbulCoverage = Record interface LcovFunction { line: number + endLine: number name: string hits: number + range?: IstanbulRange } interface LcovFileRecord { @@ -66,6 +70,8 @@ interface LcovFileRecord { lineHits: Map } +type ReadSource = (path: string) => Promise + /** * Parse an LCOV file into a list of per-file records. * @@ -76,7 +82,7 @@ interface LcovFileRecord { * DA:, — line hit count * end_of_record — terminator */ -function parseLcov(lcov: string): LcovFileRecord[] { +export function parseLcov(lcov: string): LcovFileRecord[] { const records: LcovFileRecord[] = [] let current: LcovFileRecord | null = null // FN comes before FNDA — buffer FNs by name so FNDA can backfill the hit count. @@ -96,7 +102,13 @@ function parseLcov(lcov: string): LcovFileRecord[] { if (line.startsWith('FN:')) { const [lineNumber, ...nameParts] = line.slice(3).split(',') const name = nameParts.join(',') - const fn: LcovFunction = { line: Number(lineNumber), name, hits: 0 } + const fnLine = Number(lineNumber) + const fn: LcovFunction = { + line: fnLine, + endLine: fnLine, + name, + hits: 0, + } current.functions.push(fn) fnByName.set(name, fn) continue @@ -135,18 +147,132 @@ function lineRange(line: number): IstanbulRange { } } -function toIstanbul(records: LcovFileRecord[]): IstanbulCoverage { +function sourceRange( + sourceFile: ts.SourceFile, + start: number, + end: number, +): IstanbulRange { + const startPosition = sourceFile.getLineAndCharacterOfPosition(start) + const endPosition = sourceFile.getLineAndCharacterOfPosition(end) + return { + start: { + line: startPosition.line + 1, + column: startPosition.character, + }, + end: { + line: endPosition.line + 1, + column: endPosition.character, + }, + } +} + +function propertyNameText(name: ts.PropertyName | undefined): string | null { + if (!name) return null + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text + } + if (ts.isPrivateIdentifier(name)) return name.text + return null +} + +function sourceFunctionName(node: ts.FunctionLikeDeclaration): string { + if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) { + return node.name?.text ?? '' + } + if (ts.isConstructorDeclaration(node)) return 'constructor' + if ( + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ) { + return propertyNameText(node.name) ?? '' + } + return '' +} + +function isRuntimeFunction(node: ts.Node): node is ts.FunctionLikeDeclaration { + return ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) || + ts.isConstructorDeclaration(node) + ) && node.body !== undefined +} + +/** + * Build the function inventory Bun leaves out of LCOV. Declaration lines match + * fallow's source function index; full ranges keep the output valid Istanbul. + */ +export function extractSourceFunctions( + sourceText: string, + path: string, + lineHits: ReadonlyMap, +): LcovFunction[] { + const sourceFile = ts.createSourceFile( + path, + sourceText, + ts.ScriptTarget.Latest, + true, + ) + const functions: LcovFunction[] = [] + + function visit(node: ts.Node): void { + if (isRuntimeFunction(node)) { + const range = sourceRange(sourceFile, node.getStart(sourceFile), node.end) + let hits = lineHits.get(range.start.line) + if (hits === undefined) { + for (let line = range.start.line + 1; line <= range.end.line; line++) { + const lineHitCount = lineHits.get(line) + if (lineHitCount !== undefined) { + hits = lineHitCount + break + } + } + } + functions.push({ + line: range.start.line, + endLine: range.end.line, + name: sourceFunctionName(node), + hits: hits ?? 0, + range, + }) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return functions +} + +export async function toIstanbul( + records: LcovFileRecord[], + readSource: ReadSource = (path) => readFile(path, 'utf-8'), +): Promise { const out: IstanbulCoverage = {} for (const record of records) { const absolutePath = isAbsolute(record.path) ? record.path : resolve(PROJECT_ROOT, record.path) + let functions = record.functions + if (functions.length === 0) { + try { + const sourceText = await readSource(absolutePath) + functions = extractSourceFunctions(sourceText, absolutePath, record.lineHits) + } catch (err) { + const code = err instanceof Error && 'code' in err ? err.code : undefined + if (code !== 'ENOENT') throw err + } + } + const fnMap: Record = {} const f: Record = {} - record.functions.forEach((fn, idx) => { + functions.forEach((fn, idx) => { const id = String(idx) - const range = lineRange(fn.line) + const range = fn.range ?? lineRange(fn.line) fnMap[id] = { name: fn.name, decl: range, @@ -185,7 +311,7 @@ async function main() { const lcov = await readFile(inputPath, 'utf-8') const records = parseLcov(lcov) - const istanbul = toIstanbul(records) + const istanbul = await toIstanbul(records) await mkdir(dirname(outputPath), { recursive: true }) await writeFile(outputPath, JSON.stringify(istanbul), 'utf-8') @@ -201,7 +327,9 @@ async function main() { ) } -main().catch((err) => { - console.error('[lcov-to-istanbul]', err) - process.exit(1) -}) +if (import.meta.main) { + main().catch((err) => { + console.error('[lcov-to-istanbul]', err) + process.exit(1) + }) +}