/** * Detect when two subsystems' interface assumptions drift. * * Two design points: * * 1. **Framework-agnostic.** A helper that throws a framework-specific HTTP * error ties itself to that framework. This throws a plain * `ContractDriftError` the caller maps to its own transport. * 2. **Request contracts, not just response shapes.** A shape validator * checks what came back. It cannot catch a provider that SILENTLY IGNORES * part of the request — see `expectFields` below. */ /** Anything with a zod-style `safeParse`. Avoids depending on zod itself. */ export interface Parser { safeParse: (data: unknown) => { success: true, data: T } | { success: false error: { /** * Zod 4 widened issue paths to `PropertyKey[]`, which includes symbol. * Narrowing to string|number here made a Zod 4 schema unassignable to * this interface — and where typecheck gates a pipeline, that failure * can block a deploy without anyone reading the log. */ issues: Array<{ path: ReadonlyArray, message: string }> } } } export class ContractDriftError extends Error { readonly context: string readonly issues: string[] constructor(context: string, issues: string[]) { super(`Response shape drift in ${context} — ${issues.slice(0, 3).join('; ')}`) this.name = 'ContractDriftError' this.context = context this.issues = issues } } /** * Validate a payload against a schema, throwing on drift. * * Fails LOUDLY on purpose. The bug class this exists for is an endpoint * silently dropping a column the UI needs: nothing errors, the page renders, * and the number is just wrong. A 500 naming the offending path is strictly * better than a plausible screen. */ export function assertShape(schema: Parser, data: unknown, context: string): T { const result = schema.safeParse(data) if (!result.success) { // `.join()` throws on a symbol element, so map through String first — // a symbol path renders as "Symbol(x)" rather than crashing the handler // that was trying to report a validation error. const issues = result.error.issues.map(i => `${i.path.map(String).join('.')}: ${i.message}`) throw new ContractDriftError(context, issues) } return result.data } export interface FieldExpectation { /** Dot path, e.g. `latest_invoice.confirmation_secret`. */ path: string /** Human note on why this field was requested. */ why?: string } export interface FieldCheckResult { ok: boolean missing: string[] present: string[] } function readPath(obj: unknown, path: string): unknown { return path.split('.').reduce( (acc, k) => (acc == null || typeof acc !== 'object' ? undefined : (acc as Record)[k]), obj, ) } /** * Assert a response actually contains the fields the request asked for. * * THE FAILURE THIS EXISTS FOR: Stripe removed `invoice.payment_intent` in the * 2025-03-31 (Basil) API version, and Stripe **silently ignores an unknown * `expand` path** rather than rejecting it. So a stale expand keeps succeeding * and simply returns an invoice without the field. No error, no warning — the * client secret is absent and the payment form cannot mount, while typecheck, * lint and a full test suite all pass. * * A response-shape validator cannot catch this: the response is a perfectly * valid invoice. The only way to detect it is to assert that what you ASKED FOR * actually came back. */ export function expectFields( data: unknown, expectations: Array, ): FieldCheckResult { const missing: string[] = [] const present: string[] = [] for (const e of expectations) { const path = typeof e === 'string' ? e : e.path if (readPath(data, path) === undefined) missing.push(path) else present.push(path) } return { ok: missing.length === 0, missing, present } } /** * Throwing form of `expectFields`. * * Use at an integration boundary where a silently-ignored request parameter * would otherwise surface as a confusing downstream failure far from its cause. */ export function assertFields( data: unknown, expectations: Array, context: string, ): void { const r = expectFields(data, expectations) if (r.ok) return const notes = expectations .filter((e): e is FieldExpectation => typeof e !== 'string' && !!e.why) .filter(e => r.missing.includes(e.path)) .map(e => `${e.path} (${e.why})`) throw new ContractDriftError( context, notes.length ? notes : r.missing.map(m => `${m}: requested but absent from the response`), ) } export interface CoverageReport { total: number covered: number /** 0–100, one decimal. */ pct: number uncovered: string[] /** True when below the floor — the caller decides whether that fails a build. */ belowFloor: boolean } /** * Measure how much of a surface is contract-checked, against a ratchet floor. * * Raise the floor over time (a ratchet) so coverage cannot silently regress. Reporting `uncovered` rather than just a percentage is deliberate: a * bare number tells you there is a gap, not where it is. */ export function coverage( all: string[], checked: Iterable, floorPct = 0, ): CoverageReport { const set = new Set(checked) const uncovered = all.filter(x => !set.has(x)) const covered = all.length - uncovered.length const pct = all.length === 0 ? 0 : Math.round((covered / all.length) * 1000) / 10 return { total: all.length, covered, pct, uncovered, belowFloor: pct < floorPct } } export { fieldReport, coverageReport, formatFields, formatCoverage } from './report.js' export type { FieldReportOptions } from './report.js'