From 175c3e34e51219ba01301733ace058f0abc5efcd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 14:50:02 +0000 Subject: [PATCH] fix(downgrader): loosen a `$ref` kept in 3.0 whose target lost a restriction A `oneOf` or `not` over a `$ref` to `components.schemas`, as zod emits for z.xor over registered tuples, kept its meaning even when the target lost a restriction, so it could reject values the original accepts. A kept `$ref` now counts as loose when its converted target is. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wi2EdEpXYbC3zpW37w9z4Q --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 5 +- .../downgrader/tests/v3.1-to-v3.0.test.ts | 59 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 82405b6..422f94c 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -237,7 +237,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc #### Limitations -- A `not` or `oneOf` that reaches a schema that lost a restriction through a `$ref` kept in the output, such as one to `components.schemas`, or around a cycle of objects, can still reject values the original accepts. +- 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. - 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. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index e28f077..d7fe7b7 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -260,7 +260,10 @@ function refOr(convert: Convert): Convert { function convertSchemaRef(ref: string, ctx: Context): unknown { if (!isInlined(ref, ctx)) { - return { $ref: ref } + // A `$ref` kept as written loses what its target loses. Converting the + // target here reuses the copy the components get, and stops at a cycle. + const out = { $ref: ref } + return isLoose(inline(ref, ctx, convertSchema)) ? loosened(out) : out } const out = inline(ref, ctx, convertSchema) return out === DROP ? loosened({}) : out diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 41f8e1b..8eafe7c 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -804,6 +804,65 @@ describe('downgradeSchemaV31ToV30', () => { }), ).toEqual({}) }) + + it('includes the target of a $ref kept in the output', () => { + // As zod emits z.xor over tuples registered as components. + const tuple = ( + a: OpenAPIV3_1.SchemaObjectType, + b: OpenAPIV3_1.SchemaObjectType, + ): OpenAPIV3_1.SchemaObject => ({ + type: 'array', + prefixItems: [{ type: a }, { type: b }], + items: false, + }) + const schemas = convert({ + components: { + schemas: { + StrNum: tuple('string', 'number'), + NumStr: tuple('number', 'string'), + Point: tuple('number', 'number'), + Alias: { $ref: '#/components/schemas/StrNum' }, + A: { $ref: '#/components/schemas/B' }, + B: { $ref: '#/components/schemas/A' }, + Xor: { + oneOf: [ + { $ref: '#/components/schemas/StrNum' }, + { $ref: '#/components/schemas/NumStr' }, + ], + }, + Not: { not: { $ref: '#/components/schemas/StrNum' } }, + Exact: { oneOf: [{ $ref: '#/components/schemas/Point' }, { type: 'string' }] }, + ViaAlias: { oneOf: [{ $ref: '#/components/schemas/Alias' }, { type: 'string' }] }, + Nested: { + oneOf: [ + { type: 'array', items: { $ref: '#/components/schemas/StrNum' } }, + { type: 'string' }, + ], + }, + Cycle: { oneOf: [{ $ref: '#/components/schemas/A' }, { type: 'string' }] }, + }, + }, + }).components?.schemas + expect(schemas?.Xor).toEqual({ + anyOf: [{ $ref: '#/components/schemas/StrNum' }, { $ref: '#/components/schemas/NumStr' }], + }) + expect(schemas?.Not).toEqual({}) + expect(schemas?.Exact).toEqual({ + oneOf: [{ $ref: '#/components/schemas/Point' }, { type: 'string' }], + }) + expect(schemas?.ViaAlias).toEqual({ + anyOf: [{ $ref: '#/components/schemas/Alias' }, { type: 'string' }], + }) + expect(schemas?.Nested).toEqual({ + anyOf: [ + { type: 'array', items: { $ref: '#/components/schemas/StrNum' } }, + { type: 'string' }, + ], + }) + expect(schemas?.Cycle).toEqual({ + oneOf: [{ $ref: '#/components/schemas/A' }, { type: 'string' }], + }) + }) }) it('drops an empty required, which 3.0 forbids', () => {