From 53828105d0d1ad39b07ad9192c985fe00d96cf83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 05:31:01 +0000 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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' }], }) }) }) From dd53f1c204f11d0445557b7a662f6012a4ddb85c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:51:04 +0000 Subject: [PATCH 11/19] refactor(downgrader): replace any $ref into items or additionalProperties with its target Checking which items or additionalProperties the conversion replaces needed a walk over the pointer and a lookup of each holder. Replacing every $ref into either keeps the same values accepted, with one regex test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 8 ++--- packages/downgrader/src/v3.1-to-v3.0.ts | 31 +++---------------- .../downgrader/tests/v3.1-to-v3.0.test.ts | 29 +++++++++-------- 3 files changed, 25 insertions(+), 43 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 2ffb57a..6da039f 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -193,17 +193,17 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | An [`enum`][js-enum] with no `type`, as ArkType writes a union of literals | The `type` its values share, when they share one, since code generators drop or leave untyped a bare `enum`, or read its integers as numbers. | | Numeric [`exclusiveMinimum`][js-exclusive-minimum] and [`exclusiveMaximum`][js-exclusive-maximum] | `minimum` or `maximum` with a boolean `exclusiveMinimum` or `exclusiveMaximum`, unless `minimum` or `maximum` is stricter. | | [`examples`][js-examples] | The first entry fills `example` when that is missing. | -| [`prefixItems`][js-prefix-items] | `items` matching any of the tuple's item schemas, and those of the items after them unless `items: false` or `maxItems` allows none, or `{}` when one of them accepts every value. That `anyOf` merges like the ones above, as for the key and value of a Zod `Map` entry. `items: false` becomes `maxItems`. A `$ref` into `items` is replaced by its target. | +| [`prefixItems`][js-prefix-items] | `items` matching any of the tuple's item schemas, and those of the items after them unless `items: false` or `maxItems` allows none, or `{}` when one of them accepts every value. That `anyOf` merges like the ones above, as for the key and value of a Zod `Map` entry. `items: false` becomes `maxItems`. | | [`contentEncoding`][js-content-encoding] and [`contentMediaType`][js-content-media-type] | `format: "byte"` for `base64`, and `format: "binary"` for `binary` or a media type without an encoding or a `contentSchema`, which marks structured text. It replaces a `format` that only repeats the encoding, such as Zod's `format: "base64"`. Without a `type`, it gains only the `format`, so it still takes every value, unless it holds a `$ref`, `allOf`, `anyOf`, `oneOf`, `properties`, `items` or `prefixItems`, as a JSON body marked with its media type does. Then the keywords are removed. | | An [`anyOf`][js-any-of] with a branch that holds only `contentMediaType`, as Zod writes for a file with several media types | Removed, since that branch matches every value. | | An [`anyOf`][js-any-of] with branches that become equal, as oRPC 1.x writes a file with several media types, a string for each, and Effect a nullish union, with `{ type: "null" }` twice | A branch equal to the one before it is removed, as the `anyOf` then matches the same values, and a lone branch left takes the place of the `anyOf` when the schema around it holds only annotations that the branch lacks or holds with the same value, or nothing else beside a `$ref`, so a nullable one then merges as above. NSwag and oapi-codegen read the `anyOf` of equal binary strings as a generated class or a union, not as a file upload. Branches are equal when each keyword holds the same value, or the same object rather than a copy. A `oneOf` keeps them, since it rejects a value that two branches match. | | A [`$ref`][js-ref] with siblings | `allOf: [{ $ref }]` beside the siblings, which 3.0 would ignore. A branch of a `oneOf` or `anyOf` beside a `discriminator` that stays, with only annotations beside its `$ref`, such as the `description` the Anthropic and ElevenLabs APIs write for each, becomes the bare `$ref` instead, since 3.0 maps a discriminator only to the `$ref`s among its branches, not to inline schemas, and oapi-codegen stops on the `allOf` there. So does a `$ref` with only annotations beside it to a schema whose `discriminator` stays, such as the `description` the OpenAI and Adyen APIs write beside a property holding a tagged union, or the `examples` the Anthropic API writes there, since 3.0 reads an `allOf` holding that `$ref` as one of the schemas the discriminator picks among. openapi-typescript then leaves out its tag, and Hey API pins the tag to the name of the schema around it when the target has no `oneOf`, as for Adyen's base with a `mapping`. In both cases the annotations beside the `$ref` are dropped, so a `readOnly`, `writeOnly`, `default` or `deprecated` there is lost, as for the `readOnly` the Cryptography Provider API writes beside one. | -| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | +| [`$defs`][js-defs], and `definitions`, its draft-07 name | Removed. Each `$ref` into it, or into any `items` or `additionalProperties`, where the conversion can put another schema, is replaced by its converted target, where a reference back into a target being inlined becomes `{}`. | | A [`discriminator`][3.0-schema-discriminator] over `$ref`s into `$defs`, as Pydantic and msgspec write a tagged union | Removed. A discriminator picks only among named components, not the targets that replace those `$ref`s, and its `mapping` into them would dangle, which validators that follow it, such as openapi-schema-validator, reject. | | Boolean subschemas | `{}` for `true` and `{ not: {} }` for `false`, except in `additionalProperties`, which allows booleans. | -| [`patternProperties`][js-pattern-properties] | Removed. `additionalProperties` becomes `true`, as a schema or `false` there would reject the properties `patternProperties` allowed, and code generators read a missing one as closed. A `$ref` into that `additionalProperties` is replaced by its target. | +| [`patternProperties`][js-pattern-properties] | Removed. `additionalProperties` becomes `true`, as a schema or `false` there would reject the properties `patternProperties` allowed, and code generators read a missing one as closed. | | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], [`contentSchema`][js-content-schema], [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], [`dependentRequired`][js-dependent-required], [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], [`propertyNames`][js-property-names], and [`unevaluatedItems`][js-unevaluated-items] | Removed. | -| [`propertyNames`][js-property-names] holding only an `enum` or `const`, or a `$ref` to one, as Zod and Valibot write record keys, with no `properties`, `patternProperties`, or `unevaluatedProperties` beside it | A property for each name, sharing the `additionalProperties` schema, and `additionalProperties: false`. A `$ref` into that schema is replaced by its target. Annotations beside the names, such as a `title`, `description`, or `examples`, are dropped. | +| [`propertyNames`][js-property-names] holding only an `enum` or `const`, or a `$ref` to one, as Zod and Valibot write record keys, with no `properties`, `patternProperties`, or `unevaluatedProperties` beside it | A property for each name, sharing the `additionalProperties` schema, and `additionalProperties: false`. Annotations beside the names, such as a `title`, `description`, or `examples`, are dropped. | | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | | An array without `items`, or an empty `required` | `items: {}`, which 3.0 requires, and no `required`, which 3.0 requires to be non-empty. | | An empty [`enum`][js-enum], which 3.0 forbids | `allOf: [{ not: {} }]`, which also rejects every value. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 6abac36..f31cfb4 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -277,8 +277,9 @@ const DOCUMENT_FIELDS = defineFields({ webhooks: DROP, }) -// A `$ref` into a removed part, `$defs`, or `definitions`, or into a value -// the conversion replaces, is replaced by its target, unless it already dangles. +// A `$ref` into a removed part, `$defs`, or `definitions`, or into any `items` +// or `additionalProperties`, which the conversion can replace with another +// schema, is replaced by its target, unless it already dangles. function isInlined(ref: string, ctx: Context): boolean { return ( (ref.includes('/$defs/') || @@ -287,34 +288,12 @@ function isInlined(ref: string, ctx: Context): boolean { // about 3% of the instructions of a conversion. ref.startsWith('#/webhooks/') || ref.startsWith('#/components/pathItems/') || - isIntoReplaced(ref, ctx)) && + INTO_REPLACED.test(ref)) && resolvePointer(ref, ctx) !== undefined ) } -// Where the conversion puts another schema, which a `$ref` kept as written -// would read instead: the `items` of a tuple, built from every item schema or -// left out, and the `additionalProperties` beside `patternProperties`, which -// becomes `true`, or of a record keyed by an enum, which becomes `false`. -function isIntoReplaced(ref: string, ctx: Context): boolean { - for (const key of ['/items', '/additionalProperties']) { - for (let at = ref.indexOf(key); at !== -1; at = ref.indexOf(key, at + 1)) { - const end = at + key.length - if (end === ref.length || ref[end] === '/') { - const holder = resolvePointer(ref.slice(0, at), ctx) - if ( - isRecord(holder) && - (key === '/items' - ? holder.prefixItems !== undefined - : holder.patternProperties !== undefined || enumNames(holder, ctx) !== undefined) - ) { - return true - } - } - } - } - return false -} +const INTO_REPLACED = /\/(?:items|additionalProperties)(?:\/|$)/ /** A 3.0 Reference Object holds only `$ref`. */ function refOr(convert: Convert): Convert { 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 d65f439..3c00ab1 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2534,19 +2534,21 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ properties: { a: { properties: { x: value }, additionalProperties: false }, b: value }, }) - // A record keyed by any string keeps its additionalProperties, and so the $ref. - const map = { - properties: { - a: { additionalProperties: value }, - b: { $ref: '#/properties/a/additionalProperties' }, - }, - } as const - expect(downgradeSchemaV31ToV30(map)).toEqual(map) + // So does any other $ref into additionalProperties, for simplicity. + expect( + downgradeSchemaV31ToV30({ + properties: { + a: { additionalProperties: value }, + b: { $ref: '#/properties/a/additionalProperties' }, + }, + }), + ).toEqual({ properties: { a: { additionalProperties: value }, b: value } }) }) - it('replaces a $ref into the items of a tuple, or into additionalProperties beside patternProperties, with its target', () => { - // The conversion puts other schemas there: the items built from every item - // schema, none for a closed tuple, and true. + it('replaces a $ref into items or additionalProperties with its target', () => { + // The conversion can put other schemas there: the items built from every + // item schema, none for a closed tuple, and true. Any such $ref is + // replaced, for simplicity. const ref = (path: string) => ({ $ref: `#/components/schemas/${path}` }) const out = convert({ components: { @@ -2574,7 +2576,8 @@ describe('downgradeSchemaV31ToV30', () => { NotRest: { not: ref('Pair/items') }, RestOrNumber: { oneOf: [ref('Pair/items'), { type: 'number' }] }, NotOther: { not: ref('Notes/additionalProperties') }, - // Not into a tuple, nor into items at all. + // Also one into the items of a plain array, but not into a property + // whose name only starts with items. List: { type: 'array', items: { type: 'object', properties: { a: { type: 'string' } } }, @@ -2591,7 +2594,7 @@ describe('downgradeSchemaV31ToV30', () => { expect(out?.NotRest).toEqual({ not: { type: 'string' } }) expect(out?.RestOrNumber).toEqual({ oneOf: [{ type: 'string' }, { type: 'number' }] }) expect(out?.NotOther).toEqual({ not: { type: 'string' } }) - expect(out?.Item).toEqual(ref('List/items/properties/a')) + expect(out?.Item).toEqual({ type: 'string' }) expect(out?.Count).toEqual(ref('Bag/properties/itemsLeft')) }) From 3c7ab4b1f0e8ff376b1884ff74827ffc1bbf4934 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:52:33 +0000 Subject: [PATCH 12/19] refactor(downgrader): follow a propertyNames $ref through one $ref only A target that is a $ref again now holds no names, so the record loses its key names, as it does for any other propertyNames it cannot read. This replaces the walk along a chain of annotated $refs and its visited set, which every schema allocated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/src/v3.1-to-v3.0.ts | 19 +++++++------------ .../downgrader/tests/v3.1-to-v3.0.test.ts | 9 +++------ 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index f31cfb4..8c3b55c 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -513,18 +513,13 @@ 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, and 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) - } + // for a described use, since other keywords beside it narrow the names + // further. A target that is a `$ref` again holds no names here. + const ref = getRef(schema.propertyNames) + const names = + ref !== undefined && isAnnotatedRef(schema.propertyNames as Record) + ? resolvePointer(ref, ctx) + : schema.propertyNames 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 3c00ab1..f212f11 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2430,7 +2430,7 @@ describe('downgradeSchemaV31ToV30', () => { }) }) - it('follows a propertyNames $ref on through a $ref beside only annotations, and no further', () => { + it('follows a propertyNames $ref through one $ref only', () => { const r = '#/components/schemas/' const schemas = convert({ components: { @@ -2448,11 +2448,8 @@ describe('downgradeSchemaV31ToV30', () => { }, }, }).components?.schemas - expect(schemas?.ByAlias).toEqual({ - type: 'object', - properties: { en: { type: 'string' }, fr: { type: 'string' } }, - additionalProperties: false, - }) + // A target that is a $ref again holds no names, so the record loses them. + expect(schemas?.ByAlias).toEqual({ type: 'object', additionalProperties: { type: 'string' } }) expect(schemas?.NotEnglish).toEqual({}) }) From 37fdc08bee784634b0ee040841f69f126bfda857 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:53:41 +0000 Subject: [PATCH 13/19] fix(downgrader): replace a percent-encoded $ref with its target find() percent-decodes a $ref, but the checks for $defs, the removed parts, and items or additionalProperties read it as written. So a $ref such as #/components/schemas/Pair/%69tems was kept, and read the looser schema the conversion puts there, which a not over it reads as stricter. Any $ref with a % is now replaced by its target, which needs no decoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 1 - packages/downgrader/src/v3.1-to-v3.0.ts | 7 ++++-- .../downgrader/tests/v3.1-to-v3.0.test.ts | 25 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 6da039f..a6dbbf7 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -169,7 +169,6 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem #### Limitations - A target replacing several `$ref`s stands in at each of them as the same object, so a Path Item referenced twice repeats its `operationId`s, and a YAML dump writes an alias. Where a target refers back to one it is being copied into, that inner reference becomes `{}`, or is removed where `{}` is not a valid value. -- `$ref`s into removed parts are recognized by their `#/webhooks/` or `#/components/pathItems/` prefix as written, not percent-encoded. - Links and discriminator `mapping` values that point into a removed part, and Links that name a webhook operation by `operationId`, are left as written. - Encoding Objects pass through, although 3.0 serializes `multipart` parts by other defaults: it ignores `style`, `explode`, and `allowReserved` there, and gives an untyped part no `application/octet-stream` default. - A requirement that uses only mutual TLS becomes `{}`, so 3.0 tools read its operation as needing no security, and a requirement that pairs it with another scheme asks for that scheme alone. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 8c3b55c..d6f64b1 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -279,10 +279,13 @@ const DOCUMENT_FIELDS = defineFields({ // A `$ref` into a removed part, `$defs`, or `definitions`, or into any `items` // or `additionalProperties`, which the conversion can replace with another -// schema, is replaced by its target, unless it already dangles. +// schema, is replaced by its target, unless it already dangles. So is one +// that percent-encodes a character, which `find` decodes, as it can hide +// those segments, such as `#/%24defs/Pet`. function isInlined(ref: string, ctx: Context): boolean { return ( - (ref.includes('/$defs/') || + (ref.includes('%') || + ref.includes('/$defs/') || ref.includes('/definitions/') || // The removed parts, spelled out, as a callback for each `$ref` costs // about 3% of the instructions of a conversion. 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 f212f11..d2b53be 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2595,6 +2595,31 @@ describe('downgradeSchemaV31ToV30', () => { expect(out?.Count).toEqual(ref('Bag/properties/itemsLeft')) }) + it('replaces a percent-encoded $ref with its target', () => { + // find decodes it, so it can point into $defs, a removed part, or items + // that the conversion replaces, unseen. + const ref = (path: string) => ({ $ref: `#/components/schemas/${path}` }) + const doc = convert({ + paths: { '/pets': { $ref: '#/components/path%49tems/Pets' } }, + components: { + pathItems: { Pets: { get: { responses: { 200: { description: 'OK' } } } } }, + schemas: { + Pair: { type: 'array', prefixItems: [{ type: 'number' }], items: { type: 'string' } }, + Box: { type: 'object', $defs: { Id: { type: ['string', 'null'] } } }, + Rates: { type: 'object', properties: { '50%': { type: 'integer' } } }, + NotRest: { not: ref('Pair/%69tems') }, + Id: ref('Box/%24defs/Id'), + Rate: ref('Rates/properties/50%25'), + }, + }, + }) + expect(doc.paths?.['/pets']).toEqual({ get: { responses: { 200: { description: 'OK' } } } }) + const out = doc.components?.schemas + expect(out?.NotRest).toEqual({ not: { type: 'string' } }) + expect(out?.Id).toEqual({ type: 'string', nullable: true }) + expect(out?.Rate).toEqual({ type: 'integer' }) + }) + it('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => { const properties = { name: { type: 'string' } } as const expect( From c60774667a9ee5246c86761211ca151e31571bb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:17:41 +0000 Subject: [PATCH 14/19] docs(downgrader): note that a percent-encoded $ref is replaced by its target Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index a6dbbf7..c852341 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -253,7 +253,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc #### Limitations -- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. +- `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. One that percent-encodes a character, such as `#/%24defs/Pet`, is replaced by its target wherever it points. - An `anyOf` or `oneOf` of `{ type: "null" }` and a branch that a `$ref` points into stays as written, so the `$ref` still resolves, and code generators read it as a union. - `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`. - 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. From 98c819932cb7da1e5349d8f03912ade85433f8de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:54:53 +0000 Subject: [PATCH 15/19] fix(downgrader): write */* as the contentType of an Encoding Object that sets style, explode, or allowReserved 3.1 ignores contentType, also its default, where style, explode, or allowReserved is set, while 3.0 ignores those in multipart and applies contentType, so a part 3.1 accepts in any type was held to one type in 3.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 3 +- packages/downgrader/src/v3.1-to-v3.0.ts | 15 ++++++++- .../downgrader/tests/v3.1-to-v3.0.test.ts | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index c852341..1db0390 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -153,6 +153,7 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem | Reference Object [`summary`][3.1-reference-summary] and [`description`][3.1-reference-description] | Removed. A 3.0 Reference Object holds only `$ref`. | | [`mutualTLS`][3.1-security-scheme-type] security schemes | Removed, with the `$ref`s to them and their names in security requirements. A requirement left empty stays and needs no security, since 3.0 cannot ask for mutual TLS at all. | | [Security requirement][3.1-security-requirement] scopes on `apiKey` and `http` schemes | `[]`. 3.0 allows scopes only for OAuth2 and OpenID Connect. | +| Encoding Object `contentType` beside `style`, `explode`, or `allowReserved` | `*/*`. 3.1 ignores it there, also its default, and 3.0 ignores the others in `multipart` instead. | [3.1-json-schema-dialect]: https://spec.openapis.org/oas/v3.1.2.html#oas-json-schema-dialect [3.1-webhooks]: https://spec.openapis.org/oas/v3.1.2.html#oas-webhooks @@ -170,7 +171,7 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem - A target replacing several `$ref`s stands in at each of them as the same object, so a Path Item referenced twice repeats its `operationId`s, and a YAML dump writes an alias. Where a target refers back to one it is being copied into, that inner reference becomes `{}`, or is removed where `{}` is not a valid value. - Links and discriminator `mapping` values that point into a removed part, and Links that name a webhook operation by `operationId`, are left as written. -- Encoding Objects pass through, although 3.0 serializes `multipart` parts by other defaults: it ignores `style`, `explode`, and `allowReserved` there, and gives an untyped part no `application/octet-stream` default. +- Other Encoding Objects pass through, although 3.0 gives an untyped `multipart` part no `application/octet-stream` default. - A requirement that uses only mutual TLS becomes `{}`, so 3.0 tools read its operation as needing no security, and a requirement that pairs it with another scheme asks for that scheme alone. - A 3.1 document that already uses 3.2 fields, as FastAPI writes `itemSchema` for streaming responses, keeps them as written, and the schemas inside them are not converted. Run `downgradeSpecV32ToV31` on it first. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index d6f64b1..196472c 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -213,7 +213,7 @@ const ENCODING_FIELDS = defineFields({ }) const MEDIA_TYPE_FIELDS = defineFields({ - encoding: map((item, ctx) => convertObject(item, ctx, ENCODING_FIELDS)), + encoding: map((item, ctx) => convertObject(item, ctx, ENCODING_FIELDS, finishEncoding)), examples: map(convertReference), schema: convertSchema, }) @@ -1538,6 +1538,19 @@ function convertMediaType(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, MEDIA_TYPE_FIELDS) } +// 3.1 ignores `contentType`, also its default, where `style`, `explode`, or +// `allowReserved` is set, and 3.0 ignores those in `multipart` instead, so +// the part takes any type. +function finishEncoding(out: Record, source: Record): void { + if ( + source.style !== undefined || + source.explode !== undefined || + source.allowReserved !== undefined + ) { + out.contentType = '*/*' + } +} + function convertRequestBody(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, REQUEST_BODY_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 d2b53be..0786c2b 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -403,6 +403,38 @@ describe('downgradeSpecV31ToV30', () => { }) }) + it('writes */* as the contentType of an Encoding Object that sets style, explode, or allowReserved', () => { + // 3.1 ignores contentType there, also its default, and 3.0 ignores the + // others in multipart instead. + const encoding = { + id: { contentType: 'text/plain', explode: true }, + tag: { style: 'form' }, + key: { contentType: 'application/json', allowReserved: false }, + photo: { contentType: 'image/png', headers: { 'X-Size': { schema: { type: 'integer' } } } }, + } as const + const schema = { type: 'object' } as const + const body = convert({ + paths: { + '/a': { + post: { requestBody: { content: { 'multipart/form-data': { schema, encoding } } } }, + }, + }, + }).paths['/a']?.post?.requestBody + expect(body).toEqual({ + content: { + 'multipart/form-data': { + schema, + encoding: { + id: { contentType: '*/*', explode: true }, + tag: { contentType: '*/*', style: 'form' }, + key: { contentType: '*/*', allowReserved: false }, + photo: encoding.photo, + }, + }, + }, + }) + }) + it('copies extensions in paths, responses, and callbacks as they are', () => { const extension = { $ref: '#/paths/~1a', get: {} } const operation = { responses: { 'x-a': extension }, callbacks: { cb: { 'x-a': extension } } } From ab1cc0edb5b01f862ca867c8fc8c440676e813eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:56:16 +0000 Subject: [PATCH 16/19] fix(downgrader): leave out 3.2 security requirement names that are not a component name 3.1 names a scheme in a security requirement only by its component name, where 3.2 also takes a URI reference to one. Kept as written, such a name named no scheme in 3.1, so no request could meet the requirement. Any name that is not a component name is now left out, which asks for less. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 5 ++- packages/downgrader/src/v3.2-to-v3.1.ts | 23 +++++++++++++ .../downgrader/tests/v3.2-to-v3.1.test.ts | 33 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 1db0390..4e8584c 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -87,6 +87,7 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem | Example [`dataValue`][3.2-example-data-value] and [`serializedValue`][3.2-example-serialized-value] | Removed. Without `value` or `externalValue`, `dataValue`, or else `serializedValue`, fills `value`. | | OAuth [`deviceAuthorization`][3.2-oauth-flows-device-authorization] flow | Removed. | | Security Scheme [`oauth2MetadataUrl`][3.2-security-scheme-oauth2-metadata-url] and [`deprecated`][3.2-security-scheme-deprecated] | Removed. | +| [Security requirement][3.2-security-requirement] names other than a component name, such as a URI reference to a scheme | Removed, which asks for less. | [3.2-self]: https://spec.openapis.org/oas/v3.2.1.html#oas-self [3.2-json-schema-dialect]: https://spec.openapis.org/oas/v3.2.1.html#oas-json-schema-dialect @@ -113,12 +114,14 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem [3.2-oauth-flows-device-authorization]: https://spec.openapis.org/oas/v3.2.1.html#oauth-flows-device-authorization [3.2-security-scheme-oauth2-metadata-url]: https://spec.openapis.org/oas/v3.2.1.html#security-scheme-oauth2-metadata-url [3.2-security-scheme-deprecated]: https://spec.openapis.org/oas/v3.2.1.html#security-scheme-deprecated +[3.2-security-requirement]: https://spec.openapis.org/oas/v3.2.1.html#security-requirement-object #### Limitations - Other `$ref`s into a removed part, such as a schema `$ref` into `components.mediaTypes`, and Links or discriminator `mapping` values that point at a removed operation, are left as written and dangle. - A `content` `$ref` that does not resolve is removed, which can leave a parameter or header without the one entry it needs. -- Security requirements that name a scheme by URI, and `$self`-relative references, pass through unchanged. +- A security requirement that names its scheme by URI reference loses it, so 3.1 tools can read the operation as needing no credentials for it. +- `$self`-relative references pass through unchanged. ### Schema (`downgradeSchemaV32ToV31`) diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index ceb0518..64a37b6 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -19,6 +19,7 @@ import { list, map, resolve, + resolvePointer, } from './shared' const V32_DIALECT_PREFIX = 'https://spec.openapis.org/oas/3.2/dialect/' @@ -125,6 +126,7 @@ const SECURITY_SCHEME_FIELDS = defineFields({ const OPERATION_FIELDS = defineFields({ callbacks: map(refOr(convertCallback)), + security: convertSecurity, parameters: list(convertParameterRef), requestBody: refOr(convertRequestBody), responses: map(refOr(convertResponse), isNotExtension), @@ -159,11 +161,32 @@ const DOCUMENT_FIELDS = defineFields({ jsonSchemaDialect: (item) => typeof item === 'string' && item.startsWith(V32_DIALECT_PREFIX) ? V31_DIALECT : clone(item), paths: map(convertPathItem, isPath), + security: convertSecurity, servers: convertServers, tags: list(convertTag), webhooks: map(convertPathItem), }) +// 3.1 names a scheme in a security requirement only by its component name, +// where 3.2 also takes a URI reference to one. Any other name is left out, +// which asks for less. +function convertSecurity(value: unknown, ctx: Context): unknown { + if (!Array.isArray(value)) { + return clone(value) + } + const schemes = resolvePointer('#/components/securitySchemes', ctx) + const isScheme = (name: string): boolean => isRecord(schemes) && Object.hasOwn(schemes, name) + return value.map((requirement) => + isRecord(requirement) + ? Object.fromEntries( + Object.keys(requirement) + .filter((name) => requirement[name] !== undefined && isScheme(name)) + .map((name) => [name, clone(requirement[name])]), + ) + : clone(requirement), + ) +} + /** Reference Objects are the same in 3.1, so they are copied as they are. */ function refOr(convert: Convert): Convert { return (value, ctx) => (getRef(value) === undefined ? convert(value, ctx) : clone(value)) diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index 34ac525..b9be9b4 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -415,6 +415,39 @@ describe('downgradeSpecV32ToV31', () => { }) }) + it('leaves out security requirement names that are not a component name', () => { + const key = { type: 'apiKey', in: 'header', name: 'X-Key' } as const + const out = convert({ + security: [{ '#/components/securitySchemes/key': [] }], + paths: { + '/a': { + get: { + security: [ + { '#/components/securitySchemes/key': [], 'key': [] }, + // A component name is read as one, whatever it looks like. + { '#/components/securitySchemes/other': ['read'] }, + { 'https://example.com/openapi.json#/components/securitySchemes/key': [] }, + ], + }, + }, + }, + components: { securitySchemes: { 'key': key, '#/components/securitySchemes/other': key } }, + }) + expect(out.security).toEqual([{}]) + expect(out.paths?.['/a']?.get?.security).toEqual([ + { key: [] }, + { '#/components/securitySchemes/other': ['read'] }, + {}, + ]) + // Without any schemes, every name is left out, and malformed values stay. + const malformed = convert({ + security: 'all', + paths: { '/a': { get: { security: [null, { key: [] }] } } }, + } as any) + expect(malformed.security).toBe('all') + expect(malformed.paths?.['/a']?.get?.security).toEqual([null, {}]) + }) + it('converts schemas everywhere in the document', () => { const schema: OpenAPIV3_2.SchemaObject = { type: 'string', xml: { nodeType: 'attribute' } } const out = convert({ From 261d1411c2fd4496b6068b113f6d7a8e32d18f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:57:15 +0000 Subject: [PATCH 17/19] fix(downgrader): write schema {} for a 3.2 path or cookie value sent as written 3.2 sends the value of style: cookie, or of allowReserved in a path or cookie, as written, where the form or simple style 3.1 falls back to percent-decodes it, so the schema would read another string. Such a parameter's schema now becomes {}. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 1 + packages/downgrader/src/v3.2-to-v3.1.ts | 13 +++++++++++-- .../downgrader/tests/v3.2-to-v3.1.test.ts | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 4e8584c..c8d6814 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -78,6 +78,7 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem | [`in: "querystring"`][3.2-parameter-locations] parameters | Removed, with the `$ref`s to them. | | [`allowReserved`][3.2-parameter-allow-reserved] outside `in: "query"` | Removed. 3.1 applies it only to query parameters. | | [`style: "cookie"`][3.2-style-values] | Removed, leaving the `form` default. | +| A path or cookie value that `style: "cookie"` or `allowReserved` sends as written | `schema: {}`. 3.1 percent-decodes it with the `form` or `simple` style instead. | | Parameter [`example`][3.2-parameter-example] and [`examples`][3.2-parameter-examples] beside `content` | Removed. 3.1 allows them only beside `schema`. The media types keep their own examples. | | Parameter `style`, `explode`, and `allowReserved` beside `content`, which neither version allows | Removed. | | Media Type [`description`][3.2-schema] | Removed. | diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 64a37b6..86a44c0 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -234,15 +234,24 @@ function isQuerystring(parameter: unknown): boolean { return isRecord(parameter) && parameter.in === 'querystring' } -// 3.1 allows these only beside `schema`. The media type in `content` -// carries its own examples and serialization. function finishParameter(out: Record, parameter: Record): void { + // 3.1 allows these only beside `schema`. The media type in `content` + // carries its own examples and serialization. if (parameter.content !== undefined) { delete out.allowReserved delete out.example delete out.examples delete out.explode delete out.style + } else if ( + out.schema !== undefined && + (parameter.style === 'cookie' || + (parameter.allowReserved === true && (parameter.in === 'path' || parameter.in === 'cookie'))) + ) { + // 3.2 sends such a value as written, where the `form` or `simple` style + // 3.1 falls back to percent-decodes it, so the schema would read another + // string. Neither version percent-encodes a header. + out.schema = {} } } diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index b9be9b4..99dd9a2 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -230,6 +230,25 @@ describe('downgradeSpecV32ToV31', () => { ]) }) + it('loosens the schema of a parameter that 3.2 sends as written, which 3.1 percent-decodes', () => { + const id = { type: 'string', pattern: '^[^=]*$' } as const + const out = convertOperation({ + parameters: [ + { in: 'cookie', name: 'session', style: 'cookie', schema: id, example: 'a%3D' }, + { in: 'cookie', name: 'pref', allowReserved: true, schema: id }, + { in: 'path', name: 'p', required: true, allowReserved: true, schema: id }, + // Neither version percent-encodes a header. + { in: 'header', name: 'X-Id', allowReserved: true, schema: id }, + ], + }) + expect(out?.parameters).toEqual([ + { in: 'cookie', name: 'session', schema: {}, example: 'a%3D' }, + { in: 'cookie', name: 'pref', schema: {} }, + { in: 'path', name: 'p', required: true, schema: {} }, + { in: 'header', name: 'X-Id', schema: id }, + ]) + }) + it('drops the fields 3.1 allows only beside schema from a parameter with content', () => { const content = { 'application/json': { example: 1 } } // As oRPC emits a query parameter whose queryStyles entry is 'json'. From 45dd5bc97c3d9acb97410c286d0a047c6fe2288c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:58:00 +0000 Subject: [PATCH 18/19] fix(downgrader): write {} for a 3.2 content $ref that does not resolve 3.1 allows no $ref in a content map, and one that did not resolve, such as an external one, was removed, which took no payload of that media type. It now becomes {}, which takes any payload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 4 ++-- packages/downgrader/src/v3.2-to-v3.1.ts | 6 +++++- packages/downgrader/tests/v3.2-to-v3.1.test.ts | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index c8d6814..735ae14 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -59,7 +59,7 @@ The output `openapi` is the latest patch release, `3.1.2` from `downgradeSpecV32 The input is never changed, and the output shares no plain objects or arrays with it. Input is read as JSON would see it: an object counts whatever realm it comes from or null prototype it has, as in a document oRPC returns, and a key holding `undefined`, as spreading options often leaves, counts as missing. Other values, such as a `Date`, are kept as they are. -External `$ref`s are left as written, except in a 3.2 `content` map, where 3.1 allows none and the entry is removed. The files they point at are not converted, so bundle a multi-file document into one first. +External `$ref`s are left as written, except in a 3.2 `content` map, where 3.1 allows none and the entry becomes `{}`, which takes any payload. The files they point at are not converted, so bundle a multi-file document into one first. ## 3.2 → 3.1 @@ -120,7 +120,7 @@ Schema Objects inside the document convert as in [Schema](#schema-downgradeschem #### Limitations - Other `$ref`s into a removed part, such as a schema `$ref` into `components.mediaTypes`, and Links or discriminator `mapping` values that point at a removed operation, are left as written and dangle. -- A `content` `$ref` that does not resolve is removed, which can leave a parameter or header without the one entry it needs. +- A `content` `$ref` that does not resolve, such as an external one or one through `$self`, becomes `{}`, which takes any payload. - A security requirement that names its scheme by URI reference loses it, so 3.1 tools can read the operation as needing no credentials for it. - `$self`-relative references pass through unchanged. diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 86a44c0..6ee6a34 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -286,9 +286,13 @@ function convertMediaType(value: unknown, ctx: Context): unknown { } // A 3.1 content map cannot hold a `$ref`, such as one to `components.mediaTypes`. +// One that does not resolve, such as an external one, becomes `{}`, which takes +// any payload, where removing the entry would take none of that media type. function convertMediaTypeEntry(value: unknown, ctx: Context): unknown { const ref = getRef(value) - return ref === undefined ? convertMediaType(value, ctx) : inline(ref, ctx, convertMediaTypeEntry) + const out = + ref === undefined ? convertMediaType(value, ctx) : inline(ref, ctx, convertMediaTypeEntry) + return out === DROP ? {} : out } function convertRequestBody(value: unknown, ctx: Context): unknown { diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index 99dd9a2..b315061 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -359,16 +359,20 @@ describe('downgradeSpecV32ToV31', () => { expect(out.components).toEqual({ schemas: { Pet: { type: 'object' } } }) }) - it('drops a content $ref that does not resolve or loops', () => { + it('writes {} for a content $ref that does not resolve or loops', () => { const out = convertOperation({ requestBody: { content: { 'application/json': { $ref: '#/components/mediaTypes/Missing' }, 'text/plain': { $ref: '#/paths/~1a/get/requestBody/content/text~1plain' }, + 'text/csv': { $ref: 'https://example.com/other.json#/components/mediaTypes/Csv' }, }, }, }) - expect(out?.requestBody).toStrictEqual({ content: {} }) + // A media type with no schema takes any payload. + expect(out?.requestBody).toStrictEqual({ + content: { 'application/json': {}, 'text/plain': {}, 'text/csv': {} }, + }) }) }) From 131ad58c4b87d6517cfb0a000137c64ed2ce6315 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:58:53 +0000 Subject: [PATCH 19/19] docs(downgrader): note that pattern is kept although 3.0 reads it in an older regex dialect Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV --- packages/downgrader/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 735ae14..1207d23 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -258,6 +258,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc #### Limitations +- `pattern` is kept as written, although 3.0 reads it in the ECMA-262 5.1 dialect, which has no Unicode mode and reads by UTF-16 code unit. So a pattern such as `\p{L}` is invalid there, and one such as `^.{1,64}$` counts an emoji as two characters, so it can reject values 3.1 accepts. - `$ref`s to an `$anchor`, or written relative to an `$id`, are left as written and dangle. Every `$ref` is read as a JSON Pointer from the document root, and one into `$defs` is recognized by its `/$defs/` segment. One that percent-encodes a character, such as `#/%24defs/Pet`, is replaced by its target wherever it points. - An `anyOf` or `oneOf` of `{ type: "null" }` and a branch that a `$ref` points into stays as written, so the `$ref` still resolves, and code generators read it as a union. - `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`.