Files
agentic-dev-template/docs/guides/coverage.md
Danijel Martinek f4254aae48 docs(coverage): cookbook guide + feature generator scaffolds coverage:
Adds the day-to-day cookbook for the 4-layer coverage architecture
(ADR-020) and threads it into the discovery path:

docs/guides/coverage.md (new):
  - 4 layers at a glance + when each fires
  - Single-source-of-truth pattern (feature.manifest.ts coverage:
    section) and the three readers (vitest, assertFeatureConformance,
    coverage:diff)
  - Daily workflow: pnpm test --coverage -> aggregate -> diff
  - How to read a failure (stderr human + stdout JSON examples)
  - How to fix uncovered slices (TDD walkthrough)
  - The full allowlist (test files, configs, docs, scripts, dev
    tooling, per-feature excludes)
  - Adjusting bands (manifest-first, when to override vitest)
  - CI behavior (two workflows: validate + coverage-snapshot)
  - Reading the committed trend via git log -- coverage/summary.json
  - Mutation testing primer (L3, opt-in, scope, lands in next story)
  - Troubleshooting

CLAUDE.md Read First gets the new guide pinned between audit and
template-tiers, with the L0-L3 layer summary inline so agents see the
shape at a glance.

Feature generator updates (turbo/generators/templates/feature/):
  - feature.manifest.ts.hbs: new `coverage:` block at <gen:coverage>
    anchor scaffolded with the documented defaults + mutationTargets
  - vitest.config.ts.hbs: now uses vitestThresholdsFromBands(
    DEFAULT_COVERAGE_BANDS) instead of the duplicated literal — new
    features ship conformance-compliant by default

Next features generated via `pnpm turbo gen feature` are coverage-
aware from the first commit: bands declared in manifest, vitest
config consumes the helper, no duplication to drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:15:01 +02:00

9.5 KiB

Coverage

Architecture: ADR-020. Glossary: docs/glossary.md → Coverage.

The agent-first coverage architecture has four layers. This guide is the day-to-day reference for working with them.

The four layers at a glance

Layer Question it answers Command
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:aggregatecoverage/summary.json
L3 Mutation testing "Do my tests actually assert anything?" pnpm mutate (opt-in, not in default pnpm test)

Each layer answers a distinct question. They compose, none replaces the others.

Single source of truth: feature.manifest.ts

Every feature declares its coverage expectations once, in feature.manifest.ts:

export const myFeatureManifest = defineFeature({
  // ...
  coverage: {
    bands: {
      baseline: { statements: 80, branches: 75, functions: 80, lines: 80 },
      entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
      "use-cases": {
        statements: 100,
        branches: 95,
        functions: 100,
        lines: 100,
      },
      controllers: {
        statements: 100,
        branches: 95,
        functions: 100,
        lines: 100,
      },
    },
    mutationTargets: ["entities", "use-cases"],
  },
} as const);

Three readers pick this up:

  1. vitest.config.ts — uses vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS) from @repo/core-shared/conformance/coverage. Most features import DEFAULT_COVERAGE_BANDS directly (the manifest's coverage section matches the defaults). For features with custom bands, override at the vitest config too.
  2. assertFeatureConformance — at app boot, reads the manifest's bands and asserts the produced lcov meets them. (Boot wiring lands in the next story.)
  3. pnpm coverage:diff — uses the bands for per-path expectations against the merged lcov.

Edit the manifest. The other readers pick up the change.

Daily workflow

Before pushing

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

The diff coverage step compares against origin/main by default. To compare against a different base:

pnpm coverage:diff -- --base HEAD~1
pnpm coverage:diff -- --base origin/release

For machine consumption (e.g., the agent dispatch loop):

pnpm coverage:diff -- --json | jq .uncovered

Reading a failure

pnpm coverage:diff exits with code 1 and emits both stdout (JSON) and stderr (summary):

stderr (human):

[coverage:diff] FAIL — 3 uncovered hit(s) across 2 file(s):
  packages/blog/src/application/use-cases/publish-article.use-case.ts
    uncovered lines: 47, 48
  packages/auth/src/entities/models/session.ts
    uncovered lines: 22

stdout (JSON, also written for the dispatch loop):

{
  "status": "fail",
  "summary": {
    "filesChanged": 4,
    "filesGated": 2,
    "uncoveredCount": 3
  },
  "fileSummaries": [...],
  "uncovered": [
    { "file": "...", "line": 47, "kind": "uncovered" },
    { "file": "...", "line": 48, "kind": "uncovered" },
    { "file": "...", "line": 22, "kind": "uncovered" }
  ]
}

kind is one of:

  • uncovered — line is executable per lcov, execution count is 0
  • no-coverage-data — entire file isn't in lcov (likely a new untested file)

Fixing an uncovered slice

  1. Read the JSON. For each uncovered hit, navigate to <file>:<line>.
  2. Identify which test would have exercised that line. Usually it's missing a branch case or an error path.
  3. Add the test (TDD: write failing test → make it green).
  4. Re-run pnpm test --coverage --filter @repo/<feature> to verify.
  5. Re-run pnpm coverage:diff to confirm exit 0.

For no-coverage-data hits, write the sibling test file — vitest's ESLint conformance rule usecase-must-have-test-file will start failing anyway if you don't.

What's exempt (the allowlist)

The diff coverage gate skips:

  • Test files (*.test.ts, *.test.tsx, *.test.mjs)
  • Fixtures, factories, contracts, seeds (__fixtures__/, __factories__/, __contracts__/, __seeds__/)
  • Config files (*.config.{ts,js,mjs,cjs}, package.json, tsconfig.*.json, turbo.json)
  • Docs and data (*.md, *.json, *.yaml, .gitignore, .npmrc)
  • Shell scripts (*.sh, *.bash)
  • Dev tooling under scripts/ and turbo/generators/
  • Per-feature excludes mirrored from vitest (di/bind-production.ts, application/repositories/**, application/services/**, integrations/cms/**, ui/**, *.interface.ts, index.ts barrels)
  • Build artifacts (dist/, .next/, .turbo/, node_modules/, coverage/)

The allowlist lives in scripts/coverage/diff.mjs and is unit-tested.

Adjusting bands

To raise the bar on a feature

Edit packages/<feature>/src/feature.manifest.ts:

coverage: {
  bands: {
    baseline: { statements: 90, branches: 85, functions: 90, lines: 90 },  // tighter
    entities: { statements: 100, branches: 100, functions: 100, lines: 100 },
    "use-cases": { statements: 100, branches: 100, functions: 100, lines: 100 },  // bumped branches
    controllers: { statements: 100, branches: 95, functions: 100, lines: 100 },
  },
}

If the new bands are stricter than the defaults, also update packages/<feature>/vitest.config.ts to use vitestThresholdsFromManifest(myFeatureManifest) instead of DEFAULT_COVERAGE_BANDS. (Note: importing the manifest from a vitest config has tooling constraints — see the DEFAULT_COVERAGE_BANDS route as the default path.)

To skip a layer

Omit it from bands. The layer falls through to baseline:

coverage: {
  bands: {
    baseline: { ... },
    entities: { ... },
    // controllers omitted -> matches baseline
  },
}

CI behavior

.github/workflows/ci.yml (validate job) runs three coverage steps after the test step:

  1. Test with coverage — produces per-package coverage/lcov.info
  2. Coverage — aggregate (L2) — merges to root coverage/lcov.info + coverage/summary.json
  3. Coverage — diff (L1) — only on pull requests, diffs against origin/<base-ref>

On merge to main, .github/workflows/coverage-snapshot.yml re-aggregates and commits the updated coverage/summary.json back to main. Trend history accumulates via git log -- coverage/summary.json.

Reading the trend

git log --oneline --follow -- coverage/summary.json | head -10
git show <sha> -- coverage/summary.json | grep -E '"statements"|"branches"'

coverage/summary.json is the only committed coverage artifact. Each snapshot includes:

  • generatedAt — ISO timestamp
  • commit — short SHA
  • repo — repo-wide percentages + raw counts
  • byPackage — per-package percentages, keyed by @repo/<name>

Mutation testing (L3)

Not yet wired. The coverage.mutationTargets manifest field is declarative today; the pnpm mutate runner lands in a follow-up story.

When it lands, scope is per-feature:

pnpm mutate --filter @repo/blog

Mutations run on entities/ + application/use-cases/ (the pure-business-logic surface). Default mutation-score threshold is 80% (override per-manifest via coverage.mutationScore). Not part of pnpm test; runs on-demand and nightly via GH Action.

Troubleshooting

"Cannot find module '@vitest/coverage-v8'" — your feature's package.json is missing @vitest/coverage-v8 as a dev dep. Add it. (This was the issue surfaced for media during the L0 audit.)

"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.

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.

Diff coverage flags a file I don't think should be gated — check the allowlist in scripts/coverage/diff.mjs. If the file genuinely shouldn't be gated, extend the allowlist (and the tests in diff.test.mjs).