Skip to content

fix: resolve recursive $ref in AsyncAPI-extracted fragments - #455

Open
jonaslagoni wants to merge 1 commit into
mainfrom
fix/gh-448-asyncapi-recursive-refs
Open

fix: resolve recursive $ref in AsyncAPI-extracted fragments#455
jonaslagoni wants to merge 1 commit into
mainfrom
fix/gh-448-asyncapi-recursive-refs

Conversation

@jonaslagoni

Copy link
Copy Markdown
Contributor

Closes #448 (split out of #447).

The problem

An AsyncAPI document containing a recursive schema fails generation outright:

Could not dereference $ref in input, is all the references correct?

Three design points collide:

  1. @asyncapi/parser inlines every non-circular $ref, but deliberately leaves a cycle as a literal {"$ref": "#/components/schemas/Node"}.
  2. The CLI extracts a fragment, not the document — a message payload is re-rooted as a standalone draft-07 document, and the components section the pointer aims at is not carried along.
  3. Modelina re-dereferences that fragment, resolves #/components/schemas/Node against something that has no components key, and throws.

Nothing needs to change in Modelina — circular: true is already set in its dereference options. The pointer target is genuinely absent from the fragment.

The fix

A new pure helper, src/codegen/inputs/asyncapi/refs.ts, applied at each extraction site immediately after the fragment is assembled. Every surviving #/components/schemas/X is resolved one of two ways:

  • X is already inlined in this fragment → rewrite the pointer to that copy's local JSON pointer (# for a single-message root, #/oneOf/<i> for a union member, #/properties/<id> for a v2 parameter).
  • X is not inlined anywhere → rewrite to #/definitions/X, convert the component, stamp $id: X, hoist it into definitions, and recurse so transitive targets come along.

Both halves are load-bearing, and this was determined empirically rather than by preference:

Document shape Transform Result
Self-recursion definitions only ❌ 2 models — Node and NodeChildrenItem
Self-recursion local pointer → # ✅ 1 model Node { children?: Node[] }
Mutual recursion local pointer only ❌ target misnamed Ab after the use site
Mutual recursion local pointer + definitions + $id ✅ 2 models A, B
Union w/ recursive member definitions ❌ 4 models, one name duplicated
Union w/ recursive member local pointer → #/oneOf/N ✅ 3 models, no duplicates

Duplicate class names in one output file are broken code, not cosmetic noise — hence the two-way split.

The walk is copy-on-write. The extraction sites rebuild only the top level of a fragment; nested nodes stay shared with the parser's document tree, so an in-place rewrite would corrupt every other extraction site and filter.ts. There is a dedicated regression test for this.

Sites covered

Site Inlined-target map
payloads.ts single message {<root x-parser-schema-id> → '#'}
payloads.ts oneOf union {<member id> → '#/oneOf/<i>'}
headers.ts single message / channel union same two shapes
parameters.ts (AsyncAPI v2) {<param schema id> → '#/properties/<id>'}

The plan left headers and parameters as open questions. Both were checked against the parsed model and both reproduce the failure, so both are wired rather than documented as non-reproducing. v3 Parameter Objects carry no schema, so only v2 documents can reach that path.

Not a breaking change

The transform is gated on the presence of a surviving #/components/schemas/ pointer; without one the fragment is returned by identity. Documents that hit this bug currently produce no output at all, so there is no working output to break.

Verified rather than asserted: git diff --numstat over every snapshot file shows 0 deleted lines — only new snapshots from the new fixtures, no pre-existing snapshot modified.

Verification

Check Result
npm test 72 suites, 911 passed, 96 snapshots
npm run test:blackbox 230/230 (was 215 — the new fixture × 15 configs)
npm run runtime:typescript (recursive spec) 5/5
npm run prepare:pr passes end to end
Pre-existing snapshots modified none
Issue reproduction generates children?: NodeMessage[] on a single model

Coverage added across all three tiers:

  • Unitrefs.spec.ts (local-pointer rewrite, definitions hoisting with $id, transitive collection, cycle termination, unknown component left alone, copy-on-write, no-pointer gate), plus recursive describe blocks in payloads.spec.ts, headers.spec.ts and a new parameters.spec.ts. The integration tests assert exact model counts and no duplicate names, since the duplicate-class failure mode shows up only as an extra model with a repeated name.
  • Blackboxrecursive-asyncapi.yml carrying self-recursion, mutual recursion and a recursive union member in one document, so the output has to type-check.
  • Runtime — a nested tree and an alternating GraphNode ↔ GraphEdge cycle round-trip through marshal()/unmarshal() with structure intact. No broker needed.

Docs: docs/inputs/asyncapi.md gains a recursive/self-referencing schema section showing real generator output.

No Zod schema changed, so schemas/ regeneration is not triggered by this work.

🤖 Generated with Claude Code

@asyncapi/parser leaves a circular $ref as a literal
`#/components/schemas/X` pointer. The AsyncAPI extraction sites re-root a
single payload/headers/parameters schema as a standalone draft-07 document
with no `components` section, so the pointer dangled and Modelina's
dereference step failed with "Could not dereference $ref in input".

Each surviving pointer is now resolved against the fragment itself: to the
local JSON pointer of the copy already inlined there (`#` for a
single-message root, `#/oneOf/<i>` for a union member, `#/properties/<id>`
for a v2 parameter), or, when the component is not inlined, into a
`definitions` block with `$id` stamped and transitive targets collected.
Both halves are needed — hoisting an inlined component emits a duplicate
class, and omitting `$id` names a hoisted model after its use site.

Fragments with no surviving pointer are returned unchanged by identity, so
documents that generate today are unaffected.

Closes #448

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
the-codegen-project Ready Ready Preview Aug 2, 2026 9:30am
the-codegen-project-mcp Ready Ready Preview Aug 2, 2026 9:30am

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.

Recursive $ref in AsyncAPI input: extracted payload keeps an unresolvable document-absolute $ref

1 participant