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:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -21,11 +21,17 @@ storybook-static
|
|||||||
*.local.*
|
*.local.*
|
||||||
**/settings.local.json
|
**/settings.local.json
|
||||||
|
|
||||||
# Testing — per-package and aggregated coverage output (vitest + coverage:aggregate)
|
# Testing — per-package coverage output (vitest)
|
||||||
/coverage/
|
|
||||||
packages/*/coverage/
|
packages/*/coverage/
|
||||||
apps/*/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/
|
!/scripts/coverage/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
|
|||||||
62
coverage/summary.json
Normal file
62
coverage/summary.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"conformance": "node scripts/conformance.mjs",
|
"conformance": "node scripts/conformance.mjs",
|
||||||
"coverage:diff": "node scripts/coverage/diff.mjs",
|
"coverage:diff": "node scripts/coverage/diff.mjs",
|
||||||
|
"coverage:aggregate": "node scripts/coverage/aggregate.mjs",
|
||||||
"fallow": "fallow",
|
"fallow": "fallow",
|
||||||
"fallow:audit": "fallow audit --base main",
|
"fallow:audit": "fallow audit --base main",
|
||||||
"work": "node scripts/work/cli.mjs",
|
"work": "node scripts/work/cli.mjs",
|
||||||
|
|||||||
22
scripts/coverage/__fixtures__/aggregate-pkg-a.lcov
Normal file
22
scripts/coverage/__fixtures__/aggregate-pkg-a.lcov
Normal 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
|
||||||
13
scripts/coverage/__fixtures__/aggregate-pkg-b.lcov
Normal file
13
scripts/coverage/__fixtures__/aggregate-pkg-b.lcov
Normal 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
|
||||||
211
scripts/coverage/aggregate.mjs
Normal file
211
scripts/coverage/aggregate.mjs
Normal 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();
|
||||||
|
}
|
||||||
174
scripts/coverage/aggregate.test.mjs
Normal file
174
scripts/coverage/aggregate.test.mjs
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,10 @@ const ALLOWED_GLOBS = [
|
|||||||
// Shell scripts (not Vitest-covered)
|
// Shell scripts (not Vitest-covered)
|
||||||
/\.sh$/,
|
/\.sh$/,
|
||||||
/\.bash$/,
|
/\.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)
|
// Per-package coverage excludes (mirror vitest config)
|
||||||
/\/di\/bind-production\.ts$/,
|
/\/di\/bind-production\.ts$/,
|
||||||
/\/application\/repositories\//,
|
/\/application\/repositories\//,
|
||||||
@@ -66,7 +70,10 @@ const ALLOWED_GLOBS = [
|
|||||||
/(^|\/)\.next\//,
|
/(^|\/)\.next\//,
|
||||||
/(^|\/)\.turbo\//,
|
/(^|\/)\.turbo\//,
|
||||||
/(^|\/)node_modules\//,
|
/(^|\/)node_modules\//,
|
||||||
/(^|\/)coverage\//,
|
// Coverage output (anchored to package/app/root, NOT scripts/coverage/)
|
||||||
|
/^coverage\//,
|
||||||
|
/^packages\/[^/]+\/coverage\//,
|
||||||
|
/^apps\/[^/]+\/coverage\//,
|
||||||
];
|
];
|
||||||
|
|
||||||
function isAllowed(file) {
|
function isAllowed(file) {
|
||||||
|
|||||||
Reference in New Issue
Block a user