From f7baa8bfd143d6ad0302a0a54944d27f28df7bea Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 13 May 2026 13:51:13 +0200 Subject: [PATCH] feat(core-shared/conformance): manifest coverage schema + vitest helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First implementation milestone of the agent-first coverage architecture (ADR-020, PRD 2026-05-13). Lands the keystone — coverage bands as a typed declaration in feature.manifest.ts plus a helper that derives vitest threshold shapes from them. New file packages/core-shared/src/conformance/coverage.ts (self- contained, no relative imports — loadable at vitest config time): - CoverageBand / CoverageBands / CoverageManifest / VitestThresholds types - DEFAULT_COVERAGE_BANDS (baseline 80/75/80/80; entities 100/100/100/ 100; use-cases + controllers 100/95/100/100) — matches ADR-011 - DEFAULT_MUTATION_SCORE (80) + DEFAULT_MUTATION_TARGETS (entities + use-cases) - getCoverageBands / getMutationConfig — manifest -> resolved bands, with default fallback for missing layers - vitestThresholdsFromBands / vitestThresholdsFromManifest — convert to vitest's coverage.thresholds shape with the layer-to-glob mapping define-feature.ts gains the optional coverage field on FeatureManifest (imports its type from coverage.ts to avoid a relative-import cycle at config-load time). Exposed via two subpaths: @repo/core-shared/conformance (re-exports for source/test code) and @repo/core-shared/conformance/coverage (direct subpath safe to load from vitest configs, bypasses the index re-export chain that Node ESM doesn't auto-extension-resolve). Auth wired as proof-of-concept: - packages/auth/src/feature.manifest.ts declares its coverage section - packages/auth/vitest.config.ts imports the helper + DEFAULT_COVERAGE_BANDS and emits thresholds via vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS) — no more hand-maintained per-glob thresholds block. Verified: 175/175 tests pass; 14/14 typechecks clean; auth coverage green (21 tests, 93.77% overall, all per-layer 100% bands hold). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/auth/src/feature.manifest.ts | 19 ++ packages/auth/vitest.config.ts | 36 +-- packages/core-shared/package.json | 9 +- .../core-shared/src/conformance/coverage.ts | 171 +++++++++++++ .../src/conformance/define-feature.test.ts | 230 +++++++++++++++++- .../src/conformance/define-feature.ts | 7 + packages/core-shared/src/conformance/index.ts | 15 ++ 7 files changed, 458 insertions(+), 29 deletions(-) create mode 100644 packages/core-shared/src/conformance/coverage.ts diff --git a/packages/auth/src/feature.manifest.ts b/packages/auth/src/feature.manifest.ts index 0a36f54..3ed0bdb 100644 --- a/packages/auth/src/feature.manifest.ts +++ b/packages/auth/src/feature.manifest.ts @@ -37,6 +37,25 @@ export const authManifest = defineFeature({ }, realtimeChannels: [], jobs: [], + 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); export type AuthManifest = typeof authManifest; diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts index 779a3ff..e9fb255 100644 --- a/packages/auth/vitest.config.ts +++ b/packages/auth/vitest.config.ts @@ -1,7 +1,18 @@ import path from "node:path"; import { mergeConfig } from "vitest/config"; import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node"; +import { + DEFAULT_COVERAGE_BANDS, + vitestThresholdsFromBands, +} from "@repo/core-shared/conformance/coverage"; +// Coverage thresholds derived from DEFAULT_COVERAGE_BANDS via the shared +// helper — one source of truth for the conventional band shape across all +// features (ADR-020). The feature.manifest.ts `coverage.bands` section also +// declares these for boot-time `assertFeatureConformance` (which reads the +// manifest directly, not the vitest config). For features that need +// non-default bands, override here AND in the manifest, then add a drift +// test in core-shared/conformance/. export default mergeConfig(nodeVitestConfig, { test: { coverage: { @@ -18,30 +29,7 @@ export default mergeConfig(nodeVitestConfig, { // React Query option builders — integration-tested in apps "src/ui/**", ], - thresholds: { - "src/entities/**": { - statements: 100, - branches: 100, - functions: 100, - lines: 100, - }, - "src/application/use-cases/**": { - statements: 100, - branches: 95, - functions: 100, - lines: 100, - }, - "src/interface-adapters/controllers/**": { - statements: 100, - branches: 95, - functions: 100, - lines: 100, - }, - statements: 80, - branches: 75, - functions: 80, - lines: 80, - }, + thresholds: vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS), }, }, resolve: { diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 9843220..9b4c5e9 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./audit": "./src/audit/index.ts", "./conformance": "./src/conformance/index.ts", + "./conformance/coverage": "./src/conformance/coverage.ts", "./di": "./src/di/index.ts", "./di/bind-protocols": "./src/di/bind-protocols.ts", "./di/bind-context": "./src/di/bind-context.ts", @@ -52,8 +53,12 @@ "@sentry/react": "^10.51.0" }, "peerDependenciesMeta": { - "@sentry/node": { "optional": true }, - "@sentry/react": { "optional": true } + "@sentry/node": { + "optional": true + }, + "@sentry/react": { + "optional": true + } }, "devDependencies": { "@opentelemetry/context-async-hooks": "^1.28.0", diff --git a/packages/core-shared/src/conformance/coverage.ts b/packages/core-shared/src/conformance/coverage.ts new file mode 100644 index 0000000..47af00f --- /dev/null +++ b/packages/core-shared/src/conformance/coverage.ts @@ -0,0 +1,171 @@ +/** + * Coverage primitives — types, defaults, and the vitest-threshold helper. + * + * This file is intentionally **self-contained** (no relative imports) so it + * can be loaded at vitest config time, where the Node ESM resolver doesn't + * auto-resolve `.ts` extensions on relative imports. Other files in + * `conformance/` (including `define-feature.ts`) import their coverage types + * from here, not the reverse. See ADR-020. + */ + +/** + * A coverage threshold band for a specific path or layer. All four percentages + * are required; partial bands are not supported (matches vitest's v8 coverage + * threshold shape exactly). + */ +export type CoverageBand = { + readonly statements: number; + readonly branches: number; + readonly functions: number; + readonly lines: number; +}; + +/** + * The named coverage bands a feature declares. Keys are layer aliases + * resolved to glob patterns under `src/`; `baseline` is the fallthrough + * applied to everything not matched by a layer band. + * + * Layer aliases (relative to `packages//src/`): + * - "entities" -> entities/** + * - "use-cases" -> application/use-cases/** + * - "controllers" -> interface-adapters/controllers/** + * + * `baseline` is REQUIRED. Layer bands are OPTIONAL. + */ +export type CoverageBands = { + readonly baseline: CoverageBand; + readonly entities?: CoverageBand; + readonly "use-cases"?: CoverageBand; + readonly controllers?: CoverageBand; +}; + +/** + * The coverage section of a feature manifest. + */ +export type CoverageManifest = { + readonly bands: CoverageBands; + readonly mutationTargets?: readonly ("entities" | "use-cases")[]; + readonly mutationScore?: number; +}; + +/** + * The defaults applied when a manifest omits the `coverage` section. + * Matches the historical per-feature vitest.config.ts thresholds documented + * in ADR-011 (TDD foundation). + */ +export const DEFAULT_COVERAGE_BANDS: CoverageBands = { + 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 }, +}; + +export const DEFAULT_MUTATION_SCORE = 80; +export const DEFAULT_MUTATION_TARGETS = ["entities", "use-cases"] as const; + +/** + * Maps a CoverageBands layer alias to the glob pattern vitest matches files + * against. Per ADR-020. + */ +const LAYER_GLOBS = { + entities: "src/entities/**", + "use-cases": "src/application/use-cases/**", + controllers: "src/interface-adapters/controllers/**", +} as const; + +type ThresholdNumbers = { + statements: number; + branches: number; + functions: number; + lines: number; +}; + +/** + * The shape vitest's `coverage.thresholds` config accepts: a baseline plus + * per-glob overrides. + */ +export type VitestThresholds = ThresholdNumbers & { + [glob: string]: number | ThresholdNumbers; +}; + +/** + * The minimum manifest shape this file consumes. Generic constraint so + * callers can pass any object satisfying the structural minimum (e.g. a + * full `FeatureManifest` with extra properties). + */ +type ManifestWithCoverage = { + readonly coverage?: CoverageManifest; +}; + +/** + * Resolve a manifest's effective coverage bands, filling in DEFAULT_* for + * missing layers. Always go through this helper rather than reading + * `manifest.coverage` directly. + */ +export function getCoverageBands( + manifest: M, +): CoverageBands { + const declared = manifest.coverage?.bands; + if (!declared) return DEFAULT_COVERAGE_BANDS; + return { + baseline: declared.baseline, + entities: declared.entities ?? DEFAULT_COVERAGE_BANDS.entities, + "use-cases": declared["use-cases"] ?? DEFAULT_COVERAGE_BANDS["use-cases"], + controllers: declared.controllers ?? DEFAULT_COVERAGE_BANDS.controllers, + }; +} + +/** + * Resolve a manifest's mutation configuration, falling back to defaults. + */ +export function getMutationConfig( + manifest: M, +): { + targets: readonly ("entities" | "use-cases")[]; + score: number; +} { + return { + targets: manifest.coverage?.mutationTargets ?? DEFAULT_MUTATION_TARGETS, + score: manifest.coverage?.mutationScore ?? DEFAULT_MUTATION_SCORE, + }; +} + +/** + * Convert resolved CoverageBands into the shape vitest expects under + * `coverage.thresholds`. Layer aliases become glob keys; baseline becomes + * the top-level keys. + */ +export function vitestThresholdsFromBands( + bands: CoverageBands, +): VitestThresholds { + const result: VitestThresholds = { + statements: bands.baseline.statements, + branches: bands.baseline.branches, + functions: bands.baseline.functions, + lines: bands.baseline.lines, + }; + for (const [alias, glob] of Object.entries(LAYER_GLOBS) as Array< + [keyof typeof LAYER_GLOBS, string] + >) { + const band = bands[alias]; + if (band) { + result[glob] = { + statements: band.statements, + branches: band.branches, + functions: band.functions, + lines: band.lines, + }; + } + } + return result; +} + +/** + * Convenience wrapper: resolve a manifest's effective bands then emit the + * vitest shape. Use this from a feature's `vitest.config.ts`. + */ +export function vitestThresholdsFromManifest( + manifest: M, +): VitestThresholds { + return vitestThresholdsFromBands(getCoverageBands(manifest)); +} diff --git a/packages/core-shared/src/conformance/define-feature.test.ts b/packages/core-shared/src/conformance/define-feature.test.ts index 3510c41..0ea084a 100644 --- a/packages/core-shared/src/conformance/define-feature.test.ts +++ b/packages/core-shared/src/conformance/define-feature.test.ts @@ -1,5 +1,18 @@ -import { describe, it, expectTypeOf } from "vitest"; -import { defineFeature, type FeatureManifest } from "@/conformance/define-feature"; +import { describe, it, expect, expectTypeOf } from "vitest"; +import { + defineFeature, + type FeatureManifest, +} from "@/conformance/define-feature"; +import { + DEFAULT_COVERAGE_BANDS, + DEFAULT_MUTATION_SCORE, + DEFAULT_MUTATION_TARGETS, + getCoverageBands, + getMutationConfig, + vitestThresholdsFromBands, + vitestThresholdsFromManifest, + type CoverageBand, +} from "@/conformance/coverage"; describe("defineFeature", () => { it("preserves literal types of a manifest declared with `as const`", () => { @@ -27,7 +40,9 @@ describe("defineFeature", () => { // Literal preservation: name is the literal "auth", not string expectTypeOf(manifest.name).toEqualTypeOf<"auth">(); // Use-case keys preserved - expectTypeOf(manifest.useCases.signUp.audits).toEqualTypeOf(); + expectTypeOf(manifest.useCases.signUp.audits).toEqualTypeOf< + readonly ["user.created"] + >(); expectTypeOf(manifest.useCases.signUp.mutates).toEqualTypeOf(); expectTypeOf(manifest.useCases.signIn.mutates).toEqualTypeOf(); }); @@ -42,4 +57,213 @@ describe("defineFeature", () => { } as const); expectTypeOf(manifest).toMatchTypeOf(); }); + + it("accepts an optional coverage section", () => { + const manifest = defineFeature({ + name: "auth", + requiredCores: [], + useCases: {}, + realtimeChannels: [], + jobs: [], + coverage: { + bands: { + baseline: { statements: 80, branches: 75, functions: 80, lines: 80 }, + entities: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + mutationTargets: ["entities"], + mutationScore: 85, + }, + } as const); + + expectTypeOf(manifest.coverage).toMatchTypeOf< + | undefined + | { + readonly bands: { + readonly baseline: CoverageBand; + readonly entities?: CoverageBand; + readonly "use-cases"?: CoverageBand; + readonly controllers?: CoverageBand; + }; + } + >(); + expect(manifest.coverage?.mutationScore).toBe(85); + }); +}); + +describe("getCoverageBands", () => { + const blankManifest: FeatureManifest = { + name: "test", + requiredCores: [], + useCases: {}, + realtimeChannels: [], + jobs: [], + }; + + it("returns DEFAULT_COVERAGE_BANDS when manifest has no coverage section", () => { + expect(getCoverageBands(blankManifest)).toEqual(DEFAULT_COVERAGE_BANDS); + }); + + it("returns DEFAULT_COVERAGE_BANDS when coverage section is undefined", () => { + expect(getCoverageBands({ ...blankManifest, coverage: undefined })).toEqual( + DEFAULT_COVERAGE_BANDS, + ); + }); + + it("uses declared baseline + fills missing layers from defaults", () => { + const result = getCoverageBands({ + ...blankManifest, + coverage: { + bands: { + baseline: { statements: 90, branches: 85, functions: 90, lines: 90 }, + }, + }, + }); + expect(result.baseline).toEqual({ + statements: 90, + branches: 85, + functions: 90, + lines: 90, + }); + expect(result.entities).toEqual(DEFAULT_COVERAGE_BANDS.entities); + expect(result["use-cases"]).toEqual(DEFAULT_COVERAGE_BANDS["use-cases"]); + expect(result.controllers).toEqual(DEFAULT_COVERAGE_BANDS.controllers); + }); + + it("uses declared layer bands when present", () => { + const customEntities: CoverageBand = { + statements: 95, + branches: 90, + functions: 95, + lines: 95, + }; + const result = getCoverageBands({ + ...blankManifest, + coverage: { + bands: { + baseline: DEFAULT_COVERAGE_BANDS.baseline, + entities: customEntities, + }, + }, + }); + expect(result.entities).toEqual(customEntities); + }); +}); + +describe("getMutationConfig", () => { + const blankManifest: FeatureManifest = { + name: "test", + requiredCores: [], + useCases: {}, + realtimeChannels: [], + jobs: [], + }; + + it("returns defaults when manifest has no coverage section", () => { + const config = getMutationConfig(blankManifest); + expect(config.targets).toEqual(DEFAULT_MUTATION_TARGETS); + expect(config.score).toBe(DEFAULT_MUTATION_SCORE); + }); + + it("honors declared mutationTargets + mutationScore", () => { + const config = getMutationConfig({ + ...blankManifest, + coverage: { + bands: { baseline: DEFAULT_COVERAGE_BANDS.baseline }, + mutationTargets: ["entities"], + mutationScore: 90, + }, + }); + expect(config.targets).toEqual(["entities"]); + expect(config.score).toBe(90); + }); +}); + +describe("DEFAULT_COVERAGE_BANDS", () => { + it("matches the ADR-011 / ADR-020 documented bands", () => { + expect(DEFAULT_COVERAGE_BANDS.baseline).toEqual({ + statements: 80, + branches: 75, + functions: 80, + lines: 80, + }); + expect(DEFAULT_COVERAGE_BANDS.entities).toEqual({ + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }); + expect(DEFAULT_COVERAGE_BANDS["use-cases"]).toEqual({ + statements: 100, + branches: 95, + functions: 100, + lines: 100, + }); + expect(DEFAULT_COVERAGE_BANDS.controllers).toEqual({ + statements: 100, + branches: 95, + functions: 100, + lines: 100, + }); + }); +}); + +describe("vitestThresholdsFromBands", () => { + it("emits baseline at the top level and each declared layer under its glob", () => { + const result = vitestThresholdsFromBands(DEFAULT_COVERAGE_BANDS); + expect(result.statements).toBe(80); + expect(result.branches).toBe(75); + expect(result["src/entities/**"]).toEqual({ + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }); + expect(result["src/application/use-cases/**"]).toEqual({ + statements: 100, + branches: 95, + functions: 100, + lines: 100, + }); + expect(result["src/interface-adapters/controllers/**"]).toEqual({ + statements: 100, + branches: 95, + functions: 100, + lines: 100, + }); + }); + + it("omits layer glob when its band is undefined", () => { + const result = vitestThresholdsFromBands({ + baseline: DEFAULT_COVERAGE_BANDS.baseline, + entities: DEFAULT_COVERAGE_BANDS.entities, + }); + expect(result["src/entities/**"]).toBeDefined(); + expect(result["src/application/use-cases/**"]).toBeUndefined(); + expect(result["src/interface-adapters/controllers/**"]).toBeUndefined(); + }); +}); + +describe("vitestThresholdsFromManifest", () => { + it("uses DEFAULT_COVERAGE_BANDS when the manifest omits coverage", () => { + const manifest: FeatureManifest = { + name: "test", + requiredCores: [], + useCases: {}, + realtimeChannels: [], + jobs: [], + }; + const result = vitestThresholdsFromManifest(manifest); + expect(result.statements).toBe(80); + expect(result["src/entities/**"]).toEqual({ + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }); + }); }); diff --git a/packages/core-shared/src/conformance/define-feature.ts b/packages/core-shared/src/conformance/define-feature.ts index 91ee653..a46703b 100644 --- a/packages/core-shared/src/conformance/define-feature.ts +++ b/packages/core-shared/src/conformance/define-feature.ts @@ -1,3 +1,5 @@ +import type { CoverageManifest } from "./coverage"; + /** * Per-use-case manifest entry. Declares what the use case does at the contract * level: whether it mutates state, what audit events it emits, what cross-feature @@ -14,6 +16,10 @@ export type UseCaseManifest = { /** * The feature-level manifest. One per feature package, conventionally exported * as `Manifest` from `src/feature.manifest.ts`. + * + * `coverage` is optional for backward compatibility — features without a + * declared coverage section fall back to `DEFAULT_COVERAGE_BANDS` via + * `getCoverageBands(manifest)` (see `./coverage.ts`). */ export type FeatureManifest = { readonly name: string; @@ -21,6 +27,7 @@ export type FeatureManifest = { readonly useCases: { readonly [k: string]: UseCaseManifest }; readonly realtimeChannels: readonly string[]; readonly jobs: readonly string[]; + readonly coverage?: CoverageManifest; }; /** diff --git a/packages/core-shared/src/conformance/index.ts b/packages/core-shared/src/conformance/index.ts index 13bcf94..ef3ca9a 100644 --- a/packages/core-shared/src/conformance/index.ts +++ b/packages/core-shared/src/conformance/index.ts @@ -1,6 +1,21 @@ export type { Instrumented, Captured } from "./brands"; export type { FeatureManifest, UseCaseManifest } from "./define-feature"; export { defineFeature } from "./define-feature"; +export type { + CoverageBand, + CoverageBands, + CoverageManifest, + VitestThresholds, +} from "./coverage"; +export { + DEFAULT_COVERAGE_BANDS, + DEFAULT_MUTATION_SCORE, + DEFAULT_MUTATION_TARGETS, + getCoverageBands, + getMutationConfig, + vitestThresholdsFromBands, + vitestThresholdsFromManifest, +} from "./coverage"; export type { ProductionUseCase } from "./production-use-case"; export { attachBrand,