chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo content deleted, auth-only reference feature, web-next shell, all gates green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor library traces, and product naming are curated out; generic template repairs (coverage provider devDeps, root test:coverage script, live lint fixes, root-only release-please) are kept. See TEMPLATE.md for provenance, curation list, and usage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
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
|
||||
49
scripts/coverage/__fixtures__/sample-diff.patch
Normal file
49
scripts/coverage/__fixtures__/sample-diff.patch
Normal file
@@ -0,0 +1,49 @@
|
||||
diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.ts b/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||
index abc..def 100644
|
||||
--- a/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||
+++ b/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||
@@ -1,0 +2,2 @@
|
||||
+const a = 1;
|
||||
+const b = 2;
|
||||
@@ -4,1 +5,2 @@
|
||||
-old
|
||||
+const c = 3;
|
||||
+const d = 4;
|
||||
diff --git a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||
index abc..def 100644
|
||||
--- a/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||
+++ b/packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||
@@ -10,0 +11 @@
|
||||
+new test line
|
||||
diff --git a/packages/auth/src/entities/models/user.ts b/packages/auth/src/entities/models/user.ts
|
||||
index abc..def 100644
|
||||
--- a/packages/auth/src/entities/models/user.ts
|
||||
+++ b/packages/auth/src/entities/models/user.ts
|
||||
@@ -2,1 +2,1 @@
|
||||
-old
|
||||
+modified
|
||||
diff --git a/packages/blog/src/application/use-cases/get-article.use-case.ts b/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||
index abc..def 100644
|
||||
--- a/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||
+++ b/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||
@@ -11,0 +12 @@
|
||||
+const uncovered = true;
|
||||
diff --git a/packages/media/src/application/use-cases/upload.use-case.ts b/packages/media/src/application/use-cases/upload.use-case.ts
|
||||
new file mode 100644
|
||||
index 0000000..abc
|
||||
--- /dev/null
|
||||
+++ b/packages/media/src/application/use-cases/upload.use-case.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
+export const uploadUseCase = () => {
|
||||
+ return "uploaded";
|
||||
+};
|
||||
+// Line 4
|
||||
+// Line 5
|
||||
diff --git a/CLAUDE.md b/CLAUDE.md
|
||||
index abc..def 100644
|
||||
--- a/CLAUDE.md
|
||||
+++ b/CLAUDE.md
|
||||
@@ -1,1 +1,2 @@
|
||||
-old text
|
||||
+new text
|
||||
+more text
|
||||
25
scripts/coverage/__fixtures__/sample.lcov
Normal file
25
scripts/coverage/__fixtures__/sample.lcov
Normal file
@@ -0,0 +1,25 @@
|
||||
TN:
|
||||
SF:/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts
|
||||
DA:1,5
|
||||
DA:2,5
|
||||
DA:3,5
|
||||
DA:5,0
|
||||
DA:6,0
|
||||
DA:8,3
|
||||
LF:6
|
||||
LH:4
|
||||
end_of_record
|
||||
SF:/repo/packages/auth/src/entities/models/user.ts
|
||||
DA:1,1
|
||||
DA:2,1
|
||||
DA:3,1
|
||||
LF:3
|
||||
LH:3
|
||||
end_of_record
|
||||
SF:/repo/packages/blog/src/application/use-cases/get-article.use-case.ts
|
||||
DA:10,2
|
||||
DA:11,2
|
||||
DA:12,0
|
||||
LF:3
|
||||
LH:2
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
340
scripts/coverage/diff.mjs
Normal file
340
scripts/coverage/diff.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/coverage/diff.mjs — L1 of the coverage architecture (ADR-020).
|
||||
//
|
||||
// Reads the merged lcov (`coverage/lcov.info`) and the working tree's git
|
||||
// diff against a base ref, then asserts cover-the-diff: every changed
|
||||
// *executable* line must have execution count > 0.
|
||||
//
|
||||
// Output:
|
||||
// - stdout: JSON `{ status, summary, uncovered: [{ file, line, kind }] }`
|
||||
// (machine-readable for the dispatch loop)
|
||||
// - stderr: human summary
|
||||
// Exit: 0 on pass, 1 on fail.
|
||||
//
|
||||
// Usage:
|
||||
// pnpm coverage:diff # default base: origin/main
|
||||
// pnpm coverage:diff -- --base HEAD~1 # override base ref
|
||||
// pnpm coverage:diff -- --lcov path/to.info # override lcov path
|
||||
// pnpm coverage:diff -- --json # JSON only (no stderr)
|
||||
//
|
||||
// Implementation: zero deps. Pure Node ESM + child_process + fs.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Files that don't need diff-coverage gating. Test files, configs, docs,
|
||||
* lockfiles, generated artifacts. Also covers the per-feature exclude
|
||||
* patterns documented in vitest configs (DI bootstrap, interfaces, CMS
|
||||
* collections, factories, contracts, UI).
|
||||
*/
|
||||
const ALLOWED_GLOBS = [
|
||||
// Test artifacts
|
||||
/\.test\.(ts|tsx|js|mjs)$/,
|
||||
// Storybook story files — excluded from vitest by design; tested in Storybook runner
|
||||
/\.stories\.(ts|tsx)$/,
|
||||
/\/__factories__\//,
|
||||
/\/__contracts__\//,
|
||||
/\/__fixtures__\//,
|
||||
/\/__seeds__\//,
|
||||
// Configs
|
||||
/\.config\.(ts|js|mjs|cjs)$/,
|
||||
/(^|\/)package\.json$/,
|
||||
/(^|\/)tsconfig.*\.json$/,
|
||||
/(^|\/)turbo\.json$/,
|
||||
// Docs / data
|
||||
/\.md$/,
|
||||
/\.json$/,
|
||||
/\.jsonld$/, // JSON-LD context files (e.g. core-dsr/contexts/user-data.jsonld)
|
||||
/\.ya?ml$/,
|
||||
/\.gitignore$/,
|
||||
/\.prettierignore$/,
|
||||
/\.npmrc$/,
|
||||
/(^|\/)\.env(\.[^/]+)?$/, // .env, .env.example, .env.local, etc.
|
||||
// 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\//,
|
||||
/\/application\/services\//,
|
||||
/\/integrations\/cms\//,
|
||||
/\/ui\//,
|
||||
// Tooling packages that don't generate a vitest lcov (no @vitest/coverage-v8)
|
||||
/^packages\/core-testing\//,
|
||||
/^packages\/core-eslint\//,
|
||||
// App packages (web-next, cms) do not configure
|
||||
// @vitest/coverage-v8, so their source files never appear in the merged lcov.
|
||||
/^apps\//,
|
||||
// core-shared Sentry client init files — explicitly excluded from per-package
|
||||
// vitest coverage in core-shared/vitest.config.ts ("Sentry client init —
|
||||
// browser/node SDK init, tested in apps"); they have test files but coverage
|
||||
// is excluded by design (browser SDK calls, not unit-testable in isolation).
|
||||
/\/instrumentation\/sentry\//,
|
||||
// Pure type-alias / interface files (no executable code)
|
||||
/\.d\.ts$/, // ambient declaration files — no runtime code by definition
|
||||
/\.interface\.ts$/,
|
||||
/\/index\.ts$/, // barrel re-exports — no executable code
|
||||
// Build artifacts
|
||||
/\.tsbuildinfo$/,
|
||||
/\.lock$/,
|
||||
/(^|\/)dist\//,
|
||||
/(^|\/)\.next\//,
|
||||
/(^|\/)\.turbo\//,
|
||||
/(^|\/)node_modules\//,
|
||||
// Coverage output (anchored to package/app/root, NOT scripts/coverage/)
|
||||
/^coverage\//,
|
||||
/^packages\/[^/]+\/coverage\//,
|
||||
/^apps\/[^/]+\/coverage\//,
|
||||
];
|
||||
|
||||
function isAllowed(file) {
|
||||
return ALLOWED_GLOBS.some((re) => re.test(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse lcov into a map of file -> Map<lineNumber, executionCount>.
|
||||
* Only DA records are read; LF/LH/BRDA/BRF/BRH/etc. are ignored.
|
||||
*
|
||||
* lcov is a simple line-oriented format:
|
||||
* SF:<file>
|
||||
* DA:<line>,<count>
|
||||
* ...
|
||||
* end_of_record
|
||||
*/
|
||||
export function parseLcov(text) {
|
||||
const result = new Map();
|
||||
let currentFile = null;
|
||||
let currentLines = null;
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.startsWith("SF:")) {
|
||||
currentFile = line.slice(3);
|
||||
currentLines = new Map();
|
||||
result.set(currentFile, currentLines);
|
||||
} else if (line.startsWith("DA:") && currentLines) {
|
||||
const [lineNo, count] = line.slice(3).split(",");
|
||||
currentLines.set(Number(lineNo), Number(count));
|
||||
} else if (line === "end_of_record") {
|
||||
currentFile = null;
|
||||
currentLines = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git diff --unified=0` output into a map of file -> Set<lineNumber>.
|
||||
*
|
||||
* Only NEW or MODIFIED lines in the new version are tracked (the `+N,M`
|
||||
* portion of `@@ -A,B +N,M @@`). Removed-only hunks contribute no lines
|
||||
* to check (the line is gone).
|
||||
*
|
||||
* Renamed files are tracked by their new path.
|
||||
*/
|
||||
export function parseGitDiff(text) {
|
||||
const result = new Map();
|
||||
let currentFile = null;
|
||||
let currentLines = null;
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.startsWith("+++ ")) {
|
||||
const p = line.slice(4).trim();
|
||||
if (p === "/dev/null") {
|
||||
currentFile = null;
|
||||
currentLines = null;
|
||||
continue;
|
||||
}
|
||||
// Strip `b/` prefix git adds
|
||||
currentFile = p.startsWith("b/") ? p.slice(2) : p;
|
||||
currentLines = new Set();
|
||||
result.set(currentFile, currentLines);
|
||||
} else if (line.startsWith("@@ ") && currentLines) {
|
||||
// @@ -A,B +N,M @@
|
||||
const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
||||
if (!m) continue;
|
||||
const start = Number(m[1]);
|
||||
const count = m[2] === undefined ? 1 : Number(m[2]);
|
||||
if (count === 0) continue; // hunk has no lines in new version
|
||||
for (let i = 0; i < count; i++) {
|
||||
currentLines.add(start + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given parsed lcov + parsed diff, return the list of uncovered hits.
|
||||
* Each hit: { file, line, kind }
|
||||
* - kind = "uncovered": line is executable in lcov but count is 0
|
||||
* - kind = "no-coverage-data": file is not in lcov at all
|
||||
* - kind = "non-executable": line has no DA record (not flagged; for
|
||||
* visibility only, currently filtered out)
|
||||
*/
|
||||
export function computeDiffCoverage(diff, lcov, opts = {}) {
|
||||
const repoRoot = opts.repoRoot ?? process.cwd();
|
||||
const uncovered = [];
|
||||
const fileSummaries = [];
|
||||
|
||||
for (const [file, lines] of diff) {
|
||||
if (isAllowed(file)) continue;
|
||||
|
||||
// Match lcov keys (absolute paths) to diff keys (repo-relative)
|
||||
let lcovLines = lcov.get(file);
|
||||
if (!lcovLines) {
|
||||
const abs = path.resolve(repoRoot, file);
|
||||
lcovLines = lcov.get(abs);
|
||||
}
|
||||
if (!lcovLines) {
|
||||
// Try matching by suffix in either direction. lcov paths can be:
|
||||
// - absolute (vitest with `coverage.reportsDirectory` at default)
|
||||
// - repo-relative (after `pnpm coverage:aggregate` normalizes)
|
||||
// - package-relative (per-package lcov from `pnpm test:coverage`)
|
||||
// The diff path is always repo-relative.
|
||||
for (const [k, v] of lcov.entries()) {
|
||||
if (file.endsWith("/" + k) || k.endsWith("/" + file) || k === file) {
|
||||
lcovLines = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!lcovLines) {
|
||||
uncovered.push({ file, line: 0, kind: "no-coverage-data" });
|
||||
fileSummaries.push({ file, changed: lines.size, missed: lines.size });
|
||||
continue;
|
||||
}
|
||||
|
||||
let missedInFile = 0;
|
||||
for (const line of [...lines].sort((a, b) => a - b)) {
|
||||
const count = lcovLines.get(line);
|
||||
if (count === undefined) {
|
||||
// Line isn't executable per lcov — skip (blank, comment, type-only)
|
||||
continue;
|
||||
}
|
||||
if (count === 0) {
|
||||
uncovered.push({ file, line, kind: "uncovered" });
|
||||
missedInFile++;
|
||||
}
|
||||
}
|
||||
fileSummaries.push({ file, changed: lines.size, missed: missedInFile });
|
||||
}
|
||||
|
||||
return {
|
||||
status: uncovered.length === 0 ? "pass" : "fail",
|
||||
summary: {
|
||||
filesChanged: diff.size,
|
||||
filesGated: fileSummaries.length,
|
||||
uncoveredCount: uncovered.length,
|
||||
},
|
||||
fileSummaries,
|
||||
uncovered,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- CLI ----
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { base: "origin/main", lcov: "coverage/lcov.info", json: false };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--base") out.base = argv[++i];
|
||||
else if (a === "--lcov") out.lcov = argv[++i];
|
||||
else if (a === "--json") out.json = true;
|
||||
else if (a === "--help" || a === "-h") {
|
||||
console.log(
|
||||
"Usage: pnpm coverage:diff [-- --base <ref>] [--lcov <path>] [--json]",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
// Load lcov
|
||||
const lcovPath = path.resolve(repoRoot, args.lcov);
|
||||
if (!fs.existsSync(lcovPath)) {
|
||||
process.stderr.write(
|
||||
`[coverage:diff] lcov file not found at ${lcovPath}\n` +
|
||||
`Run \`pnpm test:coverage\` first, then \`pnpm coverage:aggregate\`.\n`,
|
||||
);
|
||||
// Emit JSON anyway so the dispatch loop can read it
|
||||
process.stdout.write(
|
||||
JSON.stringify({ status: "error", reason: "lcov-missing", lcovPath }) +
|
||||
"\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const lcov = parseLcov(fs.readFileSync(lcovPath, "utf8"));
|
||||
|
||||
// Get diff
|
||||
let diffText;
|
||||
try {
|
||||
diffText = execSync(`git diff --unified=0 --no-color ${args.base}...HEAD`, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[coverage:diff] git diff against base "${args.base}" failed: ${err.message}\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ status: "error", reason: "git-diff-failed" }) + "\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const diff = parseGitDiff(diffText);
|
||||
|
||||
// Compute
|
||||
const result = computeDiffCoverage(diff, lcov, { repoRoot });
|
||||
|
||||
// Emit JSON to stdout
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
|
||||
// Emit human summary to stderr (unless --json)
|
||||
if (!args.json) {
|
||||
const { status, summary, uncovered } = result;
|
||||
if (status === "pass") {
|
||||
process.stderr.write(
|
||||
`[coverage:diff] PASS — ${summary.filesGated} file(s) gated, all changed lines covered.\n`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`[coverage:diff] FAIL — ${summary.uncoveredCount} uncovered hit(s) across ${summary.filesGated} file(s):\n`,
|
||||
);
|
||||
const byFile = new Map();
|
||||
for (const u of uncovered) {
|
||||
if (!byFile.has(u.file)) byFile.set(u.file, []);
|
||||
byFile.get(u.file).push(u);
|
||||
}
|
||||
for (const [file, hits] of byFile) {
|
||||
const noData = hits[0]?.kind === "no-coverage-data";
|
||||
if (noData) {
|
||||
process.stderr.write(
|
||||
` ${file}\n no coverage data (new untested file?)\n`,
|
||||
);
|
||||
} else {
|
||||
const lines = hits.map((h) => h.line).join(", ");
|
||||
process.stderr.write(` ${file}\n uncovered lines: ${lines}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.status === "pass" ? 0 : 1);
|
||||
}
|
||||
|
||||
// Only run main when invoked directly (not when imported by tests)
|
||||
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (invokedDirectly) {
|
||||
main();
|
||||
}
|
||||
311
scripts/coverage/diff.test.mjs
Normal file
311
scripts/coverage/diff.test.mjs
Normal file
@@ -0,0 +1,311 @@
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseLcov, parseGitDiff, computeDiffCoverage } from "./diff.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(__dirname, "__fixtures__");
|
||||
|
||||
const lcovText = fs.readFileSync(path.join(FIXTURES, "sample.lcov"), "utf8");
|
||||
const diffText = fs.readFileSync(
|
||||
path.join(FIXTURES, "sample-diff.patch"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("parseLcov", () => {
|
||||
test("groups DA records by SF file", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
assert.equal(lcov.size, 3);
|
||||
assert.ok(
|
||||
lcov.has(
|
||||
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||
),
|
||||
);
|
||||
assert.ok(lcov.has("/repo/packages/auth/src/entities/models/user.ts"));
|
||||
});
|
||||
|
||||
test("preserves per-line execution counts", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const lines = lcov.get(
|
||||
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||
);
|
||||
assert.equal(lines.get(1), 5);
|
||||
assert.equal(lines.get(5), 0);
|
||||
assert.equal(lines.get(8), 3);
|
||||
});
|
||||
|
||||
test("ignores non-DA records (LF, LH, BRDA)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const lines = lcov.get("/repo/packages/auth/src/entities/models/user.ts");
|
||||
assert.equal(lines.size, 3); // Only DA records, not LF/LH counters
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGitDiff", () => {
|
||||
test("extracts new + modified line numbers per file from the new version", () => {
|
||||
const diff = parseGitDiff(diffText);
|
||||
const signIn = diff.get(
|
||||
"packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||
);
|
||||
// Hunks: +2,2 (lines 2,3) and +5,2 (lines 5,6)
|
||||
assert.deepEqual(
|
||||
[...signIn].sort((a, b) => a - b),
|
||||
[2, 3, 5, 6],
|
||||
);
|
||||
});
|
||||
|
||||
test("strips the b/ prefix from new-version paths", () => {
|
||||
const diff = parseGitDiff(diffText);
|
||||
assert.ok(diff.has("packages/auth/src/entities/models/user.ts"));
|
||||
assert.ok(!diff.has("b/packages/auth/src/entities/models/user.ts"));
|
||||
});
|
||||
|
||||
test("handles single-line hunks (no comma in +N,M)", () => {
|
||||
const diff = parseGitDiff(diffText);
|
||||
const blog = diff.get(
|
||||
"packages/blog/src/application/use-cases/get-article.use-case.ts",
|
||||
);
|
||||
// @@ -11,0 +12 @@ -> single line at 12
|
||||
assert.deepEqual([...blog], [12]);
|
||||
});
|
||||
|
||||
test("handles new files (entire file is in the diff)", () => {
|
||||
const diff = parseGitDiff(diffText);
|
||||
const upload = diff.get(
|
||||
"packages/media/src/application/use-cases/upload.use-case.ts",
|
||||
);
|
||||
// @@ -0,0 +1,5 @@ -> lines 1-5
|
||||
assert.deepEqual(
|
||||
[...upload].sort((a, b) => a - b),
|
||||
[1, 2, 3, 4, 5],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDiffCoverage", () => {
|
||||
test("passes when changed executable lines are all covered", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
["/repo/packages/auth/src/entities/models/user.ts", new Set([1, 2, 3])],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.uncovered.length, 0);
|
||||
});
|
||||
|
||||
test("fails when a changed line is in lcov with count 0", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
[
|
||||
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||
new Set([5, 6]),
|
||||
],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "fail");
|
||||
assert.equal(result.uncovered.length, 2);
|
||||
assert.deepEqual(result.uncovered.map((u) => u.line).sort(), [5, 6]);
|
||||
assert.ok(result.uncovered.every((u) => u.kind === "uncovered"));
|
||||
});
|
||||
|
||||
test("ignores lines without DA records (non-executable)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
// Line 4 has no DA record in the fixture
|
||||
const diff = new Map([
|
||||
[
|
||||
"/repo/packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||
new Set([4]),
|
||||
],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
});
|
||||
|
||||
test("flags files with no coverage data (new untested file)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
[
|
||||
"packages/media/src/application/use-cases/upload.use-case.ts",
|
||||
new Set([1, 2, 3, 4, 5]),
|
||||
],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "fail");
|
||||
assert.equal(result.uncovered.length, 1);
|
||||
assert.equal(result.uncovered[0].kind, "no-coverage-data");
|
||||
});
|
||||
|
||||
test("skips allowed extensions (.md, .json, .test.ts, configs)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
["CLAUDE.md", new Set([1, 2])],
|
||||
["package.json", new Set([1])],
|
||||
["packages/auth/vitest.config.ts", new Set([1])],
|
||||
[
|
||||
"packages/auth/src/application/use-cases/sign-in.use-case.test.ts",
|
||||
new Set([11]),
|
||||
],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 4);
|
||||
});
|
||||
|
||||
test("skips JSON-LD context files (.jsonld)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
// JSON-LD context files are static data assets with no executable code
|
||||
// (e.g. packages/core-dsr/src/contexts/user-data.jsonld). v8 coverage
|
||||
// never sees them, so they must be exempted from the no-coverage-data gate.
|
||||
["packages/core-dsr/src/contexts/user-data.jsonld", new Set([1, 2, 3])],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 1);
|
||||
});
|
||||
|
||||
test("skips TypeScript ambient declaration files (.d.ts)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
// Ambient declaration files have no runtime code — v8 coverage never
|
||||
// sees them, so they must be exempted from the no-coverage-data gate.
|
||||
[
|
||||
"packages/core-shared/src/payload/payload-custom-ambient.d.ts",
|
||||
new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
],
|
||||
["packages/foo/src/bar/some-types.d.ts", new Set([1, 2])],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 2);
|
||||
});
|
||||
|
||||
test("skips dotfile ignore configs (.prettierignore, .gitignore)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
[".prettierignore", new Set([1, 2])],
|
||||
[".gitignore", new Set([1])],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 2);
|
||||
});
|
||||
|
||||
test("skips .env template files (.env, .env.example, .env.local)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
[".env.example", new Set([1, 2, 3])],
|
||||
[".env.local", new Set([1])],
|
||||
[".env", new Set([1])],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 3);
|
||||
});
|
||||
|
||||
test("skips Storybook story files (.stories.ts/.stories.tsx — excluded from vitest coverage)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
[
|
||||
"packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx",
|
||||
new Set([1, 2, 3, 4, 5]),
|
||||
],
|
||||
["packages/core-ui/src/atoms/button/button.stories.ts", new Set([1, 2])],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 2);
|
||||
});
|
||||
|
||||
test("skips packages/core-testing/ (tooling package, no lcov generated)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
[
|
||||
"packages/core-testing/src/instrumentation/recording-consent.ts",
|
||||
new Set([1, 2, 3, 4, 5]),
|
||||
],
|
||||
[
|
||||
"packages/core-testing/src/factory/define-factory.ts",
|
||||
new Set([1, 2, 3]),
|
||||
],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 2);
|
||||
});
|
||||
|
||||
test("skips apps/ files (app packages don't configure @vitest/coverage-v8)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
// web-next and cms have no @vitest/coverage-v8 — never in lcov
|
||||
["apps/web-next/instrumentation-client.ts", new Set([1, 2, 3, 4, 5, 6])],
|
||||
["apps/web-next/middleware.ts", new Set([1, 2, 3, 4, 5])],
|
||||
["apps/web-next/src/app/layout.tsx", new Set([1, 2, 3])],
|
||||
["apps/cms/src/instrumentation.ts", new Set([1, 2, 3, 4, 5])],
|
||||
["apps/cms/src/payload.config.ts", new Set([1, 2, 3])],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 5);
|
||||
});
|
||||
|
||||
test("skips core-shared sentry init files (excluded from vitest coverage by design)", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
// core-shared/vitest.config.ts excludes src/instrumentation/sentry/**
|
||||
[
|
||||
"packages/core-shared/src/instrumentation/sentry/init-client.ts",
|
||||
new Set([1, 2, 3, 4, 5]),
|
||||
],
|
||||
[
|
||||
"packages/core-shared/src/instrumentation/sentry/init-client-react.ts",
|
||||
new Set([1, 2, 3, 4, 5]),
|
||||
],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.equal(result.summary.filesGated, 0);
|
||||
assert.equal(result.summary.filesChanged, 2);
|
||||
});
|
||||
|
||||
test("end-to-end fixture: mixed pass/fail/skip/no-data", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = parseGitDiff(diffText);
|
||||
const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });
|
||||
|
||||
assert.equal(result.status, "fail");
|
||||
// CLAUDE.md, sign-in.use-case.test.ts -> skipped (allowlist)
|
||||
// sign-in.use-case.ts lines 2,3,5,6 -> 5,6 are uncovered, 2,3 don't have DA records (not in lcov for those lines) so they don't count
|
||||
// user.ts line 2 -> covered (count: 1)
|
||||
// get-article.use-case.ts line 12 -> uncovered (count: 0)
|
||||
// upload.use-case.ts -> no coverage data
|
||||
// The expected uncovered set: sign-in lines 5,6 + get-article line 12 + upload (no-data)
|
||||
const uncoveredKinds = result.uncovered.map((u) => u.kind);
|
||||
assert.ok(uncoveredKinds.includes("no-coverage-data"));
|
||||
assert.ok(uncoveredKinds.includes("uncovered"));
|
||||
});
|
||||
|
||||
test("resolves repo-relative diff paths against lcov absolute paths", () => {
|
||||
const lcov = parseLcov(lcovText);
|
||||
const diff = new Map([
|
||||
// Diff uses repo-relative; lcov has absolute. Suffix match should
|
||||
// bridge them.
|
||||
[
|
||||
"packages/auth/src/application/use-cases/sign-in.use-case.ts",
|
||||
new Set([8]),
|
||||
],
|
||||
]);
|
||||
const result = computeDiffCoverage(diff, lcov, { repoRoot: "/repo" });
|
||||
assert.equal(result.status, "pass");
|
||||
});
|
||||
});
|
||||
158
scripts/coverage/mutate.mjs
Normal file
158
scripts/coverage/mutate.mjs
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
// scripts/coverage/mutate.mjs — L3 of the coverage architecture (ADR-020).
|
||||
//
|
||||
// Driver for Stryker mutation testing. Discovers every package with a
|
||||
// stryker.config.json, then runs Stryker per-feature with the feature's
|
||||
// vitest config + a narrowed mutate scope (entities + use-cases by default,
|
||||
// per the shared base config).
|
||||
//
|
||||
// Usage:
|
||||
// pnpm mutate # run for every feature with a config
|
||||
// pnpm mutate -- --filter @repo/auth # run for one feature
|
||||
// pnpm mutate -- --filter @repo/auth --since main # incremental mode
|
||||
// pnpm mutate -- --json # machine-readable summary only
|
||||
//
|
||||
// This runs Stryker as a child process, not via dynamic import — Stryker's
|
||||
// runtime expects to own the test process and our wrapping logic keeps the
|
||||
// integration simple. Stryker handles concurrency internally.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const STRYKER_BIN = "node_modules/.bin/stryker";
|
||||
|
||||
/**
|
||||
* Walk packages/* and apps/* for stryker.config.json files.
|
||||
* Returns: [{ packageDir, packageName, configPath }]
|
||||
*/
|
||||
export function discoverStrykerConfigs(repoRoot) {
|
||||
const out = [];
|
||||
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 configPath = path.join(dir, pkg, "stryker.config.json");
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
const pkgJsonPath = path.join(dir, pkg, "package.json");
|
||||
let packageName = `${root}/${pkg}`;
|
||||
if (fs.existsSync(pkgJsonPath)) {
|
||||
try {
|
||||
packageName =
|
||||
JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")).name ??
|
||||
packageName;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
packageDir: path.join(root, pkg),
|
||||
packageName,
|
||||
configPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.packageDir.localeCompare(b.packageDir));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { filter: null, since: null, json: false };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--filter") out.filter = argv[++i];
|
||||
else if (a === "--since") out.since = argv[++i];
|
||||
else if (a === "--json") out.json = true;
|
||||
else if (a === "--help" || a === "-h") {
|
||||
console.log(
|
||||
"Usage: pnpm mutate [-- --filter <name>] [--since <ref>] [--json]",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const repoRoot = process.cwd();
|
||||
const all = discoverStrykerConfigs(repoRoot);
|
||||
|
||||
const targets = args.filter
|
||||
? all.filter(
|
||||
(c) =>
|
||||
c.packageName === args.filter || c.packageDir.endsWith(args.filter),
|
||||
)
|
||||
: all;
|
||||
|
||||
if (targets.length === 0) {
|
||||
process.stderr.write(
|
||||
`[mutate] No stryker.config.json found${args.filter ? ` matching ${args.filter}` : ""}.\n` +
|
||||
`Each feature wanting L3 mutation needs a stryker.config.json. See docs/guides/coverage.md.\n`,
|
||||
);
|
||||
process.exit(args.filter ? 1 : 0);
|
||||
}
|
||||
|
||||
const strykerBinAbs = path.join(repoRoot, STRYKER_BIN);
|
||||
if (!fs.existsSync(strykerBinAbs)) {
|
||||
process.stderr.write(
|
||||
`[mutate] Stryker binary not found at ${STRYKER_BIN}. Run \`pnpm install\`.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const target of targets) {
|
||||
if (!args.json) {
|
||||
process.stderr.write(
|
||||
`\n[mutate] === ${target.packageName} (${target.packageDir}) ===\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const strykerArgs = ["run", target.configPath];
|
||||
if (args.since) {
|
||||
strykerArgs.push("--since", args.since);
|
||||
}
|
||||
|
||||
const result = spawnSync(strykerBinAbs, strykerArgs, {
|
||||
cwd: path.join(repoRoot, target.packageDir),
|
||||
stdio: args.json ? "pipe" : "inherit",
|
||||
env: { ...process.env, FORCE_COLOR: args.json ? "0" : "1" },
|
||||
});
|
||||
|
||||
results.push({
|
||||
package: target.packageName,
|
||||
packageDir: target.packageDir,
|
||||
exitCode: result.status,
|
||||
status: result.status === 0 ? "pass" : "fail",
|
||||
});
|
||||
|
||||
// If a feature fails, continue to next. Surface failures at the end so
|
||||
// a single broken feature doesn't mask state for the others.
|
||||
}
|
||||
|
||||
const anyFailed = results.some((r) => r.exitCode !== 0);
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
{ status: anyFailed ? "fail" : "pass", results },
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
} else {
|
||||
process.stderr.write("\n[mutate] Summary:\n");
|
||||
for (const r of results) {
|
||||
process.stderr.write(
|
||||
` ${r.status === "pass" ? "✓" : "✗"} ${r.package}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
const invokedDirectly = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (invokedDirectly) {
|
||||
main();
|
||||
}
|
||||
71
scripts/coverage/mutate.test.mjs
Normal file
71
scripts/coverage/mutate.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
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 { discoverStrykerConfigs } from "./mutate.mjs";
|
||||
|
||||
describe("discoverStrykerConfigs", () => {
|
||||
test("finds stryker.config.json under packages/* and apps/*", () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mutate-disc-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmpRoot, "packages", "p1"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpRoot, "apps", "a1"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpRoot, "packages", "no-stryker"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(tmpRoot, "packages", "p1", "stryker.config.json"),
|
||||
"{}",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpRoot, "packages", "p1", "package.json"),
|
||||
JSON.stringify({ name: "@repo/p1" }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpRoot, "apps", "a1", "stryker.config.json"),
|
||||
"{}",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpRoot, "apps", "a1", "package.json"),
|
||||
JSON.stringify({ name: "@repo/a1" }),
|
||||
);
|
||||
|
||||
const found = discoverStrykerConfigs(tmpRoot);
|
||||
assert.equal(found.length, 2);
|
||||
const names = found.map((f) => f.packageName).sort();
|
||||
assert.deepEqual(names, ["@repo/a1", "@repo/p1"]);
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to packageDir when no package.json", () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mutate-disc2-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmpRoot, "packages", "no-pkg-json"), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(tmpRoot, "packages", "no-pkg-json", "stryker.config.json"),
|
||||
"{}",
|
||||
);
|
||||
|
||||
const found = discoverStrykerConfigs(tmpRoot);
|
||||
assert.equal(found.length, 1);
|
||||
assert.equal(found[0].packageName, "packages/no-pkg-json");
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty when nothing matches", () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mutate-disc3-"));
|
||||
try {
|
||||
const found = discoverStrykerConfigs(tmpRoot);
|
||||
assert.deepEqual(found, []);
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user