diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index abb344dbd5..9f4a52ba80 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -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 @@ -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 @@ -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 diff --git a/src/package.json b/src/package.json index 0f26ad0a6c..ede058efd9 100644 --- a/src/package.json +++ b/src/package.json @@ -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", diff --git a/src/scripts/__tests__/merge-lcov.spec.mjs b/src/scripts/__tests__/merge-lcov.spec.mjs new file mode 100644 index 0000000000..64706eaf02 --- /dev/null +++ b/src/scripts/__tests__/merge-lcov.spec.mjs @@ -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") + }) +}) diff --git a/src/scripts/merge-lcov.mjs b/src/scripts/merge-lcov.mjs new file mode 100644 index 0000000000..cfcbd46cb6 --- /dev/null +++ b/src/scripts/merge-lcov.mjs @@ -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}`) + return count +} + +const mergeCount = (records, key, count) => records.set(key, Math.max(records.get(key) ?? 0, count)) + +const parseLcov = (lcov, label) => { + const sources = new Map() + let record + + for (const line of lcov.split(/\r?\n/)) { + if (!line || line.startsWith("TN:")) continue + if (line.startsWith("SF:")) { + if (record) throw new Error(`${label} contains an unfinished source record: ${record.source}`) + const source = line.slice(3) + if (!source) throw new Error(`${label} contains an empty source path`) + 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`) + 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 ") + writeFileSync(output, `${mergeLcov(inputs.map((input) => [input, readFileSync(input, "utf8")]))}\n`) +}