From 4cbfbe738dc31d12e764ef4e61c5e9c5f3754fda Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:47:35 +0000 Subject: [PATCH 1/2] fix(downgrader): inline 3.1 to 3.0 schema $refs to a boolean additionalProperties 3.0 keeps a boolean `additionalProperties`, but a Schema Object is never a boolean, so a `$ref` kept pointing at one resolved to `true` or `false`. The 3.1 to 3.0 converter now treats a location whose output is a boolean as dangling, so such a `$ref` is replaced by `{}` or `{ not: {} }`, like any other boolean schema. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JZezsg72P27RPnwVijmprt --- packages/downgrader/src/shared.ts | 10 ++-- packages/downgrader/src/v3.1-to-v3.0.ts | 8 ++- .../v3.1-to-v3.0/schema/references.test.ts | 53 +++++++++++++++++++ .../v3.1-to-v3.0/spec/components.test.ts | 30 +++++++++++ 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 9e60bfe..9075248 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -434,7 +434,7 @@ export function removedPrefixes(tables: Readonly>): strin ) } -function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | undefined): boolean { +function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | undefined, isTarget: (value: unknown) => boolean): boolean { if (tokens === undefined) { return false } @@ -450,10 +450,10 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | return false } } - return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to)) + return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to)) || !isTarget(to) } -export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = []): unknown { +export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = [], isTarget: (value: unknown) => boolean = () => true): unknown { const targets = new Map() const resolveRef = (ref: string): unknown => { if (!targets.has(ref)) { @@ -502,7 +502,7 @@ export function downgrade(root: unknown, convert: Convert, removed: readonly str copies: new Map(), dangles: (ref) => { if (!dangling.has(ref) && !kept.has(ref)) { - if ((isRemovedPart(ref) || (previous !== root && danglesIn(previous, root, parsePointer(ref)))) && isInlinable(ref)) { + if ((isRemovedPart(ref) || (previous !== root && danglesIn(previous, root, parsePointer(ref), isTarget))) && isInlinable(ref)) { dangling.add(ref) } else { @@ -518,7 +518,7 @@ export function downgrade(root: unknown, convert: Convert, removed: readonly str }) let stale = false for (const ref of kept) { - if ((dangling.has(ref) || danglesIn(out, root, parsePointer(ref))) && isInlinable(ref)) { + if ((dangling.has(ref) || danglesIn(out, root, parsePointer(ref), isTarget)) && isInlinable(ref)) { dangling.add(ref) stale = true } diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 4c88e07..328b337 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -192,6 +192,10 @@ function reference(value: Record): unknown { return { $ref: value.$ref } } +function isRefTarget(value: unknown): boolean { + return typeof value !== 'boolean' +} + function isLoose(value: unknown): boolean { return LOOSE.has(value as object) } @@ -504,9 +508,9 @@ function convertDocument(value: unknown, ctx: Context): unknown { } export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV3_0.OpenAPIObject { - return downgrade(spec, convertDocument, REMOVED) as OpenAPIV3_0.OpenAPIObject + return downgrade(spec, convertDocument, REMOVED, isRefTarget) as OpenAPIV3_0.OpenAPIObject } export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { - return downgrade(schema, convertSchema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject + return downgrade(schema, convertSchema, [], isRefTarget) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject } diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts index 054022b..93f3b8e 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts @@ -88,3 +88,56 @@ describe('references into removed keywords', () => { expect(convertSchema(schema)).toEqual(schema) }) }) + +describe('references to a boolean additionalProperties', () => { + // `additionalProperties` is the one place 3.0 still takes a boolean, so it + // stays one (see subschemas.test.ts). A `$ref` used as a schema must still + // resolve to a Schema Object, which in 3.0 is never a boolean: + // https://spec.openapis.org/oas/v3.0.4.html#schema-object + // Such a `$ref` is replaced by the converted boolean schema instead. + it.each([ + [ + 'inlines a $ref to a false additionalProperties', + { additionalProperties: false, properties: { a: { $ref: '#/additionalProperties' } } }, + { additionalProperties: false, properties: { a: { not: {} } } }, + ], + [ + 'inlines a $ref to a true additionalProperties under not', + { additionalProperties: true, properties: { a: { not: { $ref: '#/additionalProperties' } } } }, + { additionalProperties: true, properties: { a: { not: {} } } }, + ], + [ + 'inlines a $ref with siblings into allOf', + { $ref: '#/additionalProperties', additionalProperties: false }, + { additionalProperties: false, allOf: [{ not: {} }] }, + ], + [ + 'keeps a $ref to the location that now holds the inlined schema', + { + properties: { + a: { $ref: '#/properties/m/additionalProperties' }, + b: { $ref: '#/properties/a' }, + m: { additionalProperties: false, type: 'object' }, + }, + }, + { + properties: { + a: { not: {} }, + b: { $ref: '#/properties/a' }, + m: { additionalProperties: false, type: 'object' }, + }, + }, + ], + ])('%s', (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected) + }) + + // Elsewhere a boolean schema converts to a Schema Object, so a `$ref` to it + // keeps working and stays as written. + it('keeps a $ref to a boolean schema converted in place', () => { + expect(convertSchema({ items: false, properties: { a: { $ref: '#/items' } } })).toEqual({ + items: { not: {} }, + properties: { a: { $ref: '#/items' } }, + }) + }) +}) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts index 5c2799f..cc45806 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts @@ -37,6 +37,36 @@ it('converts component callbacks and schemas, including boolean schemas', () => }) }) +// 3.0 keeps a boolean `additionalProperties`, but a Schema Object is never a +// boolean, so a `$ref` to one is replaced by the converted boolean schema +// (see schema/references.test.ts). +it('inlines schema $refs to a boolean additionalProperties', () => { + const closed = '#/components/schemas/Closed/additionalProperties' + const open = '#/components/schemas/Open/additionalProperties' + const result = convertSpec({ + components: { + schemas: { + Anything: { $ref: open }, + Closed: { additionalProperties: false, type: 'object' }, + Nothing: { $ref: closed }, + NotAnything: { not: { $ref: open } }, + Open: { additionalProperties: true, type: 'object' }, + }, + }, + paths: { '/a': { get: { responses: { 200: { content: { 'application/json': { schema: { $ref: closed } } }, description: 'ok' } } } } }, + }) + expect(result.components).toEqual({ + schemas: { + Anything: {}, + Closed: { additionalProperties: false, type: 'object' }, + Nothing: { not: {} }, + NotAnything: { not: {} }, + Open: { additionalProperties: true, type: 'object' }, + }, + }) + expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toEqual({ not: {} }) +}) + it('strips the overrides from a callback reference to a missing path item', () => { expect(convertComponent('callbacks', { $ref: '#/components/pathItems/Reusable', summary: 's' })).toEqual({ $ref: '#/components/pathItems/Reusable', From bab88a0bfd92beaa924ff27ec724a1168979a3b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:54:29 +0000 Subject: [PATCH 2/2] refactor(downgrader): fold 3.1 to 3.0 placeholders into the ref target check `danglesIn` asked two questions for one rule: a placeholder check hard-coded in shared.ts, and the converter's `isTarget`. Placeholders are 3.1 to 3.0 only, so the WeakSet moves into that converter and joins `isRefTarget`, leaving `danglesIn` with the injected check alone. Also trims the new tests to one table and the reported spec case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JZezsg72P27RPnwVijmprt --- packages/downgrader/src/shared.ts | 10 +------ packages/downgrader/src/v3.1-to-v3.0.ts | 11 +++++-- .../v3.1-to-v3.0/schema/references.test.ts | 22 +++++++------- .../v3.1-to-v3.0/spec/components.test.ts | 29 ++++++------------- 4 files changed, 29 insertions(+), 43 deletions(-) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 9075248..17ec121 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -1,7 +1,5 @@ export const DROP: unique symbol = Symbol('drop') -const PLACEHOLDERS = new WeakSet() - export interface Context { readonly resolve: (ref: string) => unknown readonly aliasEnd: (ref: string) => string | undefined @@ -143,12 +141,6 @@ export function hasType(type: unknown, name: string): boolean { return type === name || (Array.isArray(type) && type.includes(name)) } -export function placeholder(): Record { - const out = {} - PLACEHOLDERS.add(out) - return out -} - export function allOfItems(allOf: unknown): unknown[] { if (Array.isArray(allOf)) { return allOf @@ -450,7 +442,7 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | return false } } - return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to)) || !isTarget(to) + return to === undefined || !isTarget(to) } export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = [], isTarget: (value: unknown) => boolean = () => true): unknown { diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 328b337..55d1100 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -23,7 +23,6 @@ import { list, map, mergeRef, - placeholder, refOr, removedPrefixes, setOwn, @@ -66,6 +65,8 @@ const CONTENT_TYPE_OVERRIDES = ['allowReserved', 'contentType', 'explode', 'styl const LOOSE = new WeakSet() +const PLACEHOLDERS = new WeakSet() + const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaType) const convertRequestContent = map(convertRequestMediaType) @@ -193,7 +194,13 @@ function reference(value: Record): unknown { } function isRefTarget(value: unknown): boolean { - return typeof value !== 'boolean' + return typeof value !== 'boolean' && !PLACEHOLDERS.has(value as object) +} + +function placeholder(): object { + const out = {} + PLACEHOLDERS.add(out) + return out } function isLoose(value: unknown): boolean { diff --git a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts index 93f3b8e..69e22ea 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/schema/references.test.ts @@ -91,10 +91,12 @@ describe('references into removed keywords', () => { describe('references to a boolean additionalProperties', () => { // `additionalProperties` is the one place 3.0 still takes a boolean, so it - // stays one (see subschemas.test.ts). A `$ref` used as a schema must still - // resolve to a Schema Object, which in 3.0 is never a boolean: + // stays one (see subschemas.test.ts). But a 3.0 `$ref` never resolves to a + // boolean: a Schema Object is always an object. // https://spec.openapis.org/oas/v3.0.4.html#schema-object - // Such a `$ref` is replaced by the converted boolean schema instead. + // Such a `$ref` is replaced by the converted boolean schema instead. A + // boolean schema converted in place becomes a Schema Object, so a `$ref` + // to it stays as written. it.each([ [ 'inlines a $ref to a false additionalProperties', @@ -128,16 +130,12 @@ describe('references to a boolean additionalProperties', () => { }, }, ], + [ + 'keeps a $ref to a boolean schema converted in place', + { items: false, properties: { a: { $ref: '#/items' } } }, + { items: { not: {} }, properties: { a: { $ref: '#/items' } } }, + ], ])('%s', (_name, input, expected) => { expect(convertSchema(input)).toEqual(expected) }) - - // Elsewhere a boolean schema converts to a Schema Object, so a `$ref` to it - // keeps working and stays as written. - it('keeps a $ref to a boolean schema converted in place', () => { - expect(convertSchema({ items: false, properties: { a: { $ref: '#/items' } } })).toEqual({ - items: { not: {} }, - properties: { a: { $ref: '#/items' } }, - }) - }) }) diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts index cc45806..93aa87f 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/components.test.ts @@ -37,34 +37,23 @@ it('converts component callbacks and schemas, including boolean schemas', () => }) }) -// 3.0 keeps a boolean `additionalProperties`, but a Schema Object is never a -// boolean, so a `$ref` to one is replaced by the converted boolean schema +// 3.0 keeps a boolean `additionalProperties`, but a 3.0 `$ref` never resolves +// to a boolean, so a `$ref` to one is replaced by the converted boolean schema // (see schema/references.test.ts). -it('inlines schema $refs to a boolean additionalProperties', () => { - const closed = '#/components/schemas/Closed/additionalProperties' - const open = '#/components/schemas/Open/additionalProperties' - const result = convertSpec({ +it('inlines a schema $ref to a boolean additionalProperties', () => { + expect(convertSpec({ components: { schemas: { - Anything: { $ref: open }, - Closed: { additionalProperties: false, type: 'object' }, - Nothing: { $ref: closed }, - NotAnything: { not: { $ref: open } }, - Open: { additionalProperties: true, type: 'object' }, + M: { additionalProperties: false, type: 'object' }, + X: { $ref: '#/components/schemas/M/additionalProperties' }, }, }, - paths: { '/a': { get: { responses: { 200: { content: { 'application/json': { schema: { $ref: closed } } }, description: 'ok' } } } } }, - }) - expect(result.components).toEqual({ + }).components).toEqual({ schemas: { - Anything: {}, - Closed: { additionalProperties: false, type: 'object' }, - Nothing: { not: {} }, - NotAnything: { not: {} }, - Open: { additionalProperties: true, type: 'object' }, + M: { additionalProperties: false, type: 'object' }, + X: { not: {} }, }, }) - expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'content', 'application/json', 'schema')).toEqual({ not: {} }) }) it('strips the overrides from a callback reference to a missing path item', () => {