Files
contract-check/src/index.ts
T
2026-09-28 14:56:12 -04:00

162 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<T> {
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<PropertyKey>, 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<T>(schema: Parser<T>, 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<unknown>(
(acc, k) => (acc == null || typeof acc !== 'object' ? undefined : (acc as Record<string, unknown>)[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<string | FieldExpectation>,
): 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<string | FieldExpectation>,
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<string>,
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'