First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+161
@@ -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'
|
||||
Reference in New Issue
Block a user