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
19 changes: 10 additions & 9 deletions .github/workflows/code-qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ jobs:
node src/scripts/verify-lcov.mjs src/coverage/services/lcov.info
node src/scripts/verify-lcov.mjs src/coverage/misc/lcov.info
node src/scripts/verify-lcov.mjs src/coverage/tree-sitter/lcov.info
- name: Merge extension coverage reports
run: |
mkdir -p src/coverage/merged
pnpm --dir src run merge:coverage
node src/scripts/verify-lcov.mjs src/coverage/merged/lcov.info
- name: Save Turbo cache
if: steps.turbo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand All @@ -184,21 +189,16 @@ jobs:
# there mostly adds Codecov overhead without changing pass/fail
# behavior.
# Coverage is uploaded in separate steps so each LCOV gets the
# correct flag set. Codecov double-counts overlapping lines when a
# single upload carries multiple flags whose paths overlap, so the
# core lanes and webview lane must be uploaded individually with
# their own flag.
# correct flag set. Extension lanes instrument the same sources, so
# union them before upload; a line is covered when any lane executes
# it. Core and webview reports retain their independent flags.
# See https://docs.codecov.com/docs/flags
- name: Upload non-core coverage to Codecov
if: matrix.upload-coverage
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: >-
src/coverage/api/lcov.info,
src/coverage/core/lcov.info,
src/coverage/services/lcov.info,
src/coverage/misc/lcov.info,
src/coverage/tree-sitter/lcov.info,
src/coverage/merged/lcov.info,
packages/cloud/coverage/lcov.info,
packages/telemetry/coverage/lcov.info,
apps/cli/coverage/lcov.info
Expand Down Expand Up @@ -240,6 +240,7 @@ jobs:
src/coverage/services/lcov.info
src/coverage/misc/lcov.info
src/coverage/tree-sitter/lcov.info
src/coverage/merged/lcov.info
webview-ui/coverage/lcov.info
packages/cloud/coverage/lcov.info
packages/telemetry/coverage/lcov.info
Expand Down
1 change: 1 addition & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@
"check-types": "tsc --noEmit",
"test": "vitest run",
"verify:coverage-contract": "node scripts/verify-coverage-contract.mjs",
"merge:coverage": "node scripts/merge-lcov.mjs coverage/merged/lcov.info coverage/api/lcov.info coverage/core/lcov.info coverage/services/lcov.info coverage/misc/lcov.info coverage/tree-sitter/lcov.info",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:dist": "vitest run --config vitest.dist.config.ts",
"test:coverage": "vitest run --coverage",
Expand Down
55 changes: 55 additions & 0 deletions src/scripts/__tests__/merge-lcov.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest"

import { mergeLcov } from "../merge-lcov.mjs"

const report = (coveredLines) => `SF:src/example.ts
FN:1,example
FNDA:${coveredLines.has(1) ? 1 : 0},example
FNF:1
FNH:${coveredLines.has(1) ? 1 : 0}
BRDA:2,0,0,${coveredLines.has(2) ? 1 : "-"}
BRF:1
BRH:${coveredLines.has(2) ? 1 : 0}
DA:1,${coveredLines.has(1) ? 1 : 0}
DA:2,${coveredLines.has(2) ? 1 : 0}
DA:3,${coveredLines.has(3) ? 1 : 0}
LF:3
LH:${coveredLines.size}
end_of_record
`

describe("mergeLcov", () => {
it("counts a line as covered when any coverage lane executes it", () => {
const merged = mergeLcov([
["api", report(new Set([1]))],
["core", report(new Set([2]))],
])

expect(merged).toContain("FNDA:1,example")
expect(merged).toContain("BRDA:2,0,0,1")
expect(merged).toContain("DA:1,1")
expect(merged).toContain("DA:2,1")
expect(merged).toContain("LH:2")
})

it("keeps lines uncovered when no coverage lane executes them", () => {
const merged = mergeLcov([
["api", report(new Set([1]))],
["core", report(new Set([2]))],
])

expect(merged).toContain("DA:3,0")
expect(merged).not.toContain("DA:3,1")
})

it("merges disjoint source records without changing their paths", () => {
const merged = mergeLcov([
["api", report(new Set([1])).replaceAll("src/example.ts", "src/api.ts")],
["core", report(new Set([2])).replaceAll("src/example.ts", "src/core.ts")],
])

expect(merged.match(/^SF:/gm)).toHaveLength(2)
expect(merged).toContain("SF:src/api.ts")
expect(merged).toContain("SF:src/core.ts")
})
})
135 changes: 135 additions & 0 deletions src/scripts/merge-lcov.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { readFileSync, writeFileSync } from "node:fs"
import process from "node:process"

const parseCount = (value, description) => {
const count = Number(value)
if (!Number.isSafeInteger(count) || count < 0) throw new Error(`Invalid ${description}: ${value}`)

Check warning on line 6 in src/scripts/merge-lcov.mjs

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/scripts/merge-lcov.mjs:6: 4 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
return count
}

const mergeCount = (records, key, count) => records.set(key, Math.max(records.get(key) ?? 0, count))

Check warning on line 10 in src/scripts/merge-lcov.mjs

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/scripts/merge-lcov.mjs:10: Survived ArrowFunction mutant (replacement: () => undefined). See the job summary for the complete list and resolution guidance.

const parseLcov = (lcov, label) => {
const sources = new Map()
let record

for (const line of lcov.split(/\r?\n/)) {
if (!line || line.startsWith("TN:")) continue

Check warning on line 17 in src/scripts/merge-lcov.mjs

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/scripts/merge-lcov.mjs:17: Survived MethodExpression mutant (replacement: line.endsWith("TN:")). See the job summary for the complete list and resolution guidance.
if (line.startsWith("SF:")) {
if (record) throw new Error(`${label} contains an unfinished source record: ${record.source}`)

Check warning on line 19 in src/scripts/merge-lcov.mjs

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/scripts/merge-lcov.mjs:19: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
const source = line.slice(3)

Check warning on line 20 in src/scripts/merge-lcov.mjs

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/scripts/merge-lcov.mjs:20: Survived MethodExpression mutant (replacement: line). See the job summary for the complete list and resolution guidance.
if (!source) throw new Error(`${label} contains an empty source path`)

Check warning on line 21 in src/scripts/merge-lcov.mjs

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/scripts/merge-lcov.mjs:21: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
record = {
source,
functions: new Map(),
functionCounts: new Map(),
branches: new Map(),
lines: new Map(),
}
} else if (line === "end_of_record") {
if (!record) throw new Error(`${label} contains a record terminator outside a source record`)

Check warning on line 30 in src/scripts/merge-lcov.mjs

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/scripts/merge-lcov.mjs:30: 2 mutation test gaps; example: NoCoverage StringLiteral mutant (replacement: ``). See the job summary for the complete list and resolution guidance.
if (sources.has(record.source))
throw new Error(`${label} contains duplicate source record: ${record.source}`)
sources.set(record.source, record)
record = undefined
} else if (record && line.startsWith("FN:")) {
const separator = line.indexOf(",")
if (separator < 4) throw new Error(`${label} contains invalid FN for ${record.source}`)
const name = line.slice(separator + 1)
const location = line.slice(3, separator)
const existing = record.functions.get(name)
if (existing && existing !== location)
throw new Error(`${label} contains conflicting FN for ${record.source}:${name}`)
record.functions.set(name, location)
} else if (record && line.startsWith("FNDA:")) {
const [count, ...name] = line.slice(5).split(",")
if (name.length === 0) throw new Error(`${label} contains invalid FNDA for ${record.source}`)
mergeCount(record.functionCounts, name.join(","), parseCount(count, `FNDA for ${record.source}`))
} else if (record && line.startsWith("BRDA:")) {
const [lineNumber, block, branch, taken] = line.slice(5).split(",")
const key = `${lineNumber},${block},${branch}`
const count = taken === "-" ? 0 : parseCount(taken, `BRDA for ${record.source}`)
mergeCount(record.branches, key, count)
} else if (record && line.startsWith("DA:")) {
const [lineNumber, count, checksum] = line.slice(3).split(",")
const key = parseCount(lineNumber, `DA line for ${record.source}`)
if (key < 1) throw new Error(`${label} contains invalid DA line for ${record.source}`)
const existing = record.lines.get(key)
if (existing?.checksum && checksum && existing.checksum !== checksum)
throw new Error(`${label} contains conflicting DA checksum for ${record.source}:${key}`)
record.lines.set(key, {
count: Math.max(existing?.count ?? 0, parseCount(count, `DA count for ${record.source}`)),
checksum: existing?.checksum ?? checksum,
})
} else if (record && !/^(?:FNF|FNH|BRF|BRH|LF|LH):/.test(line)) {
throw new Error(`${label} contains unsupported LCOV data for ${record.source}: ${line}`)
} else if (!record) {
throw new Error(`${label} contains data outside a source record: ${line}`)
}
}

if (record) throw new Error(`${label} contains an unfinished source record: ${record.source}`)
return sources
}

export const mergeLcov = (reports) => {
const merged = new Map()
for (const [label, lcov] of reports) {
for (const [source, incoming] of parseLcov(lcov, label)) {
const record = merged.get(source) ?? {
source,
functions: new Map(),
functionCounts: new Map(),
branches: new Map(),
lines: new Map(),
}
for (const [name, location] of incoming.functions) {
const existing = record.functions.get(name)
if (existing && existing !== location) throw new Error(`Conflicting FN for ${source}:${name}`)
record.functions.set(name, location)
}
for (const [name, count] of incoming.functionCounts) mergeCount(record.functionCounts, name, count)
for (const [key, count] of incoming.branches) mergeCount(record.branches, key, count)
for (const [line, value] of incoming.lines) {
const existing = record.lines.get(line)
if (existing?.checksum && value.checksum && existing.checksum !== value.checksum)
throw new Error(`Conflicting DA checksum for ${source}:${line}`)
record.lines.set(line, {
count: Math.max(existing?.count ?? 0, value.count),
checksum: existing?.checksum ?? value.checksum,
})
}
merged.set(source, record)
}
}

return [...merged.values()]
.sort((a, b) => a.source.localeCompare(b.source))
.flatMap((record) => {
const functions = [...record.functions].sort(([a], [b]) => a.localeCompare(b))
const functionCounts = [...record.functionCounts].sort(([a], [b]) => a.localeCompare(b))
const branches = [...record.branches].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
const lines = [...record.lines].sort(([a], [b]) => a - b)
return [
`SF:${record.source}`,
...functions.map(([name, location]) => `FN:${location},${name}`),
...functionCounts.map(([name, count]) => `FNDA:${count},${name}`),
`FNF:${functions.length}`,
`FNH:${functionCounts.filter(([, count]) => count > 0).length}`,
...branches.map(([key, count]) => `BRDA:${key},${count || "-"}`),
`BRF:${branches.length}`,
`BRH:${branches.filter(([, count]) => count > 0).length}`,
...lines.map(([line, { count, checksum }]) => `DA:${line},${count}${checksum ? `,${checksum}` : ""}`),
`LF:${lines.length}`,
`LH:${lines.filter(([, { count }]) => count > 0).length}`,
"end_of_record",
]
})
.join("\n")
}

if (process.argv[1] === import.meta.filename) {
const [output, ...inputs] = process.argv.slice(2)
if (!output || inputs.length < 1) throw new Error("Usage: merge-lcov.mjs <output> <input...>")
writeFileSync(output, `${mergeLcov(inputs.map((input) => [input, readFileSync(input, "utf8")]))}\n`)
}
Loading