Skip to content

test(contract): validate six unvalidated response schemas, bundle openapi.yaml - #657

Merged
thewrz merged 11 commits into
mainfrom
fix/issue-649
Aug 5, 2026
Merged

test(contract): validate six unvalidated response schemas, bundle openapi.yaml#657
thewrz merged 11 commits into
mainfrom
fix/issue-649

Conversation

@thewrz

@thewrz thewrz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Why

The OpenAPI contract test suite (src/api/contract.integration.test.ts) had six response schemas that were never actually validated against assertResponseExact — 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

  • Validates the six previously-unvalidated response schemas against real route responses.
  • Switches the contract test's OpenAPI loading from $RefParser.dereference to $RefParser.bundle, and updates resolveResponseSchema in test-utils to resolve local $ref pointers explicitly, since bundling keeps $refs instead of inlining them.
  • Drops the undocumented specCount field from the GET /revisions/{id} response documentation (cross-fix surfaced while auditing schema exactness).
  • Adds regression tests pinning: sweep-vacuity and per-op depth invariants, local $ref context resolution across six ops, and a no-mutation invariant for the qualifyRef/in-place $ref-qualification path.

Design decisions

  • Dual-mirror necessity confirmed via a real 12-component both-context survey. Before committing to bundle-over-dereference, we surveyed all 12 schema components that appear in both request and response contexts to confirm a single shared mirror could not silently pass a case where request and response actually diverge — the survey showed dereference's inlining was masking exactly this class of bug for the six previously-unvalidated ops.
  • Bundle over dereference. $RefParser.bundle() preserves $ref pointers (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. resolveResponseSchema was extended to resolve local (#/...) pointers explicitly against the bundled document.
  • The components-retention gap. Bundling — unlike dereferencing — retains the components section 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.
  • Mutation-verify coincidence risk found and documented. While pinning the no-mutation invariant for the 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.
  • No ADR filed — explicit sprint override (test-only + one-field API-doc correction, no architectural decision).

Verification transcript (mutation-verified — #649's mandated bar)

The risk this PR had to disprove: bundling leaves $ref pointers in place, and
unevaluated-properties.ts previously assumed they were already inlined. If the walker had
silently stopped traversing at a $ref, INV-6's exact-key-match gate would have degraded to a
no-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:

Mutation Result
CHILD_SINGLESitems 2 failed | 71 passed
CHILD_SINGLEScontains 1 failed | 72 passed
CHILD_SINGLESadditionalProperties 1 failed | 72 passed
CHILD_SINGLESunevaluatedProperties 73 passed — SURVIVING MUTANT
CHILD_SINGLESunevaluatedItems 1 failed | 72 passed
IN_PLACE_MAPSdependentSchemas 1 failed | 72 passed
CHILD_MAPSpatternProperties 1 failed | 72 passed

The sweep found one real gap: unevaluatedProperties was unpinned — that traversal edge could
have been deleted with nothing going red. Fixed in 08bb5454, which adds the object-side twin of
the existing unevaluatedItems case. Re-verified red/green: 74/74 passed on current code, and
with the keyword removed that exact case is the single failure (1 failed | 73 passed).

2. The $ref walk is load-bearing, not a no-op

Bundled $refs are handled by rewriting the pointer to one of two pre-marked component mirrors
rather than by recursing (which is what avoids materializing SpecNode's circular object). To prove
those mirrors actually carry the marking, registerComponentMirrors was mutated to register the
components unmarked:

2 failed | 20 passed (22)
  × checkpoint, pending-summary, and reject responses match their documented schemas end to end
  × GET /revisions/{id} matches its documented schema exactly, including its embedded SpecTree

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 vacuous

resolveIfRef mutated to an identity function (never resolves a pointer):
28 failed | 2 passed (30) across path-id-status-openapi.test.ts and
revision-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 inside
data.children (the rogueChild helper), both asserted .rejects.toThrow(/does not document/).
The nested splice is what proves the recursive mirror walk reaches past data rather than stopping
there.

Bucket accounting confirms burn-down rather than exemption:

  • RESPONSE_COVERED 47 → 53 (+6), RESPONSE_ALLOWLIST 91 → 85 (−6) — all six moved from
    the not-yet-verified allowlist into the asserted set.
  • src/mcp/contract-write-response.integration.test.ts — which owns INV6_WRITE_EXEMPT and
    INV6_WRITE_PENDING — is untouched by this PR. Nothing was added to any exemption, pending,
    or allowlist bucket.

5. Full local run under bundling

pnpm lint clean · pnpm test 3657 passed (255 files) · pnpm test:integration
1812 passed, 141 skipped (170 files). The #640 non-vacuity sweep and INV-1/2/3/5/6 all still
hold under bundle.

Testing

  • Unit tests pass — pnpm test (255 files, 3657 tests, all green)
  • Integration tests pass — pnpm test:integration 1812 passed / 141 skipped (170 files), run locally against this branch
  • Manual verification: n/a (contract-test-only change)
  • CI green — all 6 checks passing on 08bb545

🤖 Co-authored by Claude Sonnet 5. Closes #649

Summary by CodeRabbit

  • Bug Fixes

    • Corrected revision response data so summary and detailed revision views return the appropriate fields.
    • Improved handling of referenced API response definitions, including error responses and revision-related requests.
  • Tests

    • Expanded API contract coverage for recursive specification data, exact responses, nested fields, and undocumented properties.
    • Strengthened validation of request and response schemas to catch inconsistencies more reliably.

thewrz and others added 8 commits August 4, 2026 14:35
…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>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3b21366d-c963-464e-ad16-ff6cefdc4733

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

Recursive contract validation

Layer / File(s) Summary
Schema reference walking
src/test-utils/contract/schema-refs.ts, src/test-utils/contract/unevaluated-properties.ts
Bundled $ref values now use context-specific mirrors during recursive schema traversal.
Validator reference integration
src/test-utils/contract/validate-response.ts, src/test-utils/contract/validate-response.test.ts
The validator bundles and registers OpenAPI schemas, resolves referenced branches, and validates exact recursive responses.
OpenAPI contract consumers
src/api/path-id-status-openapi.test.ts, src/api/revision-parent-openapi.test.ts
Error, revision, and request-schema assertions resolve $ref values before inspection.
Recursive response coverage
src/api/contract.integration.test.ts, src/db/queries/revisions.ts
Integration tests cover six recursive response operations and nested undocumented-key rejection. Revision mapping separates shared fields from specCount.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: validating six response schemas and bundling the OpenAPI document.
Linked Issues check ✅ Passed The changes satisfy #649 by bundling schemas, supporting recursive references, validating all six operations, and preserving exact-key and non-vacuity checks.
Out of Scope Changes check ✅ Passed The changes support #649, including required validator, schema-walker, OpenAPI test, and revision-response updates; no unrelated scope is evident.

Comment @coderabbitai help to get the list of available commands.

…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>
@thewrz

thewrz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

This was written agentically; verify its assertions and edit accordingly:

Adversarial cross-review — Codex gpt-5.6-sol (reasoning effort xhigh)

Ran once as the final draft-phase gate, against origin/main, after CI went green on 08bb5454.

Result: 0 actionable findings.

The bundled-schema registration and dual-context mirror handling correctly preserve recursive
$ref validation, while the revision response now matches its documented shape. No actionable
regressions were found.

Because a sibling PR in this sprint reported a false "0 findings" while the reviewer had actually
produced real P2s, this zero was verified against the raw transcript rather than taken from the
summary line: the run is 603,773 bytes, exits 0, contains a terminal verdict block, and carries
zero [P1]/[P2] tags and zero severity-graded findings anywhere in the transcript. The
reviewer's own checklist confirms it completed "trace affected call sites and tests for concrete
regressions" before concluding.

Independent verification run alongside the review

The review was not the only gate. The specific risk this PR carries — that switching loadSpec from
dereference to bundle leaves $ref pointers the exact-key-match walker might silently stop at,
degrading INV-6 to a no-op while staying green — was mutation-tested rather than reasoned about.
Full transcript is in the PR body; the load-bearing results:

  • One real gap found and fixed. Deleting unevaluatedProperties from the walker's
    CHILD_SINGLES left the entire suite green — a surviving mutant, i.e. an unpinned traversal edge.
    Fixed in 08bb5454; re-verified red/green (74/74 green; with the keyword removed that one case is
    the sole failure).
  • The $ref path is not a no-op. Registering the component mirrors unmarked turns both
    exact-match blocks red, so the six operations' gates genuinely gate.
  • Nothing was exempted to make the suite pass. RESPONSE_COVERED 47→53, RESPONSE_ALLOWLIST
    91→85 — a straight burn-down — and the file owning INV6_WRITE_EXEMPT/INV6_WRITE_PENDING is
    untouched by this PR.

No ADR was added or amended (git diff origin/main...HEAD -- docs/adr/ is empty); rationale lives in
the PR body and in code comments/test names per this sprint's instruction.

🤖 Co-authored by Claude Opus 5 (1M context).

@thewrz
thewrz marked this pull request as ready for review August 5, 2026 08:13
@thewrz

thewrz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
src/db/queries/revisions.ts (1)

178-190: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider declaring the shared core as its own interface.

Omit<RevisionSummary, 'specCount'> expresses the shared shape indirectly. It is correct today, because RevisionSummary minus specCount equals RevisionWithTrees minus specs. 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 RevisionCore interface that both RevisionSummary and RevisionWithTrees extend would make the relationship declarative and keep mapRevisionCore'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 mapRevisionCore returns RevisionCore.

🤖 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 value

Move the package creation and the project_specs INSERT inside the try block.

Both setup statements run before try. If the project_specs INSERT at Lines 857-860 fails, the finally block never runs, so the created design_packages row survives the test. That orphan row can then make the outer afterAll cleanup fail with a project_specs RESTRICT violation, which masks the original failure.

Enter the try immediately after baseUrl is known, and keep the existing finally cleanup. The cleanup already tolerates missing rows, because both statements are unconditional DELETEs.

🤖 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 value

Remove the as AnySchemaObject assertion at this boundary.

qualifyRefValue returns unknown, so line 55 asserts over unknown at a public export. The sibling markUnevaluatedPropertiesFalse in unevaluated-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 value

Consider a per-branch guard so a partially-derivable oneOf cannot weaken INV-4 silently.

The fail-loud guard at lines 392-398 fires only when the derived key set is completely empty. A oneOf branch that resolves to a component composed with allOf (or a nested oneOf) parses cleanly into RequestBodyBranchObject, yields properties: undefined, and contributes zero keys. If the other branches still contribute keys, body.size stays 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 value

Align the comment with the code: $ref nodes are not skipped.

The comment says the walker rewrites the pointer "and stop". The code does not return early. It falls through to walkSubschemas and shouldMark. For a pointer-only node both are effectively no-ops, so the behavior matches the intent. For a $ref node 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 early return were 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

📥 Commits

Reviewing files that changed from the base of the PR and between 14f06e3 and 08bb545.

📒 Files selected for processing (8)
  • src/api/contract.integration.test.ts
  • src/api/path-id-status-openapi.test.ts
  • src/api/revision-parent-openapi.test.ts
  • src/db/queries/revisions.ts
  • src/test-utils/contract/schema-refs.ts
  • src/test-utils/contract/unevaluated-properties.ts
  • src/test-utils/contract/validate-response.test.ts
  • src/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>
@thewrz

thewrz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

All five nitpicks handled in 9857806d4 fixed, 1 declined. Body-level nitpicks, so there are no threads to resolve.

1. validate-response.ts — per-branch INV-4 guard · fixed

The most substantive of the five, and it earns more than its 🔵 Trivial label: this is the vacuous-gate class inside the gate that exists to prevent vacuous gates.

Verified reachable rather than assumed. RequestBodyBranchObject is a plain z.object, not .strict(), so an allOf/nested-oneOf branch parses cleanly and yields properties: undefined instead of throwing. The whole-body guard in operationParamKeys fires only on body.size === 0, so any union with one derivable branch keeps the set non-empty and hides the rest — INV-4 then compares a partial key set with no signal.

Keyed the check on "could not derive" (properties === undefined), never on "derived nothing": an explicit properties: {} is a legal, genuinely-empty branch and must still pass. Both directions pinned by new tests.

Mutation-verified, because a guard against vacuity that is itself vacuous would be the whole joke:

× throws when only SOME oneOf branches are derivable, rather than checking a partial key set
AssertionError: expected [Function] to throw an error
Test Files  1 failed (1) | Tests  1 failed | 75 passed (76)

Reverted → 76/76.

2. contract.integration.test.ts — fixture leak · fixed

Real, and it compounds. The package POST ran before the try, and the package is created first — so an INSERT failure (a re-run where project_specs already has the row) skipped the finally entirely and orphaned the design_packages row just created. Both statements now sit inside the try, with the finally guarding on packageId (undefined only when the POST itself threw, when there is nothing to delete). The project_specs delete stays unconditional — id-scoped and a no-op if the INSERT never landed.

3. unevaluated-properties.ts — comment drift · fixed

Confirmed: the comment said the $ref branch rewrites "and stop", but there is no early return and the walk continues. Behaviourally harmless — a $ref node carries no other subschema-bearing keywords — but it described control flow that does not exist.

4. schema-refs.tsas AnySchemaObject · fixed

Replaced with a real runtime narrowing check rather than a cast, per this repo's no-cross-boundary-assertions rule. tsc --noEmit accepts it without the assertion.

5. revisions.ts — extract RevisionCore · declined, with reasons

The stated risk does not hold. It argues a future field added to one interface "makes the return type misleading before it makes it a compile error" — but both directions fail immediately:

  • a new RevisionSummary field lands inside Omit<RevisionSummary,'specCount'>, so mapRevisionCore must return it → compile error;
  • a new RevisionWithTrees field breaks { ...mapRevisionCore(...), specs } at the construction site → compile error.

The claim that the equality "is not stated anywhere" is also inaccurate — the 10-line comment directly above states it, including why the core was extracted (the specCount leak this PR fixes). Adding a third interface would restate in types what is already enforced by Omit<> and explained in prose, for no behavioural gain, in a non-test file. Happy to revisit if you'd rather have it declarative.

Verified: pnpm lint clean, 3659/3659 unit, contract gate 22/22 against real Postgres.

@thewrz
thewrz merged commit 7485bf6 into main Aug 5, 2026
6 checks passed
@thewrz
thewrz deleted the fix/issue-649 branch August 5, 2026 16:57
thewrz added a commit that referenced this pull request Aug 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(test-utils): six ops have NO response-schema validation — ajv cannot compile the recursive SpecNode tree

1 participant