First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# ContractCheck
|
||||
|
||||
Catches interface drift between two systems: a response whose shape changed,
|
||||
a requested field that never came back, and endpoints with no contract check.
|
||||
It is used in Hitt Hosting products.
|
||||
|
||||
## What it does and why
|
||||
|
||||
**A response can be perfectly valid while the field you asked for is
|
||||
missing.** Stripe removed `invoice.payment_intent` in its 2025-03-31 (Basil)
|
||||
API version, and Stripe ignores an unknown `expand` path instead of rejecting
|
||||
it. A stale expand keeps succeeding and returns a well-formed invoice without
|
||||
the field. There is no error and no warning; the client secret is absent and
|
||||
the payment form cannot mount, while typecheck, lint and the test suite all
|
||||
pass. A schema validator cannot catch this, because the invoice is valid. The
|
||||
only way to detect it is to check that what you requested actually came back.
|
||||
|
||||
ContractCheck has three parts.
|
||||
|
||||
- `assertShape()` validates a payload against any schema with a zod-style
|
||||
`safeParse` and throws a `ContractDriftError` naming the context and the
|
||||
offending paths. It fails loudly because the bug it targets is an endpoint
|
||||
dropping a field the UI needs: nothing errors, the page renders, and the
|
||||
number is just wrong. A 500 naming the path beats a plausible screen. The
|
||||
error is a plain `Error`, not tied to a web framework, so you map it to your
|
||||
own transport.
|
||||
- `expectFields()` and `assertFields()` check that each requested dot path is
|
||||
present in the response. An explicit `null` counts as present: `null` means
|
||||
the provider answered "no value", while `undefined` means it never
|
||||
acknowledged the request. A `why` note on each expectation is quoted in the
|
||||
failure, so it says what breaks and not only what is missing.
|
||||
- `coverage()` measures how much of an API surface is contract-checked,
|
||||
against a floor you can raise over time so coverage cannot slip back. It
|
||||
lists the uncovered endpoints, because a bare percentage tells you there is
|
||||
a gap, not where it is.
|
||||
|
||||
The `Parser` type accepts Zod 4 schemas, whose issue paths may include
|
||||
symbols, and symbol path segments are rendered without crashing.
|
||||
|
||||
## Reports
|
||||
|
||||
`fieldReport()` and `formatFields()` make each requested path its own
|
||||
finding, so "the response is a valid object" and "the field you asked for is
|
||||
absent" never collapse into one verdict. If no fields were asserted, the
|
||||
report is not assessed.
|
||||
|
||||
`coverageReport()` and `formatCoverage()` report an empty surface as not
|
||||
assessed, not 0%. "0% covered" for a surface with no endpoints describes a
|
||||
failure that did not happen. Uncovered endpoints are listed one by one as not
|
||||
assessed. Reports use the `@extant2000/evidence-record` format.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { assertShape, expectFields, assertFields, formatFields, coverage, formatCoverage } from '@extant2000/contract-check'
|
||||
import { z } from 'zod' // or any parser with a zod-style safeParse
|
||||
|
||||
// 1. Response shape
|
||||
const payload: unknown = { id: 'm1', name: 'Survey' } // e.g. a parsed response body
|
||||
const Mission = z.object({ id: z.string(), name: z.string() })
|
||||
const mission = assertShape(Mission, payload, 'GET /missions/:id')
|
||||
|
||||
// 2. Requested fields actually returned
|
||||
const subscription = { id: 'sub_1', latest_invoice: { id: 'in_1' } } // response from the provider
|
||||
const expectations = [
|
||||
{ path: 'latest_invoice.payment_intent', why: 'the client secret is needed to mount the payment form' },
|
||||
'id',
|
||||
]
|
||||
const result = expectFields(subscription, expectations)
|
||||
console.log(formatFields(result, { context: 'stripe.subscriptions.create', expectations }))
|
||||
assertFields(subscription, expectations, 'stripe.subscriptions.create') // or throw
|
||||
|
||||
// 3. Coverage against a floor
|
||||
const c = coverage(['GET /a', 'GET /b', 'POST /c', 'GET /d'], ['GET /a', 'GET /b', 'POST /c'], 80)
|
||||
console.log(formatCoverage(c, 80))
|
||||
```
|
||||
|
||||
For a subscription whose invoice lacks `payment_intent`, the field report
|
||||
fails at critical severity on `latest_invoice.payment_intent`, quotes the
|
||||
`why` note, and passes `id`. The coverage example prints:
|
||||
|
||||
```
|
||||
✗ HIGH 3 of 4 endpoints contract-checked (75%) [NOT MET]
|
||||
Below the ratchet floor of 80%.
|
||||
evidence: Contract coverage = 75% (4 samples)
|
||||
· MEDIUM GET /d has no contract check [NOT ASSESSED]
|
||||
```
|
||||
|
||||
ContractCheck does not depend on zod. Any object with a compatible
|
||||
`safeParse` works.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
npm install @extant2000/contract-check
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
Reference in New Issue
Block a user