Skip to content

Commit fdef106

Browse files
fix(ci): union extension coverage before upload (#1650)
Co-authored-by: Roomote <roomote@roomote.dev>
1 parent 8c96629 commit fdef106

4 files changed

Lines changed: 201 additions & 9 deletions

File tree

.github/workflows/code-qa.yml

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ jobs:
173173
node src/scripts/verify-lcov.mjs src/coverage/services/lcov.info
174174
node src/scripts/verify-lcov.mjs src/coverage/misc/lcov.info
175175
node src/scripts/verify-lcov.mjs src/coverage/tree-sitter/lcov.info
176+
- name: Merge extension coverage reports
177+
run: |
178+
mkdir -p src/coverage/merged
179+
pnpm --dir src run merge:coverage
180+
node src/scripts/verify-lcov.mjs src/coverage/merged/lcov.info
176181
- name: Save Turbo cache
177182
if: steps.turbo-cache.outputs.cache-hit != 'true'
178183
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -184,21 +189,16 @@ jobs:
184189
# there mostly adds Codecov overhead without changing pass/fail
185190
# behavior.
186191
# Coverage is uploaded in separate steps so each LCOV gets the
187-
# correct flag set. Codecov double-counts overlapping lines when a
188-
# single upload carries multiple flags whose paths overlap, so the
189-
# core lanes and webview lane must be uploaded individually with
190-
# their own flag.
192+
# correct flag set. Extension lanes instrument the same sources, so
193+
# union them before upload; a line is covered when any lane executes
194+
# it. Core and webview reports retain their independent flags.
191195
# See https://docs.codecov.com/docs/flags
192196
- name: Upload non-core coverage to Codecov
193197
if: matrix.upload-coverage
194198
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
195199
with:
196200
files: >-
197-
src/coverage/api/lcov.info,
198-
src/coverage/core/lcov.info,
199-
src/coverage/services/lcov.info,
200-
src/coverage/misc/lcov.info,
201-
src/coverage/tree-sitter/lcov.info,
201+
src/coverage/merged/lcov.info,
202202
packages/cloud/coverage/lcov.info,
203203
packages/telemetry/coverage/lcov.info,
204204
apps/cli/coverage/lcov.info
@@ -240,6 +240,7 @@ jobs:
240240
src/coverage/services/lcov.info
241241
src/coverage/misc/lcov.info
242242
src/coverage/tree-sitter/lcov.info
243+
src/coverage/merged/lcov.info
243244
webview-ui/coverage/lcov.info
244245
packages/cloud/coverage/lcov.info
245246
packages/telemetry/coverage/lcov.info

src/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@
443443
"check-types": "tsc --noEmit",
444444
"test": "vitest run",
445445
"verify:coverage-contract": "node scripts/verify-coverage-contract.mjs",
446+
"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",
446447
"test:unit": "vitest run --config vitest.unit.config.ts",
447448
"test:dist": "vitest run --config vitest.dist.config.ts",
448449
"test:coverage": "vitest run --coverage",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { mergeLcov } from "../merge-lcov.mjs"
4+
5+
const report = (coveredLines) => `SF:src/example.ts
6+
FN:1,example
7+
FNDA:${coveredLines.has(1) ? 1 : 0},example
8+
FNF:1
9+
FNH:${coveredLines.has(1) ? 1 : 0}
10+
BRDA:2,0,0,${coveredLines.has(2) ? 1 : "-"}
11+
BRF:1
12+
BRH:${coveredLines.has(2) ? 1 : 0}
13+
DA:1,${coveredLines.has(1) ? 1 : 0}
14+
DA:2,${coveredLines.has(2) ? 1 : 0}
15+
DA:3,${coveredLines.has(3) ? 1 : 0}
16+
LF:3
17+
LH:${coveredLines.size}
18+
end_of_record
19+
`
20+
21+
describe("mergeLcov", () => {
22+
it("counts a line as covered when any coverage lane executes it", () => {
23+
const merged = mergeLcov([
24+
["api", report(new Set([1]))],
25+
["core", report(new Set([2]))],
26+
])
27+
28+
expect(merged).toContain("FNDA:1,example")
29+
expect(merged).toContain("BRDA:2,0,0,1")
30+
expect(merged).toContain("DA:1,1")
31+
expect(merged).toContain("DA:2,1")
32+
expect(merged).toContain("LH:2")
33+
})
34+
35+
it("keeps lines uncovered when no coverage lane executes them", () => {
36+
const merged = mergeLcov([
37+
["api", report(new Set([1]))],
38+
["core", report(new Set([2]))],
39+
])
40+
41+
expect(merged).toContain("DA:3,0")
42+
expect(merged).not.toContain("DA:3,1")
43+
})
44+
45+
it("merges disjoint source records without changing their paths", () => {
46+
const merged = mergeLcov([
47+
["api", report(new Set([1])).replaceAll("src/example.ts", "src/api.ts")],
48+
["core", report(new Set([2])).replaceAll("src/example.ts", "src/core.ts")],
49+
])
50+
51+
expect(merged.match(/^SF:/gm)).toHaveLength(2)
52+
expect(merged).toContain("SF:src/api.ts")
53+
expect(merged).toContain("SF:src/core.ts")
54+
})
55+
})

src/scripts/merge-lcov.mjs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { readFileSync, writeFileSync } from "node:fs"
2+
import process from "node:process"
3+
4+
const parseCount = (value, description) => {
5+
const count = Number(value)
6+
if (!Number.isSafeInteger(count) || count < 0) throw new Error(`Invalid ${description}: ${value}`)
7+
return count
8+
}
9+
10+
const mergeCount = (records, key, count) => records.set(key, Math.max(records.get(key) ?? 0, count))
11+
12+
const parseLcov = (lcov, label) => {
13+
const sources = new Map()
14+
let record
15+
16+
for (const line of lcov.split(/\r?\n/)) {
17+
if (!line || line.startsWith("TN:")) continue
18+
if (line.startsWith("SF:")) {
19+
if (record) throw new Error(`${label} contains an unfinished source record: ${record.source}`)
20+
const source = line.slice(3)
21+
if (!source) throw new Error(`${label} contains an empty source path`)
22+
record = {
23+
source,
24+
functions: new Map(),
25+
functionCounts: new Map(),
26+
branches: new Map(),
27+
lines: new Map(),
28+
}
29+
} else if (line === "end_of_record") {
30+
if (!record) throw new Error(`${label} contains a record terminator outside a source record`)
31+
if (sources.has(record.source))
32+
throw new Error(`${label} contains duplicate source record: ${record.source}`)
33+
sources.set(record.source, record)
34+
record = undefined
35+
} else if (record && line.startsWith("FN:")) {
36+
const separator = line.indexOf(",")
37+
if (separator < 4) throw new Error(`${label} contains invalid FN for ${record.source}`)
38+
const name = line.slice(separator + 1)
39+
const location = line.slice(3, separator)
40+
const existing = record.functions.get(name)
41+
if (existing && existing !== location)
42+
throw new Error(`${label} contains conflicting FN for ${record.source}:${name}`)
43+
record.functions.set(name, location)
44+
} else if (record && line.startsWith("FNDA:")) {
45+
const [count, ...name] = line.slice(5).split(",")
46+
if (name.length === 0) throw new Error(`${label} contains invalid FNDA for ${record.source}`)
47+
mergeCount(record.functionCounts, name.join(","), parseCount(count, `FNDA for ${record.source}`))
48+
} else if (record && line.startsWith("BRDA:")) {
49+
const [lineNumber, block, branch, taken] = line.slice(5).split(",")
50+
const key = `${lineNumber},${block},${branch}`
51+
const count = taken === "-" ? 0 : parseCount(taken, `BRDA for ${record.source}`)
52+
mergeCount(record.branches, key, count)
53+
} else if (record && line.startsWith("DA:")) {
54+
const [lineNumber, count, checksum] = line.slice(3).split(",")
55+
const key = parseCount(lineNumber, `DA line for ${record.source}`)
56+
if (key < 1) throw new Error(`${label} contains invalid DA line for ${record.source}`)
57+
const existing = record.lines.get(key)
58+
if (existing?.checksum && checksum && existing.checksum !== checksum)
59+
throw new Error(`${label} contains conflicting DA checksum for ${record.source}:${key}`)
60+
record.lines.set(key, {
61+
count: Math.max(existing?.count ?? 0, parseCount(count, `DA count for ${record.source}`)),
62+
checksum: existing?.checksum ?? checksum,
63+
})
64+
} else if (record && !/^(?:FNF|FNH|BRF|BRH|LF|LH):/.test(line)) {
65+
throw new Error(`${label} contains unsupported LCOV data for ${record.source}: ${line}`)
66+
} else if (!record) {
67+
throw new Error(`${label} contains data outside a source record: ${line}`)
68+
}
69+
}
70+
71+
if (record) throw new Error(`${label} contains an unfinished source record: ${record.source}`)
72+
return sources
73+
}
74+
75+
export const mergeLcov = (reports) => {
76+
const merged = new Map()
77+
for (const [label, lcov] of reports) {
78+
for (const [source, incoming] of parseLcov(lcov, label)) {
79+
const record = merged.get(source) ?? {
80+
source,
81+
functions: new Map(),
82+
functionCounts: new Map(),
83+
branches: new Map(),
84+
lines: new Map(),
85+
}
86+
for (const [name, location] of incoming.functions) {
87+
const existing = record.functions.get(name)
88+
if (existing && existing !== location) throw new Error(`Conflicting FN for ${source}:${name}`)
89+
record.functions.set(name, location)
90+
}
91+
for (const [name, count] of incoming.functionCounts) mergeCount(record.functionCounts, name, count)
92+
for (const [key, count] of incoming.branches) mergeCount(record.branches, key, count)
93+
for (const [line, value] of incoming.lines) {
94+
const existing = record.lines.get(line)
95+
if (existing?.checksum && value.checksum && existing.checksum !== value.checksum)
96+
throw new Error(`Conflicting DA checksum for ${source}:${line}`)
97+
record.lines.set(line, {
98+
count: Math.max(existing?.count ?? 0, value.count),
99+
checksum: existing?.checksum ?? value.checksum,
100+
})
101+
}
102+
merged.set(source, record)
103+
}
104+
}
105+
106+
return [...merged.values()]
107+
.sort((a, b) => a.source.localeCompare(b.source))
108+
.flatMap((record) => {
109+
const functions = [...record.functions].sort(([a], [b]) => a.localeCompare(b))
110+
const functionCounts = [...record.functionCounts].sort(([a], [b]) => a.localeCompare(b))
111+
const branches = [...record.branches].sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
112+
const lines = [...record.lines].sort(([a], [b]) => a - b)
113+
return [
114+
`SF:${record.source}`,
115+
...functions.map(([name, location]) => `FN:${location},${name}`),
116+
...functionCounts.map(([name, count]) => `FNDA:${count},${name}`),
117+
`FNF:${functions.length}`,
118+
`FNH:${functionCounts.filter(([, count]) => count > 0).length}`,
119+
...branches.map(([key, count]) => `BRDA:${key},${count || "-"}`),
120+
`BRF:${branches.length}`,
121+
`BRH:${branches.filter(([, count]) => count > 0).length}`,
122+
...lines.map(([line, { count, checksum }]) => `DA:${line},${count}${checksum ? `,${checksum}` : ""}`),
123+
`LF:${lines.length}`,
124+
`LH:${lines.filter(([, { count }]) => count > 0).length}`,
125+
"end_of_record",
126+
]
127+
})
128+
.join("\n")
129+
}
130+
131+
if (process.argv[1] === import.meta.filename) {
132+
const [output, ...inputs] = process.argv.slice(2)
133+
if (!output || inputs.length < 1) throw new Error("Usage: merge-lcov.mjs <output> <input...>")
134+
writeFileSync(output, `${mergeLcov(inputs.map((input) => [input, readFileSync(input, "utf8")]))}\n`)
135+
}

0 commit comments

Comments
 (0)