From 53828105d0d1ad39b07ad9192c985fe00d96cf83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:31:01 +0000 Subject: [PATCH 01/10] fix(downgrader): a $ref cut at recursion in a copy reused later tells no oneOf branch apart, and takes null Where several $refs enter the same cycle, the first copy made is reused, so a later one can hold a $ref that was cut to {} while another target was being inlined. isDisjoint and the nullable pair merge read such a $ref in the input, saw its target's type or tag, and kept a oneOf that the {} then let a value match twice. The holders of cut $refs are now recorded, and follow() and throughRef() read them as keeping nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 4 +- packages/downgrader/src/v3.1-to-v3.0.ts | 40 ++++++++++--- .../downgrader/tests/v3.1-to-v3.0.test.ts | 56 +++++++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d5a0565..e257510 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -211,7 +211,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`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. | | 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. | | [`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 schema that loses one only through a `$ref` around a cycle counts too, which converts the input a second time. | -| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}` tells no branch apart. | +| [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}`, also in a copy reused later, tells no branch apart. | [js-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-type [js-const]: https://json-schema.org/draft/2020-12/json-schema-validation#name-const @@ -254,7 +254,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. - An `anyOf` 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`. -- 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. +- 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' }`. - `patternProperties` that matches every name, as TypeBox emits for a record, gives `additionalProperties: true`, so its value schema is lost. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index a9704c5..90fdb56 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -143,6 +143,11 @@ const refOutputs = new Map() // Targets that did so, which the next pass reads as loose from the start. const looseTargets = new Set() +// Schemas whose `$ref` a copy cut to `{}` at recursion. The copy is reused +// wherever its target is inlined again, or a `$ref` reads the component that +// holds it, also once the target it was cut at is no longer being inlined. +const cutHolders = new Set() + // The 3.0 keywords that apply only to objects, which `convertType` moves into // the `"object"` branch of a `type` list. const OBJECT_KEYWORDS = [ @@ -356,7 +361,7 @@ function convertBranches(item: unknown, ctx: Context, schema: Record) - ? convertSchemaRef(ref, ctx) + ? convertSchemaRef(ref, ctx, value) : convertSchema(value, ctx) } @@ -388,7 +393,7 @@ function isTaggedRef(ref: string, ctx: Context): boolean { return isRecord(target) && keepsDiscriminator(target, ctx) && !isInlined(ref, ctx) } -function convertSchemaRef(ref: string, ctx: Context): unknown { +function convertSchemaRef(ref: string, ctx: Context, holder: unknown): unknown { if (!isInlined(ref, ctx)) { for (const key of ['/anyOf/', '/oneOf/']) { for (let at = ref.indexOf(key); at !== -1; at = ref.indexOf(key, at + 1)) { @@ -412,7 +417,11 @@ function convertSchemaRef(ref: string, ctx: Context): unknown { return out } const out = inline(ref, ctx, convertSchema) - return out === DROP ? loosened({}) : out + if (out === DROP) { + cutHolders.add(holder) + return loosened({}) + } + return out } // 3.0 `items` applies one schema to every item, so a tuple becomes an array @@ -647,12 +656,16 @@ function isApart(a: Tags, b: Tags): boolean { // The schema that a chain of `$ref`s from `value` ends at. A `$ref` back into // a target being inlined is cut to `{}`, which keeps nothing, and so is a -// `$ref` whose chain of `$ref`s reaches one. +// `$ref` whose chain of `$ref`s reaches one, or that a copy reused here cut. function follow(value: unknown, ctx: Context): unknown { const visited = new Set() let target = value for (let ref = getRef(target); ref !== undefined; ref = getRef(target)) { - if (visited.has(ref) || (isInlined(ref, ctx) && ctx.inlining.has(resolvePointer(ref, ctx)))) { + if ( + visited.has(ref) || + cutHolders.has(target) || + (isInlined(ref, ctx) && ctx.inlining.has(resolvePointer(ref, ctx))) + ) { return undefined } visited.add(ref) @@ -863,10 +876,14 @@ function rejectsNull(schema: unknown): boolean { : typeof type === 'string' && type !== 'null' } -// The schema that `schema` stands for, read through one `$ref`. +// The schema that `schema` stands for, read through one `$ref`, which keeps +// nothing where a copy cut it to `{}`. function throughRef(schema: unknown, root: unknown): unknown { const ref = getRef(schema) - return ref === undefined ? schema : find(root, ref) + if (ref === undefined) { + return schema + } + return cutHolders.has(schema) ? undefined : find(root, ref) } // A schema that rejects null by its `type`, read through one `$ref`. @@ -1333,7 +1350,10 @@ function finishSchema( ): void { // 3.0 ignores the siblings of a `$ref`, but not the members of an `allOf`. if (typeof schema.$ref === 'string') { - out.allOf = [convertSchemaRef(schema.$ref, ctx), ...(Array.isArray(out.allOf) ? out.allOf : [])] + out.allOf = [ + convertSchemaRef(schema.$ref, ctx, schema), + ...(Array.isArray(out.allOf) ? out.allOf : []), + ] } if (out.discriminator !== undefined && !keepsDiscriminator(schema, ctx)) { delete out.discriminator @@ -1477,7 +1497,7 @@ function convertSchema(value: unknown, ctx: Context): unknown { (isBareRef(value as Record) || (isAnnotatedRef(value as Record) && isTaggedRef(ref, ctx))) ) { - return convertSchemaRef(ref, ctx) + return convertSchemaRef(ref, ctx, value) } return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema) } @@ -1584,6 +1604,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { unionTargets.clear() refTargets.clear() refOutputs.clear() + cutHolders.clear() out = downgrade(root, convert) } for (const [schema, input, key] of nullableUnions) { @@ -1608,6 +1629,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { refTargets.clear() refOutputs.clear() looseTargets.clear() + cutHolders.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 d344d39..4cab66d 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3034,6 +3034,62 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ anyOf: [{}, { type: 'object' }] }) }) + it('counts a copy cut at recursion and reused later as telling no branch apart', () => { + // Derived extends Base, which holds a Derived child. Inlining Base first + // cuts Derived's $ref back to it, and that copy of Derived is reused + // after Base is done, though it no longer requires kind "base". + const p = '#/$defs/' + const extra = { type: 'object', properties: { extra: { type: 'string' } } } as const + const out = downgradeSchemaV31ToV30({ + type: 'object', + properties: { + first: { $ref: `${p}Base` }, + either: { oneOf: [{ $ref: `${p}Derived` }, { $ref: `${p}Other` }] }, + }, + $defs: { + Base: { + type: 'object', + properties: { kind: { const: 'base' }, child: { $ref: `${p}Derived` } }, + required: ['kind'], + }, + Derived: { allOf: [{ $ref: `${p}Base` }, extra] }, + Other: { type: 'object', properties: { kind: { const: 'other' } }, required: ['kind'] }, + }, + }) + expect(out).toHaveProperty(['properties', 'either'], { + anyOf: [ + { allOf: [{}, extra] }, + { + type: 'object', + properties: { kind: { type: 'string', enum: ['other'] } }, + required: ['kind'], + }, + ], + }) + // Nor does such a copy reject null, so the pair of it and null stays. + const r = '#/components/schemas/' + const schemas = convert({ + components: { + schemas: { + Tree: { $ref: `${r}Forest/$defs/Node` }, + Forest: { + $defs: { + Node: { + type: 'object', + properties: { next: { anyOf: [{ $ref: `${r}Link` }, { type: 'null' }] } }, + }, + }, + }, + Link: { allOf: [{ $ref: `${r}Forest/$defs/Node` }] }, + }, + }, + }).components?.schemas + expect(schemas?.Link).toEqual({ allOf: [{}] }) + expect(schemas?.Tree).toHaveProperty(['properties', 'next'], { + anyOf: [{ $ref: `${r}Link` }, { type: 'string', nullable: true, enum: [null] }], + }) + }) + it('includes the target of a $ref kept in the output', () => { // As zod emits z.xor over tuples registered as components. const tuple = ( From 34ef504b27405c25cceaacf163e1e33878c804b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:35:32 +0000 Subject: [PATCH 02/10] fix(downgrader): keep a oneOf of object literals, and an additionalProperties beside other properties, out of enum merging A oneOf rejects a value two of its branches share, so mergeEnums keeps a oneOf whose literals repeat one. It compared them serialized, which tells equal objects with their keys in another order apart, so it merged such a oneOf into an enum that took the shared value. A oneOf of object or array literals now stays as it is. And an additionalProperties in a literal branch counted as repeating the one around it although that one sits beside other properties, which it then let through, so it repeats only beside the same properties. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/src/v3.1-to-v3.0.ts | 23 +++++++- .../downgrader/tests/v3.1-to-v3.0.test.ts | 56 +++++++++++++++++++ 2 files changed, 77 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 90fdb56..87a74e9 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -477,7 +477,8 @@ function acceptsAll(schema: unknown): boolean { ) } -// Equal JSON values get the same key. A cyclic value is only equal to itself. +// Equal JSON values get the same key when their keys come in the same order, +// which equal objects need not do. A cyclic value is only equal to itself. function jsonKey(value: unknown): unknown { try { return JSON.stringify(value) @@ -486,6 +487,12 @@ function jsonKey(value: unknown): unknown { } } +// Whether a `oneOf` of literals may share `value` unseen: equal objects can +// hold their keys in another order. +function mayRepeatUnseen(key: 'anyOf' | 'oneOf', value: unknown): boolean { + return key === 'oneOf' && typeof value === 'object' && value !== null +} + // Serializes `value` once, not once per item, so a large `value` beside many // items costs their sizes added, not multiplied. With no items, not at all: a // value that shares objects is written out once per path to them. @@ -808,12 +815,18 @@ function isInputAnnotation(name: string): boolean { // The schema around a branch holds `name` with the same value, as ArkType // writes the description of a described union into every branch too. +// `additionalProperties` reads the `properties` beside it, so it repeats only +// beside the same ones. function repeats( out: Record, branch: Record, name: string, ): boolean { - return Object.hasOwn(out, name) && jsonKey(out[name]) === jsonKey(branch[name]) + return ( + Object.hasOwn(out, name) && + jsonKey(out[name]) === jsonKey(branch[name]) && + (name !== 'additionalProperties' || jsonKey(out.properties) === jsonKey(branch.properties)) + ) } // Only null matches the branch, as `{ type: "null" }` becomes, and its other @@ -1162,6 +1175,9 @@ function mergeEnums(out: Record, key: 'anyOf' | 'oneOf'): void return } for (const value of branch.enum) { + if (mayRepeatUnseen(key, value)) { + return + } const valueKey = jsonKey(value) if (!seen.has(valueKey)) { seen.add(valueKey) @@ -1210,6 +1226,9 @@ function mergeEnumsByType(out: Record, key: 'anyOf' | 'oneOf'): result.push({ type: branch.type, enum: group.enum }) } for (const value of branch.enum) { + if (mayRepeatUnseen(key, value)) { + return + } const valueKey = jsonKey(value) if (!group.seen.has(valueKey)) { group.seen.add(valueKey) 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 4cab66d..fb78f14 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1549,6 +1549,62 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + it('keeps a oneOf of literals that share a value whatever the order of its keys', () => { + // Equal objects match both branches, which the oneOf rejects. + const objects = { oneOf: [{ const: { a: 1, b: 2 } }, { const: { b: 2, a: 1 } }] } + expect(downgradeSchemaV31ToV30({ not: objects })).toEqual({ + not: { + type: 'object', + oneOf: [ + { type: 'object', enum: [{ a: 1, b: 2 }] }, + { type: 'object', enum: [{ b: 2, a: 1 }] }, + ], + }, + }) + // Also beside literals of other types. + expect(downgradeSchemaV31ToV30({ not: { oneOf: [...objects.oneOf, { const: 'x' }] } })).toEqual( + { + not: { + oneOf: [ + { type: 'object', enum: [{ a: 1, b: 2 }] }, + { type: 'object', enum: [{ b: 2, a: 1 }] }, + { type: 'string', enum: ['x'] }, + ], + }, + }, + ) + // An anyOf takes it either way. + expect(downgradeSchemaV31ToV30({ anyOf: objects.oneOf })).toEqual({ + type: 'object', + enum: [ + { a: 1, b: 2 }, + { b: 2, a: 1 }, + ], + }) + }) + + it('keeps an additionalProperties in a literal branch unless the same properties surround it', () => { + // In the branch it sees no properties, so it rejects { a: 1 }. + const closed = { additionalProperties: false } as const + const literal = (a: number) => ({ type: 'object', enum: [{ a }], ...closed }) as const + expect( + downgradeSchemaV31ToV30({ + type: 'object', + properties: { a: {} }, + ...closed, + anyOf: [ + { const: { a: 1 }, ...closed }, + { const: { a: 2 }, ...closed }, + ], + }), + ).toEqual({ + type: 'object', + properties: { a: {} }, + ...closed, + anyOf: [literal(1), literal(2)], + }) + }) + it('types an enum with no type after its values, as ArkType writes a union of literals', () => { expect(downgradeSchemaV31ToV30({ enum: ['a', 'b'] })).toEqual({ type: 'string', From 39f4ca44e274d8c443cac70243b91d3686a3aa8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:37:06 +0000 Subject: [PATCH 03/10] fix(downgrader): follow a propertyNames $ref on only through $refs beside annotations enumNames read the names at the end of the propertyNames $ref chain, past a $ref with an enum, maxLength or other keyword beside it that narrows the names further. The record then got a property for each name of the last enum, allowing keys the input rejects, and counted as converted exactly, so a not or oneOf over it stayed. The chain now stops at such a $ref, so the record loses its key names and counts as loose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/src/v3.1-to-v3.0.ts | 18 ++++++++----- .../downgrader/tests/v3.1-to-v3.0.test.ts | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 87a74e9..354c3ad 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -544,13 +544,19 @@ function isUnevaluatedAdditional(schema: Record): boolean { // enum or literal keys, when no other keyword names properties. Each becomes a // property of its own, which 3.0 can express. A reusable enum is a `$ref` to it. function enumNames(schema: Record, ctx: Context): string[] | undefined { - const ref = getRef(schema.propertyNames) // Followed alone or beside annotations, such as the `description` Zod writes - // for a described use, since other keywords beside it narrow the names further. - const names = - ref !== undefined && isAnnotatedRef(schema.propertyNames as Record) - ? resolve(ref, ctx) - : schema.propertyNames + // for a described use, also on along a chain of them, since other keywords + // beside a `$ref` narrow the names further. + let names = schema.propertyNames + const visited = new Set() + for ( + let ref = getRef(names); + ref !== undefined && isAnnotatedRef(names as Record) && !visited.has(names); + ref = getRef(names) + ) { + visited.add(names) + names = resolvePointer(ref, ctx) + } if (!isRecord(names)) { return undefined } diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index fb78f14..d65c6ee 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2430,6 +2430,32 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + it('follows a propertyNames $ref on through a $ref beside only annotations, and no further', () => { + const r = '#/components/schemas/' + const schemas = convert({ + components: { + schemas: { + Lang: { type: 'string', enum: ['en', 'fr'] }, + Alias: { $ref: `${r}Lang`, description: 'A language' }, + // The enum beside it allows only "en". + English: { $ref: `${r}Lang`, enum: ['en'] }, + ByAlias: { + type: 'object', + propertyNames: { $ref: `${r}Alias` }, + additionalProperties: { type: 'string' }, + }, + NotEnglish: { not: { type: 'object', propertyNames: { $ref: `${r}English` } } }, + }, + }, + }).components?.schemas + expect(schemas?.ByAlias).toEqual({ + type: 'object', + properties: { en: { type: 'string' }, fr: { type: 'string' } }, + additionalProperties: false, + }) + expect(schemas?.NotEnglish).toEqual({}) + }) + it('turns a propertyNames $ref to an enum, as Zod writes a record keyed by a reusable enum, into one property per name', () => { const value = { type: 'number' } as const const Lang = { type: 'string' as const, enum: ['en', 'fr'], title: 'Lang' } From c09da8cb0ace39fc0ec624b6df2c16c3f313a13e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:44:30 +0000 Subject: [PATCH 04/10] fix(downgrader): rerun the conversion for a schema that loses a restriction around an alias component or a cycle of objects #309 converts the input again when a not or oneOf read a $ref's target before that target lost a restriction around a cycle. It missed two cycles. A component that is only a $ref, an alias, has no output of its own, so the output recorded for it was undefined; it now records the output of the target its chain of kept $refs ends at. And a dereferenced document closes a cycle with the object itself rather than a $ref; convertObject now tells convertSchema when it meets a schema again, and one that was not loose then but is once converted makes the next pass read it as loose. First visits pay nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 4 +- packages/downgrader/src/shared.ts | 5 +- packages/downgrader/src/v3.1-to-v3.0.ts | 48 ++++++++++++++++--- .../downgrader/tests/v3.1-to-v3.0.test.ts | 23 ++++++++- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index e257510..2e2b3b8 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -210,7 +210,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | 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. | | 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. | -| [`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 schema that loses one only through a `$ref` around a cycle counts too, which converts the input a second time. | +| [`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 schema that loses one only around a cycle, of `$ref`s or of objects, counts too, which converts the input a second time. | | [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}`, also in a copy reused later, tells no branch apart. | [js-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-type @@ -312,7 +312,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. -Four shapes no generator emits fall outside this. A schema that loses a restriction only through a `$ref` around a cycle converts the input again, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. +Four shapes no generator emits fall outside this. A schema that loses a restriction only around a cycle, of `$ref`s or of objects, converts the input again, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. [Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, a document generated by oRPC, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions. diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index b19209c..c2aae27 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -115,13 +115,15 @@ function copy(value: unknown, copies: Map): unknown { /** * Converts the object `value` key by key: a key listed in `fields` goes * through its converter or is dropped, and any other key is copied. `finish` - * then edits the output in place. + * then edits the output in place. An object met again gives the same output, + * which `revisit` is told of. */ export function convertObject( value: unknown, ctx: Context, fields: Fields, finish?: Finish, + revisit?: (value: object, out: object) => void, ): unknown { if (!isRecord(value)) { return clone(value) @@ -133,6 +135,7 @@ export function convertObject( } const known = seen.get(value) if (known !== undefined) { + revisit?.(value, known) return known } const out: Record = {} diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 354c3ad..34a55ac 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -143,6 +143,13 @@ const refOutputs = new Map() // Targets that did so, which the next pass reads as loose from the start. const looseTargets = new Set() +// The same for a cycle of objects, as a dereferencing parser leaves: each +// schema met again, also where a document shares it, with its output, not +// loose yet, and the schemas whose output then lost a restriction, which the +// next pass reads as loose when it meets them again. +const revisits: [object, object][] = [] +const looseInputs = new Set() + // Schemas whose `$ref` a copy cut to `{}` at recursion. The copy is reused // wherever its target is inlined again, or a `$ref` reads the component that // holds it, also once the target it was cut at is no longer being inlined. @@ -410,10 +417,7 @@ function convertSchemaRef(ref: string, ctx: Context, holder: unknown): unknown { if (isLoose(converted) || looseTargets.has(target)) { return loosened(out) } - refOutputs.set( - target, - converted === DROP ? ctx.shared.get(SCHEMA_FIELDS)?.get(target as object) : converted, - ) + refOutputs.set(target, converted === DROP ? sharedOutput(target, ctx) : converted) return out } const out = inline(ref, ctx, convertSchema) @@ -424,6 +428,19 @@ function convertSchemaRef(ref: string, ctx: Context, holder: unknown): unknown { return out } +// The output of a target still being converted. A target that is only a +// `$ref`, an alias, has none of its own, so it is the output of the target its +// chain of kept `$ref`s ends at. +function sharedOutput(target: unknown, ctx: Context): unknown { + const visited = new Set() + let at = target + while (isRecord(at) && isBareRef(at) && !visited.has(at) && !isInlined(at.$ref as string, ctx)) { + visited.add(at) + at = resolvePointer(at.$ref as string, ctx) + } + return isRecord(at) ? ctx.shared.get(SCHEMA_FIELDS)?.get(at) : undefined +} + // 3.0 `items` applies one schema to every item, so a tuple becomes an array // whose items match any of its item schemas, and those of the items after // them, unless `items: false` or `maxItems` allows none. `items: false` @@ -545,7 +562,7 @@ function isUnevaluatedAdditional(schema: Record): boolean { // property of its own, which 3.0 can express. A reusable enum is a `$ref` to it. function enumNames(schema: Record, ctx: Context): string[] | undefined { // Followed alone or beside annotations, such as the `description` Zod writes - // for a described use, also on along a chain of them, since other keywords + // for a described use, and along a chain of them, since other keywords // beside a `$ref` narrow the names further. let names = schema.propertyNames const visited = new Set() @@ -1502,6 +1519,16 @@ function finishSchema( } } +// Met again before its output was done, around a cycle, a schema not loose +// yet may lose a restriction after a `not` or `oneOf` around it read it. +function revisitSchema(value: object, out: object): void { + if (looseInputs.has(value)) { + LOOSE.add(out) + } else if (!isLoose(out)) { + revisits.push([value, out]) + } +} + // Spelled out, not through `hasOnly`, since it runs for every `$ref`. function isBareRef(value: Record): boolean { for (const key in value) { @@ -1524,7 +1551,7 @@ function convertSchema(value: unknown, ctx: Context): unknown { ) { return convertSchemaRef(ref, ctx, value) } - return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema) + return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema, revisitSchema) } function convertParameter(value: unknown, ctx: Context): unknown { @@ -1630,6 +1657,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { refTargets.clear() refOutputs.clear() cutHolders.clear() + revisits.length = 0 out = downgrade(root, convert) } for (const [schema, input, key] of nullableUnions) { @@ -1655,6 +1683,8 @@ function downgradeV30(root: unknown, convert: Convert): unknown { refOutputs.clear() looseTargets.clear() cutHolders.clear() + revisits.length = 0 + looseInputs.clear() } } @@ -1667,6 +1697,12 @@ function markLooseTargets(): boolean { added = true } } + for (const [input, out] of revisits) { + if (isLoose(out) && !looseInputs.has(input)) { + looseInputs.add(input) + added = true + } + } return added } 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 d65c6ee..941bc5f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3010,7 +3010,7 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({}) }) - it('includes a schema that loses it only around a cycle of $refs, whichever comes first', () => { + it('includes a schema that loses it only around a cycle of $refs or objects, whichever comes first', () => { const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }) const lost = { type: 'object', propertyNames: { pattern: '^a' } } as const // A not and a oneOf over a $ref back into the schema around them. @@ -3053,6 +3053,27 @@ describe('downgradeSchemaV31ToV30', () => { }).components?.schemas expect(out?.Outer).toEqual({}) } + // Entered through a component that is only a $ref to the one that loses it. + expect( + convert({ + components: { + schemas: { + Start: ref('Alias'), + Alias: ref('Node'), + Node: { ...lost, properties: { a: { not: ref('Alias') } } }, + }, + }, + }).components?.schemas?.Node, + ).toEqual({ type: 'object', properties: { a: {} } }) + // And around a cycle of objects, as a dereferencing parser leaves. + const node: Record = { ...lost } + node.properties = { + a: { not: node }, + b: { oneOf: [node, { type: 'object', required: ['z'] }] }, + } + const out = downgradeSchemaV31ToV30(node) as Record> + expect(out.properties?.a).toEqual({}) + expect(out.properties?.b).toEqual({ anyOf: [out, { type: 'object', required: ['z'] }] }) }) it('counts a $defs target cut at recursion as telling no branch apart', () => { From e9f7f25908731436908852de3691cc1c714b7ca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:45:21 +0000 Subject: [PATCH 05/10] fix(downgrader): follow an alias chain through inlined $refs and tagged-union $refs The rerun recorded nothing for an alias whose chain passes through a $ref the conversion inlines, such as one into $defs, or that is a $ref to a tagged union beside only annotations, so a not over it around a cycle was kept over a target that lost a restriction. sharedOutput now follows every $ref convertSchema treats as an alias, and reads an inlined hop's output from its inlined copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/src/v3.1-to-v3.0.ts | 38 +++++++++++++------ .../downgrader/tests/v3.1-to-v3.0.test.ts | 35 +++++++++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 34a55ac..319e46a 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -428,17 +428,24 @@ function convertSchemaRef(ref: string, ctx: Context, holder: unknown): unknown { return out } -// The output of a target still being converted. A target that is only a -// `$ref`, an alias, has none of its own, so it is the output of the target its -// chain of kept `$ref`s ends at. +// The output of a target still being converted. A target that converts to the +// output of the target of its `$ref`, an alias, has none of its own, so it is +// the output of the target its chain of `$ref`s ends at: the copy the +// components get, or for a `$ref` the conversion inlines, its inlined copy. function sharedOutput(target: unknown, ctx: Context): unknown { const visited = new Set() let at = target - while (isRecord(at) && isBareRef(at) && !visited.has(at) && !isInlined(at.$ref as string, ctx)) { + let seen: Context['seen'] | undefined = ctx.shared + for ( + let ref = aliasRef(at, ctx); + ref !== undefined && !visited.has(at); + ref = aliasRef(at, ctx) + ) { visited.add(at) - at = resolvePointer(at.$ref as string, ctx) + at = resolvePointer(ref, ctx) + seen = isInlined(ref, ctx) ? ctx.apart.get(at) : ctx.shared } - return isRecord(at) ? ctx.shared.get(SCHEMA_FIELDS)?.get(at) : undefined + return isRecord(at) ? seen?.get(SCHEMA_FIELDS)?.get(at) : undefined } // 3.0 `items` applies one schema to every item, so a tuple becomes an array @@ -1543,17 +1550,24 @@ function convertSchema(value: unknown, ctx: Context): unknown { if (typeof value === 'boolean') { return value ? {} : { not: {} } } - const ref = getRef(value) - if ( - ref !== undefined && - (isBareRef(value as Record) || - (isAnnotatedRef(value as Record) && isTaggedRef(ref, ctx))) - ) { + const ref = aliasRef(value, ctx) + if (ref !== undefined) { return convertSchemaRef(ref, ctx, value) } return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema, revisitSchema) } +// The `$ref` of a schema that becomes its target's output: a bare `$ref`, or +// one to a tagged union beside only annotations. +function aliasRef(value: unknown, ctx: Context): string | undefined { + const ref = getRef(value) + return ref !== undefined && + (isBareRef(value as Record) || + (isAnnotatedRef(value as Record) && isTaggedRef(ref, ctx))) + ? ref + : undefined +} + function convertParameter(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, PARAMETER_FIELDS) } 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 941bc5f..bb5f564 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3065,6 +3065,41 @@ describe('downgradeSchemaV31ToV30', () => { }, }).components?.schemas?.Node, ).toEqual({ type: 'object', properties: { a: {} } }) + // Also when the alias reaches it through a $ref the conversion inlines. + expect( + convert({ + components: { + schemas: { + Start: { not: ref('Alias') }, + Alias: ref('Node/$defs/Sub'), + Node: { $defs: { Sub: { ...lost, properties: { a: { not: ref('Alias') } } } } }, + }, + }, + }).components?.schemas?.Alias, + ).toEqual({ type: 'object', properties: { a: {} } }) + // Or is a $ref to a tagged union beside only annotations. + expect( + convert({ + components: { + schemas: { + Start: { not: ref('Tagged') }, + Tagged: { ...ref('Union'), description: 'A tagged union' }, + Union: { oneOf: [ref('A'), ref('B')], discriminator: { propertyName: 'k' } }, + A: { + type: 'object', + propertyNames: { pattern: '^[kx]$' }, + required: ['k'], + properties: { k: { const: 'a' }, x: { not: ref('Tagged') } }, + }, + B: { type: 'object', required: ['k'], properties: { k: { const: 'b' } } }, + }, + }, + }).components?.schemas?.A, + ).toEqual({ + type: 'object', + required: ['k'], + properties: { k: { type: 'string', enum: ['a'] }, x: {} }, + }) // And around a cycle of objects, as a dereferencing parser leaves. const node: Record = { ...lost } node.properties = { From 997f21b2fb9ae17d923626f298fdee289c75e2ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:55:08 +0000 Subject: [PATCH 06/10] perf(downgrader): skip the second pass when no not or oneOf kept its form A schema that loses a restriction around a cycle made the conversion run again, so a not or oneOf that read it too early could drop or widen. Only such a choice changes when more schemas read as loose, so a pass that kept no not, and no oneOf without a loose branch, needs no other. A document with a recursive schema that loses a restriction and no not or oneOf now converts in one pass, about half the time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 27 ++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 2e2b3b8..2b3aec3 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -312,7 +312,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. -Four shapes no generator emits fall outside this. A schema that loses a restriction only around a cycle, of `$ref`s or of objects, converts the input again, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. +Four shapes no generator emits fall outside this. A schema that loses a restriction only around a cycle, of `$ref`s or of objects, converts the input again when it keeps a `not` or `oneOf`, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. [Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, a document generated by oRPC, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 319e46a..6c50fd2 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -122,6 +122,11 @@ const IN_PLACE_APPLICATORS = [ // Converted schemas that lost a restriction, directly or in a subschema. const LOOSE = new WeakSet() +// Whether a `not` or `oneOf` kept its form, since nothing under it was loose. +// Only such a choice changes when more schemas read as loose, so without one +// the conversion need not run again. +let keptStrict = false + // Converted schemas with an `anyOf` or `oneOf` of several branches, for // `mergeEnums` and `mergeNullable`. They run once the whole input is converted, // since a cycle can meet a branch before its conversion is done. @@ -756,13 +761,19 @@ function loosen( if (isLoose(out.not)) { delete out.not loose = true - } - if (Array.isArray(out.oneOf) && out.oneOf.some(isLoose)) { - if (!isDisjoint(schema.oneOf as unknown[], ctx)) { - addAnyOf(out, out.oneOf) - delete out.oneOf + } else if (out.not !== undefined) { + keptStrict = true + } + if (Array.isArray(out.oneOf)) { + if (out.oneOf.some(isLoose)) { + if (!isDisjoint(schema.oneOf as unknown[], ctx)) { + addAnyOf(out, out.oneOf) + delete out.oneOf + } + loose = true + } else { + keptStrict = true } - loose = true } return ( loose || @@ -1665,7 +1676,8 @@ function downgradeV30(root: unknown, convert: Convert): unknown { // A `not` or `oneOf` over a `$ref` around a cycle read its target before // that target lost a restriction, so the conversion runs again, reading // such targets as loose from the start, until no other target does so. - while (markLooseTargets()) { + while (keptStrict && markLooseTargets()) { + keptStrict = false nullableUnions.length = 0 unionTargets.clear() refTargets.clear() @@ -1691,6 +1703,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { return out } finally { // Cleared even when conversion throws, so nothing keeps the input alive. + keptStrict = false nullableUnions.length = 0 unionTargets.clear() refTargets.clear() From 7d46cbc036ae7a0329bdcf33a06e14c92c6d401a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:02:42 +0000 Subject: [PATCH 07/10] fix(downgrader): count an object that requires a readOnly or writeOnly property as loose OAS 3.0 applies a required readOnly property to responses only and a required writeOnly property to requests only, while 3.1 requires it both ways. The 3.0 output so loses that restriction, so a not or oneOf over such an object must not keep it, and the property tells no oneOf branch apart. The property is read through a chain of $refs too, as 3.0 reads each target in its place. Such a schema in a cycle, as a recursive DTO with a required readOnly id, makes a document that keeps a not or oneOf convert twice, which the README performance note now says. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 5 +- packages/downgrader/src/v3.1-to-v3.0.ts | 36 +++++++++++-- .../downgrader/tests/v3.1-to-v3.0.test.ts | 54 +++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 2b3aec3..7bbc5af 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -210,6 +210,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | 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. | | 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. | +| `required` naming a [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] property | Kept. 3.0 requires a `readOnly` property in responses only and a `writeOnly` one in requests only, where 3.1 requires both everywhere, so the schema counts as having lost a restriction, and that 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 schema that loses one only around a cycle, of `$ref`s or of objects, counts too, which converts the input a second time. | | [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}`, also in a copy reused later, tells no branch apart. | @@ -248,6 +249,8 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc [js-one-of]: https://json-schema.org/draft/2020-12/json-schema-core#name-oneof [3.0-schema-nullable]: https://spec.openapis.org/oas/v3.0.4.html#schema-nullable [3.0-schema-discriminator]: https://spec.openapis.org/oas/v3.0.4.html#schema-discriminator +[3.0-schema-read-only]: https://spec.openapis.org/oas/v3.0.4.html#schema-read-only +[3.0-schema-write-only]: https://spec.openapis.org/oas/v3.0.4.html#schema-write-only #### Limitations @@ -312,7 +315,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. -Four shapes no generator emits fall outside this. A schema that loses a restriction only around a cycle, of `$ref`s or of objects, converts the input again when it keeps a `not` or `oneOf`, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. +A schema that loses a restriction only around a cycle, of `$ref`s or of objects, as a recursive schema that requires a `readOnly` property does, converts the input again when it keeps a `not` or `oneOf`, once for each such schema found after the last pass. Three shapes no generator emits fall outside this too. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. [Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, a document generated by oRPC, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 6c50fd2..fd2651c 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -655,7 +655,10 @@ function tagsOf(schema: Record, ctx: Context): Tags { if (schema.nullable === true) { return { type: undefined, values } } - const required = schema.type === 'object' && Array.isArray(schema.required) ? schema.required : [] + const required = + schema.type === 'object' && Array.isArray(schema.required) + ? schema.required.filter((name) => !isOneWay(schema, name, ctx)) + : [] const properties = isRecord(schema.properties) ? schema.properties : {} for (const name of Object.keys(properties)) { const value = properties[name] @@ -676,6 +679,29 @@ function tagsOf(schema: Record, ctx: Context): Tags { return { type: schema.type, values } } +// A property that 3.0 requires in one direction only, as it applies +// `required` to a `readOnly` property in responses only, and to a `writeOnly` +// one in requests only, which 2020-12 does not. Read through `$ref`s too. +function isOneWay(schema: Record, name: unknown, ctx: Context): boolean { + const { properties } = schema + if (!isRecord(properties) || typeof name !== 'string' || !Object.hasOwn(properties, name)) { + return false + } + let visited: Set | undefined + for (let part = properties[name]; isRecord(part);) { + if (part.readOnly === true || part.writeOnly === true) { + return true + } + const ref = getRef(part) + if (ref === undefined || visited?.has(part)) { + return false + } + ;(visited ??= new Set()).add(part) + part = resolvePointer(ref, ctx) + } + return false +} + // Whether no value matches both: they have different types, other than an // integer and a number, or one requires a property that the other holds to // other values, as OpenAI, Zod and oRPC write a tagged union. @@ -1441,7 +1467,10 @@ function finishSchema( schema.const !== undefined && Array.isArray(schema.enum) && !includesJson(schema.enum, schema.const) - let loose = loosen(out, schema, exactTuple, ctx) || constOutsideEnum + let loose = + loosen(out, schema, exactTuple, ctx) || + constOutsideEnum || + (Array.isArray(schema.required) && schema.required.some((name) => isOneWay(schema, name, ctx))) // 3.0 lacks `patternProperties`, and `additionalProperties` beside it is // dropped, as it would reject the properties `patternProperties` allowed. // Code generators read a missing one as allowing no other properties. @@ -1675,7 +1704,8 @@ function downgradeV30(root: unknown, convert: Convert): unknown { let out = downgrade(root, convert) // A `not` or `oneOf` over a `$ref` around a cycle read its target before // that target lost a restriction, so the conversion runs again, reading - // such targets as loose from the start, until no other target does so. + // such targets as loose from the start, until no other target does so, + // or the pass kept no `not` or `oneOf` that one could change. while (keptStrict && markLooseTargets()) { keptStrict = false nullableUnions.length = 0 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 bb5f564..e5b649c 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2990,6 +2990,60 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({}) }) + it('includes an object that requires a readOnly or writeOnly property, which 3.0 requires one way only', () => { + // 3.0 applies required to a readOnly property in responses only, so a + // request without it matches the object, which the not then rejects. + const id = { type: 'string', readOnly: true } as const + const withId: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { id }, + required: ['id'], + } + expect(downgradeSchemaV31ToV30({ not: withId })).toEqual({}) + expect( + downgradeSchemaV31ToV30({ + not: { ...withId, properties: { id: { type: 'string', writeOnly: true } } }, + }), + ).toEqual({}) + // Also through a chain of $refs, which 3.0 reads in their targets' place. + const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }) + expect( + convert({ + components: { + schemas: { + Not: { not: { ...withId, properties: { id: ref('IdAlias') } } }, + IdAlias: ref('Id'), + Id: id, + }, + }, + }).components?.schemas?.Not, + ).toEqual({}) + // Nor does such a property tell branches apart as a required tag. + const tagged = (kind: string, more: object = {}): OpenAPIV3_1.SchemaObject => ({ + type: 'object', + properties: { kind: { const: kind, ...more } }, + required: ['kind'], + }) + const tagged30 = (kind: string, more: object = {}) => ({ + type: 'object', + properties: { kind: { type: 'string', enum: [kind], ...more } }, + required: ['kind'], + }) + // A request without either tag matches both. + const readOnly = { readOnly: true } + expect( + downgradeSchemaV31ToV30({ oneOf: [tagged('a', readOnly), tagged('b', readOnly)] }), + ).toEqual({ anyOf: [tagged30('a', readOnly), tagged30('b', readOnly)] }) + // One required both ways still tells them apart. + expect(downgradeSchemaV31ToV30({ oneOf: [tagged('a', readOnly), tagged('b')] })).toEqual({ + oneOf: [tagged30('a', readOnly), tagged30('b')], + }) + // An optional one changes nothing. + expect(downgradeSchemaV31ToV30({ not: { ...withId, required: undefined } })).toEqual({ + not: { type: 'object', properties: { id } }, + }) + }) + it('includes a const outside the enum beside it, which matched nothing', () => { expect(downgradeSchemaV31ToV30({ not: { const: 'a', enum: ['b'] } })).toEqual({}) expect(downgradeSchemaV31ToV30({ not: { const: 'a', enum: [] } })).toEqual({}) From 9962db1b2cfb060bd8357d0ca9d829cee3353145 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:15:48 +0000 Subject: [PATCH 08/10] perf(downgrader): check one-way required properties without a closure or a set per name CodSpeed read the order schema bench 19% slower with the check. It now loops over `required` once per schema, reads each property's fields directly, and makes a visited set only past the first `$ref`. Instructions per conversion of that schema drop from 4% over main to 2% under callgrind with CodSpeed's V8 flags. A cycle of `$ref`s, which never reaches a schema, is tested too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/src/v3.1-to-v3.0.ts | 47 ++++++++++++------- .../downgrader/tests/v3.1-to-v3.0.test.ts | 11 +++++ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index fd2651c..77b8b6b 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -655,11 +655,11 @@ function tagsOf(schema: Record, ctx: Context): Tags { if (schema.nullable === true) { return { type: undefined, values } } + const properties = isRecord(schema.properties) ? schema.properties : {} const required = schema.type === 'object' && Array.isArray(schema.required) - ? schema.required.filter((name) => !isOneWay(schema, name, ctx)) + ? schema.required.filter((name) => !isOneWay(properties[name as string], ctx)) : [] - const properties = isRecord(schema.properties) ? schema.properties : {} for (const name of Object.keys(properties)) { const value = properties[name] const property = @@ -679,25 +679,40 @@ function tagsOf(schema: Record, ctx: Context): Tags { return { type: schema.type, values } } -// A property that 3.0 requires in one direction only, as it applies -// `required` to a `readOnly` property in responses only, and to a `writeOnly` -// one in requests only, which 2020-12 does not. Read through `$ref`s too. -function isOneWay(schema: Record, name: unknown, ctx: Context): boolean { - const { properties } = schema - if (!isRecord(properties) || typeof name !== 'string' || !Object.hasOwn(properties, name)) { - return false +// Whether an object requires a property that 3.0 requires in one direction +// only, as it applies `required` to a `readOnly` property in responses only, +// and to a `writeOnly` one in requests only, which 2020-12 does not. Spelled +// out, as it runs for every schema with `required`. +function requiresOneWay(required: unknown[], properties: unknown, ctx: Context): boolean { + if (isRecord(properties)) { + for (let i = 0; i < required.length; i++) { + if (isOneWay(properties[required[i] as string], ctx)) { + return true + } + } } + return false +} + +// Read through `$ref`s too, as 3.0 reads each target in its place. +function isOneWay(property: unknown, ctx: Context): boolean { + let part = property let visited: Set | undefined - for (let part = properties[name]; isRecord(part);) { - if (part.readOnly === true || part.writeOnly === true) { + while (typeof part === 'object' && part !== null) { + const { readOnly, writeOnly, $ref } = part as Record + if (readOnly === true || writeOnly === true) { return true } - const ref = getRef(part) - if (ref === undefined || visited?.has(part)) { + if (typeof $ref !== 'string') { return false } - ;(visited ??= new Set()).add(part) - part = resolvePointer(ref, ctx) + if (part !== property) { + if (visited?.has(part)) { + return false + } + ;(visited ??= new Set()).add(part) + } + part = resolvePointer($ref, ctx) } return false } @@ -1470,7 +1485,7 @@ function finishSchema( let loose = loosen(out, schema, exactTuple, ctx) || constOutsideEnum || - (Array.isArray(schema.required) && schema.required.some((name) => isOneWay(schema, name, ctx))) + (Array.isArray(schema.required) && requiresOneWay(schema.required, schema.properties, ctx)) // 3.0 lacks `patternProperties`, and `additionalProperties` beside it is // dropped, as it would reject the properties `patternProperties` allowed. // Code generators read a missing one as allowing no other properties. 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 e5b649c..e009633 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3018,6 +3018,17 @@ describe('downgradeSchemaV31ToV30', () => { }, }).components?.schemas?.Not, ).toEqual({}) + // A cycle of $refs, which never reaches a schema, holds none. + const loop: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { id: ref('Loop') }, + required: ['id'], + } + expect( + convert({ + components: { schemas: { Not: { not: loop }, Loop: ref('Back'), Back: ref('Loop') } }, + }).components?.schemas?.Not, + ).toEqual({ not: loop }) // Nor does such a property tell branches apart as a required tag. const tagged = (kind: string, more: object = {}): OpenAPIV3_1.SchemaObject => ({ type: 'object', From 9811aebc677423f46e130ac9c9bfbddaa5073928 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:35:29 +0000 Subject: [PATCH 09/10] refactor(downgrader): count any readOnly or writeOnly schema as loose Marking the schema itself loose lets a $ref to it, and every object around it, read as loose through the existing checks, so no walk over required names is needed. Only the tag check still reads through $refs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 4 +- packages/downgrader/src/v3.1-to-v3.0.ts | 51 +++++++------------ .../downgrader/tests/v3.1-to-v3.0.test.ts | 44 ++++++++-------- 3 files changed, 41 insertions(+), 58 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 7bbc5af..0409def 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -210,7 +210,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | 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. | | 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. | -| `required` naming a [`readOnly`][3.0-schema-read-only] or [`writeOnly`][3.0-schema-write-only] property | Kept. 3.0 requires a `readOnly` property in responses only and a `writeOnly` one in requests only, where 3.1 requires both everywhere, so the schema counts as having lost a restriction, and that property tells no `oneOf` branch apart. | +| [`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 schema that loses one only around a cycle, of `$ref`s or of objects, counts too, which converts the input a second time. | | [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}`, also in a copy reused later, tells no branch apart. | @@ -315,7 +315,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. -A schema that loses a restriction only around a cycle, of `$ref`s or of objects, as a recursive schema that requires a `readOnly` property does, converts the input again when it keeps a `not` or `oneOf`, once for each such schema found after the last pass. Three shapes no generator emits fall outside this too. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. +Four shapes no generator emits fall outside this. A schema that loses a restriction only around a cycle, of `$ref`s or of objects, converts the input again when it keeps a `not` or `oneOf`, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. [Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, a document generated by oRPC, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 77b8b6b..2158838 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -679,40 +679,23 @@ function tagsOf(schema: Record, ctx: Context): Tags { return { type: schema.type, values } } -// Whether an object requires a property that 3.0 requires in one direction -// only, as it applies `required` to a `readOnly` property in responses only, -// and to a `writeOnly` one in requests only, which 2020-12 does not. Spelled -// out, as it runs for every schema with `required`. -function requiresOneWay(required: unknown[], properties: unknown, ctx: Context): boolean { - if (isRecord(properties)) { - for (let i = 0; i < required.length; i++) { - if (isOneWay(properties[required[i] as string], ctx)) { - return true - } - } - } - return false -} - -// Read through `$ref`s too, as 3.0 reads each target in its place. -function isOneWay(property: unknown, ctx: Context): boolean { - let part = property - let visited: Set | undefined - while (typeof part === 'object' && part !== null) { - const { readOnly, writeOnly, $ref } = part as Record - if (readOnly === true || writeOnly === true) { +// Whether 3.0 requires the property in one direction only, as it applies +// `required` to a `readOnly` property in responses only, and to a `writeOnly` +// one in requests only. Read through `$ref`s, as 3.0 reads each target in its +// place. +function isOneWay(value: unknown, ctx: Context): boolean { + const visited = new Set() + let part = value + while (isRecord(part) && !visited.has(part)) { + if (part.readOnly === true || part.writeOnly === true) { return true } - if (typeof $ref !== 'string') { + const ref = getRef(part) + if (ref === undefined) { return false } - if (part !== property) { - if (visited?.has(part)) { - return false - } - ;(visited ??= new Set()).add(part) - } - part = resolvePointer($ref, ctx) + visited.add(part) + part = resolvePointer(ref, ctx) } return false } @@ -1482,10 +1465,13 @@ function finishSchema( schema.const !== undefined && Array.isArray(schema.enum) && !includesJson(schema.enum, schema.const) + // 3.0 requires a `readOnly` property in responses only and a `writeOnly` one + // in requests only, where 2020-12 requires both everywhere. let loose = loosen(out, schema, exactTuple, ctx) || constOutsideEnum || - (Array.isArray(schema.required) && requiresOneWay(schema.required, schema.properties, ctx)) + schema.readOnly === true || + schema.writeOnly === true // 3.0 lacks `patternProperties`, and `additionalProperties` beside it is // dropped, as it would reject the properties `patternProperties` allowed. // Code generators read a missing one as allowing no other properties. @@ -1719,8 +1705,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { let out = downgrade(root, convert) // A `not` or `oneOf` over a `$ref` around a cycle read its target before // that target lost a restriction, so the conversion runs again, reading - // such targets as loose from the start, until no other target does so, - // or the pass kept no `not` or `oneOf` that one could change. + // such targets as loose from the start, until no other target does so. while (keptStrict && markLooseTargets()) { keptStrict = false nullableUnions.length = 0 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 e009633..e62188c 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2990,7 +2990,7 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({}) }) - it('includes an object that requires a readOnly or writeOnly property, which 3.0 requires one way only', () => { + it('includes a readOnly or writeOnly schema, which 3.0 requires one way only', () => { // 3.0 applies required to a readOnly property in responses only, so a // request without it matches the object, which the not then rejects. const id = { type: 'string', readOnly: true } as const @@ -3000,35 +3000,20 @@ describe('downgradeSchemaV31ToV30', () => { required: ['id'], } expect(downgradeSchemaV31ToV30({ not: withId })).toEqual({}) - expect( - downgradeSchemaV31ToV30({ - not: { ...withId, properties: { id: { type: 'string', writeOnly: true } } }, - }), - ).toEqual({}) - // Also through a chain of $refs, which 3.0 reads in their targets' place. + expect(downgradeSchemaV31ToV30({ not: { type: 'string', writeOnly: true } })).toEqual({}) + // Also an optional one, and through a chain of $refs. const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }) expect( convert({ components: { schemas: { - Not: { not: { ...withId, properties: { id: ref('IdAlias') } } }, + Not: { not: { type: 'object', properties: { id: ref('IdAlias') } } }, IdAlias: ref('Id'), Id: id, }, }, }).components?.schemas?.Not, ).toEqual({}) - // A cycle of $refs, which never reaches a schema, holds none. - const loop: OpenAPIV3_1.SchemaObject = { - type: 'object', - properties: { id: ref('Loop') }, - required: ['id'], - } - expect( - convert({ - components: { schemas: { Not: { not: loop }, Loop: ref('Back'), Back: ref('Loop') } }, - }).components?.schemas?.Not, - ).toEqual({ not: loop }) // Nor does such a property tell branches apart as a required tag. const tagged = (kind: string, more: object = {}): OpenAPIV3_1.SchemaObject => ({ type: 'object', @@ -3049,10 +3034,23 @@ describe('downgradeSchemaV31ToV30', () => { expect(downgradeSchemaV31ToV30({ oneOf: [tagged('a', readOnly), tagged('b')] })).toEqual({ oneOf: [tagged30('a', readOnly), tagged30('b')], }) - // An optional one changes nothing. - expect(downgradeSchemaV31ToV30({ not: { ...withId, required: undefined } })).toEqual({ - not: { type: 'object', properties: { id } }, - }) + // A cycle of $refs, which never reaches a schema, holds neither keyword. + const loop: OpenAPIV3_1.SchemaObject = { + type: 'object', + properties: { kind: ref('Loop') }, + required: ['kind'], + } + expect( + convert({ + components: { + schemas: { + Union: { oneOf: [id, loop] }, + Loop: ref('Back'), + Back: ref('Loop'), + }, + }, + }).components?.schemas?.Union, + ).toEqual({ oneOf: [id, loop] }) }) it('includes a const outside the enum beside it, which matched nothing', () => { From 6de99ef4f1b071411ad0560d088456cd5ba8d528 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:41:07 +0000 Subject: [PATCH 10/10] refactor(downgrader): count a schema met again mid-cycle as loose, instead of converting again A $ref or object cycle that reaches a schema before its conversion is done cannot know yet what that schema loses, so it now counts as loose. This replaces the second pass (#309, #316) and its skip (#317) with two checks, at the cost of a recursive schema always counting as loose: a not over it is removed, and a oneOf with it becomes anyOf unless its branches are disjoint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 4 +- packages/downgrader/src/shared.ts | 4 +- packages/downgrader/src/v3.1-to-v3.0.ts | 134 +++--------------- .../downgrader/tests/v3.1-to-v3.0.test.ts | 3 +- 4 files changed, 27 insertions(+), 118 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 0409def..2ffb57a 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -211,7 +211,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`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. | | 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 schema that loses one only around a cycle, of `$ref`s or of objects, counts too, which converts the input a second time. | +| [`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. | | [`oneOf`][js-one-of] with a branch that lost a restriction above | `anyOf`. Looser branches may overlap, and then match more than one. It stays a `oneOf` when no two branches can match the same value however loose they become: they have different types, other than `integer` and `number`, or one is an object that requires a property the other holds to other `const` or `enum` values, as OpenAI, Zod and oRPC write a tagged union, also through a `$ref` with only annotations beside it, as Zod writes a literal with its own id, and in a member of an `allOf`, as OpenAI writes a resource. A `$ref` that recursion cuts to `{}`, also in a copy reused later, tells no branch apart. | [js-type]: https://json-schema.org/draft/2020-12/json-schema-validation#name-type @@ -315,7 +315,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc Each object is converted once (inside an inlined `$ref` target, once per target), however many `$ref`s or shared references reach it, so the work grows with the size of the document, not with the number of paths through it. A cycle of objects in the input, as a dereferencing parser leaves, becomes the same cycle in the output. -Four shapes no generator emits fall outside this. A schema that loses a restriction only around a cycle, of `$ref`s or of objects, converts the input again when it keeps a `not` or `oneOf`, once for each such schema found after the last pass. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. +Three shapes no generator emits fall outside this. A chain of `$ref`s that point at `$ref`s is followed again from each place that reaches it, so its cost grows with the square of its length. A `oneOf` with a branch that lost a restriction compares its branches pair by pair, so its cost grows with the square of their number, about 100 ms for a thousand. And conversion is recursive, so input nested, or chained through `$ref`s, more than about a thousand levels deep can throw a `RangeError`. If you convert untrusted documents, limit their size and depth first. [Benchmarks](https://github.com/middleapi/openapi-spec/tree/main/packages/downgrader/benches) cover each converter on the official example documents, a document generated by oRPC, generated APIs, and worst-case reference graphs. [CodSpeed](https://app.codspeed.io/middleapi/openapi-spec) runs them on every pull request to catch regressions. diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index c2aae27..44b4c2a 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -123,7 +123,7 @@ export function convertObject( ctx: Context, fields: Fields, finish?: Finish, - revisit?: (value: object, out: object) => void, + revisit?: (out: object) => void, ): unknown { if (!isRecord(value)) { return clone(value) @@ -135,7 +135,7 @@ export function convertObject( } const known = seen.get(value) if (known !== undefined) { - revisit?.(value, known) + revisit?.(known) return known } const out: Record = {} diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2158838..6abac36 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -122,10 +122,9 @@ const IN_PLACE_APPLICATORS = [ // Converted schemas that lost a restriction, directly or in a subschema. const LOOSE = new WeakSet() -// Whether a `not` or `oneOf` kept its form, since nothing under it was loose. -// Only such a choice changes when more schemas read as loose, so without one -// the conversion need not run again. -let keptStrict = false +// Converted schemas whose conversion is done. One met again before that, +// around a cycle, counts as loose, since what it loses is not known yet. +const DONE = new WeakSet() // Converted schemas with an `anyOf` or `oneOf` of several branches, for // `mergeEnums` and `mergeNullable`. They run once the whole input is converted, @@ -140,21 +139,6 @@ const unionTargets = new Set() // Input schemas a `$ref` kept as written points to, for `scalarOneOf`. const refTargets = new Set() -// The output of each target a `$ref` kept as written read as not loose. Around -// a cycle, that output was still being converted, and can lose a restriction -// after the `$ref` read it. -const refOutputs = new Map() - -// Targets that did so, which the next pass reads as loose from the start. -const looseTargets = new Set() - -// The same for a cycle of objects, as a dereferencing parser leaves: each -// schema met again, also where a document shares it, with its output, not -// loose yet, and the schemas whose output then lost a restriction, which the -// next pass reads as loose when it meets them again. -const revisits: [object, object][] = [] -const looseInputs = new Set() - // Schemas whose `$ref` a copy cut to `{}` at recursion. The copy is reused // wherever its target is inlined again, or a `$ref` reads the component that // holds it, also once the target it was cut at is no longer being inlined. @@ -416,14 +400,10 @@ function convertSchemaRef(ref: string, ctx: Context, holder: unknown): unknown { refTargets.add(target) // 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, - // where the target is still being converted. + // where the target is still being converted, so it counts as loose. const out = { $ref: ref } const converted = inline(ref, ctx, convertSchema, true) - if (isLoose(converted) || looseTargets.has(target)) { - return loosened(out) - } - refOutputs.set(target, converted === DROP ? sharedOutput(target, ctx) : converted) - return out + return isLoose(converted) || (converted === DROP && target !== undefined) ? loosened(out) : out } const out = inline(ref, ctx, convertSchema) if (out === DROP) { @@ -433,26 +413,6 @@ function convertSchemaRef(ref: string, ctx: Context, holder: unknown): unknown { return out } -// The output of a target still being converted. A target that converts to the -// output of the target of its `$ref`, an alias, has none of its own, so it is -// the output of the target its chain of `$ref`s ends at: the copy the -// components get, or for a `$ref` the conversion inlines, its inlined copy. -function sharedOutput(target: unknown, ctx: Context): unknown { - const visited = new Set() - let at = target - let seen: Context['seen'] | undefined = ctx.shared - for ( - let ref = aliasRef(at, ctx); - ref !== undefined && !visited.has(at); - ref = aliasRef(at, ctx) - ) { - visited.add(at) - at = resolvePointer(ref, ctx) - seen = isInlined(ref, ctx) ? ctx.apart.get(at) : ctx.shared - } - return isRecord(at) ? seen?.get(SCHEMA_FIELDS)?.get(at) : undefined -} - // 3.0 `items` applies one schema to every item, so a tuple becomes an array // whose items match any of its item schemas, and those of the items after // them, unless `items: false` or `maxItems` allows none. `items: false` @@ -785,19 +745,13 @@ function loosen( if (isLoose(out.not)) { delete out.not loose = true - } else if (out.not !== undefined) { - keptStrict = true - } - if (Array.isArray(out.oneOf)) { - if (out.oneOf.some(isLoose)) { - if (!isDisjoint(schema.oneOf as unknown[], ctx)) { - addAnyOf(out, out.oneOf) - delete out.oneOf - } - loose = true - } else { - keptStrict = true + } + if (Array.isArray(out.oneOf) && out.oneOf.some(isLoose)) { + if (!isDisjoint(schema.oneOf as unknown[], ctx)) { + addAnyOf(out, out.oneOf) + delete out.oneOf } + loose = true } return ( loose || @@ -1565,15 +1519,12 @@ function finishSchema( if (loose) { LOOSE.add(out) } + DONE.add(out) } -// Met again before its output was done, around a cycle, a schema not loose -// yet may lose a restriction after a `not` or `oneOf` around it read it. -function revisitSchema(value: object, out: object): void { - if (looseInputs.has(value)) { +function revisitSchema(out: object): void { + if (!DONE.has(out)) { LOOSE.add(out) - } else if (!isLoose(out)) { - revisits.push([value, out]) } } @@ -1591,22 +1542,15 @@ function convertSchema(value: unknown, ctx: Context): unknown { if (typeof value === 'boolean') { return value ? {} : { not: {} } } - const ref = aliasRef(value, ctx) - if (ref !== undefined) { - return convertSchemaRef(ref, ctx, value) - } - return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema, revisitSchema) -} - -// The `$ref` of a schema that becomes its target's output: a bare `$ref`, or -// one to a tagged union beside only annotations. -function aliasRef(value: unknown, ctx: Context): string | undefined { const ref = getRef(value) - return ref !== undefined && + if ( + ref !== undefined && (isBareRef(value as Record) || (isAnnotatedRef(value as Record) && isTaggedRef(ref, ctx))) - ? ref - : undefined + ) { + return convertSchemaRef(ref, ctx, value) + } + return convertObject(value, ctx, SCHEMA_FIELDS, finishSchema, revisitSchema) } function convertParameter(value: unknown, ctx: Context): unknown { @@ -1702,20 +1646,7 @@ function convertDocument(value: unknown, ctx: Context): unknown { function downgradeV30(root: unknown, convert: Convert): unknown { try { - let out = downgrade(root, convert) - // A `not` or `oneOf` over a `$ref` around a cycle read its target before - // that target lost a restriction, so the conversion runs again, reading - // such targets as loose from the start, until no other target does so. - while (keptStrict && markLooseTargets()) { - keptStrict = false - nullableUnions.length = 0 - unionTargets.clear() - refTargets.clear() - refOutputs.clear() - cutHolders.clear() - revisits.length = 0 - out = downgrade(root, convert) - } + const out = downgrade(root, convert) for (const [schema, input, key] of nullableUnions) { if (!unionTargets.has(input) && (key === 'oneOf' || dropEqualBranches(schema))) { const isTarget = refTargets.has(input) @@ -1733,34 +1664,11 @@ function downgradeV30(root: unknown, convert: Convert): unknown { return out } finally { // Cleared even when conversion throws, so nothing keeps the input alive. - keptStrict = false nullableUnions.length = 0 unionTargets.clear() refTargets.clear() - refOutputs.clear() - looseTargets.clear() cutHolders.clear() - revisits.length = 0 - looseInputs.clear() - } -} - -// Adds each target whose output became loose after a `$ref` read it. -function markLooseTargets(): boolean { - let added = false - for (const [target, out] of refOutputs) { - if (isLoose(out)) { - looseTargets.add(target) - added = true - } - } - for (const [input, out] of revisits) { - if (isLoose(out) && !looseInputs.has(input)) { - looseInputs.add(input) - added = true - } } - return added } /** Downgrades an OpenAPI 3.1 document to 3.0, setting `openapi` to `3.0.4`. The input is never changed. */ 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 e62188c..d65f439 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -3353,8 +3353,9 @@ describe('downgradeSchemaV31ToV30', () => { { type: 'array', items: { type: 'string' } }, ], }) + // A $ref met again while its target is still being converted counts as loose. expect(schemas?.Cycle).toEqual({ - oneOf: [{ $ref: '#/components/schemas/A' }, { type: 'string' }], + anyOf: [{ $ref: '#/components/schemas/A' }, { type: 'string' }], }) }) })