From dbb858f9a0db059ff302c6194434eb40cb975183 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:12:39 +0000 Subject: [PATCH 1/8] feat(downgrader): turn a propertyNames enum into one property per name in 3.1 to 3.0 Zod and Valibot write a record with enum keys as a propertyNames enum beside additionalProperties. 3.0 has no propertyNames, so generators typed it as a string-keyed map and an enclosing oneOf became anyOf. Each name now becomes a property sharing the additionalProperties schema, with additionalProperties: false. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/README.md | 1 + packages/downgrader/src/v3.1-to-v3.0.ts | 29 +++++++++++++ .../downgrader/tests/v3.1-to-v3.0.test.ts | 42 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 1f618a0..b3245c1 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -194,6 +194,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | | [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | | [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | +| [`propertyNames`][js-property-names] holding only an `enum`, as Zod and Valibot write a record with enum keys, with no `properties`, `patternProperties`, or `unevaluatedProperties` beside it | A property for each name, sharing the `additionalProperties` schema, and `additionalProperties: false`. | | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | | [`unevaluatedItems`][js-unevaluated-items] | 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. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 0b8ff35..ed687ce 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -370,6 +370,28 @@ function isUnevaluatedAdditional(schema: Record): boolean { ) } +// The names a `propertyNames` holding only an `enum` allows, as Zod and +// Valibot write a record with enum keys, when no other keyword names +// properties. Each becomes a property of its own, which 3.0 can express. +function enumNames(schema: Record): string[] | undefined { + const names = schema.propertyNames + if ( + !isRecord(names) || + !Array.isArray(names.enum) || + schema.properties !== undefined || + schema.patternProperties !== undefined || + schema.unevaluatedProperties !== undefined || + schema.additionalProperties === false || + !Object.entries(names).every( + ([name, item]) => + item === undefined || name === 'enum' || (name === 'type' && item === 'string'), + ) + ) { + return undefined + } + return names.enum.filter((name) => typeof name === 'string') +} + // Whether removing `key` from `schema` lets it accept more values. Property // names are strings anyway, and `unevaluatedProperties` does nothing beside // `additionalProperties`. @@ -385,6 +407,7 @@ function losesRestriction( const names = schema.propertyNames return !( names === true || + enumNames(schema) !== undefined || (isRecord(names) && Object.entries(names).every( ([name, item]) => item === undefined || (name === 'type' && item === 'string'), @@ -600,6 +623,12 @@ function finishSchema( out.additionalProperties = typeof additional === 'boolean' ? additional : convertSchema(additional, ctx) } + const names = enumNames(schema) + if (names !== undefined) { + const value = isRecord(out.additionalProperties) ? out.additionalProperties : {} + out.properties = Object.fromEntries(names.map((name) => [name, value])) + out.additionalProperties = false + } // Before `convertType` moves `items` into an `anyOf` branch. // A `const` outside the `enum` beside it matched nothing, and now matches itself. const constOutsideEnum = 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 9b9d80a..dbc4d9d 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1036,6 +1036,48 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ type: 'array', items: {} }) }) + it('turns a propertyNames enum, as Zod writes a record with enum keys, into one property per name', () => { + const value = { type: 'number' } as const + expect( + downgradeSchemaV31ToV30({ + type: 'object', + propertyNames: { type: 'string', enum: ['a', 'b', 1] }, + additionalProperties: value, + required: ['a', 'b'], + }), + ).toEqual({ + type: 'object', + properties: { a: value, b: value }, + additionalProperties: false, + required: ['a', 'b'], + }) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] } })).toEqual({ + properties: { a: {} }, + additionalProperties: false, + }) + // Beside other keywords that name properties, it is removed. + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, properties: { a: value } }), + ).toEqual({ properties: { a: value } }) + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, unevaluatedProperties: false }), + ).toEqual({ additionalProperties: false }) + // Converted exactly, it keeps an enclosing oneOf. + expect( + downgradeSchemaV31ToV30({ + oneOf: [ + { type: 'object', propertyNames: { enum: ['a'] }, additionalProperties: value }, + { type: 'string' }, + ], + }), + ).toEqual({ + oneOf: [ + { type: 'object', properties: { a: value }, additionalProperties: false }, + { type: 'string' }, + ], + }) + }) + it('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => { const properties = { name: { type: 'string' } } as const expect( From cea5a3ee3c6eceea8da8a42fd0395bbdea09b2ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:15:54 +0000 Subject: [PATCH 2/8] test(downgrader): cover the cases where a propertyNames enum stays removed Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 dbc4d9d..dd80214 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1055,13 +1055,27 @@ describe('downgradeSchemaV31ToV30', () => { properties: { a: {} }, additionalProperties: false, }) - // Beside other keywords that name properties, it is removed. + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], description: undefined } }), + ).toEqual({ properties: { a: {} }, additionalProperties: false }) + // Beside other keywords that name properties, or with more than an enum, it is removed. expect( downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, properties: { a: value } }), ).toEqual({ properties: { a: value } }) + expect( + downgradeSchemaV31ToV30({ + propertyNames: { enum: ['a'] }, + patternProperties: { '^a$': value }, + }), + ).toEqual({}) expect( downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, unevaluatedProperties: false }), ).toEqual({ additionalProperties: false }) + expect( + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, additionalProperties: false }), + ).toEqual({ additionalProperties: false }) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], minLength: 2 } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], type: 'number' } })).toEqual({}) // Converted exactly, it keeps an enclosing oneOf. expect( downgradeSchemaV31ToV30({ From a8c92080d39d221d9a5e295a8fa7c5e5db46f823 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 22:47:20 +0000 Subject: [PATCH 3/8] feat(downgrader): follow a propertyNames $ref to an enum in 3.1 to 3.0 Zod's .meta({ id }) enum keys and Pydantic dict[Enum, V] write a record's keys as a $ref to a reusable enum. It is followed when nothing sits beside it, so the record still gets one property per name. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 25 ++++++--- .../downgrader/tests/v3.1-to-v3.0.test.ts | 52 +++++++++++++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index b3245c1..f98afd3 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -194,7 +194,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | | [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | | [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | -| [`propertyNames`][js-property-names] holding only an `enum`, as Zod and Valibot write a record with enum keys, with no `properties`, `patternProperties`, or `unevaluatedProperties` beside it | A property for each name, sharing the `additionalProperties` schema, and `additionalProperties: false`. | +| [`propertyNames`][js-property-names] holding only an `enum` or a `$ref` to one, as Zod and Valibot write a record with enum keys, with no `properties`, `patternProperties`, or `unevaluatedProperties` beside it | A property for each name, sharing the `additionalProperties` schema, and `additionalProperties: false`. | | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | | [`unevaluatedItems`][js-unevaluated-items] | 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. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index ed687ce..13ee13a 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -372,9 +372,18 @@ function isUnevaluatedAdditional(schema: Record): boolean { // The names a `propertyNames` holding only an `enum` allows, as Zod and // Valibot write a record with enum keys, when no other keyword names -// properties. Each becomes a property of its own, which 3.0 can express. -function enumNames(schema: Record): string[] | undefined { - const names = schema.propertyNames +// 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 only alone, since keywords beside it narrow the names further. + const names = + ref !== undefined && + Object.entries(schema.propertyNames as Record).every( + ([name, item]) => item === undefined || name === '$ref', + ) + ? resolve(ref, ctx) + : schema.propertyNames if ( !isRecord(names) || !Array.isArray(names.enum) || @@ -399,6 +408,7 @@ function losesRestriction( key: string, schema: Record, exactTuple: boolean, + ctx: Context, ): boolean { switch (key) { case 'prefixItems': @@ -407,7 +417,7 @@ function losesRestriction( const names = schema.propertyNames return !( names === true || - enumNames(schema) !== undefined || + enumNames(schema, ctx) !== undefined || (isRecord(names) && Object.entries(names).every( ([name, item]) => item === undefined || (name === 'type' && item === 'string'), @@ -429,13 +439,14 @@ function loosen( out: Record, schema: Record, exactTuple: boolean, + ctx: Context, ): boolean { let loose = false for (const key in schema) { if ( RESTRICTING_KEYWORDS.has(key) && schema[key] !== undefined && - losesRestriction(key, schema, exactTuple) + losesRestriction(key, schema, exactTuple, ctx) ) { loose = true } @@ -623,7 +634,7 @@ function finishSchema( out.additionalProperties = typeof additional === 'boolean' ? additional : convertSchema(additional, ctx) } - const names = enumNames(schema) + const names = enumNames(schema, ctx) if (names !== undefined) { const value = isRecord(out.additionalProperties) ? out.additionalProperties : {} out.properties = Object.fromEntries(names.map((name) => [name, value])) @@ -635,7 +646,7 @@ function finishSchema( schema.const !== undefined && Array.isArray(schema.enum) && !includesJson(schema.enum, schema.const) - let loose = loosen(out, schema, exactTuple) || constOutsideEnum + let loose = loosen(out, schema, exactTuple, ctx) || constOutsideEnum convertType(out, schema.type) // Written 3.0-style in a 3.1 document, it can only mean what it means in 3.0. if (schema.nullable === true && typeof out.type === 'string' && out.nullable !== true) { diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index dd80214..db00a8f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1092,6 +1092,58 @@ describe('downgradeSchemaV31ToV30', () => { }) }) + 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'] } + const names = { + type: 'object', + properties: { en: value, fr: value }, + additionalProperties: false, + } as const + expect( + convert({ + components: { + schemas: { + Lang, + Names: { + type: 'object', + propertyNames: { $ref: '#/components/schemas/Lang' }, + additionalProperties: value, + }, + }, + }, + }).components?.schemas, + ).toEqual({ Lang, Names: names }) + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + oneOf: [ + { type: 'object', propertyNames: { $ref: '#/$defs/Lang' }, additionalProperties: value }, + { type: 'string' }, + ], + }), + ).toEqual({ oneOf: [names, { type: 'string' }] }) + // A target that is not only an enum, or a $ref with more beside it, is removed as before. + expect( + downgradeSchemaV31ToV30({ + $defs: { Short: { type: 'string', maxLength: 2 } }, + propertyNames: { $ref: '#/$defs/Short' }, + }), + ).toEqual({}) + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + propertyNames: { $ref: '#/$defs/Lang', maxLength: 1 }, + }), + ).toEqual({}) + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + propertyNames: { $ref: '#/$defs/Lang', description: undefined }, + }), + ).toEqual({ properties: { en: {}, fr: {} }, additionalProperties: false }) + }) + it('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => { const properties = { name: { type: 'string' } } as const expect( From 5847cca844d873ff6d91835ff137fb54045be8f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:56:55 +0000 Subject: [PATCH 4/8] feat(downgrader): accept a const key and a title or description in a propertyNames enum Zod z.literal and Valibot v.literal write a record's single key as a const, and .describe(), v.title, or a reusable enum's meta add a title or description beside the enum. Both still give one property per name in 3.0. A union of literal keys, an anyOf of consts, is listed as a limitation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/README.md | 3 ++- packages/downgrader/src/v3.1-to-v3.0.ts | 23 +++++++++++------ .../downgrader/tests/v3.1-to-v3.0.test.ts | 25 ++++++++++++++++++- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index f98afd3..8a90fdd 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -194,7 +194,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | | [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | | [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | Removed. | -| [`propertyNames`][js-property-names] holding only an `enum` or a `$ref` to one, as Zod and Valibot write a record with enum keys, with no `properties`, `patternProperties`, or `unevaluatedProperties` beside it | A property for each name, sharing the `additionalProperties` schema, and `additionalProperties: false`. | +| [`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 `title` or `description` beside the names is dropped. | | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | | [`unevaluatedItems`][js-unevaluated-items] | 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. | @@ -248,6 +248,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. - Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, and array-form `items` passes through. Convert such schemas to 2020-12 first. - `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. +- A record keyed by a union of literals, which Zod and Valibot write as an `anyOf` of `const` branches in `propertyNames`, loses its key names, so 3.0 allows any key. Key it by an enum instead, such as `z.enum` or `v.picklist`. - An untyped schema that gains a `format` from `contentEncoding` or `contentMediaType` also gains `type: "string"`, as 3.0 binary strings need, so it rejects non-string values it accepted. 3.0 tools find file uploads only by `type: "string"` with `format: "binary"`. - OpenAPI Generator merges the `"null"` branch beside a `$ref` to a string enum into a new enum, so the reference is lost. Pass `--openapi-normalizer SIMPLIFY_ONEOF_ANYOF_ENUM=false` to keep it. - datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null. diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 13ee13a..11e1412 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -370,10 +370,10 @@ function isUnevaluatedAdditional(schema: Record): boolean { ) } -// The names a `propertyNames` holding only an `enum` allows, as Zod and -// Valibot write a record with enum 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. +// The names allowed by a `propertyNames` holding only an `enum`, or a `const` +// for a single name, as Zod and Valibot write a record with 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 only alone, since keywords beside it narrow the names further. @@ -384,21 +384,28 @@ function enumNames(schema: Record, ctx: Context): string[] | un ) ? resolve(ref, ctx) : schema.propertyNames + if (!isRecord(names)) { + return undefined + } + // An `enum` beside a `const` allows only the names in both, so both stay as before. + const values = + names.const === undefined ? names.enum : names.enum === undefined ? [names.const] : undefined if ( - !isRecord(names) || - !Array.isArray(names.enum) || + !Array.isArray(values) || schema.properties !== undefined || schema.patternProperties !== undefined || schema.unevaluatedProperties !== undefined || schema.additionalProperties === false || !Object.entries(names).every( ([name, item]) => - item === undefined || name === 'enum' || (name === 'type' && item === 'string'), + item === undefined || + ['enum', 'const', 'title', 'description'].includes(name) || + (name === 'type' && item === 'string'), ) ) { return undefined } - return names.enum.filter((name) => typeof name === 'string') + return values.filter((name) => typeof name === 'string') } // Whether removing `key` from `schema` lets it accept more values. Property 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 db00a8f..c399e6e 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1058,6 +1058,25 @@ describe('downgradeSchemaV31ToV30', () => { expect( downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], description: undefined } }), ).toEqual({ properties: { a: {} }, additionalProperties: false }) + // A title or description changes nothing, and a literal key is a const. + expect( + downgradeSchemaV31ToV30({ + propertyNames: { enum: ['a'], title: 'Key', description: 'A key' }, + }), + ).toEqual({ properties: { a: {} }, additionalProperties: false }) + expect( + downgradeSchemaV31ToV30({ + type: 'object', + propertyNames: { type: 'string', const: 'a' }, + additionalProperties: value, + required: ['a'], + }), + ).toEqual({ + type: 'object', + properties: { a: value }, + additionalProperties: false, + required: ['a'], + }) // Beside other keywords that name properties, or with more than an enum, it is removed. expect( downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'] }, properties: { a: value } }), @@ -1076,6 +1095,10 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ additionalProperties: false }) expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], minLength: 2 } })).toEqual({}) expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], type: 'number' } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ propertyNames: { enum: ['a', 'b'], const: 'a' } })).toEqual({}) + expect( + downgradeSchemaV31ToV30({ propertyNames: { anyOf: [{ const: 'a' }, { const: 'b' }] } }), + ).toEqual({}) // Converted exactly, it keeps an enclosing oneOf. expect( downgradeSchemaV31ToV30({ @@ -1094,7 +1117,7 @@ describe('downgradeSchemaV31ToV30', () => { 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'] } + const Lang = { type: 'string' as const, enum: ['en', 'fr'], title: 'Lang' } const names = { type: 'object', properties: { en: value, fr: value }, From 06b568dd2c44715e64142ee8237b16fa9d749fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:07:51 +0000 Subject: [PATCH 5/8] feat(downgrader): follow a propertyNames $ref beside a title or description Zod writes z.record(Lang.describe('key'), V) for a reusable enum as a $ref with a description beside it, which does not narrow the names. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/src/v3.1-to-v3.0.ts | 5 +++-- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 7 +++++++ 2 files changed, 10 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 11e1412..6cd6e2d 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -376,11 +376,12 @@ function isUnevaluatedAdditional(schema: Record): boolean { // 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 only alone, since keywords beside it narrow the names further. + // Followed alone or beside a `title` or `description`, as Zod writes a + // described use, since other keywords beside it narrow the names further. const names = ref !== undefined && Object.entries(schema.propertyNames as Record).every( - ([name, item]) => item === undefined || name === '$ref', + ([name, item]) => item === undefined || ['$ref', 'title', 'description'].includes(name), ) ? resolve(ref, ctx) : schema.propertyNames 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 c399e6e..272447d 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1165,6 +1165,13 @@ describe('downgradeSchemaV31ToV30', () => { propertyNames: { $ref: '#/$defs/Lang', description: undefined }, }), ).toEqual({ properties: { en: {}, fr: {} }, additionalProperties: false }) + // A described use, as Zod writes for `z.record(Lang.describe('...'), ...)`, drops the description. + expect( + downgradeSchemaV31ToV30({ + $defs: { Lang }, + propertyNames: { $ref: '#/$defs/Lang', title: 'Key', description: 'A key' }, + }), + ).toEqual({ properties: { en: {}, fr: {} }, additionalProperties: false }) }) it('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => { From c5b5c1bcec7b9a5337b81664aa7460972795f798 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:59:37 +0000 Subject: [PATCH 6/8] test(downgrader): use a restricting keyword for the undefined-key propertyNames cases description is now allowed beside the names, so it no longer shows that a key holding undefined counts as missing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 272447d..d2990de 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1056,7 +1056,7 @@ describe('downgradeSchemaV31ToV30', () => { additionalProperties: false, }) expect( - downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], description: undefined } }), + downgradeSchemaV31ToV30({ propertyNames: { enum: ['a'], minLength: undefined } }), ).toEqual({ properties: { a: {} }, additionalProperties: false }) // A title or description changes nothing, and a literal key is a const. expect( @@ -1162,7 +1162,7 @@ describe('downgradeSchemaV31ToV30', () => { expect( downgradeSchemaV31ToV30({ $defs: { Lang }, - propertyNames: { $ref: '#/$defs/Lang', description: undefined }, + propertyNames: { $ref: '#/$defs/Lang', minLength: undefined }, }), ).toEqual({ properties: { en: {}, fr: {} }, additionalProperties: false }) // A described use, as Zod writes for `z.record(Lang.describe('...'), ...)`, drops the description. From 416f183b3a0ef4c77fe5ce031677af8c8aab52b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:10:04 +0000 Subject: [PATCH 7/8] docs(downgrader): note that enum-keyed records repeat their value schema per name in 3.0 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 8a90fdd..1c66e16 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -249,6 +249,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc - 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. - `patternProperties` that matches every name, as TypeBox emits for a record, is removed with its value schema rather than turned into `additionalProperties`. - A record keyed by a union of literals, which Zod and Valibot write as an `anyOf` of `const` branches in `propertyNames`, loses its key names, so 3.0 allows any key. Key it by an enum instead, such as `z.enum` or `v.picklist`. +- A record keyed by an enum repeats its value schema for each name once serialized, so a large enum, or such records nested in each other, make a large document: three levels of 20 names repeat the innermost schema 8,000 times. Register the value schema, as with Zod's `.meta({ id })`, so each name repeats only a `$ref`. - An untyped schema that gains a `format` from `contentEncoding` or `contentMediaType` also gains `type: "string"`, as 3.0 binary strings need, so it rejects non-string values it accepted. 3.0 tools find file uploads only by `type: "string"` with `format: "binary"`. - OpenAPI Generator merges the `"null"` branch beside a `$ref` to a string enum into a new enum, so the reference is lost. Pass `--openapi-normalizer SIMPLIFY_ONEOF_ANYOF_ENUM=false` to keep it. - datamodel-code-generator reads a required property with `nullable: true` as never null. Pass `--strict-nullable` to keep null. From 0ed9fa0ff6f0fae0c50a05e2ab94a719aa7b7e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 05:42:27 +0000 Subject: [PATCH 8/8] fix(downgrader): replace a $ref into an enum-keyed record's value schema with its target That additionalProperties becomes false in 3.0, so a $ref into or through it, as zod-to-json-schema and swagger-parser bundle() write, dangled or pointed at false. It is now inlined like a $defs ref. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY --- packages/downgrader/README.md | 2 +- packages/downgrader/src/v3.1-to-v3.0.ts | 22 +++++++++++++++--- .../downgrader/tests/v3.1-to-v3.0.test.ts | 23 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 1c66e16..34d58d6 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -194,7 +194,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc | [`$schema`][js-schema], [`$vocabulary`][js-vocabulary], [`$id`][js-id], [`$anchor`][js-anchor], [`$dynamicRef`, `$dynamicAnchor`][js-dynamic], [`$comment`][js-comment], and [`contentSchema`][js-content-schema] | Removed. | | [`if`][js-if], [`then`][js-then], [`else`][js-else], [`dependentSchemas`][js-dependent-schemas], and [`dependentRequired`][js-dependent-required] | Removed. | | [`contains`][js-contains], [`minContains`][js-min-contains], [`maxContains`][js-max-contains], and [`propertyNames`][js-property-names] | 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 `title` or `description` beside the names is 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`. A `$ref` into that schema is replaced by its target. A `title` or `description` beside the names is dropped. | | [`unevaluatedProperties`][js-unevaluated-properties] | `additionalProperties`, when nothing beside it but `properties` evaluates properties. Otherwise removed. | | [`unevaluatedItems`][js-unevaluated-items] | 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. | diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 6cd6e2d..a403a95 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -254,17 +254,33 @@ const DOCUMENT_FIELDS = defineFields({ webhooks: DROP, }) -// A `$ref` into a removed part, `$defs`, or `definitions` is replaced by its -// target, unless it already dangles. +// A `$ref` into a removed part, `$defs`, or `definitions`, or into the +// `additionalProperties` of a record keyed by an enum, which becomes `false`, +// is replaced by its target, unless it already dangles. function isInlined(ref: string, ctx: Context): boolean { return ( (ref.includes('/$defs/') || ref.includes('/definitions/') || - REMOVED_PARTS.some((prefix) => ref.startsWith(prefix))) && + REMOVED_PARTS.some((prefix) => ref.startsWith(prefix)) || + isIntoNamedValue(ref, ctx)) && resolvePointer(ref, ctx) !== undefined ) } +function isIntoNamedValue(ref: string, ctx: Context): boolean { + for ( + let at = ref.indexOf('/additionalProperties'); + at !== -1; + at = ref.indexOf('/additionalProperties', at + 1) + ) { + const holder = resolvePointer(ref.slice(0, at), ctx) + if (isRecord(holder) && enumNames(holder, ctx) !== undefined) { + return true + } + } + return false +} + /** A 3.0 Reference Object holds only `$ref`. */ function refOr(convert: Convert): Convert { const self: Convert = (value, ctx) => { 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 d2990de..6ea9b7f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -1174,6 +1174,29 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ properties: { en: {}, fr: {} }, additionalProperties: false }) }) + it('replaces a $ref into the value schema of a record keyed by an enum with its target', () => { + // zod-to-json-schema points a reused schema at its first use. + const value = { type: 'number' } as const + expect( + downgradeSchemaV31ToV30({ + properties: { + a: { propertyNames: { enum: ['x'] }, additionalProperties: value }, + b: { $ref: '#/properties/a/additionalProperties' }, + }, + }), + ).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) + }) + it('turns unevaluatedProperties into additionalProperties where nothing else evaluates properties', () => { const properties = { name: { type: 'string' } } as const expect(