From bd5a0772277240252a65bb3a249304d1fca8ccf0 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 13 May 2026 14:10:22 +0200 Subject: [PATCH] feat(coverage): pnpm coverage:aggregate + L2 implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands L2 of the agent-first coverage architecture (ADR-020) — the aggregated trend store. Script: scripts/coverage/aggregate.mjs (zero-dep Node ESM) - discoverLcovs: walks packages/* and apps/* for coverage/lcov.info - normalizeLcov: rewrites SF entries from package-relative (vitest's output) to repo-relative, so the merged file matches git diff paths - summarizeLcov: computes statement/branch/function/line percentages from LF/LH/BRF/BRH/FNF/FNH summary records - aggregate: merges all lcovs and returns mergedLcov + summary - Writes coverage/lcov.info (gitignored — large) and coverage/summary.json (committed — trend via git log -- ...) with timestamp, short commit SHA, repo + per-package percentages Test surface: scripts/coverage/aggregate.test.mjs (10 tests, all green) - Fixtures at __fixtures__/aggregate-pkg-a.lcov + aggregate-pkg-b.lcov (synthetic, structured to make percentages deterministic) - Covers: path normalization (prefix, absolute, double-prefix avoidance), summary computation (percentages, zero-division, rounding), discovery (packages + apps, missing dirs), full aggregation in a tmp repo Wired: - root package.json adds "coverage:aggregate" script - .gitignore restructured: per-package coverage/ stays ignored, aggregated /coverage/ ignored EXCEPT summary.json (committed for trend) and .gitkeep markers L1 allowlist fix folded in (scripts/coverage/diff.mjs): - The previous (^|/)coverage/ regex accidentally caught scripts/coverage/* — replaced with anchored patterns (^coverage/, ^packages/*/coverage/, ^apps/*/coverage/) - Allowlist scripts/ and turbo/generators/ since they're dev tooling tested via node --test, outside vitest's v8 lcov pipeline Smoke-tested end-to-end: - pnpm coverage:aggregate merged 3 lcovs (auth + media + navigation from this session's earlier runs), repo coverage 95.22% statements - pnpm coverage:diff against HEAD~1 with the new merged lcov reports PASS — all 6 diff files correctly allowlisted First committed snapshot of coverage/summary.json lands with this commit, anchoring the trend history at this state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 12 +- coverage/summary.json | 62 +++++ package.json | 1 + .../__fixtures__/aggregate-pkg-a.lcov | 22 ++ .../__fixtures__/aggregate-pkg-b.lcov | 13 ++ scripts/coverage/aggregate.mjs | 211 ++++++++++++++++++ scripts/coverage/aggregate.test.mjs | 174 +++++++++++++++ scripts/coverage/diff.mjs | 9 +- 8 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 coverage/summary.json create mode 100644 scripts/coverage/__fixtures__/aggregate-pkg-a.lcov create mode 100644 scripts/coverage/__fixtures__/aggregate-pkg-b.lcov create mode 100644 scripts/coverage/aggregate.mjs create mode 100644 scripts/coverage/aggregate.test.mjs diff --git a/.gitignore b/.gitignore index f3d4e5b..9eaf6cc 100644 --- a/.gitignore +++ b/.gitignore @@ -21,11 +21,17 @@ storybook-static *.local.* **/settings.local.json -# Testing — per-package and aggregated coverage output (vitest + coverage:aggregate) -/coverage/ +# Testing — per-package coverage output (vitest) packages/*/coverage/ apps/*/coverage/ -# But keep the source scripts at scripts/coverage/ tracked +# Aggregated coverage output (pnpm coverage:aggregate): ignore everything +# under /coverage/ EXCEPT summary.json (committed for trend history per +# ADR-020) and .gitkeep markers. +/coverage/* +!/coverage/summary.json +!/coverage/.gitkeep +# Keep the source scripts at scripts/coverage/ tracked (allows files +# named "coverage" elsewhere) !/scripts/coverage/ # OS diff --git a/coverage/summary.json b/coverage/summary.json new file mode 100644 index 0000000..d155d0f --- /dev/null +++ b/coverage/summary.json @@ -0,0 +1,62 @@ +{ + "generatedAt": "2026-05-13T12:08:33.303Z", + "commit": "412d994", + "repo": { + "statements": 95.22, + "branches": 90.52, + "functions": 98.77, + "lines": 95.22, + "counts": { + "lf": 1633, + "lh": 1555, + "brf": 232, + "brh": 210, + "fnf": 81, + "fnh": 80 + } + }, + "byPackage": { + "@repo/auth": { + "statements": 93.77, + "branches": 90.82, + "functions": 100, + "lines": 93.77, + "counts": { + "lf": 787, + "lh": 738, + "brf": 98, + "brh": 89, + "fnf": 44, + "fnh": 44 + } + }, + "@repo/media": { + "statements": 96.72, + "branches": 89.47, + "functions": 100, + "lines": 96.72, + "counts": { + "lf": 548, + "lh": 530, + "brf": 95, + "brh": 85, + "fnf": 24, + "fnh": 24 + } + }, + "@repo/navigation": { + "statements": 96.31, + "branches": 92.31, + "functions": 92.31, + "lines": 96.31, + "counts": { + "lf": 298, + "lh": 287, + "brf": 39, + "brh": 36, + "fnf": 13, + "fnh": 12 + } + } + } +} diff --git a/package.json b/package.json index d1b38d8..9497c95 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "typecheck": "turbo run typecheck", "conformance": "node scripts/conformance.mjs", "coverage:diff": "node scripts/coverage/diff.mjs", + "coverage:aggregate": "node scripts/coverage/aggregate.mjs", "fallow": "fallow", "fallow:audit": "fallow audit --base main", "work": "node scripts/work/cli.mjs", diff --git a/scripts/coverage/__fixtures__/aggregate-pkg-a.lcov b/scripts/coverage/__fixtures__/aggregate-pkg-a.lcov new file mode 100644 index 0000000..d1cc171 --- /dev/null +++ b/scripts/coverage/__fixtures__/aggregate-pkg-a.lcov @@ -0,0 +1,22 @@ +TN: +SF:src/foo.ts +DA:1,5 +DA:2,5 +DA:3,0 +LF:3 +LH:2 +BRF:2 +BRH:1 +FNF:1 +FNH:1 +end_of_record +SF:src/bar.ts +DA:1,10 +DA:2,10 +LF:2 +LH:2 +BRF:0 +BRH:0 +FNF:1 +FNH:1 +end_of_record diff --git a/scripts/coverage/__fixtures__/aggregate-pkg-b.lcov b/scripts/coverage/__fixtures__/aggregate-pkg-b.lcov new file mode 100644 index 0000000..958d5a8 --- /dev/null +++ b/scripts/coverage/__fixtures__/aggregate-pkg-b.lcov @@ -0,0 +1,13 @@ +TN: +SF:src/baz.ts +DA:1,3 +DA:2,3 +DA:3,3 +DA:4,0 +LF:4 +LH:3 +BRF:2 +BRH:2 +FNF:2 +FNH:1 +end_of_record diff --git a/scripts/coverage/aggregate.mjs b/scripts/coverage/aggregate.mjs new file mode 100644 index 0000000..b4a9958 --- /dev/null +++ b/scripts/coverage/aggregate.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// scripts/coverage/aggregate.mjs — L2 of the coverage architecture (ADR-020). +// +// Discovers every per-package lcov (`packages/*/coverage/lcov.info`, +// `apps/*/coverage/lcov.info`), normalizes their paths to repo-relative, +// merges into `coverage/lcov.info` at the repo root, and emits +// `coverage/summary.json` — the committed trend store. +// +// Output: +// - coverage/lcov.info (gitignored — large) +// - coverage/summary.json (committed — trend via `git log -- ...`) +// - stdout: short status line +// Exit: 0 on success, 1 if no lcov files found. +// +// Usage: +// pnpm coverage:aggregate # default discovery + emit +// pnpm coverage:aggregate -- --json # print summary to stdout +// +// Implementation: zero deps. Pure Node ESM. + +import fs from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +/** + * Find every per-package / per-app lcov.info file under packages/* and apps/*. + * Returns absolute paths. + */ +export function discoverLcovs(repoRoot) { + const results = []; + for (const root of ["packages", "apps"]) { + const dir = path.join(repoRoot, root); + if (!fs.existsSync(dir)) continue; + for (const pkg of fs.readdirSync(dir)) { + const lcov = path.join(dir, pkg, "coverage", "lcov.info"); + if (fs.existsSync(lcov)) { + results.push({ + packageDir: path.join(root, pkg), // repo-relative + lcov, + }); + } + } + } + return results.sort((a, b) => a.packageDir.localeCompare(b.packageDir)); +} + +/** + * Normalize lcov text so every SF line is repo-relative. Vitest emits paths + * relative to the package's vitest.config.ts (e.g. `src/foo.ts`), so we + * prepend `packages//` (or `apps//`) to each SF. + */ +export function normalizeLcov(text, packageDir) { + return text + .split("\n") + .map((line) => { + if (!line.startsWith("SF:")) return line; + const p = line.slice(3); + // If already absolute or already prefixed with packages/apps, leave alone + if (path.isAbsolute(p) || p.startsWith(packageDir + "/")) return line; + return `SF:${packageDir}/${p}`; + }) + .join("\n"); +} + +/** + * Compute lcov-level summary stats from a parsed lcov map. + * Returns { statements, branches, functions, lines } as percentages + * (statements ≈ lines in V8's lcov output). + * + * Algorithm: walk all SF blocks (each record has LF/LH for line totals, + * BRF/BRH for branches, FNF/FNH for functions). Sum across files; divide. + */ +export function summarizeLcov(lcovText) { + let lf = 0, + lh = 0, + brf = 0, + brh = 0, + fnf = 0, + fnh = 0; + for (const line of lcovText.split("\n")) { + if (line.startsWith("LF:")) lf += Number(line.slice(3)); + else if (line.startsWith("LH:")) lh += Number(line.slice(3)); + else if (line.startsWith("BRF:")) brf += Number(line.slice(4)); + else if (line.startsWith("BRH:")) brh += Number(line.slice(4)); + else if (line.startsWith("FNF:")) fnf += Number(line.slice(4)); + else if (line.startsWith("FNH:")) fnh += Number(line.slice(4)); + } + const pct = (hit, found) => + found === 0 ? 100 : Math.round((hit / found) * 10000) / 100; + return { + statements: pct(lh, lf), // V8 lcov: statements ≈ lines + branches: pct(brh, brf), + functions: pct(fnh, fnf), + lines: pct(lh, lf), + counts: { lf, lh, brf, brh, fnf, fnh }, + }; +} + +/** + * Aggregate the discovered lcovs. Returns: + * { + * mergedLcov: string, + * summary: { generatedAt, commit, repo: {...}, byPackage: { ... } } + * } + */ +export function aggregate(repoRoot, opts = {}) { + const lcovs = opts.lcovs ?? discoverLcovs(repoRoot); + if (lcovs.length === 0) { + return { mergedLcov: "", summary: null, lcovs: [] }; + } + + const merged = []; + const byPackage = {}; + + for (const { packageDir, lcov } of lcovs) { + const text = fs.readFileSync(lcov, "utf8"); + const normalized = normalizeLcov(text, packageDir); + merged.push(normalized); + // The package's @repo/ identifier comes from its package.json + const pkgJsonPath = path.join(repoRoot, packageDir, "package.json"); + let pkgName = packageDir; + if (fs.existsSync(pkgJsonPath)) { + try { + pkgName = + JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")).name ?? packageDir; + } catch { + // fall through + } + } + byPackage[pkgName] = summarizeLcov(normalized); + } + + const mergedLcov = merged.join("\n"); + const repo = summarizeLcov(mergedLcov); + + let commit = "unknown"; + try { + commit = execSync("git rev-parse --short HEAD", { + cwd: repoRoot, + encoding: "utf8", + }).trim(); + } catch { + // not in a git repo, leave as "unknown" + } + + return { + mergedLcov, + lcovs, + summary: { + generatedAt: opts.now ?? new Date().toISOString(), + commit, + repo, + byPackage, + }, + }; +} + +// ---- CLI ---- + +function parseArgs(argv) { + const out = { json: false }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--json") out.json = true; + else if (a === "--help" || a === "-h") { + console.log("Usage: pnpm coverage:aggregate [-- --json]"); + process.exit(0); + } + } + return out; +} + +function main() { + const args = parseArgs(process.argv); + const repoRoot = process.cwd(); + const { mergedLcov, summary, lcovs } = aggregate(repoRoot); + + if (lcovs.length === 0) { + process.stderr.write( + `[coverage:aggregate] No per-package lcov.info files found.\n` + + `Run \`pnpm test -- --coverage\` first.\n`, + ); + process.exit(1); + } + + const outDir = path.join(repoRoot, "coverage"); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, "lcov.info"), mergedLcov); + fs.writeFileSync( + path.join(outDir, "summary.json"), + JSON.stringify(summary, null, 2) + "\n", + ); + + if (args.json) { + process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); + } else { + process.stdout.write( + `[coverage:aggregate] Merged ${lcovs.length} lcov(s); ` + + `repo coverage: statements ${summary.repo.statements}%, ` + + `branches ${summary.repo.branches}%, ` + + `functions ${summary.repo.functions}%, ` + + `lines ${summary.repo.lines}%\n` + + `Wrote coverage/lcov.info + coverage/summary.json\n`, + ); + } +} + +const invokedDirectly = import.meta.url === `file://${process.argv[1]}`; +if (invokedDirectly) { + main(); +} diff --git a/scripts/coverage/aggregate.test.mjs b/scripts/coverage/aggregate.test.mjs new file mode 100644 index 0000000..676fe5f --- /dev/null +++ b/scripts/coverage/aggregate.test.mjs @@ -0,0 +1,174 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { + discoverLcovs, + normalizeLcov, + summarizeLcov, + aggregate, +} from "./aggregate.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join(__dirname, "__fixtures__"); + +const pkgA = fs.readFileSync( + path.join(FIXTURES, "aggregate-pkg-a.lcov"), + "utf8", +); +const pkgB = fs.readFileSync( + path.join(FIXTURES, "aggregate-pkg-b.lcov"), + "utf8", +); + +describe("normalizeLcov", () => { + test("prefixes packageDir onto each SF line", () => { + const result = normalizeLcov(pkgA, "packages/auth"); + assert.ok(result.includes("SF:packages/auth/src/foo.ts")); + assert.ok(result.includes("SF:packages/auth/src/bar.ts")); + assert.ok(!result.includes("SF:src/foo.ts\n")); // no unprefixed paths remain + }); + + test("leaves absolute paths untouched", () => { + const text = "SF:/absolute/path/foo.ts\nDA:1,5\nend_of_record"; + const result = normalizeLcov(text, "packages/auth"); + assert.ok(result.includes("SF:/absolute/path/foo.ts")); + }); + + test("doesn't double-prefix when already prefixed", () => { + const text = "SF:packages/auth/src/foo.ts\nDA:1,5\nend_of_record"; + const result = normalizeLcov(text, "packages/auth"); + assert.ok(result.includes("SF:packages/auth/src/foo.ts")); + assert.ok(!result.includes("SF:packages/auth/packages/auth/")); + }); + + test("preserves non-SF lines verbatim", () => { + const result = normalizeLcov(pkgA, "packages/auth"); + assert.ok(result.includes("DA:1,5")); + assert.ok(result.includes("LH:2")); + assert.ok(result.includes("end_of_record")); + }); +}); + +describe("summarizeLcov", () => { + test("computes percentages from LF/LH/BRF/BRH/FNF/FNH summary records", () => { + const summary = summarizeLcov(pkgA); + // LF=3+2=5, LH=2+2=4 -> 80% statements/lines + assert.equal(summary.statements, 80); + assert.equal(summary.lines, 80); + // BRF=2+0=2, BRH=1+0=1 -> 50% branches + assert.equal(summary.branches, 50); + // FNF=1+1=2, FNH=1+1=2 -> 100% functions + assert.equal(summary.functions, 100); + }); + + test("treats zero-found as 100% (avoids division by zero)", () => { + const text = + "SF:src/x.ts\nLF:0\nLH:0\nBRF:0\nBRH:0\nFNF:0\nFNH:0\nend_of_record"; + const summary = summarizeLcov(text); + assert.equal(summary.statements, 100); + assert.equal(summary.branches, 100); + assert.equal(summary.functions, 100); + }); + + test("rounds percentages to 2 decimals", () => { + // LF=3, LH=2 -> 66.67% + const text = "SF:x\nLF:3\nLH:2\nend_of_record"; + const summary = summarizeLcov(text); + assert.equal(summary.statements, 66.67); + }); +}); + +describe("aggregate", () => { + test("returns null summary when no lcovs are found", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cov-agg-empty-")); + try { + const result = aggregate(tmpRoot); + assert.equal(result.summary, null); + assert.equal(result.lcovs.length, 0); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); + + test("merges multiple lcovs and emits per-package summary", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cov-agg-merge-")); + try { + // Create packages/pkg-a + packages/pkg-b with their lcovs + fs.mkdirSync(path.join(tmpRoot, "packages", "pkg-a", "coverage"), { + recursive: true, + }); + fs.mkdirSync(path.join(tmpRoot, "packages", "pkg-b", "coverage"), { + recursive: true, + }); + fs.writeFileSync( + path.join(tmpRoot, "packages", "pkg-a", "coverage", "lcov.info"), + pkgA, + ); + fs.writeFileSync( + path.join(tmpRoot, "packages", "pkg-b", "coverage", "lcov.info"), + pkgB, + ); + // Synthetic package.json for name resolution + fs.writeFileSync( + path.join(tmpRoot, "packages", "pkg-a", "package.json"), + JSON.stringify({ name: "@repo/pkg-a" }), + ); + fs.writeFileSync( + path.join(tmpRoot, "packages", "pkg-b", "package.json"), + JSON.stringify({ name: "@repo/pkg-b" }), + ); + + const result = aggregate(tmpRoot, { now: "2026-05-13T00:00:00Z" }); + + assert.equal(result.lcovs.length, 2); + assert.ok(result.mergedLcov.includes("SF:packages/pkg-a/src/foo.ts")); + assert.ok(result.mergedLcov.includes("SF:packages/pkg-b/src/baz.ts")); + + // Per-package summaries + assert.ok(result.summary.byPackage["@repo/pkg-a"]); + assert.ok(result.summary.byPackage["@repo/pkg-b"]); + assert.equal(result.summary.byPackage["@repo/pkg-a"].statements, 80); + assert.equal(result.summary.byPackage["@repo/pkg-b"].statements, 75); + + // Repo-level summary: lines hit 4+3=7 of 5+4=9 -> 77.78% + assert.equal(result.summary.repo.statements, 77.78); + assert.equal(result.summary.generatedAt, "2026-05-13T00:00:00Z"); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); + +describe("discoverLcovs", () => { + test("finds lcovs under packages/* and apps/*", () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cov-discover-")); + try { + fs.mkdirSync(path.join(tmpRoot, "packages", "p1", "coverage"), { + recursive: true, + }); + fs.mkdirSync(path.join(tmpRoot, "apps", "a1", "coverage"), { + recursive: true, + }); + // p2 has no coverage dir + fs.mkdirSync(path.join(tmpRoot, "packages", "p2"), { recursive: true }); + fs.writeFileSync( + path.join(tmpRoot, "packages", "p1", "coverage", "lcov.info"), + "", + ); + fs.writeFileSync( + path.join(tmpRoot, "apps", "a1", "coverage", "lcov.info"), + "", + ); + + const found = discoverLcovs(tmpRoot); + assert.equal(found.length, 2); + const dirs = found.map((f) => f.packageDir).sort(); + assert.deepEqual(dirs, ["apps/a1", "packages/p1"]); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/coverage/diff.mjs b/scripts/coverage/diff.mjs index 700f91d..3145bcb 100644 --- a/scripts/coverage/diff.mjs +++ b/scripts/coverage/diff.mjs @@ -50,6 +50,10 @@ const ALLOWED_GLOBS = [ // Shell scripts (not Vitest-covered) /\.sh$/, /\.bash$/, + // Dev-tooling scripts — tested via `node --test`, outside vitest's v8 lcov. + // (Their own test coverage is gated separately via the scripts' own tests.) + /^scripts\//, + /^turbo\/generators\//, // Per-package coverage excludes (mirror vitest config) /\/di\/bind-production\.ts$/, /\/application\/repositories\//, @@ -66,7 +70,10 @@ const ALLOWED_GLOBS = [ /(^|\/)\.next\//, /(^|\/)\.turbo\//, /(^|\/)node_modules\//, - /(^|\/)coverage\//, + // Coverage output (anchored to package/app/root, NOT scripts/coverage/) + /^coverage\//, + /^packages\/[^/]+\/coverage\//, + /^apps\/[^/]+\/coverage\//, ]; function isAllowed(file) {