feat(coverage): pnpm coverage:aggregate + L2 implementation

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 14:10:22 +02:00
parent 412d994733
commit bd5a077227
8 changed files with 500 additions and 4 deletions

12
.gitignore vendored
View File

@@ -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

62
coverage/summary.json Normal file
View File

@@ -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
}
}
}
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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/<pkg>/` (or `apps/<pkg>/`) 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/<name> 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();
}

View File

@@ -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 });
}
});
});

View File

@@ -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) {