From 9585878cd384667edfb1bbd319078c5b93458865 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:09:50 +0000 Subject: [PATCH 1/2] fix(downgrader): inline $refs into an anyOf or oneOf in 3.1 to 3.0 The nullable merge removes an `anyOf` of a branch and null, and a `oneOf` can become `anyOf`, so a `$ref` such as `#/.../home/anyOf/0` (written by zod-to-json-schema's default root `$refStrategy` and by swagger-parser `bundle()`) dangled in 3.0. Such refs are now replaced by their target, like refs into `$defs`. Also lists refs into keywords 3.0 removes as a README limitation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013DHN6pxmaQMB2i4Rv3Kx35 --- packages/downgrader/README.md | 5 ++-- packages/downgrader/src/v3.1-to-v3.0.ts | 5 +++- .../downgrader/tests/v3.1-to-v3.0.test.ts | 23 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index cab1d34..51ae437 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -188,7 +188,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | | An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | -| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | +| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. So is each `$ref` into an `anyOf` or `oneOf`, which 3.0 can merge away or rename. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | | [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | @@ -241,7 +241,8 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc #### Limitations - A `not` or `oneOf` inside a cycle of schemas, over a `$ref` back into that cycle to a schema that lost a restriction, can still reject values the original accepts. -- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. +- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs`, `anyOf`, or `oneOf` is recognized by its `/$defs/`, `/anyOf/`, or `/oneOf/` segment. +- `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, are left as written and dangle. - Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. - Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, and array-form `items` passes through. Convert such schemas to 2020-12 first. - `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2a0fe60..3dbf840 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -251,11 +251,14 @@ const DOCUMENT_FIELDS = defineFields({ }) // A `$ref` into a removed part, `$defs`, or `definitions` is replaced by its -// target, unless it already dangles. +// target, unless it already dangles. So is one into an `anyOf` or `oneOf`, +// which 3.0 can merge away or rename. function isInlined(ref: string, ctx: Context): boolean { return ( (ref.includes('/$defs/') || ref.includes('/definitions/') || + ref.includes('/anyOf/') || + ref.includes('/oneOf/') || REMOVED_PARTS.some((prefix) => ref.startsWith(prefix))) && resolvePointer(ref, ctx) !== undefined ) 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 c895db6..8212378 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -844,6 +844,29 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ properties: { a: { type: 'string', nullable: true } } }) }) + it('inlines $refs into an anyOf or oneOf, which 3.0 can merge away or rename', () => { + const address = { type: 'object', properties: { city: { type: 'string' } } } as const + expect( + downgradeSchemaV31ToV30({ + type: 'object', + properties: { + home: { anyOf: [address, { type: 'null' }] }, + work: { anyOf: [{ $ref: '#/properties/home/anyOf/0' }, { type: 'null' }] }, + id: { oneOf: [{ type: 'string' }, { type: 'integer' }] }, + parentId: { $ref: '#/properties/id/oneOf/0' }, + }, + }), + ).toEqual({ + type: 'object', + properties: { + home: { ...address, nullable: true }, + work: { ...address, nullable: true }, + id: { oneOf: [{ type: 'string' }, { type: 'integer' }] }, + parentId: { type: 'string' }, + }, + }) + }) + it('leaves a $ref into $defs that does not resolve as written', () => { expect(downgradeSchemaV31ToV30({ $ref: '#/$defs/Missing' })).toEqual({ $ref: '#/$defs/Missing', From 4cabe6530531197e8d56bb3a41570a2ade8740dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:21:35 +0000 Subject: [PATCH 2/2] fix(downgrader): keep a nullable anyOf that a $ref points into in 3.1 to 3.0 Inlining such `$ref`s, as the previous commit did, reused the in-progress conversion of an ancestor for a recursive `$ref`, so the 3.0 output held a cycle and JSON.stringify threw. Instead, a `$ref` kept as written marks each schema whose `anyOf` it passes through, and the nullable merge (and its `oneOf` rename) skips those, so the `$ref` still resolves. `$ref`s into a `oneOf` that becomes `anyOf`, or into a removed keyword, are listed as a README limitation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_013DHN6pxmaQMB2i4Rv3Kx35 --- packages/downgrader/README.md | 7 ++--- packages/downgrader/src/v3.1-to-v3.0.ts | 27 +++++++++++++------ .../downgrader/tests/v3.1-to-v3.0.test.ts | 25 ++++++++++++----- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 51ae437..6ccf377 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -188,7 +188,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, the schema gains `type: "string"`. | | An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. | -| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. So is each `$ref` into an `anyOf` or `oneOf`, which 3.0 can merge away or rename. | +| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | | [`patternProperties`][js-pattern-properties] | Removed, with the `additionalProperties` beside it, which would reject the properties it allowed. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | @@ -241,8 +241,9 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc #### Limitations - A `not` or `oneOf` inside a cycle of schemas, over a `$ref` back into that cycle to a schema that lost a restriction, can still reject values the original accepts. -- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs`, `anyOf`, or `oneOf` is recognized by its `/$defs/`, `/anyOf/`, or `/oneOf/` segment. -- `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, are left as written and dangle. +- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. +- An `anyOf` of `{ type: "null" }` and a branch that a `$ref` points into stays as written, so the `$ref` still resolves, and code generators read it as a union. +- `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, or into a `oneOf` that becomes `anyOf`, are left as written and dangle. - Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. - Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, and array-form `items` passes through. Convert such schemas to 2020-12 first. - `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 3dbf840..742c404 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -127,7 +127,11 @@ const LOOSE = new WeakSet() // Converted schemas with an `anyOf` of two branches, for `mergeNullable`. It // runs once the whole input is converted, since a cycle can meet a branch // before its conversion is done. -const twoBranchAnyOfs: Record[] = [] +const twoBranchAnyOfs: [schema: Record, input: object][] = [] + +// Input schemas whose `anyOf` a `$ref` kept as written points into. They keep +// it, so the `$ref` still resolves. +const anyOfTargets = new Set() // A branch with these stays: null can still fail them beside `nullable: true`, // and a `nullable` already there was written 3.0-style. @@ -251,14 +255,11 @@ const DOCUMENT_FIELDS = defineFields({ }) // A `$ref` into a removed part, `$defs`, or `definitions` is replaced by its -// target, unless it already dangles. So is one into an `anyOf` or `oneOf`, -// which 3.0 can merge away or rename. +// target, unless it already dangles. function isInlined(ref: string, ctx: Context): boolean { return ( (ref.includes('/$defs/') || ref.includes('/definitions/') || - ref.includes('/anyOf/') || - ref.includes('/oneOf/') || REMOVED_PARTS.some((prefix) => ref.startsWith(prefix))) && resolvePointer(ref, ctx) !== undefined ) @@ -278,6 +279,12 @@ function refOr(convert: Convert): Convert { function convertSchemaRef(ref: string, ctx: Context): unknown { if (!isInlined(ref, ctx)) { + for (let at = ref.indexOf('/anyOf/'); at !== -1; at = ref.indexOf('/anyOf/', at + 1)) { + const holder = resolvePointer(ref.slice(0, at), ctx) + if (isRecord(holder)) { + anyOfTargets.add(holder) + } + } // 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 } @@ -664,7 +671,7 @@ function finishSchema( } } if (Array.isArray(out.anyOf) && out.anyOf.length === 2) { - twoBranchAnyOfs.push(out) + twoBranchAnyOfs.push([out, schema]) } if (loose) { LOOSE.add(out) @@ -791,10 +798,14 @@ function convertDocument(value: unknown, ctx: Context): unknown { function downgradeV30(root: unknown, convert: Convert): unknown { twoBranchAnyOfs.length = 0 + anyOfTargets.clear() const out = downgrade(root, convert) - for (const schema of twoBranchAnyOfs.splice(0)) { - mergeNullable(schema, root) + for (const [schema, input] of twoBranchAnyOfs.splice(0)) { + if (!anyOfTargets.has(input)) { + mergeNullable(schema, root) + } } + anyOfTargets.clear() return 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 8212378..d449cc3 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -844,7 +844,7 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ properties: { a: { type: 'string', nullable: true } } }) }) - it('inlines $refs into an anyOf or oneOf, which 3.0 can merge away or rename', () => { + it('keeps an anyOf of a branch and null that a $ref points into, so the $ref still resolves', () => { const address = { type: 'object', properties: { city: { type: 'string' } } } as const expect( downgradeSchemaV31ToV30({ @@ -852,21 +852,32 @@ describe('downgradeSchemaV31ToV30', () => { properties: { home: { anyOf: [address, { type: 'null' }] }, work: { anyOf: [{ $ref: '#/properties/home/anyOf/0' }, { type: 'null' }] }, - id: { oneOf: [{ type: 'string' }, { type: 'integer' }] }, - parentId: { $ref: '#/properties/id/oneOf/0' }, }, }), ).toEqual({ type: 'object', properties: { - home: { ...address, nullable: true }, - work: { ...address, nullable: true }, - id: { oneOf: [{ type: 'string' }, { type: 'integer' }] }, - parentId: { type: 'string' }, + home: { anyOf: [address, { type: 'string', nullable: true, enum: [null] }] }, + work: { + oneOf: [ + { $ref: '#/properties/home/anyOf/0' }, + { type: 'string', nullable: true, enum: [null] }, + ], + }, }, }) }) + it('keeps an anyOf of a branch and null that a $ref inside it points into', () => { + const node = { + type: 'object', + properties: { children: { type: 'array', items: { $ref: '#/anyOf/0' } } }, + } as const + const out = downgradeSchemaV31ToV30({ anyOf: [node, { type: 'null' }] }) + expect(out).toEqual({ anyOf: [node, { type: 'string', nullable: true, enum: [null] }] }) + expect(() => JSON.stringify(out)).not.toThrow() + }) + it('leaves a $ref into $defs that does not resolve as written', () => { expect(downgradeSchemaV31ToV30({ $ref: '#/$defs/Missing' })).toEqual({ $ref: '#/$defs/Missing',