diff --git a/.gitignore b/.gitignore index 2b029e0..f3d4e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,12 @@ storybook-static *.local.* **/settings.local.json -# Testing -coverage +# Testing — per-package and aggregated coverage output (vitest + coverage:aggregate) +/coverage/ +packages/*/coverage/ +apps/*/coverage/ +# But keep the source scripts at scripts/coverage/ tracked +!/scripts/coverage/ # OS .DS_Store diff --git a/package.json b/package.json index 9506fa0..d1b38d8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:visual": "pnpm --filter @repo/storybook exec concurrently -k -s first -n 'SB,VRT' -c 'magenta,blue' 'pnpm --filter @repo/storybook exec http-server storybook-static --port 6006 --silent' 'pnpm --filter @repo/storybook exec wait-on tcp:6006 && pnpm exec playwright test'", "typecheck": "turbo run typecheck", "conformance": "node scripts/conformance.mjs", + "coverage:diff": "node scripts/coverage/diff.mjs", "fallow": "fallow", "fallow:audit": "fallow audit --base main", "work": "node scripts/work/cli.mjs", diff --git a/scripts/coverage/__fixtures__/sample-diff.patch b/scripts/coverage/__fixtures__/sample-diff.patch new file mode 100644 index 0000000..c6de80e --- /dev/null +++ b/scripts/coverage/__fixtures__/sample-diff.patch @@ -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 diff --git a/scripts/coverage/__fixtures__/sample.lcov b/scripts/coverage/__fixtures__/sample.lcov new file mode 100644 index 0000000..c9ada8f --- /dev/null +++ b/scripts/coverage/__fixtures__/sample.lcov @@ -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 diff --git a/scripts/coverage/diff.mjs b/scripts/coverage/diff.mjs new file mode 100644 index 0000000..700f91d --- /dev/null +++ b/scripts/coverage/diff.mjs @@ -0,0 +1,316 @@ +#!/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)$/, + /\/__factories__\//, + /\/__contracts__\//, + /\/__fixtures__\//, + /\/__seeds__\//, + // Configs + /\.config\.(ts|js|mjs|cjs)$/, + /(^|\/)package\.json$/, + /(^|\/)tsconfig.*\.json$/, + /(^|\/)turbo\.json$/, + // Docs / data + /\.md$/, + /\.json$/, + /\.ya?ml$/, + /\.gitignore$/, + /\.npmrc$/, + // Shell scripts (not Vitest-covered) + /\.sh$/, + /\.bash$/, + // Per-package coverage excludes (mirror vitest config) + /\/di\/bind-production\.ts$/, + /\/application\/repositories\//, + /\/application\/services\//, + /\/integrations\/cms\//, + /\/ui\//, + // Pure type-alias / interface files (no executable code) + /\.interface\.ts$/, + /\/index\.ts$/, // barrel re-exports — no executable code + // Build artifacts + /\.tsbuildinfo$/, + /\.lock$/, + /(^|\/)dist\//, + /(^|\/)\.next\//, + /(^|\/)\.turbo\//, + /(^|\/)node_modules\//, + /(^|\/)coverage\//, +]; + +function isAllowed(file) { + return ALLOWED_GLOBS.some((re) => re.test(file)); +} + +/** + * Parse lcov into a map of file -> Map. + * Only DA records are read; LF/LH/BRDA/BRF/BRH/etc. are ignored. + * + * lcov is a simple line-oriented format: + * SF: + * DA:, + * ... + * 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. + * + * 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 ] [--lcov ] [--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(); +} diff --git a/scripts/coverage/diff.test.mjs b/scripts/coverage/diff.test.mjs new file mode 100644 index 0000000..5aa9626 --- /dev/null +++ b/scripts/coverage/diff.test.mjs @@ -0,0 +1,187 @@ +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("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"); + }); +});