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'
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
type CapabilityReport,
|
||||
type Finding,
|
||||
type FormatOptions,
|
||||
evidence,
|
||||
formatReport as renderReport,
|
||||
} from '@extant2000/evidence-record'
|
||||
import type { CoverageReport, FieldCheckResult, FieldExpectation } from './index.js'
|
||||
|
||||
export interface FieldReportOptions {
|
||||
/** The call being checked, e.g. "stripe.subscriptions.create". */
|
||||
context?: string
|
||||
/** The expectations, so a finding can quote why a field was requested. */
|
||||
expectations?: Array<string | FieldExpectation>
|
||||
}
|
||||
|
||||
const why = (path: string, exps: Array<string | FieldExpectation> = []): string | undefined => {
|
||||
const e = exps.find(x => typeof x !== 'string' && x.path === path)
|
||||
return typeof e === 'object' ? e.why : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a field-presence check.
|
||||
*
|
||||
* The distinction this library exists for survives into the vocabulary: a
|
||||
* response can be a perfectly valid object (schema PASSES) while the field
|
||||
* you asked for is absent (assertion FAILS), because the provider silently
|
||||
* ignored part of the request. Each requested path is its own finding so the
|
||||
* two cannot be collapsed into one verdict.
|
||||
*/
|
||||
export function fieldReport(r: FieldCheckResult, opts: FieldReportOptions = {}): CapabilityReport {
|
||||
const context = opts.context ?? 'response'
|
||||
const findings: Finding[] = [
|
||||
...r.missing.map((p): Finding => ({
|
||||
id: `missing:${p}`,
|
||||
summary: `${p} was requested but is absent from the response`,
|
||||
determination: 'fail',
|
||||
severity: 'critical',
|
||||
detail: why(p, opts.expectations)
|
||||
?? 'The provider may be silently ignoring this request parameter. A schema check cannot catch this: the response is a valid object.',
|
||||
evidence: [evidence.document(context, p, undefined, 'requested, not returned')],
|
||||
})),
|
||||
...r.present.map((p): Finding => ({
|
||||
id: `present:${p}`,
|
||||
summary: `${p} present`,
|
||||
determination: 'pass',
|
||||
severity: 'info',
|
||||
evidence: [evidence.document(context, p, undefined, 'requested and returned')],
|
||||
})),
|
||||
]
|
||||
|
||||
const total = r.missing.length + r.present.length
|
||||
return {
|
||||
capability: 'ContractCheck',
|
||||
scope: `${total} requested field(s) on ${context}`,
|
||||
examined: total,
|
||||
findings,
|
||||
notes: total === 0
|
||||
? ['No fields were asserted, so nothing was checked. A response that is never inspected cannot be said to conform.']
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report contract coverage against a ratchet floor.
|
||||
*
|
||||
* An empty surface is `not-assessed`, not 0%. `coverage([], …)` correctly
|
||||
* returns zero, but rendering "0% covered" for a surface with no endpoints
|
||||
* describes a failure that did not happen — the same error as reporting 100%
|
||||
* uptime from zero samples, inverted.
|
||||
*/
|
||||
export function coverageReport(c: CoverageReport, floorPct = 0): CapabilityReport {
|
||||
if (c.total === 0) {
|
||||
return {
|
||||
capability: 'ContractCheck',
|
||||
scope: 'no surface supplied',
|
||||
examined: 0,
|
||||
findings: [],
|
||||
notes: ['No endpoints were supplied, so coverage could not be computed. This is not 0% coverage — nothing was measured.'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
capability: 'ContractCheck',
|
||||
scope: `${c.total} endpoint(s)`,
|
||||
examined: c.total,
|
||||
findings: [
|
||||
{
|
||||
id: 'coverage',
|
||||
summary: `${c.covered} of ${c.total} endpoints contract-checked (${c.pct}%)`,
|
||||
determination: c.belowFloor ? 'fail' : 'pass',
|
||||
severity: c.belowFloor ? 'high' : 'info',
|
||||
detail: c.belowFloor ? `Below the ratchet floor of ${floorPct}%.` : undefined,
|
||||
evidence: [evidence.measurement('Contract coverage', c.pct, c.total, { unit: '%' })],
|
||||
},
|
||||
// Named individually rather than left as a percentage: a bare number
|
||||
// tells you there is a gap, not where it is.
|
||||
...c.uncovered.map((path): Finding => ({
|
||||
id: `uncovered:${path}`,
|
||||
summary: `${path} has no contract check`,
|
||||
determination: 'not-assessed',
|
||||
severity: 'medium',
|
||||
evidence: [],
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFields(r: FieldCheckResult, opts: FieldReportOptions & FormatOptions = {}): string {
|
||||
return renderReport(fieldReport(r, opts), opts)
|
||||
}
|
||||
|
||||
export function formatCoverage(c: CoverageReport, floorPct = 0, opts: FormatOptions = {}): string {
|
||||
return renderReport(coverageReport(c, floorPct), opts)
|
||||
}
|
||||
Reference in New Issue
Block a user