First public release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 14:56:12 -04:00
co-authored by Claude Opus 5.5
commit 7646fe162d
14 changed files with 2253 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
/**
* 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'