From 45bbc4c5bd487ffc0304129ad387815204a0f6f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:11:44 +0000 Subject: [PATCH] fix(downgrader): stop recursive $refs into part of an inlined target 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) Claude-Session: https://claude.ai/code/session_01RnCVj9oJRTNuTKj5n1FBeh --- packages/downgrader/README.md | 2 +- packages/downgrader/src/shared.ts | 29 ++++- packages/downgrader/src/v3.1-to-v3.0.ts | 2 +- .../downgrader/tests/v3.1-to-v3.0.test.ts | 118 ++++++++++++++++++ 4 files changed, 145 insertions(+), 6 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 6ccf377..1f618a0 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -254,7 +254,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc ## Performance -Each object is converted once, however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. +Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. Two shapes no generator emits fall outside this. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index ef2eccf..39fbb9e 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -8,6 +8,10 @@ export interface Context { readonly targets: Map /** The output of each object converted so far, per field table, so a shared object is converted once and a cycle ends. */ readonly seen: Map>> + /** The `seen` outside any inlined target, which each keeps its own. */ + readonly shared: Context['seen'] + /** The `seen` of each target inlined so far. */ + readonly apart: Map /** The targets of the `$ref`s being inlined, so a reference back into one stops there. */ readonly inlining: Set } @@ -239,19 +243,36 @@ export function resolve(ref: string, ctx: Context): unknown { /** * Converts the target of `ref` to stand in for the reference. Returns `DROP` * when the target is missing, or when it is already being inlined around - * this point, where inlining it again would never end. + * this point, where inlining it again would never end. Each target keeps its + * own outputs, so a `$ref` into part of one being inlined, or around it, + * never reaches an output still being filled, which would then hold itself. + * With `shared`, for a caller that only reads the output, the target gets the + * one it has outside any inlined target. */ -export function inline(ref: string, ctx: Context, convert: Convert): unknown { +export function inline(ref: string, ctx: Context, convert: Convert, shared = false): unknown { const target = resolvePointer(ref, ctx) if (target === undefined || ctx.inlining.has(target)) { return DROP } + let seen = shared ? ctx.shared : ctx.apart.get(target) + if (seen === undefined) { + seen = new Map() + ctx.apart.set(target, seen) + } ctx.inlining.add(target) - const out = convert(target, ctx) + const out = convert(target, seen === ctx.seen ? ctx : { ...ctx, seen }) ctx.inlining.delete(target) return out } export function downgrade(root: unknown, convert: Convert): unknown { - return convert(root, { inlining: new Set(), root, seen: new Map(), targets: new Map() }) + const seen: Context['seen'] = new Map() + return convert(root, { + apart: new Map(), + inlining: new Set(), + root, + seen, + shared: seen, + targets: new Map(), + }) } diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 742c404..39e4b35 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -288,7 +288,7 @@ function convertSchemaRef(ref: string, ctx: Context): unknown { // A `$ref` kept as written loses what its target loses. Converting the // target here reuses the copy the components get, and stops at a cycle. const out = { $ref: ref } - return isLoose(inline(ref, ctx, convertSchema)) ? loosened(out) : out + return isLoose(inline(ref, ctx, convertSchema, true)) ? loosened(out) : out } const out = inline(ref, ctx, convertSchema) return out === DROP ? loosened({}) : out diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index d449cc3..5abdc9f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -239,6 +239,34 @@ describe('downgradeSpecV31ToV30', () => { { in: 'query', name: 'q', schema: { type: 'integer', enum: [1] } }, ]) }) + + it('inlines a $ref into part of a webhook from inside it, cutting recursion with {}', () => { + const schema = { + type: 'object', + properties: { + next: { $ref: '#/webhooks/hook/post/requestBody/content/application~1json/schema' }, + }, + } as const + const out = convert({ + paths: { '/a': { $ref: '#/webhooks/hook' } }, + webhooks: { + hook: { + post: { requestBody: { content: { 'application/json': { schema } } }, responses }, + }, + }, + }) + expect(out.paths['/a']?.post?.requestBody).toEqual({ + content: { + 'application/json': { + schema: { + type: 'object', + properties: { next: { type: 'object', properties: { next: {} } } }, + }, + }, + }, + }) + expect(() => JSON.stringify(out)).not.toThrow() + }) }) describe('mutual TLS', () => { @@ -825,6 +853,96 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + it('inlines a $ref into part of a $defs entry from inside it, cutting recursion with {}', () => { + const out = downgradeSchemaV31ToV30({ + type: 'object', + properties: { a: { $ref: '#/$defs/A' } }, + $defs: { + A: { + type: 'object', + properties: { + child: { + type: 'object', + properties: { next: { $ref: '#/$defs/A/properties/child' } }, + }, + }, + }, + }, + }) + expect(out).toEqual({ + type: 'object', + properties: { + a: { + type: 'object', + properties: { + child: { + type: 'object', + properties: { next: { type: 'object', properties: { next: {} } } }, + }, + }, + }, + }, + }) + expect(() => JSON.stringify(out)).not.toThrow() + }) + + it('inlines a $ref into part of a $defs entry from another entry inlined inside it', () => { + const out = downgradeSchemaV31ToV30({ + type: 'object', + properties: { a: { $ref: '#/$defs/A' } }, + $defs: { + A: { + type: 'object', + properties: { c: { type: 'object', properties: { go: { $ref: '#/$defs/B' } } } }, + }, + B: { type: 'object', properties: { back: { $ref: '#/$defs/A/properties/c' } } }, + }, + }) + const back = { type: 'object', properties: { go: {} } } + expect(out).toEqual({ + type: 'object', + properties: { + a: { + type: 'object', + properties: { + c: { + type: 'object', + properties: { go: { type: 'object', properties: { back } } }, + }, + }, + }, + }, + }) + expect(() => JSON.stringify(out)).not.toThrow() + }) + + it('inlines a $ref to the $defs entry around a part being inlined', () => { + const out = downgradeSchemaV31ToV30({ + type: 'object', + properties: { p: { $ref: '#/$defs/A/properties/c' }, q: { $ref: '#/$defs/A' } }, + $defs: { + A: { + type: 'object', + properties: { + c: { + type: 'object', + properties: { n: { $ref: '#/$defs/A/properties/c' }, up: { $ref: '#/$defs/A' } }, + }, + }, + }, + }, + }) + const a = { + type: 'object', + properties: { c: { type: 'object', properties: { n: {}, up: {} } } }, + } + expect(out).toEqual({ + type: 'object', + properties: { p: { type: 'object', properties: { n: {}, up: a } }, q: a }, + }) + expect(() => JSON.stringify(out)).not.toThrow() + }) + it('reads definitions, the draft-07 name of $defs, as $defs', () => { expect( downgradeSchemaV31ToV30({