From 0b7e5649110791dc0c0a7358717bdf68b265383e Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 26 Sep 2026 20:58:10 +0700 Subject: [PATCH 1/2] fix(downgrader): stop nested multi-type arrays from doubling 3.1 to 3.0 output A schema like `{ type: ['array', 'object'], items: ... }` kept `items` at the top level and cloned it into the synthesized array variant, so every nesting level doubled the output. `items` now moves into the array variant instead. --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 24 +++++++++++++++++--- packages/downgrader/src/v3.1-to-v3.0.ts | 8 +++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 9869d34..2a7ba9f 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -116,7 +116,7 @@ Schema Objects: | `true` / `false` boolean schemas | `{}` / `{ not: {} }` | | `$ref` with sibling keywords | siblings kept, `$ref` moved into `allOf` | | `type: ["T", "null"]` | `type: "T"` plus `nullable: true` | -| `type` with several non-null entries | `anyOf` of single-type schemas, each `nullable` when `null` was listed | +| `type` with several non-null entries | `anyOf` of single-type schemas, each `nullable` when `null` was listed. A sibling `items` moves into the `array` variant | | `type: "null"` | `nullable: true` plus `enum: [null]`. A sibling `enum` or `const` is intersected with the null type: an `enum` containing `null` collapses to `[null]`, and one excluding it yields `not: {}`, since the source accepted no value | | `const` | single-value `enum`, plus `nullable: true` when the value is `null` | | numeric `exclusiveMinimum` / `exclusiveMaximum` | `minimum` / `maximum` plus the boolean flag; a tighter existing bound wins | diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index b7f7462..bbbb456 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -893,13 +893,20 @@ describe('downgradeSchemaV31ToV30', () => { { anyOf: [{ items: {}, type: 'array' }, { type: 'string' }] }, ], [ - 'copies existing items into the synthesized array variant', + 'moves existing items into the synthesized array variant', { items: { type: 'integer' }, type: ['array', 'string', 'null'] }, { anyOf: [ { items: { type: 'integer' }, nullable: true, type: 'array' }, { nullable: true, type: 'string' }, ], + }, + ], + [ + 'keeps items in place when the type union has no array variant', + { items: { type: 'integer' }, type: ['object', 'string'] }, + { + anyOf: [{ type: 'object' }, { type: 'string' }], items: { type: 'integer' }, }, ], @@ -931,9 +938,10 @@ describe('downgradeSchemaV31ToV30', () => { { allOf: 'junk', anyOf: [{ type: 'string' }], - type: ['integer', 'string'], + items: { type: 'integer' }, + type: ['array', 'string'], }, - { allOf: 'junk', anyOf: [{ type: 'string' }] }, + { allOf: 'junk', anyOf: [{ type: 'string' }], items: { type: 'integer' } }, ], [ 'deduplicates type array entries', @@ -1348,6 +1356,16 @@ describe('downgradeSchemaV31ToV30', () => { expect(() => downgradeSchemaV31ToV30(deep)).not.toThrow() }) + it('keeps nested multi-type arrays linear instead of doubling per level', () => { + let input: OpenAPIV3_1.SchemaObject = { type: 'string' } + let expected: unknown = { type: 'string' } + for (let index = 0; index < 10; index += 1) { + input = { items: input, type: ['array', 'object'] } + expected = { anyOf: [{ items: expected, type: 'array' }, { type: 'object' }] } + } + expect(convertSchema(input)).toEqual(expected) + }) + it('converts a dereferenced cyclic schema, pointing the cycle at the converted ancestor', () => { const properties: Record = {} const node: Record = { diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 4736ff0..8a1a8f8 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -58,10 +58,14 @@ function applyTypes(types: string[], schema: Record, out: Recor } return } + if (out.anyOf !== undefined && out.allOf !== undefined && !Array.isArray(out.allOf)) { + return + } const variants = rest.map((item) => { const variant: Record = { type: item } if (item === 'array') { - variant.items = out.items === undefined ? {} : deepClone(out.items) + variant.items = out.items === undefined ? {} : out.items + delete out.items } if (nullable) { variant.nullable = true @@ -71,7 +75,7 @@ function applyTypes(types: string[], schema: Record, out: Recor if (out.anyOf === undefined) { out.anyOf = variants } - else if (out.allOf === undefined || Array.isArray(out.allOf)) { + else { out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), { anyOf: variants }] } } From 8c7c7b1eda49d2c89504d91060d39e29edf629b1 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 26 Sep 2026 21:21:15 +0700 Subject: [PATCH 2/2] test(downgrader): pin the cyclic multi-type array variant in 3.1 to 3.0 --- packages/downgrader/src/v3.1-to-v3.0.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 8731e24..903c7fd 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -1402,5 +1402,15 @@ describe('downgradeSchemaV31ToV30', () => { expect(dig(result, 'properties', 'children', 'items')).toBe(result) expect(node.type).toEqual(['object', 'null']) }) + + it('points the array variant of a cyclic multi-type schema at the converted schema', () => { + const node: Record = { type: ['array', 'object'] } + node.items = node + const result = convertSchema(node) as Record + expect(result).not.toHaveProperty('items') + expect(dig(result, 'anyOf', '0', 'items')).toBe(result) + expect(dig(result, 'anyOf', '1')).toEqual({ type: 'object' }) + expect(node.items).toBe(node) + }) }) })