Skip to content

fix(downgrader): stop recursive $refs into part of an inlined target from making circular output - #93

Merged
dinwwwh merged 1 commit into
mainfrom
claude/project-thread-nn2203
Oct 24, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/project-thread-nn2203

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 24, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Problem

In 3.1 to 3.0 (since v0.3.0), a $ref into part of a target the downgrader inlines (a $defs/definitions entry, a webhook, or a components.pathItems entry), or a $ref from such a part to the entry around it, could reuse an output that was still being filled. The output then contained itself, and JSON.stringify threw Converting circular structure to JSON.

zod-to-json-schema 3.25 writes both shapes for z.lazy inside definitions:

// into part of an entry, from inside it
{ properties: { a: { $ref: '#/definitions/A' } }, definitions: { A: { properties: { child: { properties: { next: { $ref: '#/definitions/A/properties/child' } } } } } } }
// into part of an entry, which refers back to the entry around it
{ properties: { n: { $ref: '#/definitions/Tree/properties/node' } }, definitions: { Tree: { properties: { node: { properties: { children: { items: { $ref: '#/definitions/Tree' } } } } } } } }

Fix

inline gives each inlined target its own seen map (ctx.apart, keyed by target, reused when the same target is inlined again). That way an inlined output never reaches one that is still being filled. The kept-$ref looseness check in convertSchemaRef passes shared = true and keeps using the outside seen (ctx.shared), so components are still converted once.

Tests

Four new tests. Each one fails on main and passes here, and each asserts that JSON.stringify does not throw: a $ref into part of a $defs entry from inside it, the same for a webhook, a $ref back from another inlined entry, and a $ref to the entry around a part being inlined. README Performance now says objects inside an inlined $ref target are converted once per target.

Checks

  • pnpm run check passes, 262 tests pass, and coverage is unchanged.
  • Output is identical to main on the bench docs (generated API with 100 resources, official corpus, callback graph, path item chain).
  • Bundled and timed against main (200 shuffled rounds, median): every bench case is within noise, and the graph benches stay linear.
  • In a fuzz of 3,000 random $ref graphs, main gives circular output in 358 conversions and this branch in none.

Generated by Claude Code

…from making circular output

A `$ref` into part of a `$defs` entry, webhook, or `components.pathItems`
entry that is being inlined, or to the entry around a part being inlined,
reused an output still being filled, so the 3.0 output held itself and
`JSON.stringify` threw. zod-to-json-schema writes both for `z.lazy` inside
`definitions`.

Each inlined target now keeps its own `seen` map, so no inlined output
reaches one still being filled. The kept-`$ref` looseness check still uses
the outside one, so components are converted once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnCVj9oJRTNuTKj5n1FBeh
@dinwwwh dinwwwh self-assigned this Oct 24, 2026
@codecov

codecov Bot commented Oct 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing claude/project-thread-nn2203 (45bbc4c) with main (d14fb04)

Open in CodSpeed

@pullfrog pullfrog 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.

✅ No new issues found.

Reviewed changes

  • Per-target seen for inlined $refs — Context gains shared and apart; inline(ref, ctx, convert, shared = false) now converts each inlined target with its own seen map (created on first inline, reused for later inlines of the same target). A still-active target is rejected by ctx.inlining before apart is consulted, so apart only returns completed outputs — an inlined target can no longer reach an output still being filled and embed it.
  • Kept-$ref looseness check stays on the shared seen — convertSchemaRef passes shared = true, so refs kept as written still reuse the component copy and components are converted once. The inline result is only read for isLoose, never embedded, so this path stays acyclic.
  • Four regression tests — a $ref into part of a $defs entry from inside it, from another entry inlined inside it, into the entry around a part being inlined, plus the webhook equivalent; each asserts exact output and that JSON.stringify does not throw.
  • README Performance — notes objects inside an inlined $ref target are converted once per target.

Verification

  • Full suite: 262 passing; uncheck (oxlint / oxfmt / tsc) clean on the three changed source/test files.
  • Reverting the inline body to the pre-fix global-seen behavior makes all four new tests fail (4 failed | 86 skipped), so they are live guards, not theatre.
  • A 3000-case fuzz over random $defs + part-$ref graphs reports circular output in 1033/3000 conversions pre-fix and 0 post-fix.

The behavioral change — objects inside an inlined target are now converted once per target, so their identity is no longer shared with the component copy — is documented and consistent with the existing corpus/chained/snapshot tests.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit c2c70f3 into main Oct 24, 2026
11 checks passed
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.

2 participants