fix(tooling): restore L0 coverage sweep across all packages

The template baseline shipped 9 packages without @vitest/coverage-v8
and documented an L0 invocation turbo cannot parse — the full coverage
sweep had never run. Adds the provider (pinned to the feature-package
spec), a root test:coverage script, corrects every live doc/prompt/CI
occurrence, and calibrates coverage excludes for the 4 packages whose
latent threshold failures the outage masked (framework glue only —
zero threshold numbers changed). L0 sweep now passes 21/21.

Squash of a284baa + 5c6af95 (worktree-agent-a013c67cea83b2636),
implemented and reviewed via the dispatch loop under the epic's
baseline waiver (lint pending story 03).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 13:55:00 +02:00
parent 1d00c23604
commit 093de02711
24 changed files with 123 additions and 21 deletions

View File

@@ -80,7 +80,7 @@ jobs:
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
run: pnpm test -- --coverage
run: pnpm test:coverage
# L2 — merge per-package lcovs to coverage/lcov.info + emit
# coverage/summary.json (ADR-020). Runs even on test failure so the
# artifact still captures what was produced.

View File

@@ -52,7 +52,7 @@ jobs:
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test
PAYLOAD_SECRET: test-secret-do-not-use-in-prod
run: pnpm test -- --coverage
run: pnpm test:coverage
- name: Aggregate
run: pnpm coverage:aggregate
- name: Commit summary.json if changed

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ storybook-static
# Testing — per-package coverage output (vitest)
packages/*/coverage/
apps/*/coverage/
turbo/generators/coverage/
# Aggregated coverage output (pnpm coverage:aggregate): ignore everything
# under /coverage/ EXCEPT summary.json (committed for trend history per
# ADR-020) and .gitkeep markers.

View File

@@ -51,7 +51,7 @@ All five pass before you commit. If any fail, fix or report BLOCKED — do not p
The coverage architecture has its own multi-layer enforcement that's distinct from the conformance gates above. Run all of these before declaring done:
```
pnpm test -- --coverage # L0 — per-layer thresholds (100% on entities/use-cases/controllers)
pnpm test:coverage # L0 — per-layer thresholds (100% on entities/use-cases/controllers)
pnpm coverage:aggregate # L2 — merges per-package lcovs to coverage/lcov.info + coverage/summary.json
pnpm coverage:diff -- --base <base-ref> # L1 — cover-the-diff: every changed line must be exercised
```

View File

@@ -35,7 +35,7 @@ docker compose up -d # Start PostgreSQL
```bash
pnpm test --watch --filter @repo/<feature> # watch one feature
pnpm test -- --coverage # full run with coverage
pnpm test:coverage # full run with coverage
pnpm test:stories # Storybook smoke tests
pnpm test:e2e # Playwright e2e
```
@@ -85,7 +85,7 @@ Coverage runs in parallel to the 5-gate conformance system above — same multi-
| Layer | Catches | Surface |
| ---------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------- |
| **L0** Per-layer vitest thresholds | Drift below declared bands (entities/use-cases/controllers at 100%) | `pnpm test -- --coverage` |
| **L0** Per-layer vitest thresholds | Drift below declared bands (entities/use-cases/controllers at 100%) | `pnpm test:coverage` |
| **L1** Diff coverage | Changed line not exercised by tests | `pnpm coverage:diff` CI-gated on PRs + dispatch post-task |
| **L2** Aggregate trend | Codebase coverage drifted over time | `pnpm coverage:aggregate` committed `coverage/summary.json` |
| **L3** Mutation testing | Tests that exist + execute the code but assert nothing | `pnpm mutate` on-demand + nightly GH Action |

View File

@@ -32,6 +32,7 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.0.0"
}
}

View File

@@ -2,6 +2,20 @@ import path from "node:path";
import { mergeConfig } from "vitest/config";
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
// Coverage excludes mirror the feature-package pattern (see
// packages/auth/vitest.config.ts): framework glue is excluded, thresholds
// stay inherited from the shared base — never lowered here.
export default mergeConfig(nodeVitestConfig, {
test: {
coverage: {
exclude: [
// Payload-generated admin UI + API mount points: importMap.js is
// stamped "GENERATED AUTOMATICALLY BY PAYLOAD"; the layout/page/
// route files are one-line re-exports of @payloadcms/next handlers.
// Exercised through the running CMS, not unit-testable.
"src/app/**",
],
},
},
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});

View File

@@ -47,6 +47,7 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^25.0.0",
"tsx": "^4.0.0",
"vitest": "^3.0.0"

View File

@@ -2,7 +2,25 @@ import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
// Coverage excludes mirror the feature-package pattern (see
// packages/auth/vitest.config.ts): framework glue is excluded, thresholds
// stay inherited from the shared base — never lowered here.
export default mergeConfig(jsdomVitestConfig, {
esbuild: { jsx: "automatic" },
test: {
coverage: {
exclude: [
// Next.js App Router entry points — async server components and the
// tRPC route-handler mount, invoked by the framework and covered by
// Playwright e2e; not unit-testable in jsdom. providers.tsx and
// src/server/** stay counted (they have unit tests).
"src/app/**/layout.tsx",
"src/app/**/page.tsx",
"src/app/**/route.ts",
// Ambient type declarations — no executable code
"src/**/*.d.ts",
],
},
},
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});

View File

@@ -38,6 +38,7 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^25.0.0",
"vitest": "^3.0.0"
}

View File

@@ -2,7 +2,25 @@ import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
// Coverage excludes mirror the feature-package pattern (see
// packages/auth/vitest.config.ts): framework glue is excluded, thresholds
// stay inherited from the shared base — never lowered here.
export default mergeConfig(jsdomVitestConfig, {
esbuild: { jsx: "automatic" },
test: {
coverage: {
exclude: [
// TanStack Router route definitions — framework-invoked entry
// points, covered by Playwright e2e; not unit tests
"src/routes/**",
// Server-entry OTel/Sentry boot wiring — import-time side effects
// run once at startup (same class as feature DI bootstrap).
// instrumentation-client.ts stays counted (it has a unit test).
"src/instrumentation.ts",
// Ambient type declarations — no executable code
"src/**/*.d.ts",
],
},
},
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});

View File

@@ -8,7 +8,7 @@ The agent-first coverage architecture has four layers. This guide is the day-to-
| Layer | Question it answers | Command |
| ---------------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
| **L0** Per-layer vitest thresholds | "Did the last test run meet the declared bands?" | `pnpm test -- --coverage` |
| **L0** Per-layer vitest thresholds | "Did the last test run meet the declared bands?" | `pnpm test:coverage` |
| **L1** Diff coverage | "Did this PR/slice cover its own changed lines?" | `pnpm coverage:diff` |
| **L2** Aggregate trend | "How is coverage trending across the repo?" | `pnpm coverage:aggregate``coverage/summary.json` |
| **L3** Mutation testing | "Do my tests actually assert anything?" | `pnpm mutate` _(opt-in, not in default `pnpm test`)_ |
@@ -58,7 +58,7 @@ Two readers pick this up today:
### Before pushing
```bash
pnpm test -- --coverage # L0 — per-package thresholds enforced
pnpm test:coverage # L0 — per-package thresholds enforced
pnpm coverage:aggregate # L2 — produce coverage/lcov.info + summary.json
pnpm coverage:diff # L1 — fails if changed lines aren't covered
```
@@ -255,7 +255,7 @@ Fix: read the surviving mutant's diff in `reports/mutation/index.html`, identify
**"Coverage for lines (X%) does not meet 'src/...' threshold (Y%)"** — L0 failure. Real test gap. Either write the missing test or adjust the manifest band downward (rare; band relaxation should be justified).
**`pnpm coverage:diff` says "lcov file not found"** — run `pnpm test -- --coverage && pnpm coverage:aggregate` first. The diff script reads the merged root `coverage/lcov.info`.
**`pnpm coverage:diff` says "lcov file not found"** — run `pnpm test:coverage && pnpm coverage:aggregate` first. The diff script reads the merged root `coverage/lcov.info`.
**`coverage/summary.json` differs every commit** — expected. It includes `generatedAt` (ISO timestamp) and `commit` (SHA). The snapshot workflow only commits it when the underlying numbers change; in local dev, regenerating it shows diff noise.

View File

@@ -290,7 +290,7 @@ HTML report lands at `packages/blog/coverage/index.html` — open it in a browse
To run coverage across all packages:
```bash
pnpm test -- --coverage
pnpm test:coverage
```
Coverage thresholds are enforced in `packages/core-typescript/vitest.base.ts` and inherited by every package's `vitest.config.ts` via `nodeVitestConfig` / `jsdomVitestConfig`.
@@ -655,10 +655,10 @@ Then attach Chrome DevTools at `chrome://inspect`.
**Coverage:**
```bash
pnpm test --coverage --filter @repo/blog
pnpm test --filter @repo/blog -- --coverage
# HTML report: packages/blog/coverage/index.html
pnpm test -- --coverage
pnpm test:coverage
# HTML reports: packages/*/coverage/index.html
```

View File

@@ -11,6 +11,7 @@
"dev": "dotenv -- turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"test:coverage": "turbo run test -- --coverage",
"test:e2e": "turbo run test:e2e",
"test:stories": "turbo run test:stories",
"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'",

View File

@@ -37,6 +37,7 @@
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.2.4",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"reflect-metadata": "^0.2.2",

View File

@@ -14,20 +14,21 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@payloadcms/db-postgres": "^3.14.0",
"@payloadcms/richtext-lexical": "^3.14.0",
"@repo/auth": "workspace:*",
"@repo/blog": "workspace:*",
"@repo/media": "workspace:*",
"@repo/marketing-pages": "workspace:*",
"@repo/media": "workspace:*",
"@repo/navigation": "workspace:*",
"payload": "^3.14.0",
"@payloadcms/db-postgres": "^3.14.0",
"@payloadcms/richtext-lexical": "^3.14.0"
"payload": "^3.14.0"
},
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-testing": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.4",
"vitest": "^3.0.0"
}
}

View File

@@ -16,6 +16,7 @@
"@eslint/js": "^9.20.0",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.1.0",
"eslint-plugin-boundaries": "^4.2.2",

View File

@@ -25,16 +25,16 @@
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"@testing-library/jest-dom": "^6.5.0",
"zod": "^3.23.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.0",
"@trpc/client": "^11.0.0",
"@tanstack/react-query": "^5.59.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"superjson": "^2.2.0",
"vitest": "^3.0.0"
"vitest": "^3.0.0",
"zod": "^3.23.0"
},
"peerDependencies": {
"@trpc/server": "^11.0.0",
@@ -54,6 +54,7 @@
"@sentry/nextjs": "^10.51.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^25.0.0",
"typescript": "^5.8.0"
}

View File

@@ -31,6 +31,7 @@
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.2.4",
"jsdom": "^25.0.0",
"vitest": "^3.0.0"
}

View File

@@ -2,6 +2,20 @@ import path from "node:path";
import { mergeConfig } from "vitest/config";
import { jsdomVitestConfig } from "@repo/core-typescript/vitest.base.jsdom";
// Coverage excludes mirror the feature-package pattern (see
// packages/auth/vitest.config.ts): framework glue is excluded, thresholds
// stay inherited from the shared base — never lowered here.
export default mergeConfig(jsdomVitestConfig, {
test: {
coverage: {
exclude: [
// React context providers wiring tRPC + React Query into each
// framework's render tree — integration-tested in the apps
"src/providers/**",
// QueryClient bootstrap factory consumed by the providers
"src/query-client.ts",
],
},
},
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});

27
pnpm-lock.yaml generated
View File

@@ -110,6 +110,9 @@ importers:
"@types/react-dom":
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
@@ -264,6 +267,9 @@ importers:
"@types/react-dom":
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
jsdom:
specifier: ^25.0.0
version: 25.0.1
@@ -349,6 +355,9 @@ importers:
"@types/react-dom":
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
jsdom:
specifier: ^25.0.0
version: 25.0.1
@@ -570,6 +579,9 @@ importers:
"@repo/core-typescript":
specifier: workspace:*
version: link:../core-typescript
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
inversify:
specifier: ^6.2.0
version: 6.2.2(reflect-metadata@0.2.2)
@@ -625,6 +637,9 @@ importers:
"@types/node":
specifier: ^22.0.0
version: 22.19.17
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
@@ -724,6 +739,9 @@ importers:
"@typescript-eslint/parser":
specifier: ^8.25.0
version: 8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
eslint:
specifier: ^9.20.0
version: 9.39.4(jiti@2.7.0)
@@ -891,6 +909,9 @@ importers:
"@types/react-dom":
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
jsdom:
specifier: ^25.0.0
version: 25.0.1
@@ -943,6 +964,9 @@ importers:
"@types/react":
specifier: ^19.0.0
version: 19.2.14
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
jsdom:
specifier: ^25.0.0
version: 25.0.1
@@ -1179,6 +1203,9 @@ importers:
"@repo/core-typescript":
specifier: workspace:*
version: link:../../packages/core-typescript
"@vitest/coverage-v8":
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.7.0)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
typescript:
specifier: ^5.8.0
version: 5.9.3

View File

@@ -178,7 +178,7 @@ function main() {
if (lcovs.length === 0) {
process.stderr.write(
`[coverage:aggregate] No per-package lcov.info files found.\n` +
`Run \`pnpm test -- --coverage\` first.\n`,
`Run \`pnpm test:coverage\` first.\n`,
);
process.exit(1);
}

View File

@@ -193,7 +193,7 @@ export function computeDiffCoverage(diff, lcov, opts = {}) {
// 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`)
// - 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) {
@@ -264,7 +264,7 @@ function main() {
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`,
`Run \`pnpm test:coverage\` first, then \`pnpm coverage:aggregate\`.\n`,
);
// Emit JSON anyway so the dispatch loop can read it
process.stdout.write(

View File

@@ -14,6 +14,7 @@
"devDependencies": {
"@repo/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@vitest/coverage-v8": "^3.2.4",
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}