Skip to content
Merged
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
100 changes: 100 additions & 0 deletions scripts/lcov-to-istanbul.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<arrow>', 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 })
})
})
158 changes: 143 additions & 15 deletions scripts/lcov-to-istanbul.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
* `coverage-final.json` format that `fallow health --coverage <path>` 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
Expand All @@ -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)), '..')

Expand Down Expand Up @@ -55,8 +57,10 @@ type IstanbulCoverage = Record<string, IstanbulFileCoverage>

interface LcovFunction {
line: number
endLine: number
name: string
hits: number
range?: IstanbulRange
}

interface LcovFileRecord {
Expand All @@ -66,6 +70,8 @@ interface LcovFileRecord {
lineHits: Map<number, number>
}

type ReadSource = (path: string) => Promise<string>

/**
* Parse an LCOV file into a list of per-file records.
*
Expand All @@ -76,7 +82,7 @@ interface LcovFileRecord {
* DA:<line>,<hits> — 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.
Expand All @@ -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
Expand Down Expand Up @@ -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 ?? '<function>'
}
if (ts.isConstructorDeclaration(node)) return 'constructor'
if (
ts.isMethodDeclaration(node) ||
ts.isGetAccessorDeclaration(node) ||
ts.isSetAccessorDeclaration(node)
) {
return propertyNameText(node.name) ?? '<function>'
}
return '<arrow>'
}

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<number, number>,
): 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<IstanbulCoverage> {
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<string, IstanbulFunction> = {}
const f: Record<string, number> = {}
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,
Expand Down Expand Up @@ -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')
Expand All @@ -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)
})
}
Loading