From c848f6ea559cecb17e0cab72a147ea64e75349cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 00:51:34 +0000 Subject: [PATCH 01/28] test(downgrader): cover the cut-$ref reset, writeOnly and $ref tags, and repeated keywords beside properties Stryker on 94c60c7 leaves 77 mutants alive where 0.5.0 left 70. Seven of the new ones are untested branches from #313, #314 and #318: - cutHolders.clear() removed: a $ref object cut in one call still tells no oneOf branch apart in the next. - follow's check for a $ref into a target being inlined removed: a $ref to a component whose own $ref into that target is cut only later keeps a oneOf whose branches now overlap. - isOneWay ignoring writeOnly, or not following a $ref to the tag. - repeats(): a repeated additionalProperties beside the same properties, and another keyword beside different properties. - mayRepeatUnseen counting null as an object. Each of these mutants now fails a test. No source change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- .../downgrader/tests/v3.1-to-v3.0.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) 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 0786c2b..fcbd8e5 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1605,6 +1605,10 @@ describe('downgradeSchemaV31ToV30', () => { }, }, ) + // Null, which is no object, merges. + expect( + downgradeSchemaV31ToV30({ oneOf: [{ type: 'string', enum: ['a', null] }, { const: 'b' }] }), + ).toEqual({ type: 'string', enum: ['a', null, 'b'] }) // An anyOf takes it either way. expect(downgradeSchemaV31ToV30({ anyOf: objects.oneOf })).toEqual({ type: 'object', @@ -1635,6 +1639,34 @@ describe('downgradeSchemaV31ToV30', () => { ...closed, anyOf: [literal(1), literal(2)], }) + // Beside the same properties, here none, it repeats the schema around it. + expect( + downgradeSchemaV31ToV30({ + type: 'object', + ...closed, + anyOf: [ + { const: { a: 1 }, ...closed }, + { const: { a: 2 }, ...closed }, + ], + }), + ).toEqual({ type: 'object', ...closed, enum: [{ a: 1 }, { a: 2 }] }) + // Other keywords repeat whatever properties surround them. + expect( + downgradeSchemaV31ToV30({ + type: 'object', + properties: { a: {} }, + description: 'd', + anyOf: [ + { const: { a: 1 }, description: 'd' }, + { const: { a: 2 }, description: 'd' }, + ], + }), + ).toEqual({ + type: 'object', + properties: { a: {} }, + description: 'd', + enum: [{ a: 1 }, { a: 2 }], + }) }) it('types an enum with no type after its values, as ArkType writes a union of literals', () => { @@ -3087,6 +3119,28 @@ describe('downgradeSchemaV31ToV30', () => { expect( downgradeSchemaV31ToV30({ oneOf: [tagged('a', readOnly), tagged('b', readOnly)] }), ).toEqual({ anyOf: [tagged30('a', readOnly), tagged30('b', readOnly)] }) + // A response without either writeOnly tag matches both too. + const writeOnly = { writeOnly: true } + expect( + downgradeSchemaV31ToV30({ oneOf: [tagged('a', writeOnly), tagged('b', writeOnly)] }), + ).toEqual({ anyOf: [tagged30('a', writeOnly), tagged30('b', writeOnly)] }) + // Also through a $ref to the readOnly tag. + const byRef = (kind: string): OpenAPIV3_1.SchemaObject => ({ + type: 'object', + properties: { kind: ref(`Kind${kind}`) }, + required: ['kind'], + }) + expect( + convert({ + components: { + schemas: { + Union: { oneOf: [byRef('A'), byRef('B')] }, + KindA: { const: 'a', readOnly: true }, + KindB: { const: 'b', readOnly: true }, + }, + }, + }).components?.schemas?.Union, + ).toEqual({ anyOf: [byRef('A'), byRef('B')] }) // One required both ways still tells them apart. expect(downgradeSchemaV31ToV30({ oneOf: [tagged('a', readOnly), tagged('b')] })).toEqual({ oneOf: [tagged30('a', readOnly), tagged30('b')], @@ -3290,6 +3344,55 @@ describe('downgradeSchemaV31ToV30', () => { $defs: { A: { $ref: '#/$defs/B' }, B: { $ref: '#/$defs/A' } }, }), ).toEqual({ anyOf: [{}, { type: 'object' }] }) + // Nor does a $ref to a component whose $ref into the target is still to + // be cut, as C's is once its other keywords are converted. + const r = '#/components/schemas/' + const tag = (value: string) => ({ tag: { const: value } }) + const c = convert({ + components: { + schemas: { + S: { $ref: `${r}H/$defs/T` }, + C: { + $ref: `${r}H/$defs/T`, + properties: { + p: { + oneOf: [ + { $ref: `${r}C` }, + { type: 'object', properties: tag('u'), required: ['tag'] }, + ], + }, + }, + }, + H: { + $defs: { + T: { + type: 'object', + properties: { ...tag('t'), c: { $ref: `${r}C` } }, + required: ['tag'], + }, + }, + }, + }, + }, + }).components?.schemas?.C + expect(c).toHaveProperty('allOf', [{}]) + expect(c).toHaveProperty(['properties', 'p', 'anyOf']) + }) + + it('forgets the $refs that an earlier conversion cut at recursion', () => { + // The same $ref object is cut in one input, where its target holds it, + // and tells the branches apart in the next. + const ref = { $ref: '#/$defs/A' } + downgradeSchemaV31ToV30({ + properties: { a: { $ref: '#/$defs/A' } }, + $defs: { A: { type: 'object', properties: { next: ref } } }, + }) + expect( + downgradeSchemaV31ToV30({ + oneOf: [ref, { type: 'number', propertyNames: { maxLength: 1 } }], + $defs: { A: { type: 'object' } }, + }), + ).toEqual({ oneOf: [{ type: 'object' }, { type: 'number' }] }) }) it('counts a copy cut at recursion and reused later as telling no branch apart', () => { From 90d9ae5c45a5e914c60507efb2309a6d3109483b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 00:57:09 +0000 Subject: [PATCH 02/28] fix(downgrader): leave out a 3.2 security requirement name whose scheme holds undefined #324 keeps a requirement name only when components.securitySchemes has it as an own key, so a scheme entry holding undefined, as spreading options often leaves, still counted as a scheme. The 3.1 output leaves that entry out, so the requirement named a scheme the document no longer defines, and the same input with the key deleted gave a different output. The check now also needs the entry to hold a value, as every other key holding undefined counts as missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.2-to-v3.1.ts | 3 ++- packages/downgrader/tests/v3.2-to-v3.1.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 6ee6a34..6f4598b 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -175,7 +175,8 @@ function convertSecurity(value: unknown, ctx: Context): unknown { return clone(value) } const schemes = resolvePointer('#/components/securitySchemes', ctx) - const isScheme = (name: string): boolean => isRecord(schemes) && Object.hasOwn(schemes, name) + const isScheme = (name: string): boolean => + isRecord(schemes) && Object.hasOwn(schemes, name) && schemes[name] !== undefined return value.map((requirement) => isRecord(requirement) ? Object.fromEntries( diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index b315061..3e6a8e9 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -462,6 +462,13 @@ describe('downgradeSpecV32ToV31', () => { { '#/components/securitySchemes/other': ['read'] }, {}, ]) + // A scheme holding undefined is missing, as the output leaves it out. + const unset = convert({ + security: [{ key: [], unset: [] }], + components: { securitySchemes: { key, unset: undefined } }, + } as any) + expect(unset.security).toEqual([{ key: [] }]) + expect(unset.components?.securitySchemes).toStrictEqual({ key }) // Without any schemes, every name is left out, and malformed values stay. const malformed = convert({ security: 'all', From 720cf6ebaef907156d863dbcda381e6eab5e8105 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 01:43:35 +0000 Subject: [PATCH 03/28] test(downgrader): cover a 3.2 security requirement name holding undefined Since #331 a scheme holding undefined fails the scheme check first, so the oRPC undefined-key test no longer reaches the check that leaves out a requirement name holding undefined, and Stryker leaves that mutant alive. A requirement name holding undefined whose scheme exists now must come out as a missing key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.2-to-v3.1.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index 3e6a8e9..c7910c4 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -462,12 +462,13 @@ describe('downgradeSpecV32ToV31', () => { { '#/components/securitySchemes/other': ['read'] }, {}, ]) - // A scheme holding undefined is missing, as the output leaves it out. + // A scheme or a requirement name holding undefined is missing, as the + // output leaves it out. const unset = convert({ - security: [{ key: [], unset: [] }], + security: [{ key: [], unset: [] }, { key: undefined }], components: { securitySchemes: { key, unset: undefined } }, } as any) - expect(unset.security).toEqual([{ key: [] }]) + expect(unset.security).toStrictEqual([{ key: [] }, {}]) expect(unset.components?.securitySchemes).toStrictEqual({ key }) // Without any schemes, every name is left out, and malformed values stay. const malformed = convert({ From 85e0979d9f0761a625cae0e3a7f3925cda06b279 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:25:28 +0000 Subject: [PATCH 04/28] test(downgrader): cover a null branch beside enums in a malformed anyOf #340 reads each branch's `enum` through optional chaining to count the enums before merging them. No test holds a null branch there, so Stryker leaves the mutant without `?.` alive, which throws on `anyOf: [null, { const: 'a' }, { const: 'b' }]`. The malformed-input test now converts such a union. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 fcbd8e5..8c98bec 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3750,6 +3750,7 @@ describe('unusual input', () => { S: { properties: 'none', allOf: {}, example: { a: 1, b: undefined } }, R: { $ref: 1 }, U: { anyOf: [1, 2] }, + N: { anyOf: [null, { const: 'a' }, { const: 'b' }] }, }, }, } @@ -3764,6 +3765,7 @@ describe('unusual input', () => { S: { properties: 'none', allOf: {}, example: { a: 1 } }, R: {}, U: { anyOf: [1, 2] }, + N: { anyOf: [null, { type: 'string', enum: ['a', 'b'] }] }, }) }) }) From 4ea0add7570fe42dd571920293aeb2f1a6e564ab Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:40:30 +0000 Subject: [PATCH 05/28] test(downgrader): keep the allOf beside a restricting keyword on a tagged $ref #344 turned the only case that reached isTaggedRef's sibling-key check into a bare $ref, so three Stryker mutants there survived. A $ref to a tagged target beside a matching type and minProperties keeps the allOf. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 f6c2a8c..d56bea2 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2278,6 +2278,8 @@ describe('downgradeSchemaV31ToV30', () => { typed: { ...ref('Identification'), type: 'object', title: 'Typed' }, nullable: { ...ref('Identification'), type: ['object', 'null'] }, other: { ...ref('Identification'), type: 'string' }, + // A keyword that restricts more keeps the `allOf`. + bounded: { ...ref('Identification'), type: 'object', minProperties: 1 }, alias: { ...ref('Alias'), type: 'object' }, nullableTarget: { ...ref('Nullable'), type: 'object' }, pet: { ...ref('Pet'), description: 'Pet' }, @@ -2295,6 +2297,7 @@ describe('downgradeSchemaV31ToV30', () => { typed: ref('Identification'), nullable: { type: 'object', nullable: true, allOf: [ref('Identification')] }, other: { type: 'string', allOf: [ref('Identification')] }, + bounded: { type: 'object', minProperties: 1, allOf: [ref('Identification')] }, alias: { type: 'object', allOf: [ref('Alias')] }, nullableTarget: { type: 'object', allOf: [ref('Nullable')] }, pet: { description: 'Pet', allOf: [ref('Pet')] }, From 7531a72e6b0549a0b126b80ed6c5636f899a312c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:05:43 +0000 Subject: [PATCH 06/28] docs(downgrader): a $ref into an anyOf removed for a media-type-only branch dangles Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 4631109..e4bdcfd 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -261,7 +261,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `pattern` is kept as written, although 3.0 reads it in the ECMA-262 5.1 dialect, which has no Unicode mode and reads by UTF-16 code unit. So a pattern such as `\p{L}` is invalid there, and one such as `^.{1,64}$` counts an emoji as two characters, so it can reject values 3.1 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. One that percent-encodes a character, such as `#/%24defs/Pet`, is replaced by its target wherever it points. - An `anyOf` or `oneOf` 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`, into a `oneOf` that becomes `anyOf`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. +- `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, into an `anyOf` removed for a branch that holds only `contentMediaType`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. - 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. Such a cut `$ref` tells no `oneOf` branch apart, and counts as taking null, wherever its copy is reused. - Each `$ref` into `$defs` repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written. - 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. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents. From 946a4f6fa99b24ef5bdd85424796b750bc73dcd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:32:56 +0000 Subject: [PATCH 07/28] docs(downgrader): a $ref into a removed not dangles Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 20b2824..eb69d89 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -261,7 +261,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `pattern` is kept as written, although 3.0 reads it in the ECMA-262 5.1 dialect, which has no Unicode mode and reads by UTF-16 code unit. So a pattern such as `\p{L}` is invalid there, and one such as `^.{1,64}$` counts an emoji as two characters, so it can reject values 3.1 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. One that percent-encodes a character, such as `#/%24defs/Pet`, is replaced by its target wherever it points. - An `anyOf` or `oneOf` 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`, into a `oneOf` that becomes `anyOf`, into an `anyOf` removed for a branch that holds only `contentMediaType`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. +- `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, into a `not` removed over a schema that lost a restriction, into an `anyOf` removed for a branch that holds only `contentMediaType`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. - 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. Such a cut `$ref` tells no `oneOf` branch apart, and counts as taking null, wherever its copy is reused. - Each `$ref` into `$defs` repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written. - 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. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents. From 69c1aaeddd1a7562b2080230432068183b5442c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:36:06 +0000 Subject: [PATCH 08/28] docs(downgrader): a $ref into a 3.2 itemSchema that moves into schema.items dangles Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index eb69d89..d460191 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -119,7 +119,7 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem #### Limitations -- Other `$ref`s into a removed part, such as a schema `$ref` into `components.mediaTypes`, and Links or discriminator `mapping` values that point at a removed operation, are left as written and dangle. +- Other `$ref`s into a removed part, such as a schema `$ref` into `components.mediaTypes`, or into an `itemSchema` that moves into `schema.items`, and Links or discriminator `mapping` values that point at a removed operation, are left as written and dangle. - A `content` `$ref` that does not resolve, such as an external one or one through `$self`, becomes `{}`, which takes any payload. - A security requirement that names its scheme by URI reference loses it, so 3.1 tools can read the operation as needing no credentials for it. - `$self`-relative references pass through unchanged. From f4f0c9b34d75a7c1857875ea1061a377d75dbd14 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:41:19 +0000 Subject: [PATCH 09/28] docs(downgrader): a $ref by index past a removed 3.2 querystring parameter resolves one entry off Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d460191..0360cd2 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -119,7 +119,7 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem #### Limitations -- Other `$ref`s into a removed part, such as a schema `$ref` into `components.mediaTypes`, or into an `itemSchema` that moves into `schema.items`, and Links or discriminator `mapping` values that point at a removed operation, are left as written and dangle. +- Other `$ref`s into a removed part, such as a schema `$ref` into `components.mediaTypes`, or into an `itemSchema` that moves into `schema.items`, and Links or discriminator `mapping` values that point at a removed operation, are left as written and dangle. A `$ref` by index to a parameter that comes after a removed `querystring` parameter in its list, such as `#/paths/~1a/get/parameters/1/schema`, resolves to the next parameter, or dangles when none follows. - A `content` `$ref` that does not resolve, such as an external one or one through `$self`, becomes `{}`, which takes any payload. - A security requirement that names its scheme by URI reference loses it, so 3.1 tools can read the operation as needing no credentials for it. - `$self`-relative references pass through unchanged. From 25fb9cad04aa050f8a9ac2181ebc4a484ab15ff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:24:39 +0000 Subject: [PATCH 10/28] fix(downgrader): keep a $ref bare beside only a 3.0-style nullable A 3.0-style `nullable` beside a `$ref` without a `type` means nothing in 3.1 and is removed, so the `$ref` became an `allOf` with nothing beside it. It now stays the bare `$ref`, also as a discriminator branch and beside annotations to a tagged union. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 22 +++++++++++++------ .../downgrader/tests/v3.1-to-v3.0.test.ts | 17 ++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 05ef45d..6852516 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -212,7 +212,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | | An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | | Keywords that 3.0 does not define | `x-` extensions of the same name, since the 3.0 Schema Object allows no others. An existing extension of that name wins. | -| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise. | +| [`nullable`][3.0-schema-nullable], a 3.0 keyword | Kept beside a single `type`, where it means in 3.0 what its author meant, and the schema counts as having lost a restriction. Removed otherwise, so a `$ref` beside it, as aide writes one, gets no `allOf`. | | A boolean `exclusiveMinimum` or `exclusiveMaximum`, the 3.0 form, as NestJS writes in 3.1 | Kept beside a numeric `minimum` or `maximum`, where it means in 3.0 what its author meant. Removed otherwise. | | [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] | Kept. 3.0 requires such a property in responses or in requests only, where 3.1 requires it everywhere, so the schema counts as having lost a restriction, and the property tells no `oneOf` branch apart. | | [`not`][js-not] over a schema that lost a restriction above | Removed. Negating the looser schema would reject values the original accepts. A keyword that restricts nothing, such as `propertyNames: { type: "string" }`, or a tuple converted exactly, does not count. A recursive schema, which a cycle of `$ref`s or of objects reaches again before its conversion is done, counts too. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 72103f2..a9b5535 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -335,11 +335,20 @@ function convertBranches(item: unknown, ctx: Context, schema: Record) + return ref !== undefined && isAnnotatedRef(refSiblings(value as Record)) ? convertSchemaRef(ref, ctx, value) : convertSchema(value, ctx) } +// A 3.0-style `nullable` means nothing in 3.1, and 3.0 keeps it only beside a +// single `type`, so beside a `$ref` without one it counts as missing, as aide +// writes it. The `$ref` then stays bare, not an `allOf` with nothing beside it. +function refSiblings(value: Record): Record { + return value.nullable !== undefined && value.type === undefined + ? { ...value, nullable: undefined } + : value +} + // Whether each key of `value` that holds a value passes `test`, since a key // holding `undefined` counts as missing. function hasOnly(value: Record, test: (key: string) => boolean): boolean { @@ -1554,12 +1563,11 @@ function convertSchema(value: unknown, ctx: Context): unknown { return value ? {} : { not: {} } } const ref = getRef(value) - if ( - ref !== undefined && - (isBareRef(value as Record) || - isTaggedRef(value as Record, ref, ctx)) - ) { - return convertSchemaRef(ref, ctx, value) + if (ref !== undefined) { + const siblings = refSiblings(value as Record) + if (isBareRef(siblings) || isTaggedRef(siblings, ref, ctx)) { + return convertSchemaRef(ref, ctx, value) + } } return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema, revisitSchema) } 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 f8128e4..80428b9 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -486,6 +486,20 @@ describe('downgradeSchemaV31ToV30', () => { expect(schema({ type: 'string', nullable: true })).toEqual({ type: 'string', nullable: true }) expect(schema({ nullable: true })).toEqual({}) expect(schema({ type: 'string', nullable: false })).toEqual({ type: 'string' }) + // Beside a `$ref` without a `type`, as aide writes it, the `$ref` stays bare + // rather than an `allOf` with nothing beside it. + const pet = '#/components/schemas/Pet' + expect(schema({ $ref: pet, nullable: true })).toStrictEqual({ $ref: pet }) + expect(schema({ $ref: pet, nullable: false })).toStrictEqual({ $ref: pet }) + expect(schema({ $ref: pet, nullable: true, description: 'd' })).toStrictEqual({ + description: 'd', + allOf: [{ $ref: pet }], + }) + // A discriminator maps only to the `$ref`s among its branches. + const discriminator = { propertyName: 'type' } + expect( + schema({ oneOf: [{ $ref: pet, nullable: true, description: 'd' }], discriminator }), + ).toStrictEqual({ oneOf: [{ $ref: pet }], discriminator }) // It admits null, which the 3.1 branch did not, so the branches can overlap, // and the `anyOf` the `oneOf` becomes takes the branch in place of the pair. expect(schema({ oneOf: [{ type: 'string', nullable: true }, { type: 'null' }] })).toEqual({ @@ -2283,6 +2297,8 @@ describe('downgradeSchemaV31ToV30', () => { alias: { ...ref('Alias'), type: 'object' }, nullableTarget: { ...ref('Nullable'), type: 'object' }, pet: { ...ref('Pet'), description: 'Pet' }, + // A 3.0-style `nullable` means nothing beside a `$ref` without a `type`. + nullableRef: { ...ref('Strategy'), nullable: true, description: 'd' }, }, }, }, @@ -2301,6 +2317,7 @@ describe('downgradeSchemaV31ToV30', () => { alias: { type: 'object', allOf: [ref('Alias')] }, nullableTarget: { type: 'object', allOf: [ref('Nullable')] }, pet: { description: 'Pet', allOf: [ref('Pet')] }, + nullableRef: ref('Strategy'), }, }) // A target that replaces the `$ref` keeps the annotations beside it. From ffa455e6859a47504fe0784d4fcfc1cfa784ae78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:37:38 +0000 Subject: [PATCH 11/28] fix(downgrader): keep a $ref bare beside identifiers 3.0 removes `$id`, `$anchor`, `$comment`, `$schema`, `$defs` or `definitions` beside a `$ref` restrict nothing and 3.0 removes them, but the `$ref` still became an `allOf` with nothing beside it. They now count as missing, as #354 did for a 3.0-style `nullable`, so the `$ref` stays bare, also as a discriminator branch and to a tagged union. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 61 ++++++++++++++----- .../downgrader/tests/v3.1-to-v3.0.test.ts | 20 ++++++ 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 6852516..00b7cf5 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -201,7 +201,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`, it gains only the `format`, so it still takes every value, unless it holds a `$ref`, `allOf`, `anyOf`, `oneOf`, `properties`, `items` or `prefixItems`, as a JSON body marked with its media type does. Then the keywords are removed. | | 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. | | An [`anyOf`][js-any-of] with branches that become equal, as oRPC 1.x writes a file with several media types, a string for each, and Effect a nullish union, with `{ type: "null" }` twice | A branch equal to the one before it is removed, as the `anyOf` then matches the same values, and a lone branch left takes the place of the `anyOf` when the schema around it holds only annotations that the branch lacks or holds with the same value, or nothing else beside a `$ref`, so a nullable one then merges as above. NSwag and oapi-codegen read the `anyOf` of equal binary strings as a generated class or a union, not as a file upload. Branches are equal when each keyword holds the same value, or the same object rather than a copy. A `oneOf` keeps them, since it rejects a value that two branches match. | -| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. A branch of a `oneOf` or `anyOf` beside a `discriminator` that stays, with only annotations beside its `$ref`, such as the `description` the Anthropic and ElevenLabs APIs write for each, becomes the bare `$ref` instead, since 3.0 maps a discriminator only to the `$ref`s among its branches, not to inline schemas, and oapi-codegen stops on the `allOf` there. So does a `$ref` with only annotations beside it to a schema whose `discriminator` stays, or to one whose `allOf` holds a `$ref` to such a schema, such as the `description` the OpenAI and Adyen APIs write beside a property holding a tagged union, or the `examples` the Anthropic API writes there, also beside a `type` the target holds too, as OpenAI writes for an input item, since 3.0 reads an `allOf` holding that `$ref` as one of the schemas the discriminator picks among. openapi-typescript then leaves out its tag, and Hey API pins the tag to the name of the schema around it when the target has no `oneOf`, as for Adyen's base with a `mapping` or the NPP API's GeoJSON point. In both cases the annotations beside the `$ref` are dropped, so a `readOnly`, `writeOnly`, `default` or `deprecated` there is lost, as for the `readOnly` the Cryptography Provider API writes beside one. | +| A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. Ones that only identify, such as `$id`, leave it bare. A branch of a `oneOf` or `anyOf` beside a `discriminator` that stays, with only annotations beside its `$ref`, such as the `description` the Anthropic and ElevenLabs APIs write for each, becomes the bare `$ref` instead, since 3.0 maps a discriminator only to the `$ref`s among its branches, not to inline schemas, and oapi-codegen stops on the `allOf` there. So does a `$ref` with only annotations beside it to a schema whose `discriminator` stays, or to one whose `allOf` holds a `$ref` to such a schema, such as the `description` the OpenAI and Adyen APIs write beside a property holding a tagged union, or the `examples` the Anthropic API writes there, also beside a `type` the target holds too, as OpenAI writes for an input item, since 3.0 reads an `allOf` holding that `$ref` as one of the schemas the discriminator picks among. openapi-typescript then leaves out its tag, and Hey API pins the tag to the name of the schema around it when the target has no `oneOf`, as for Adyen's base with a `mapping` or the NPP API's GeoJSON point. In both cases the annotations beside the `$ref` are dropped, so a `readOnly`, `writeOnly`, `default` or `deprecated` there is lost, as for the `readOnly` the Cryptography Provider API writes beside one. | | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it, or into any `items` or `additionalProperties`, where the conversion can put another schema, is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | A [`discriminator`][3.0-schema-discriminator] over `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union | Removed. A discriminator picks only among named components, not the targets that replace those `$ref`s, and its `mapping` into them would dangle, which validators that follow it, such as openapi-schema-validator, reject. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index a9b5535..93a15f5 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -335,18 +335,29 @@ function convertBranches(item: unknown, ctx: Context, schema: Record)) + return ref !== undefined && isAnnotatedRef(value as Record) ? convertSchemaRef(ref, ctx, value) : convertSchema(value, ctx) } -// A 3.0-style `nullable` means nothing in 3.1, and 3.0 keeps it only beside a -// single `type`, so beside a `$ref` without one it counts as missing, as aide -// writes it. The `$ref` then stays bare, not an `allOf` with nothing beside it. -function refSiblings(value: Record): Record { - return value.nullable !== undefined && value.type === undefined - ? { ...value, nullable: undefined } - : value +// Keywords 3.0 removes that restrict nothing, such as identifiers and the +// `definitions` zod-to-json-schema writes, count as missing beside a `$ref`, as +// does a 3.0-style `nullable`, which means nothing in 3.1, and which 3.0 keeps +// only beside a single `type`, as aide writes it. The `$ref` then stays bare, +// not an `allOf` with nothing beside it. +const IDENTIFIER_KEYWORDS = new Set([ + '$anchor', + '$comment', + '$defs', + '$dynamicAnchor', + '$id', + '$schema', + '$vocabulary', + 'definitions', +]) + +function isRemovedBesideRef(value: Record, key: string): boolean { + return IDENTIFIER_KEYWORDS.has(key) || (key === 'nullable' && value.type === undefined) } // Whether each key of `value` that holds a value passes `test`, since a key @@ -363,7 +374,10 @@ function hasOnly(value: Record, test: (key: string) => boolean) // The 3.1 annotations `examples` and `$comment` count too, as the Anthropic // API writes `examples` beside a `$ref` to a tagged union. function isAnnotatedRef(value: Record): boolean { - return hasOnly(value, (key) => key === '$ref' || isInputAnnotation(key)) + return hasOnly( + value, + (key) => key === '$ref' || isInputAnnotation(key) || isRemovedBesideRef(value, key), + ) } // 3.0 reads an `allOf` that holds a `$ref` to a schema with a `discriminator` @@ -377,7 +391,16 @@ function isAnnotatedRef(value: Record): boolean { // Hey API pins the tag of a subtype too, one whose `allOf` holds a `$ref` to // such a schema, as the NPP API describes a GeoJSON point. function isTaggedRef(value: Record, ref: string, ctx: Context): boolean { - if (!hasOnly(value, (key) => key === '$ref' || key === 'type' || isInputAnnotation(key))) { + if ( + !hasOnly( + value, + (key) => + key === '$ref' || + key === 'type' || + isInputAnnotation(key) || + isRemovedBesideRef(value, key), + ) + ) { return false } const target = resolvePointer(ref, ctx) @@ -1551,7 +1574,12 @@ function revisitSchema(out: object): void { // Spelled out, not through `hasOnly`, since it runs for every `$ref`. function isBareRef(value: Record): boolean { for (const key in value) { - if (key !== '$ref' && Object.hasOwn(value, key) && value[key] !== undefined) { + if ( + key !== '$ref' && + Object.hasOwn(value, key) && + value[key] !== undefined && + !isRemovedBesideRef(value, key) + ) { return false } } @@ -1563,11 +1591,12 @@ function convertSchema(value: unknown, ctx: Context): unknown { return value ? {} : { not: {} } } const ref = getRef(value) - if (ref !== undefined) { - const siblings = refSiblings(value as Record) - if (isBareRef(siblings) || isTaggedRef(siblings, ref, ctx)) { - return convertSchemaRef(ref, ctx, value) - } + if ( + ref !== undefined && + (isBareRef(value as Record) || + isTaggedRef(value as Record, ref, ctx)) + ) { + return convertSchemaRef(ref, ctx, value) } return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema, revisitSchema) } 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 80428b9..d5ee210 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -495,6 +495,21 @@ describe('downgradeSchemaV31ToV30', () => { description: 'd', allOf: [{ $ref: pet }], }) + // So do identifiers 3.0 lacks, as zod-to-json-schema writes `definitions` beside one. + expect( + schema({ + $ref: pet, + $comment: 'c', + $anchor: 'a', + $dynamicAnchor: 'd', + $id: 'https://example.com/pet', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $vocabulary: {}, + $defs: { A: {} }, + definitions: { B: {} }, + nullable: true, + }), + ).toStrictEqual({ $ref: pet }) // A discriminator maps only to the `$ref`s among its branches. const discriminator = { propertyName: 'type' } expect( @@ -2299,6 +2314,9 @@ describe('downgradeSchemaV31ToV30', () => { pet: { ...ref('Pet'), description: 'Pet' }, // A 3.0-style `nullable` means nothing beside a `$ref` without a `type`. nullableRef: { ...ref('Strategy'), nullable: true, description: 'd' }, + identified: { ...ref('Strategy'), $id: 'https://example.com/s', title: 'S' }, + // Beside a `type`, it lets the target take null in 3.0. + typedNullable: { ...ref('Identification'), type: 'object', nullable: true }, }, }, }, @@ -2318,6 +2336,8 @@ describe('downgradeSchemaV31ToV30', () => { nullableTarget: { type: 'object', allOf: [ref('Nullable')] }, pet: { description: 'Pet', allOf: [ref('Pet')] }, nullableRef: ref('Strategy'), + identified: ref('Strategy'), + typedNullable: { type: 'object', nullable: true, allOf: [ref('Identification')] }, }, }) // A target that replaces the `$ref` keeps the annotations beside it. From 24cfba248d4bfa19d9ef602cdde418b1fa39f661 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:26:13 +0000 Subject: [PATCH 12/28] fix(downgrader): keep null in a loop of nullable $ref pairs A pair of null and a `$ref` becomes the `$ref` alone when its target admits null. When the target is itself such a pair that leads back, as in `A: { anyOf: [{ $ref: A }, { type: null }] }`, every pair in the loop became a bare `$ref`, which matches nothing, so null, which 3.1 accepts, was lost. A loop of such pairs now keeps its pairs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 31 +++++++++++++--- .../downgrader/tests/v3.1-to-v3.0.test.ts | 35 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 93a15f5..60cf49e 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -931,6 +931,31 @@ function admitsNull(schema: unknown): boolean { ) } +// A `$ref` whose target admits null, which a pair of null and the `$ref` +// becomes. A target that holds only such a pair becomes its own `$ref` in +// turn, so a loop of them, which in 3.1 admits only null, would become +// `$ref`s that match nothing. One that does not admit null keeps its pair. +function refAdmitsNull(ref: string, ctx: Context): boolean { + const seen = new Set() + for (let next: string | undefined = ref; next !== undefined;) { + const target = resolvePointer(next, ctx) + if (!admitsNull(target)) { + return next !== ref + } + if (seen.has(next)) { + return false + } + seen.add(next) + const { anyOf } = target as Record + const others = + Array.isArray(anyOf) && Object.keys(target as object).length === 1 + ? anyOf.filter((branch) => !acceptsNull(branch)) + : [] + next = others.length === 1 ? getRef(others[0]) : undefined + } + return true +} + // A `type` with no `nullable: true` beside it, in 3.1 or 3.0, rejects null. function rejectsNull(schema: unknown): boolean { if (!isRecord(schema) || schema.nullable === true) { @@ -1151,11 +1176,7 @@ function mergeNullable( const nullBranch = branches.find((branch) => isNullBranch(branch, out)) out.oneOf = branches[0] === other ? [other, nullBranch] : [nullBranch, other] delete out.anyOf - } else if ( - ref !== undefined && - Object.keys(out).length === 1 && - admitsNull(resolvePointer(ref, ctx)) - ) { + } else if (ref !== undefined && Object.keys(out).length === 1 && refAdmitsNull(ref, ctx)) { // GitHub writes a nullable reference this way to a schema that already // admits null, and Zod and Pydantic to a nullable one, which 3.0 writes as // the `$ref` alone. NSwag reads the pair as an empty class, and 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 d5ee210..225ff66 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1328,6 +1328,41 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + it('keeps the null of an anyOf with null whose $ref leads back to it through such pairs', () => { + const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }) + const pair = (name: string) => ({ anyOf: [ref(name), { type: 'null' as const }] }) + const schemas = (input: Record) => + convert({ components: { schemas: input } }).components?.schemas + // In 3.1 these admit only null, and the bare `$ref`s would match nothing. + expect(schemas({ A: pair('A') })).toEqual({ A: { anyOf: [ref('A'), onlyNull] } }) + expect(schemas({ A: pair('B'), B: pair('A') })).toEqual({ + A: { anyOf: [ref('B'), onlyNull] }, + B: { anyOf: [ref('A'), onlyNull] }, + }) + expect(schemas({ A: pair('B'), B: pair('C'), C: pair('B') })).toEqual({ + A: { anyOf: [ref('B'), onlyNull] }, + B: { anyOf: [ref('C'), onlyNull] }, + C: { anyOf: [ref('B'), onlyNull] }, + }) + // A chain that ends at a schema admitting null keeps each `$ref` alone. + expect(schemas({ A: pair('B'), B: pair('C'), C: { type: ['string', 'null'] } })).toEqual({ + A: ref('B'), + B: ref('C'), + C: { type: 'string', nullable: true }, + }) + // A pair whose `$ref` rejects null becomes a `oneOf`, which keeps null. + expect(schemas({ A: pair('B'), B: pair('C'), C: { type: 'string' } })).toEqual({ + A: ref('B'), + B: { oneOf: [ref('C'), onlyNull] }, + C: { type: 'string' }, + }) + // A pair with an annotation stays, so the `$ref` to it gets null from it. + expect(schemas({ A: pair('B'), B: { description: 'B', ...pair('A') } })).toEqual({ + A: ref('B'), + B: { description: 'B', anyOf: [ref('A'), onlyNull] }, + }) + }) + it('merges a branch in full where a cycle meets it before its conversion is done', () => { const node: Record = { type: 'object', properties: {} } node.properties.parent = { anyOf: [node, { type: 'null' }] } From bc1fe1509156a7ba03b8045330ae7b27afa0f9d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:42:06 +0000 Subject: [PATCH 13/28] fix(downgrader): follow nullable pairs beside annotations 3.0 drops A pair beside a `$comment` or an empty `examples`, which 3.0 drops, still collapsed to a bare `$ref` inside a loop, as the walk read the input's key count. It now follows any target with an `anyOf`, which `admitsNull` already limits to annotations beside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 12 +++++------- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 60cf49e..236a46b 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -932,9 +932,10 @@ function admitsNull(schema: unknown): boolean { } // A `$ref` whose target admits null, which a pair of null and the `$ref` -// becomes. A target that holds only such a pair becomes its own `$ref` in -// turn, so a loop of them, which in 3.1 admits only null, would become -// `$ref`s that match nothing. One that does not admit null keeps its pair. +// becomes. A target that holds such a pair, beside annotations that 3.0 may +// drop, such as `$comment`, can become its own `$ref` in turn, so a loop of +// them, which in 3.1 admits only null, would become `$ref`s that match +// nothing. One that does not admit null keeps its pair. function refAdmitsNull(ref: string, ctx: Context): boolean { const seen = new Set() for (let next: string | undefined = ref; next !== undefined;) { @@ -947,10 +948,7 @@ function refAdmitsNull(ref: string, ctx: Context): boolean { } seen.add(next) const { anyOf } = target as Record - const others = - Array.isArray(anyOf) && Object.keys(target as object).length === 1 - ? anyOf.filter((branch) => !acceptsNull(branch)) - : [] + const others = Array.isArray(anyOf) ? anyOf.filter((branch) => !acceptsNull(branch)) : [] next = others.length === 1 ? getRef(others[0]) : undefined } return true 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 225ff66..31c22b6 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1356,9 +1356,22 @@ describe('downgradeSchemaV31ToV30', () => { B: { oneOf: [ref('C'), onlyNull] }, C: { type: 'string' }, }) - // A pair with an annotation stays, so the `$ref` to it gets null from it. - expect(schemas({ A: pair('B'), B: { description: 'B', ...pair('A') } })).toEqual({ + // A target with more branches stays a union that holds null itself. + const union = { anyOf: [ref('A'), { type: 'string' as const }, { type: 'null' as const }] } + expect(schemas({ A: pair('B'), B: union })).toEqual({ A: ref('B'), + B: { anyOf: [ref('A'), { type: 'string' }, onlyNull] }, + }) + // Annotations beside a pair count for nothing, as 3.0 drops some of them. + expect(schemas({ A: { $comment: 'c', ...pair('A') } })).toEqual({ + A: { anyOf: [ref('A'), onlyNull] }, + }) + expect(schemas({ A: pair('B'), B: { examples: [], ...pair('A') } })).toEqual({ + A: { anyOf: [ref('B'), onlyNull] }, + B: { anyOf: [ref('A'), onlyNull] }, + }) + expect(schemas({ A: pair('B'), B: { description: 'B', ...pair('A') } })).toEqual({ + A: { anyOf: [ref('B'), onlyNull] }, B: { description: 'B', anyOf: [ref('A'), onlyNull] }, }) }) From f7cdda71385a5340deb92a60985a350c3526fdac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:07:18 +0000 Subject: [PATCH 14/28] fix(downgrader): drop a discriminator that a $ref or type list would give a composition A discriminator beside none of oneOf, anyOf and allOf does nothing in 3.1, as Pydantic writes one beside the $ref of a tagged union with a single member (ElevenLabs). The $ref becomes an allOf in 3.0, and a type list an anyOf, which 3.0 validators then apply the discriminator to, rejecting values without the tag. Such a discriminator is now dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 10 +++++++ .../downgrader/tests/v3.1-to-v3.0.test.ts | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 99bb176..09d7da9 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -322,9 +322,19 @@ function refOr(convert: Convert): Convert { // A discriminator picks only among named components, not the targets that // replace `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union. // Its `mapping` into them would dangle, which validators that follow it reject. +// Beside none of `oneOf`, `anyOf` and `allOf`, it does nothing, as Pydantic +// writes one beside the `$ref` of a tagged union with a single member, but 3.0 +// tools would apply it to the `allOf` the `$ref` becomes, or the `anyOf` a +// `type` list does, and reject what lacks the tag. function keepsDiscriminator(schema: Record, ctx: Context): boolean { + const { type } = schema return ( schema.discriminator !== undefined && + (schema.oneOf !== undefined || + schema.anyOf !== undefined || + schema.allOf !== undefined || + (typeof schema.$ref !== 'string' && + !(Array.isArray(type) && type.filter((item) => item !== 'null').length > 1))) && ![schema.oneOf, schema.anyOf].some( (branches) => Array.isArray(branches) && 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 f3762bd..c9fe9f8 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2280,6 +2280,33 @@ describe('downgradeSchemaV31ToV30', () => { } }) + it('removes a discriminator that a $ref or a type list would give an allOf or anyOf to act on', () => { + const discriminator = { propertyName: 'type', mapping: { a: '#/components/schemas/A' } } + const ref = { $ref: '#/components/schemas/A' } + // As Pydantic writes a tagged union of a single member. + expect(downgradeSchemaV31ToV30({ title: 'T', discriminator, ...ref })).toEqual({ + title: 'T', + allOf: [ref], + }) + expect(downgradeSchemaV31ToV30({ type: ['integer', 'object'], discriminator })).toEqual({ + anyOf: [{ type: 'integer' }, { type: 'object' }], + }) + // A base object keeps it, and so does a `$ref` beside branches. + const base = { type: 'object', properties: { type: { type: 'string' } } } as const + expect(downgradeSchemaV31ToV30({ ...base, discriminator })).toEqual({ + ...base, + discriminator, + }) + expect(downgradeSchemaV31ToV30({ ...base, type: ['object', 'null'], discriminator })).toEqual( + { ...base, nullable: true, discriminator }, + ) + expect(downgradeSchemaV31ToV30({ ...ref, oneOf: [ref], discriminator })).toEqual({ + oneOf: [ref], + discriminator, + allOf: [ref], + }) + }) + it('keeps a $ref beside only annotations bare in a union beside a discriminator', () => { // As Anthropic describes each branch of a tagged union. 3.0 maps a // discriminator only to the `$ref`s among the branches. From 660afb1c33b2f6fb578ab447969ba56e1722c7ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:07:53 +0000 Subject: [PATCH 15/28] docs(downgrader): note the discriminator dropped beside a $ref or type list Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 9b261ee..7dc9a65 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -205,6 +205,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it, or into any `items` or `additionalProperties`, where the conversion can put another schema, is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | A [`discriminator`][3.0-schema-discriminator] over `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union | Removed. A discriminator picks only among named components, not the targets that replace those `$ref`s, and its `mapping` into them would dangle, which validators that follow it, such as openapi-schema-validator, reject. | | Extensions in a [`discriminator`][3.0-schema-discriminator] | Removed. 3.0 allows none there. | +| A [`discriminator`][3.0-schema-discriminator] beside a `$ref` or a `type` list, with no `oneOf`, `anyOf`, or `allOf`, as Pydantic writes for a single-member tagged union | Removed. It does nothing there, but 3.0 tools would apply it to the `allOf` or `anyOf` those become. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | | [`patternProperties`][js-pattern-properties] | Removed. `additionalProperties` becomes `true`, as a schema or `false` there would reject the properties `patternProperties` allowed, and code generators read a missing one as closed. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], [`contentSchema`][js-content-schema], [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], [`dependentRequired`][js-dependent-required], [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], [`propertyNames`][js-property-names], and [`unevaluatedItems`][js-unevaluated-items] | Removed. | From 6718cbd51e82e0c2675b195e5a571aebc66f8120 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:12:15 +0000 Subject: [PATCH 16/28] fix(downgrader): keep a $ref bare beside only a removed discriminator Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 11 ++++++++--- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 09d7da9..47a4e4a 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -365,8 +365,9 @@ function convertTaggedBranch(value: unknown, ctx: Context): unknown { // Keywords 3.0 removes that restrict nothing, such as identifiers and the // `definitions` zod-to-json-schema writes, count as missing beside a `$ref`, as // does a 3.0-style `nullable`, which means nothing in 3.1, and which 3.0 keeps -// only beside a single `type`, as aide writes it. The `$ref` then stays bare, -// not an `allOf` with nothing beside it. +// only beside a single `type`, as aide writes it, and a `discriminator`, which +// does nothing there, as Exa writes it. The `$ref` then stays bare, not an +// `allOf` with nothing beside it. const IDENTIFIER_KEYWORDS = new Set([ '$anchor', '$comment', @@ -379,7 +380,11 @@ const IDENTIFIER_KEYWORDS = new Set([ ]) function isRemovedBesideRef(value: Record, key: string): boolean { - return IDENTIFIER_KEYWORDS.has(key) || (key === 'nullable' && value.type === undefined) + return ( + IDENTIFIER_KEYWORDS.has(key) || + key === 'discriminator' || + (key === 'nullable' && value.type === undefined) + ) } // Whether each key of `value` that holds a value passes `test`, since a key 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 c9fe9f8..ca2a28c 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2288,6 +2288,8 @@ describe('downgradeSchemaV31ToV30', () => { title: 'T', allOf: [ref], }) + // As Exa writes it, the `$ref` then stays bare. + expect(downgradeSchemaV31ToV30({ discriminator, ...ref })).toStrictEqual(ref) expect(downgradeSchemaV31ToV30({ type: ['integer', 'object'], discriminator })).toEqual({ anyOf: [{ type: 'integer' }, { type: 'object' }], }) From 80c69aea126cf8f3b63593d742e2981de81b24c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:43:43 +0000 Subject: [PATCH 17/28] test(downgrader): keep a discriminator beside a $ref and an anyOf or allOf Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 ca2a28c..64d3d55 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2307,6 +2307,12 @@ describe('downgradeSchemaV31ToV30', () => { discriminator, allOf: [ref], }) + for (const key of ['anyOf', 'allOf'] as const) { + expect(downgradeSchemaV31ToV30({ ...ref, [key]: [ref], discriminator })).toHaveProperty( + 'discriminator', + discriminator, + ) + } }) it('keeps a $ref beside only annotations bare in a union beside a discriminator', () => { From c80c90cd9bc594142a6e91b37b5b6dfec8b62941 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:47:09 +0000 Subject: [PATCH 18/28] fix(downgrader): follow a null pair nested in a null pair around a $ref loop Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 15 +++++++++++++-- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 47a4e4a..293ca50 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -962,7 +962,8 @@ function admitsNull(schema: unknown): boolean { // becomes. A target that holds such a pair, beside annotations that 3.0 may // drop, such as `$comment`, can become its own `$ref` in turn, so a loop of // them, which in 3.1 admits only null, would become `$ref`s that match -// nothing. One that does not admit null keeps its pair. +// nothing. One that does not admit null keeps its pair. A pair nested in the +// target's `anyOf`, as `anyOf: [{ anyOf: [$ref, null] }, null]`, leads on too. function refAdmitsNull(ref: string, ctx: Context): boolean { const seen = new Set() for (let next: string | undefined = ref; next !== undefined;) { @@ -975,12 +976,22 @@ function refAdmitsNull(ref: string, ctx: Context): boolean { } seen.add(next) const { anyOf } = target as Record - const others = Array.isArray(anyOf) ? anyOf.filter((branch) => !acceptsNull(branch)) : [] + const others = Array.isArray(anyOf) ? nonNullBranches(anyOf) : [] next = others.length === 1 ? getRef(others[0]) : undefined } return true } +function nonNullBranches(branches: unknown[]): unknown[] { + return branches.flatMap((branch) => { + if (acceptsNull(branch)) { + return [] + } + const nested = isRecord(branch) ? (branch.anyOf ?? branch.oneOf) : undefined + return Array.isArray(nested) && nested.some(acceptsNull) ? nonNullBranches(nested) : [branch] + }) +} + // A `type` with no `nullable: true` beside it, in 3.1 or 3.0, rejects null. function rejectsNull(schema: unknown): boolean { if (!isRecord(schema) || schema.nullable === true) { 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 64d3d55..8ec1f96 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1374,6 +1374,22 @@ describe('downgradeSchemaV31ToV30', () => { A: { anyOf: [ref('B'), onlyNull] }, B: { description: 'B', anyOf: [ref('A'), onlyNull] }, }) + // So does a pair nested in the target's union. + const nested = { anyOf: [{ anyOf: [ref('A'), onlyNull] }, onlyNull] } + expect(schemas({ A: { anyOf: [pair('A'), { type: 'null' }] } })).toEqual({ A: nested }) + expect(schemas({ A: { anyOf: [{ oneOf: pair('A').anyOf }, { type: 'null' }] } })).toEqual({ + A: nested, + }) + expect( + schemas({ + A: pair('B'), + B: { anyOf: [{ anyOf: [ref('A'), ref('B')] }, { type: 'null' }] }, + }), + ).toEqual({ A: ref('B'), B: { anyOf: [{ anyOf: [ref('A'), ref('B')] }, onlyNull] } }) + expect(schemas({ A: pair('B'), B: { anyOf: [false, ref('A'), { type: 'null' }] } })).toEqual({ + A: ref('B'), + B: { anyOf: [{ not: {} }, ref('A'), onlyNull] }, + }) }) it('merges a branch in full where a cycle meets it before its conversion is done', () => { From 96fe14dbc5e20a89a2b89490f5863e8cd5e6e901 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:52:27 +0000 Subject: [PATCH 19/28] fix(downgrader): stop the nested pair walk at a cycle of objects Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 11 +++++++++-- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 293ca50..207c925 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -982,13 +982,20 @@ function refAdmitsNull(ref: string, ctx: Context): boolean { return true } -function nonNullBranches(branches: unknown[]): unknown[] { +// Met again, around a cycle of objects, a union adds nothing. +function nonNullBranches(branches: unknown[], seen = new Set()): unknown[] { + if (seen.has(branches)) { + return [] + } + seen.add(branches) return branches.flatMap((branch) => { if (acceptsNull(branch)) { return [] } const nested = isRecord(branch) ? (branch.anyOf ?? branch.oneOf) : undefined - return Array.isArray(nested) && nested.some(acceptsNull) ? nonNullBranches(nested) : [branch] + return Array.isArray(nested) && nested.some(acceptsNull) + ? nonNullBranches(nested, seen) + : [branch] }) } 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 8ec1f96..888e513 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1390,6 +1390,10 @@ describe('downgradeSchemaV31ToV30', () => { A: ref('B'), B: { anyOf: [{ not: {} }, ref('A'), onlyNull] }, }) + // A cycle of objects ends the walk. + const cyclic: Record = { anyOf: [{ type: 'null' }] } + cyclic.anyOf.unshift({ anyOf: [cyclic, { type: 'null' }] }) + expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual(ref('B')) }) it('merges a branch in full where a cycle meets it before its conversion is done', () => { From b3e253696c34eccf18f7d8bb960398fb7b8df2ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:15:10 +0000 Subject: [PATCH 20/28] test(downgrader): follow a $ref beside an object cycle in a nested null pair Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 888e513..6d95410 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1394,6 +1394,9 @@ describe('downgradeSchemaV31ToV30', () => { const cyclic: Record = { anyOf: [{ type: 'null' }] } cyclic.anyOf.unshift({ anyOf: [cyclic, { type: 'null' }] }) expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual(ref('B')) + // A $ref beside the cycle is still followed. + cyclic.anyOf[0].anyOf.push(ref('A')) + expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual({ anyOf: [ref('B'), onlyNull] }) }) it('merges a branch in full where a cycle meets it before its conversion is done', () => { From 956f651d204d56734869ce1958cbe97a2504238a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:27:24 +0000 Subject: [PATCH 21/28] fix(downgrader): keep a null pair only when its $ref would loop back to it in 3.0 The walk over the input that kept a pair of null and a $ref around a loop missed null written as const or enum, a duplicate $ref, and other nesting, so such a loop still became a $ref to itself. Each pair whose target admits null now becomes the $ref alone, and one that then leads back to itself through bare $refs in the output keeps its pair. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 78 +++++++++---------- .../downgrader/tests/v3.1-to-v3.0.test.ts | 28 ++++--- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 207c925..2d99ccb 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -141,6 +141,9 @@ const refTargets = new Set() // holds it, also once the target it was cut at is no longer being inlined. const cutHolders = new Set() +// Pairs of null and a `$ref` that became the `$ref` alone, with their branches. +const collapsedPairs: [schema: Record, anyOf: unknown[]][] = [] + // The 3.0 keywords that apply only to objects, which `convertType` moves into // the `"object"` branch of a `type` list. const OBJECT_KEYWORDS = [ @@ -958,47 +961,6 @@ function admitsNull(schema: unknown): boolean { ) } -// A `$ref` whose target admits null, which a pair of null and the `$ref` -// becomes. A target that holds such a pair, beside annotations that 3.0 may -// drop, such as `$comment`, can become its own `$ref` in turn, so a loop of -// them, which in 3.1 admits only null, would become `$ref`s that match -// nothing. One that does not admit null keeps its pair. A pair nested in the -// target's `anyOf`, as `anyOf: [{ anyOf: [$ref, null] }, null]`, leads on too. -function refAdmitsNull(ref: string, ctx: Context): boolean { - const seen = new Set() - for (let next: string | undefined = ref; next !== undefined;) { - const target = resolvePointer(next, ctx) - if (!admitsNull(target)) { - return next !== ref - } - if (seen.has(next)) { - return false - } - seen.add(next) - const { anyOf } = target as Record - const others = Array.isArray(anyOf) ? nonNullBranches(anyOf) : [] - next = others.length === 1 ? getRef(others[0]) : undefined - } - return true -} - -// Met again, around a cycle of objects, a union adds nothing. -function nonNullBranches(branches: unknown[], seen = new Set()): unknown[] { - if (seen.has(branches)) { - return [] - } - seen.add(branches) - return branches.flatMap((branch) => { - if (acceptsNull(branch)) { - return [] - } - const nested = isRecord(branch) ? (branch.anyOf ?? branch.oneOf) : undefined - return Array.isArray(nested) && nested.some(acceptsNull) - ? nonNullBranches(nested, seen) - : [branch] - }) -} - // A `type` with no `nullable: true` beside it, in 3.1 or 3.0, rejects null. function rejectsNull(schema: unknown): boolean { if (!isRecord(schema) || schema.nullable === true) { @@ -1219,11 +1181,16 @@ function mergeNullable( const nullBranch = branches.find((branch) => isNullBranch(branch, out)) out.oneOf = branches[0] === other ? [other, nullBranch] : [nullBranch, other] delete out.anyOf - } else if (ref !== undefined && Object.keys(out).length === 1 && refAdmitsNull(ref, ctx)) { + } else if ( + ref !== undefined && + Object.keys(out).length === 1 && + admitsNull(resolvePointer(ref, ctx)) + ) { // GitHub writes a nullable reference this way to a schema that already // admits null, and Zod and Pydantic to a nullable one, which 3.0 writes as // the `$ref` alone. NSwag reads the pair as an empty class, and // oapi-codegen as a union. + collapsedPairs.push([out, out.anyOf as unknown[]]) delete out.anyOf out.$ref = ref } @@ -1769,6 +1736,23 @@ function convertDocument(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, DOCUMENT_FIELDS, finishDocument) } +// A chain of bare `$ref`s from `schema` that comes back to it. +function leadsBack(schema: Record, ctx: Context): boolean { + const seen = new Set() + for (let next: unknown = schema; isRecord(next) && Object.keys(next).length === 1;) { + const ref = getRef(next) + if (ref === undefined || seen.has(next)) { + return false + } + seen.add(next) + next = resolvePointer(ref, ctx) + if (next === schema) { + return true + } + } + return false +} + function downgradeV30(root: unknown, convert: Convert): unknown { try { const ctx = createContext(root) @@ -1787,6 +1771,15 @@ function downgradeV30(root: unknown, convert: Convert): unknown { } } } + // A loop of such `$ref`s, which in 3.1 admits only null, would match + // nothing, so each keeps its pair. + if (collapsedPairs.length > 0) { + const outCtx = createContext(out) + for (const [schema, anyOf] of collapsedPairs.filter(([item]) => leadsBack(item, outCtx))) { + delete schema.$ref + schema.anyOf = anyOf + } + } return out } finally { // Cleared even when conversion throws, so nothing keeps the input alive. @@ -1794,6 +1787,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { unionTargets.clear() refTargets.clear() cutHolders.clear() + collapsedPairs.length = 0 LOOSE.clear() } } 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 6d95410..73889a3 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1339,8 +1339,9 @@ describe('downgradeSchemaV31ToV30', () => { A: { anyOf: [ref('B'), onlyNull] }, B: { anyOf: [ref('A'), onlyNull] }, }) + // One that leads into such a loop, without being on it, keeps the `$ref`. expect(schemas({ A: pair('B'), B: pair('C'), C: pair('B') })).toEqual({ - A: { anyOf: [ref('B'), onlyNull] }, + A: ref('B'), B: { anyOf: [ref('C'), onlyNull] }, C: { anyOf: [ref('B'), onlyNull] }, }) @@ -1370,12 +1371,13 @@ describe('downgradeSchemaV31ToV30', () => { A: { anyOf: [ref('B'), onlyNull] }, B: { anyOf: [ref('A'), onlyNull] }, }) + // A description that 3.0 keeps beside the pair breaks the loop. expect(schemas({ A: pair('B'), B: { description: 'B', ...pair('A') } })).toEqual({ - A: { anyOf: [ref('B'), onlyNull] }, + A: ref('B'), B: { description: 'B', anyOf: [ref('A'), onlyNull] }, }) - // So does a pair nested in the target's union. - const nested = { anyOf: [{ anyOf: [ref('A'), onlyNull] }, onlyNull] } + // So does a pair nested in the target's union, which keeps one pair. + const nested = { anyOf: [ref('A'), onlyNull] } expect(schemas({ A: { anyOf: [pair('A'), { type: 'null' }] } })).toEqual({ A: nested }) expect(schemas({ A: { anyOf: [{ oneOf: pair('A').anyOf }, { type: 'null' }] } })).toEqual({ A: nested, @@ -1390,13 +1392,17 @@ describe('downgradeSchemaV31ToV30', () => { A: ref('B'), B: { anyOf: [{ not: {} }, ref('A'), onlyNull] }, }) - // A cycle of objects ends the walk. - const cyclic: Record = { anyOf: [{ type: 'null' }] } - cyclic.anyOf.unshift({ anyOf: [cyclic, { type: 'null' }] }) - expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual(ref('B')) - // A $ref beside the cycle is still followed. - cyclic.anyOf[0].anyOf.push(ref('A')) - expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual({ anyOf: [ref('B'), onlyNull] }) + // As does a loop through a nested pair, or a duplicate `$ref`, or null + // written as `const`. + expect( + schemas({ + A: pair('B'), + B: { anyOf: [{ anyOf: [ref('A'), { const: null }] }, { type: 'null' }] }, + }), + ).toEqual({ A: { anyOf: [ref('B'), onlyNull] }, B: { anyOf: [ref('A'), onlyNull] } }) + expect(schemas({ A: { anyOf: [ref('A'), ref('A'), { type: 'null' }] } })).toEqual({ + A: { anyOf: [ref('A'), onlyNull] }, + }) }) it('merges a branch in full where a cycle meets it before its conversion is done', () => { From 587947c8bed77ea7cdd84d8a04798d2d290f2c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:07:47 +0000 Subject: [PATCH 22/28] refactor(downgrader): follow any $ref in the 3.0 loop check, and test its edges 3.0 output has no $ref with siblings, so the loop check needs no bare-key test. Tests cover an object cycle in a pair's target and a $ref whose target moves in 3.0, which Stryker found unchecked after #365. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/src/v3.1-to-v3.0.ts | 16 +++++++--------- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2d99ccb..3d83809 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -1736,19 +1736,17 @@ function convertDocument(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, DOCUMENT_FIELDS, finishDocument) } -// A chain of bare `$ref`s from `schema` that comes back to it. +// A chain of `$ref`s from `schema` that comes back to it. 3.0 has no `$ref` +// with siblings, so each is bare. function leadsBack(schema: Record, ctx: Context): boolean { - const seen = new Set() - for (let next: unknown = schema; isRecord(next) && Object.keys(next).length === 1;) { - const ref = getRef(next) - if (ref === undefined || seen.has(next)) { - return false - } - seen.add(next) - next = resolvePointer(ref, ctx) + const seen = new Set() + for (let ref = getRef(schema); ref !== undefined && !seen.has(ref);) { + seen.add(ref) + const next = resolvePointer(ref, ctx) if (next === schema) { return true } + ref = getRef(next) } return false } 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 73889a3..87e086b 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1403,6 +1403,17 @@ describe('downgradeSchemaV31ToV30', () => { expect(schemas({ A: { anyOf: [ref('A'), ref('A'), { type: 'null' }] } })).toEqual({ A: { anyOf: [ref('A'), onlyNull] }, }) + // A cycle of objects in the target converts. + const cyclic: Record = { anyOf: [{ type: 'null' }] } + cyclic.anyOf.unshift({ anyOf: [cyclic, { type: 'null' }] }) + expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual(ref('B')) + // A `$ref` whose target moves in 3.0 leads nowhere. + expect( + schemas({ + A: pair('B/properties/a'), + B: { type: ['object', 'string'], properties: { a: { type: 'null' } } }, + })?.A, + ).toEqual(ref('B/properties/a')) }) it('merges a branch in full where a cycle meets it before its conversion is done', () => { From d8db86138bcb2975c679ef47b82c13a606414822 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:06:33 +0000 Subject: [PATCH 23/28] test(downgrader): pin a oneOf pair to a null-admitting $ref, and null on both sides of a branch Stryker left three mutants alive: mergeNullable's anyOf-only guard (a oneOf of null and a $ref whose target admits null gained a $ref beside it) and the post-pass check that lets an anyOf with null twice merge (null on both sides of one branch stayed a oneOf). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 87e086b..f450897 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -771,6 +771,11 @@ describe('downgradeSchemaV31ToV30', () => { anyOf: [{ type: 'string' }, { type: 'null' }, { type: 'null' }], }), ).toEqual({ type: 'string', nullable: true }) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'null' }, { type: 'string' }, { type: 'null' }], + }), + ).toEqual({ type: 'string', nullable: true }) // One branch holds no null to merge, as Valibot writes a union of one schema. expect(downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }] })).toEqual({ anyOf: [{ type: 'string' }], @@ -1291,6 +1296,12 @@ describe('downgradeSchemaV31ToV30', () => { description: 'A', anyOf: [onlyNull, ref], }) + // A `oneOf` rejects the null that both branches match, so it stays. + const pair = { oneOf: [{ type: 'null' as const }, ref] } + expect( + convert({ components: { schemas: { Pet: { type: ['object', 'null'] }, Owner: pair } } }) + .components?.schemas?.Owner, + ).toEqual({ oneOf: [onlyNull, ref] }) }) it('keeps only the $ref of an anyOf with null when the target is a nullable anyOf, as Zod and Pydantic write it', () => { From be4ac8b69365fc081a8b98cf6a353eab73208b84 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:29:44 +0000 Subject: [PATCH 24/28] test(downgrader): pin an enum branch beside null, a $ref beside a nullable type list, and a nullable type list's looseness A differential run of each surviving Stryker mutant over 50k fuzz docs found six that change output: isSameBranch took an enum branch for the null branch beside it (losing null), a $ref beside a type list with null counted as admitting null (losing null), and a 3.0-style nullable beside a type list that already holds null marked the schema loose (a oneOf to it became an anyOf). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- .../downgrader/tests/v3.1-to-v3.0.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 f450897..8d472df 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -486,6 +486,15 @@ describe('downgradeSchemaV31ToV30', () => { expect(schema({ type: 'string', nullable: true })).toEqual({ type: 'string', nullable: true }) expect(schema({ nullable: true })).toEqual({}) expect(schema({ type: 'string', nullable: false })).toEqual({ type: 'string' }) + // Beside a `type` list that holds null, it adds nothing, so a `oneOf` with a `$ref` to it stays. + const branches = [{ $ref: '#/components/schemas/T' }, { type: 'integer' as const }] + expect( + convert({ + components: { + schemas: { T: { type: ['array', 'null'], nullable: true }, N: { oneOf: branches } }, + }, + }).components?.schemas?.N, + ).toEqual({ oneOf: branches }) // Beside a `$ref` without a `type`, as aide writes it, the `$ref` stays bare // rather than an `allOf` with nothing beside it. const pet = '#/components/schemas/Pet' @@ -776,6 +785,12 @@ describe('downgradeSchemaV31ToV30', () => { anyOf: [{ type: 'null' }, { type: 'string' }, { type: 'null' }], }), ).toEqual({ type: 'string', nullable: true }) + // A branch whose `enum` rejects null is not the null branch beside it. + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: ['string', 'null'], enum: ['a'] }, { type: 'null' }], + }), + ).toEqual({ anyOf: [{ type: 'string', enum: ['a'], nullable: true }, onlyNull] }) // One branch holds no null to merge, as Valibot writes a union of one schema. expect(downgradeSchemaV31ToV30({ anyOf: [{ type: 'string' }] })).toEqual({ anyOf: [{ type: 'string' }], @@ -1296,6 +1311,16 @@ describe('downgradeSchemaV31ToV30', () => { description: 'A', anyOf: [onlyNull, ref], }) + // So can the target of a `$ref` beside the `type`. + const pet: OpenAPIV3_1.SchemaObject = { + $ref: '#/components/schemas/Base', + type: ['object', 'null'], + } + const owner3 = { anyOf: [{ type: 'null' as const }, ref] } + expect( + convert({ components: { schemas: { Base: { type: 'object' }, Pet: pet, Owner: owner3 } } }) + .components?.schemas?.Owner, + ).toEqual({ anyOf: [onlyNull, ref] }) // A `oneOf` rejects the null that both branches match, so it stays. const pair = { oneOf: [{ type: 'null' as const }, ref] } expect( From abcbc8fa6d4c32123a13e0c19501987e14096418 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 18:30:01 +0000 Subject: [PATCH 25/28] test(downgrader): clearer comment and name in the $ref-beside-type case Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 8d472df..5cdd311 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1311,15 +1311,16 @@ describe('downgradeSchemaV31ToV30', () => { description: 'A', anyOf: [onlyNull, ref], }) - // So can the target of a `$ref` beside the `type`. + // The target of a `$ref` beside the `type` can reject null too, so the pair stays. const pet: OpenAPIV3_1.SchemaObject = { $ref: '#/components/schemas/Base', type: ['object', 'null'], } - const owner3 = { anyOf: [{ type: 'null' as const }, ref] } + const nullablePet = { anyOf: [{ type: 'null' as const }, ref] } expect( - convert({ components: { schemas: { Base: { type: 'object' }, Pet: pet, Owner: owner3 } } }) - .components?.schemas?.Owner, + convert({ + components: { schemas: { Base: { type: 'object' }, Pet: pet, Owner: nullablePet } }, + }).components?.schemas?.Owner, ).toEqual({ anyOf: [onlyNull, ref] }) // A `oneOf` rejects the null that both branches match, so it stays. const pair = { oneOf: [{ type: 'null' as const }, ref] } From 28eee96858547e932b370af8e45f7324a4da589b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 02:26:35 +0000 Subject: [PATCH 26/28] test(downgrader): pin that a bare null in a type list counts only as null A null alone gets no string format, which kills the last Stryker survivor on #372's hasType change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 113a9cd..23e5a29 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3979,6 +3979,10 @@ describe('unusual input', () => { expect(downgradeSchemaV31ToV30({ type: [null] } as never)).toEqual( downgradeSchemaV31ToV30({ type: ['null'] }), ) + // A bare null counts only as "null", so null alone gets no string format. + expect( + downgradeSchemaV31ToV30({ type: [null], contentEncoding: 'base64' } as never), + ).toStrictEqual(onlyNull) expect(downgradeSchemaV31ToV30({ type: null } as never)).toEqual({}) }) From b4b66a2f8b3dea23e45de9bc91df3e847f25f72a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:34:15 +0000 Subject: [PATCH 27/28] test(downgrader): pin that a $ref into a kept key or another component type stays as written Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 eae773f..280a93a 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2981,7 +2981,12 @@ describe('downgradeSchemaV31ToV30', () => { Nested: ref('FieldOptions/Text/properties/type'), Kept: ref('FieldOptions/x-note'), Removed: ref('FieldOptions/prefixItems/0'), + // A key 3.0 keeps, and a key of another component type, are not renamed. + Negated: { not: { type: 'string' } }, + IntoKept: ref('Negated/not'), + IntoParameter: { $ref: '#/components/parameters/Query/schema' }, }, + parameters: { Query: { name: 'q', in: 'query', schema: { type: 'string' } } }, }, }) // The discriminator goes, as its mapping would dangle. @@ -2998,6 +3003,10 @@ describe('downgradeSchemaV31ToV30', () => { Kept: ref('FieldOptions/x-note'), Removed: ref('FieldOptions/prefixItems/0'), }) + expect(out.components?.schemas?.IntoKept).toStrictEqual(ref('Negated/not')) + expect(out.components?.schemas?.IntoParameter).toStrictEqual({ + $ref: '#/components/parameters/Query/schema', + }) }) it('replaces a percent-encoded $ref with its target', () => { From 9928840ea89c7340bb5e2b955ced2786abd52826 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 05:37:57 +0000 Subject: [PATCH 28/28] test(downgrader): assert the kept $ref targets survive Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 280a93a..b24e9b2 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3002,7 +3002,9 @@ describe('downgradeSchemaV31ToV30', () => { Nested: { type: 'string', enum: ['Text'] }, Kept: ref('FieldOptions/x-note'), Removed: ref('FieldOptions/prefixItems/0'), + Negated: { not: { type: 'string' } }, }) + expect(out.components?.parameters?.Query).toMatchObject({ schema: { type: 'string' } }) expect(out.components?.schemas?.IntoKept).toStrictEqual(ref('Negated/not')) expect(out.components?.schemas?.IntoParameter).toStrictEqual({ $ref: '#/components/parameters/Query/schema',