test(contract): validate six unvalidated response schemas, bundle openapi.yaml - #657
Conversation
…ns/{id}
getPackageRevision built its RevisionWithTrees result as
`{ ...mapSummary(...), specs }`, spreading mapSummary's full
RevisionSummary shape (specCount included) and appending specs.
TypeScript's excess-property check never fires on a spread's inferred
return type, so every real GET /revisions/{id} response silently carried
an undocumented specCount field. Invisible until issue #649 fixed the
self-referential-schema stack overflow that had excluded this operation
from response-schema validation entirely — the new exact-match check
caught it immediately.
Extracts the shared mapRevisionCore() so each caller builds the specific
shape its return type promises (RevisionSummary keeps specCount via
mapSummary(); RevisionWithTrees no longer carries it) instead of a
superset of it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
loadSpec() used $RefParser.dereference(), which resolves every $ref into
its literal target. For a self-referential component (SpecNode:
children: SpecNode[]) that builds a real circular JS object, and ajv's
compile-time schema traversal has no cycle guard — RangeError: maximum
call stack size exceeded. Six operations whose success response embeds
SpecNode or SpecTree were excluded from response-schema validation
entirely as a result (GET /specs/{id}, POST .../paragraphs, PATCH
.../paragraphs/{nodeId}, .../removal, .../reject, GET /revisions/{id}).
Switches to $RefParser.bundle(), which preserves $ref pointers instead of
inlining them (openapi.yaml has zero external-file refs, so bundle's
output is structurally identical to the existing un-dereferenced
loadRawSpec()). ajv then resolves $ref lazily at validate time instead of
the walker eagerly materializing a circular object.
This changes what every consumer of loadSpec()'s OpenApiDoc sees: $refs
that used to be fully inlined objects are now literal `{ $ref }` pointers
(response-level refs to components/responses/*, parameter refs to
components/parameters/*, request-body refs to components/schemas/*).
`resolveIfRef()` (schema-refs.ts) resolves one level of local $ref for
any caller that needs the actual shape rather than just a validator;
operationParamKeys()'s request-body/parameter reading now goes through
it, or its INV-4 vacuity guard would false-positive on `post
/packages/{id}/revisions` and any $ref-bodied write op.
Design decisions (no ADR per sprint policy — recorded here and at the
qualifyRef/markObject $ref-branch call sites):
- getValidator() (assertResponse / INV-5 conformance path) registers the
whole bundled doc once under one ajv $id and rewrites each response
schema's $ref strings to point into it (qualifyRefs in schema-refs.ts).
Never inlines a $ref target — that would reproduce the exact circular
shape bundling exists to avoid.
- assertResponseExact() (INV-6 exact-key-match) cannot reuse that
approach: marking a component once, standalone, with
unevaluatedProperties:false is wrong whenever the SAME component is
referenced from both an in-place applicator (allOf/oneOf/anyOf branch)
and a child position (properties/items) somewhere in openapi.yaml —
confirmed for 12 real components including SuccessResponse and
ErrorResponse. Marking SuccessResponse standalone made it reject the
sibling `data` branch's own keys: a false rejection of a fully
documented payload, not just a missed detection. Fixed by registering
TWO component mirrors (CHILD_MIRROR_ID, IN_PLACE_MIRROR_ID) built
eagerly for every component name, with nested $refs qualified by the
LOCAL walking context they're found in (unevaluated-properties.ts's
markObject, via the new optional RefQualifyOptions on
markUnevaluatedPropertiesFalse). No dependency worklist needed — ajv
resolves $ref lazily by id, so a mirror entry never needs its own
dependencies pre-built.
- OpenApiDocSchema was silently stripping `components` via Zod's default
object behavior — harmless under dereference (nothing read it), but
load-bearing now: ajv needs doc.components.schemas present in the
registered document for any #/components/schemas/X pointer to resolve.
Widened to retain components.{schemas,responses,parameters}.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g switch path-id-status-openapi.test.ts and revision-parent-openapi.test.ts read raw response/request schema shapes out of loadSpec()'s OpenApiDoc directly (400/404/500 responses $ref'd to components/responses/*, a RevisionWithTrees/RevisionSummary/array-items data schema $ref'd to components/schemas/*). Under the new bundle-based loadSpec() (#649) those stay literal `{ $ref }` pointers instead of dereference's fully-inlined objects, so both files now resolve one level via resolveIfRef() before narrowing into their local Zod shapes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mas (#649) Moves GET /specs/{id}, POST /specs/{id}/paragraphs, PATCH /specs/{id}/paragraphs/{nodeId} (and its /removal and /reject siblings), and GET /revisions/{id} from RESPONSE_ALLOWLIST to RESPONSE_COVERED now that loadSpec()'s bundle switch lets ajv compile their self-referential SpecNode/SpecTree schemas. Drives each op with a real request against the existing checkpoint fixture and a new package/revision fixture, asserts schema conformance (assertResponse) and exact-key-match (assertResponseExact) against the real response, and splices an undocumented key onto each real payload — at the top level and nested inside the recursive SpecNode/SpecTree structure — to prove INV-6 actually rejects it for all six ops, not just the previously-compilable ones. Adds unit-level regression coverage in validate-response.test.ts: - a hand-built `items` case in the applicator-coverage table (previously absent — the mutation-verify bar had nothing to go red for the CHILD keyword that recurses into SpecNode's own `children`, confirmed by temporarily removing it from CHILD_SINGLES and watching the new case fail; restored afterward) - a context-crossing case pinning that a $ref inside `items` nested in an allOf branch is qualified with CHILD context, never the branch's own IN_PLACE context (found during implementation: a real-openapi.yaml- driven version of this mutation did NOT reliably fail, because an unqualified/mis-qualified ref can accidentally self-resolve against the wrong mirror document by URI-shape coincidence — this hand-built, spec-independent case doesn't have that coincidence to hide behind) - getValidator compiling and validating a 200-deep synthetic self-referential SpecNode payload with zero RangeError - assertResponseExact accepting a clean SuccessResponse-enveloped SpecNode payload (the false-rejection pitfall the dual-mirror design exists to avoid) and rejecting a key nested inside SpecNode.children Also corrects a stale comment framing the "reached via two different composition contexts" tests as a dereference-object-identity artifact — that mechanism (SeenContexts) is orthogonal to #649 and still pins a genuinely different, ref-parser-independent case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two invariants from the bundle-switch design weren't directly pinned yet: - successJsonOps() fed every 2xx response through OperationObject, which parses `$ref` responses into a content-less shape before the loop that checks for `application/json` ever sees them. No operation in today's openapi.yaml documents a 2xx response as a bare `$ref` (only 4xx/5xx do), but bundling leaves that door open — such an operation would silently drop out of successJsonOps' output and pass the "response-covered or allowlisted" sweep vacuously (never appearing in either RESPONSE_COVERED or the failing "uncovered" list). Fixed by resolving one level of `$ref` via resolveIfRef before the has2xxJson check, using a new raw-responses schema so the `$ref` key survives long enough to resolve. Pinned against a synthetic doc, matching this file's existing vacuity-guard pattern. - assertResponseExact's "rejects an undocumented key at any depth" claim was only integration-tested at the top level for four of the six #649 operations (POST .../paragraphs, PATCH .../nodeId, .../removal, .../reject) — only the two SpecTree-wrapping ops (GET /specs/{id}, GET /revisions/{id}) had a nested-depth splice. Extends each of the four direct-SpecNode-response ops with a splice nested one level down inside `data.children`, driven through the same real HTTP round trip as their existing top-level splice assertions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cceptance (#649) Closes two acceptance-criteria gaps left after the bundle switch: (1) the six-op it.each only proved each response schema COMPILES via getValidator, never that assertResponseExact actually accepts a real, fully-documented payload end-to-end for all six; (2) the LOCAL-walking-context invariant (a $ref's mirror qualification is decided per-occurrence, never by the component's identity) had only a hand-built regression, no real-spec-driven one. Adds a six-op assertResponseExact acceptance sweep (folding the prior single-op SuccessResponse+SpecNode accept test into it), and a dedicated describe block driving DELETE /specs/{id}/lock — a real op where SuccessResponse is referenced bare (CHILD context) instead of via its usual allOf branch (IN_PLACE context) — proving the same component is qualified correctly in both real contexts, not just the hand-built ones. Mutation-verified: inverting buildMirrorQualifyRef's inPlace ternary turned all 9 new tests red (plus the pre-existing /health accept test); reverting restored all 70/70 green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eSchema
resolveResponseSchema parsed a response entry straight into ResponseObject,
which has no `$ref` field — a `$ref`-pointer 2xx response (e.g.
`{ $ref: '#/components/responses/Ok' }`) silently lost its pointer and read
as `{ content: undefined }`, falling into the "documented non-JSON" no-op
branch instead of resolving to its real schema. That reopened the same
vacuous-gate class #649 fixed for successJsonOps, one call site over:
assertResponse/assertResponseExact would both silently no-op instead of
validating the response body at all.
Fixed by parsing through RawOperationObject and resolving the specific
status's response via resolveIfRef before narrowing into ResponseObject,
mirroring the pattern successJsonOps already uses. Pinned with a synthetic-doc
regression test (no 2xx response in today's openapi.yaml is $ref'd this way,
only 4xx/5xx are).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…path (#649) markUnevaluatedPropertiesFalse's existing no-mutation test only exercised the default (no-options) call shape. Production never calls it that way: assertResponseExact and registerComponentMirrors always pass { inPlace, qualifyRef: buildMirrorQualifyRef() } against doc-owned component schema objects shared for the lifetime of the cached loadSpec() document, so a regression that mutated the original only along that branch would go undetected and corrupt shared schema state process-wide. Adds a mutation-verified regression test exercising the exact qualifyRef/ inPlace shape production uses (confirmed red against an injected mutation bug — the default-path test stayed green under the same regression). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughChangesRecursive contract validation
Sequence Diagram(s)sequenceDiagram
participant API as API operation
participant Contract as Contract validator
participant OpenAPI as Bundled OpenAPI document
participant AJV
API->>Contract: submit response
Contract->>OpenAPI: resolve response and schema references
Contract->>AJV: compile recursive schema
AJV-->>Contract: validate exact response
Contract-->>API: accept or reject response
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
…roperties) The #649 mutation-verify sweep over the walker's applicator lists found exactly one surviving mutant: deleting 'unevaluatedProperties' from CHILD_SINGLES left the entire suite green, so that traversal edge was unpinned and could have been dropped without any gate going red. Every other keyword in CHILD_SINGLES/CHILD_MAPS/IN_PLACE_MAPS already had a case in the applicator-coverage table (items 2 red, contains/additionalProperties/ unevaluatedItems/dependentSchemas/patternProperties 1 red each). This adds the object-side twin of the existing unevaluatedItems case. The edge is worth pinning for a second reason beyond list completeness: shouldMark deliberately refuses to mark a node that DECLARES unevaluatedProperties (an openness decision openapi.yaml already made). That refusal is correct for the declaring node but must not stop the walker descending into the schema-valued subschema, which sits at a CHILD instance location and closes normally. Red/green verified: passes on current code (74/74); with the keyword removed from CHILD_SINGLES this exact case is the single failure (1 failed | 73 passed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
This was written agentically; verify its assertions and edit accordingly: Adversarial cross-review — Codex
|
|
@coderabbitai full review |
✅ Action performedFull review finished. |
There was a problem hiding this comment.
🧹 Nitpick comments (5)
src/db/queries/revisions.ts (1)
178-190: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider declaring the shared core as its own interface.
Omit<RevisionSummary, 'specCount'>expresses the shared shape indirectly. It is correct today, becauseRevisionSummaryminusspecCountequalsRevisionWithTreesminusspecs. That equality is not stated anywhere, so a future field added to only one of the two interfaces makes the return type misleading before it makes it a compile error.An explicit
RevisionCoreinterface that bothRevisionSummaryandRevisionWithTreesextend would make the relationship declarative and keepmapRevisionCore's return type stable.♻️ Proposed shape
+export interface RevisionCore { + readonly revisionId: string; + readonly packageId: string; + readonly label: string; + readonly displayName: string; + readonly type: string; + readonly date: string; + readonly sortOrder: number; + readonly number: string | null; + readonly attributes: RevisionAttributes; + readonly issuedAt: string; + readonly parentRevisionId: string | null; + readonly baseRevisionId: string | null; +} + -export interface RevisionSummary { - readonly revisionId: string; - // ... 12 shared fields ... +export interface RevisionSummary extends RevisionCore { readonly specCount: number; } -export interface RevisionWithTrees { - readonly revisionId: string; - // ... 12 shared fields ... +export interface RevisionWithTrees extends RevisionCore { readonly specs: readonly RevisionSpecEntry[]; }Then
mapRevisionCorereturnsRevisionCore.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/db/queries/revisions.ts` around lines 178 - 190, Declare a shared RevisionCore interface for the fields common to RevisionSummary and RevisionWithTrees, and update mapRevisionCore to return RevisionCore instead of Omit<RevisionSummary, 'specCount'>. Adjust RevisionSummary and RevisionWithTrees to extend RevisionCore so the relationship is explicit and the shared-core shape stays stable if either type changes.src/api/contract.integration.test.ts (1)
846-861: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMove the package creation and the
project_specsINSERT inside thetryblock.Both setup statements run before
try. If theproject_specsINSERT at Lines 857-860 fails, thefinallyblock never runs, so the createddesign_packagesrow survives the test. That orphan row can then make the outerafterAllcleanup fail with aproject_specsRESTRICT violation, which masks the original failure.Enter the
tryimmediately afterbaseUrlis known, and keep the existingfinallycleanup. The cleanup already tolerates missing rows, because both statements are unconditionalDELETEs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/api/contract.integration.test.ts` around lines 846 - 861, Move the package creation in the contract integration test into the existing try/finally flow so both the POST to /projects/${projectId}/packages and the project_specs INSERT execute after the try starts, using the test block around packageId and setPackageSpecs setup as the anchor. Keep the current finally cleanup unchanged so any failure during pkg creation or the INSERT still triggers the DELETE cleanup and prevents orphaned design_packages rows from leaking into afterAll.src/test-utils/contract/schema-refs.ts (1)
54-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the
as AnySchemaObjectassertion at this boundary.
qualifyRefValuereturnsunknown, so line 55 asserts overunknownat a public export. The siblingmarkUnevaluatedPropertiesFalseinunevaluated-properties.ts(line 238) documents the opposite approach: it types the inner helper so the return type is proven, not asserted. Add a small object-only helper and let the type follow.♻️ Proposed refactor
export function qualifyRefs(schema: AnySchemaObject, toId: string): AnySchemaObject { - return qualifyRefValue(schema, toId) as AnySchemaObject; + return qualifyRefObject(schema, toId); } +function qualifyRefObject(value: object, toId: string): Record<string, unknown> { + const out: Record<string, unknown> = {}; + for (const [key, val] of Object.entries(value)) { + if (key === '$ref' && typeof val === 'string') { + if (!val.startsWith('`#/`')) { + throw new Error(`qualifyRefs: unsupported non-local $ref "${val}"`); + } + out[key] = `${toId}${val}`; + } else { + out[key] = qualifyRefValue(val, toId); + } + } + return out; +} + function qualifyRefValue(value: unknown, toId: string): unknown { if (Array.isArray(value)) return value.map((item) => qualifyRefValue(item, toId)); if (typeof value !== 'object' || value === null) return value; - const out: Record<string, unknown> = {}; - for (const [key, val] of Object.entries(value)) { - if (key === '$ref' && typeof val === 'string') { - if (!val.startsWith('`#/`')) { - throw new Error(`qualifyRefs: unsupported non-local $ref "${val}"`); - } - out[key] = `${toId}${val}`; - } else { - out[key] = qualifyRefValue(val, toId); - } - } - return out; + return qualifyRefObject(value, toId); }As per coding guidelines: "Do not use
any,as unknown as, cross-boundary type assertions, or non-null assertions (!) outside tests."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test-utils/contract/schema-refs.ts` around lines 54 - 56, Update qualifyRefs and its qualifyRefValue helper to eliminate the public-boundary as AnySchemaObject assertion. Add a small object-only helper, following markUnevaluatedPropertiesFalse, that proves and returns the AnySchemaObject type before qualifyRefs returns it; do not replace the assertion with any, as unknown as, or a non-null assertion.Source: Coding guidelines
src/test-utils/contract/validate-response.ts (1)
343-352: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low valueConsider a per-branch guard so a partially-derivable
oneOfcannot weaken INV-4 silently.The fail-loud guard at lines 392-398 fires only when the derived key set is completely empty. A
oneOfbranch that resolves to a component composed withallOf(or a nestedoneOf) parses cleanly intoRequestBodyBranchObject, yieldsproperties: undefined, and contributes zero keys. If the other branches still contribute keys,body.sizestays non-zero and the guard never fires. INV-4 then checks a partial key set with no signal.No current operation reaches this state: the request bodies named in the comment above are all plain
properties-bearing objects. The gap is forward-looking, and it is the same vacuity class this reader already guards against one level up.🛡️ Proposed per-branch guard
const keys = new Set(Object.keys(schema.properties ?? {})); for (const rawBranch of schema.oneOf ?? []) { const branch = RequestBodyBranchObject.parse(resolveIfRef(doc, rawBranch)); + if (branch.properties === undefined) { + throw new Error( + 'a requestBody `oneOf` branch declares no top-level `properties` — an unsupported ' + + 'composition (allOf, nested oneOf, non-object branch) would contribute zero keys and ' + + 'weaken INV-4 without emptying the key set. Extend bodyPropertyKeys() to handle it.' + ); + } for (const key of Object.keys(branch.properties ?? {})) keys.add(key); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test-utils/contract/validate-response.ts` around lines 343 - 352, Update bodyPropertyKeys to detect any oneOf branch that cannot yield properties, including branches resolved through allOf or nested oneOf, and fail loudly instead of silently adding only keys from derivable branches. Preserve the existing top-level empty-key guard while ensuring partially derivable oneOf schemas cannot produce a partial key set.src/test-utils/contract/unevaluated-properties.ts (1)
207-211: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAlign the comment with the code:
$refnodes are not skipped.The comment says the walker rewrites the pointer "and stop". The code does not return early. It falls through to
walkSubschemasandshouldMark. For a pointer-only node both are effectively no-ops, so the behavior matches the intent. For a$refnode that carries sibling applicators — legal and evaluated in JSON Schema 2020-12 / OpenAPI 3.1, for example{ $ref: '…/Foo', properties: { extra: … } }— the walker correctly descends into the siblings and can mark the node. The comment reads as if an earlyreturnwere the contract, which could invite a future change that silently drops those sibling applicators.📝 Proposed comment change
- // A bundled `$ref` pointer: rewrite it to the mirror-qualified id for THIS local context and stop - // — it has no other subschema-bearing keywords worth walking (siblings like `description` are - // plain data), and `shouldMark` below naturally leaves it unmarked since it evaluates no - // properties of its own (see the `$ref` note in the applicator-classification comment). + // A bundled `$ref` pointer: rewrite it to the mirror-qualified id for THIS local context. The + // walk below deliberately CONTINUES rather than returning early — `$ref` siblings are legal and + // evaluated in 2020-12, so a `{ $ref, properties }` node's own applicators must still be walked + // and marked. For a pointer-only node both `walkSubschemas` and `shouldMark` are no-ops, since it + // evaluates no properties of its own (see the `$ref` note in the applicator-classification + // comment). Do not add an early `return` here: it would silently drop sibling applicators.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/test-utils/contract/unevaluated-properties.ts` around lines 207 - 211, Update the comment above the `$ref` handling in the schema walker to accurately state that the pointer is rewritten before normal traversal continues, rather than claiming the walker stops. Preserve traversal through sibling applicators and the existing `walkSubschemas` and `shouldMark` behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/api/contract.integration.test.ts`:
- Around line 846-861: Move the package creation in the contract integration
test into the existing try/finally flow so both the POST to
/projects/${projectId}/packages and the project_specs INSERT execute after the
try starts, using the test block around packageId and setPackageSpecs setup as
the anchor. Keep the current finally cleanup unchanged so any failure during pkg
creation or the INSERT still triggers the DELETE cleanup and prevents orphaned
design_packages rows from leaking into afterAll.
In `@src/db/queries/revisions.ts`:
- Around line 178-190: Declare a shared RevisionCore interface for the fields
common to RevisionSummary and RevisionWithTrees, and update mapRevisionCore to
return RevisionCore instead of Omit<RevisionSummary, 'specCount'>. Adjust
RevisionSummary and RevisionWithTrees to extend RevisionCore so the relationship
is explicit and the shared-core shape stays stable if either type changes.
In `@src/test-utils/contract/schema-refs.ts`:
- Around line 54-56: Update qualifyRefs and its qualifyRefValue helper to
eliminate the public-boundary as AnySchemaObject assertion. Add a small
object-only helper, following markUnevaluatedPropertiesFalse, that proves and
returns the AnySchemaObject type before qualifyRefs returns it; do not replace
the assertion with any, as unknown as, or a non-null assertion.
In `@src/test-utils/contract/unevaluated-properties.ts`:
- Around line 207-211: Update the comment above the `$ref` handling in the
schema walker to accurately state that the pointer is rewritten before normal
traversal continues, rather than claiming the walker stops. Preserve traversal
through sibling applicators and the existing `walkSubschemas` and `shouldMark`
behavior.
In `@src/test-utils/contract/validate-response.ts`:
- Around line 343-352: Update bodyPropertyKeys to detect any oneOf branch that
cannot yield properties, including branches resolved through allOf or nested
oneOf, and fail loudly instead of silently adding only keys from derivable
branches. Preserve the existing top-level empty-key guard while ensuring
partially derivable oneOf schemas cannot produce a partial key set.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5fa5211d-b22d-4f05-b74b-8e02a0036ee8
📒 Files selected for processing (8)
src/api/contract.integration.test.tssrc/api/path-id-status-openapi.test.tssrc/api/revision-parent-openapi.test.tssrc/db/queries/revisions.tssrc/test-utils/contract/schema-refs.tssrc/test-utils/contract/unevaluated-properties.tssrc/test-utils/contract/validate-response.test.tssrc/test-utils/contract/validate-response.ts
…guard (#649) 1. validate-response.ts — bodyPropertyKeys could hand INV-4 a PARTIAL key set with no signal. The whole-body fail-loud guard in operationParamKeys fires only when the derived set is completely empty, so a `oneOf` whose other branches still contribute keys masks an underivable branch entirely. RequestBodyBranchObject is a plain z.object (not .strict()), so an allOf/nested-oneOf branch parses cleanly and yields `properties: undefined` rather than throwing — confirmed reachable, not theoretical. Added a per-branch guard, keyed on "could not derive" (properties undefined), NOT on "derived nothing": an explicit `properties: {}` is legal and must still pass. Both directions pinned, and the new guard mutation-verified. 2. contract.integration.test.ts — the package POST and the project_specs INSERT ran BEFORE the try. The package is created first, so an INSERT failure (a re-run where the row already exists) skipped the finally and orphaned the design_packages row — a leak that compounds every run. Both now sit inside the try, with the finally guarding on packageId. 3. unevaluated-properties.ts — the comment claimed the `$ref` branch rewrites "and stop"; the code has no early return and walks on. Harmless (a $ref node carries no other subschema-bearing keywords) but the comment described control flow that does not exist. 4. schema-refs.ts — replaced the `as AnySchemaObject` cast with a real runtime narrowing check, per the repo's no-cross-boundary-assertions rule. DECLINED: extracting a RevisionCore interface. Its stated risk — that divergence would be "misleading before it makes it a compile error" — does not hold: a new RevisionSummary field lands in Omit<> and mapRevisionCore must return it, while a new RevisionWithTrees field breaks the {...core, specs} construction. Either direction is an immediate compile error, and the relationship is already spelled out in the comment above. pnpm lint clean, 3659/3659 unit, contract gate 22/22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All five nitpicks handled in 1.
|
…wlisting Resolves the RESPONSE_ALLOWLIST conflict with #657 (issue #649). Taking either side wholesale is wrong: #658's side silently reverts #657's entire deliverable and CI stays green anyway, because an allowlisted op is simply not validated; #657's side alone leaves this branch's two new ops uncovered. The two ops were allowlisted here for the SpecNode-cycle reason #657 has now removed, so rather than carry the exemption forward with a corrected comment, they move to RESPONSE_COVERED and gain real `assertResponse` + `assertResponseExact` calls beside their behavioural assertions in readiness-clearance.integration.test.ts. Each carries the INV-6 undocumented-key probe, so the new coverage cannot pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Why
The OpenAPI contract test suite (
src/api/contract.integration.test.ts) had six response schemas that were never actually validated againstassertResponseExact— a sweep bug meant they silently passed regardless of shape drift. Issue #649 asked us to close that gap and prove the fix with tests that fail red on the pre-fix code.What
$RefParser.dereferenceto$RefParser.bundle, and updatesresolveResponseSchemain test-utils to resolve local$refpointers explicitly, since bundling keeps$refs instead of inlining them.specCountfield from theGET /revisions/{id}response documentation (cross-fix surfaced while auditing schema exactness).$refcontext resolution across six ops, and a no-mutation invariant for thequalifyRef/in-place$ref-qualification path.Design decisions
$RefParser.bundle()preserves$refpointers (resolving only across files, not inline), so the contract test now walks the same schema shape the OpenAPI document itself describes, rather than a fully-inlined copy that can drift from what client tooling actually sees.resolveResponseSchemawas extended to resolve local (#/...) pointers explicitly against the bundled document.componentssection as live, referenced structure. We had to make sure the sweep-vacuity check still walks all reachable schemas via$ref, not just top-level paths, or the six ops would have gone right back to being invisible to the checker under the new loading strategy.qualifyRef/in-place path, we found that a naive before/after deep-equal check on the return value could pass even if the input object were mutated in place, because the function also returns the same reference — a coincidental green. The regression test instead snapshots the input independently before the call and diffs it against the post-call input, not the return value, so it can't pass by that coincidence.Verification transcript (mutation-verified — #649's mandated bar)
The risk this PR had to disprove: bundling leaves
$refpointers in place, andunevaluated-properties.tspreviously assumed they were already inlined. If the walker hadsilently stopped traversing at a
$ref, INV-6's exact-key-match gate would have degraded to ano-op for exactly the six operations this issue exists to close — and the suite would still be
green. Every claim below was produced by running the mutation, not by inspection.
1. Walker applicator lists — remove a keyword, confirm a case goes red
Each keyword deleted independently from a pristine copy, then
vitest run --project unit src/test-utils/contract/validate-response.test.ts:CHILD_SINGLES−itemsCHILD_SINGLES−containsCHILD_SINGLES−additionalPropertiesCHILD_SINGLES−unevaluatedPropertiesCHILD_SINGLES−unevaluatedItemsIN_PLACE_MAPS−dependentSchemasCHILD_MAPS−patternPropertiesThe sweep found one real gap:
unevaluatedPropertieswas unpinned — that traversal edge couldhave been deleted with nothing going red. Fixed in
08bb5454, which adds the object-side twin ofthe existing
unevaluatedItemscase. Re-verified red/green: 74/74 passed on current code, andwith the keyword removed that exact case is the single failure (1 failed | 73 passed).
2. The
$refwalk is load-bearing, not a no-opBundled
$refs are handled by rewriting the pointer to one of two pre-marked component mirrorsrather than by recursing (which is what avoids materializing SpecNode's circular object). To prove
those mirrors actually carry the marking,
registerComponentMirrorswas mutated to register thecomponents unmarked:
Those two blocks hold all six operations' exact-match assertions. The gate goes red when the walk
goes blind — it is not passing vacuously.
3. The adapted
$ref-resolving tests are not vacuousresolveIfRefmutated to an identity function (never resolves a pointer):28 failed | 2 passed (30) across
path-id-status-openapi.test.tsandrevision-parent-openapi.test.ts(baseline: 30/30 passed).4. All six operations genuinely validate — nothing exempted
Each of the six carries, against a real driven response body:
assertResponse+assertResponseExact+ a top-level spliced undocumented key + a key spliced one level down insidedata.children(therogueChildhelper), both asserted.rejects.toThrow(/does not document/).The nested splice is what proves the recursive mirror walk reaches past
datarather than stoppingthere.
Bucket accounting confirms burn-down rather than exemption:
RESPONSE_COVERED47 → 53 (+6),RESPONSE_ALLOWLIST91 → 85 (−6) — all six moved fromthe not-yet-verified allowlist into the asserted set.
src/mcp/contract-write-response.integration.test.ts— which ownsINV6_WRITE_EXEMPTandINV6_WRITE_PENDING— is untouched by this PR. Nothing was added to any exemption, pending,or allowlist bucket.
5. Full local run under bundling
pnpm lintclean ·pnpm test3657 passed (255 files) ·pnpm test:integration1812 passed, 141 skipped (170 files). The #640 non-vacuity sweep and INV-1/2/3/5/6 all still
hold under
bundle.Testing
pnpm test(255 files, 3657 tests, all green)pnpm test:integration1812 passed / 141 skipped (170 files), run locally against this branch🤖 Co-authored by Claude Sonnet 5. Closes #649
Summary by CodeRabbit
Bug Fixes
Tests