From cf2140a031c5f828ac5096ccdd463cfd655a99e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:48:35 +0000 Subject: [PATCH 1/2] fix(downgrader): inline $refs into keywords the 3.0 conversion replaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `danglesIn` kept a `$ref` whenever its location still existed in the output, but the 3.1 → 3.0 converter writes new values over some converted keywords: `const` replaces the `enum` beside it, a null-only `type` whose `enum` excludes null sets `not: {}`, and `"null"` in `type` sets `nullable: true`. A `$ref` into the original keyword was kept and resolved to the replacement, e.g. a root `not: {$ref: '#/properties/a/not'}` came out rejecting everything. Such writes now go through `replace()`, which tags the key on its parent. `danglesIn` treats a tagged key like a removed one, so the `$ref` is inlined with the converted original. This replaces the value-based `placeholder()` tag, which only `items` used and which could not mark an array entry or a primitive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pgu7SuAf7FD4Muuwafi9z5 --- packages/downgrader/src/shared.ts | 25 +++++++++++----- packages/downgrader/src/v3.1-to-v3.0.ts | 10 +++---- .../v3.1-to-v3.0/schema/references.test.ts | 30 +++++++++++++++++++ .../v3.1-to-v3.0/spec/components.test.ts | 18 +++++++++++ 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 9e60bfe..fbd0358 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -1,6 +1,6 @@ export const DROP: unique symbol = Symbol('drop') -const PLACEHOLDERS = new WeakSet() +const REPLACED = new WeakMap>() export interface Context { readonly resolve: (ref: string) => unknown @@ -143,10 +143,21 @@ 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 +// Sets a value that stands in for whatever the source holds under `key` +// instead of converting it. A `$ref` into that source location then dangles, +// so it is inlined rather than kept pointing at the stand-in. +export function replace(target: Record, key: string, value: unknown): void { + let keys = REPLACED.get(target) + if (keys === undefined) { + keys = new Set() + REPLACED.set(target, keys) + } + keys.add(key) + setOwn(target, key, value) +} + +function isReplaced(value: unknown, key: string): boolean { + return typeof value === 'object' && value !== null && REPLACED.get(value)?.has(key) === true } export function allOfItems(allOf: unknown): unknown[] { @@ -441,7 +452,7 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | let from = source let to = output for (const token of tokens) { - if (Array.isArray(from) && !(Array.isArray(to) && to.length === from.length)) { + if (isReplaced(to, token) || (Array.isArray(from) && !(Array.isArray(to) && to.length === from.length))) { to = undefined } from = child(from, token) @@ -450,7 +461,7 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | return false } } - return to === undefined || (isRecord(to) && PLACEHOLDERS.has(to)) + return to === undefined } export function downgrade(root: unknown, convert: Convert, removed: readonly string[] = []): unknown { diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 4c88e07..78b6a93 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -23,9 +23,9 @@ import { list, map, mergeRef, - placeholder, refOr, removedPrefixes, + replace, setOwn, } from './shared' @@ -249,7 +249,7 @@ function convertType(out: Record, type: unknown): boolean { if (rest.length === 1) { out.type = rest[0] if (nullable) { - out.nullable = true + replace(out, 'nullable', true) } } else if (rest.length > 1) { @@ -272,7 +272,7 @@ function convertType(out: Record, type: unknown): boolean { out.enum = [null] } else { - out.not = {} + replace(out, 'not', {}) } return false } @@ -293,7 +293,7 @@ function finishSchema(out: Record, schema: Record, schema: Record { }) }) + // Some keywords get a new value instead of a converted one: `const` + // replaces the `enum` beside it, an `enum` without null under a null-only + // `type` sets `not: {}`, and `"null"` in `type` sets `nullable: true`. + // A `$ref` to the original keyword must get the original schema, not the + // value that replaced it. + it.each([ + [ + 'a not that a null-only type replaces', + { not: { $ref: '#/properties/a/not' }, properties: { a: { enum: ['x'], not: { type: 'string' }, type: 'null' } } }, + { not: { type: 'string' }, properties: { a: { enum: ['x'], not: {} } } }, + ], + [ + 'an enum that const replaces', + { properties: { t: { const: { type: 'integer' }, enum: [{ type: 'string' }] }, x: { $ref: '#/properties/t/enum/0' } } }, + { properties: { t: { enum: [{ type: 'integer' }] }, x: { type: 'string' } } }, + ], + [ + 'an enum that a null const replaces and a null-only type narrows', + { properties: { t: { const: null, enum: [false], type: 'null' }, x: { $ref: '#/properties/t/enum/0' } } }, + { properties: { t: { enum: [null] }, x: { not: {} } } }, + ], + [ + 'a 3.0 nullable that null in type replaces', + { not: { $ref: '#/properties/a/nullable' }, properties: { a: { nullable: false, type: ['string', 'null'] } } }, + { not: { not: {} }, properties: { a: { nullable: true, type: 'string' } } }, + ], + ])('inlines a $ref to %s, rather than pointing it at the replacement', (_name, input, expected) => { + expect(convertSchema(input)).toEqual(expected) + }) + // A standalone schema has no document around it, so pointers into // `components` or `webhooks` cannot be checked and stay as written. it('leaves references and mapping entries that point outside the schema as written', () => { 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..5363a16 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 @@ -51,6 +51,24 @@ it('keeps examples as they are, since 3.0 examples have the same fields', () => }) }) +// The conversion replaces this `not` with `not: {}` (see +// schema/references.test.ts), so the `$ref` to it gets the original. +it('inlines a schema $ref to a keyword the conversion replaced', () => { + expect(convertSpec({ + components: { + schemas: { + A: { enum: ['x'], not: { type: 'string' }, type: 'null' }, + B: { not: { $ref: '#/components/schemas/A/not' } }, + }, + }, + }).components).toEqual({ + schemas: { + A: { enum: ['x'], not: {} }, + B: { not: { type: 'string' } }, + }, + }) +}) + it('clones a malformed components value unchanged', () => { expect(convertSpec({ components: 'junk' }).components).toBe('junk') }) From 2ecd06d327565c7ba8a57e063a19fd8f55a7718e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 12:59:01 +0000 Subject: [PATCH 2/2] refactor(downgrader): tag replaced keys with one WeakSet per key name Only four key names are ever replaced, so a WeakSet per name avoids a Set per tagged object, drops the hand-written object guard, and lets `danglesIn` check the tag where it steps into the output, leaving the array-length check as it was. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pgu7SuAf7FD4Muuwafi9z5 --- packages/downgrader/src/shared.ts | 17 ++++------------- .../v3.1-to-v3.0/schema/references.test.ts | 8 +++----- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index fbd0358..3d18e1a 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -1,6 +1,6 @@ export const DROP: unique symbol = Symbol('drop') -const REPLACED = new WeakMap>() +const REPLACED = new Map>() export interface Context { readonly resolve: (ref: string) => unknown @@ -147,19 +147,10 @@ export function hasType(type: unknown, name: string): boolean { // instead of converting it. A `$ref` into that source location then dangles, // so it is inlined rather than kept pointing at the stand-in. export function replace(target: Record, key: string, value: unknown): void { - let keys = REPLACED.get(target) - if (keys === undefined) { - keys = new Set() - REPLACED.set(target, keys) - } - keys.add(key) + REPLACED.set(key, (REPLACED.get(key) ?? new WeakSet()).add(target)) setOwn(target, key, value) } -function isReplaced(value: unknown, key: string): boolean { - return typeof value === 'object' && value !== null && REPLACED.get(value)?.has(key) === true -} - export function allOfItems(allOf: unknown): unknown[] { if (Array.isArray(allOf)) { return allOf @@ -452,11 +443,11 @@ function danglesIn(output: unknown, source: unknown, tokens: readonly string[] | let from = source let to = output for (const token of tokens) { - if (isReplaced(to, token) || (Array.isArray(from) && !(Array.isArray(to) && to.length === from.length))) { + if (Array.isArray(from) && !(Array.isArray(to) && to.length === from.length)) { to = undefined } from = child(from, token) - to = child(to, token) + to = REPLACED.get(token)?.has(to as object) ? undefined : child(to, token) if (from === undefined) { return false } 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 07a88a8..2aab916 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 @@ -78,11 +78,9 @@ describe('references into removed keywords', () => { }) }) - // Some keywords get a new value instead of a converted one: `const` - // replaces the `enum` beside it, an `enum` without null under a null-only - // `type` sets `not: {}`, and `"null"` in `type` sets `nullable: true`. - // A `$ref` to the original keyword must get the original schema, not the - // value that replaced it. + // `const`, a null-only `type`, and `"null"` in `type` write new values over + // `enum`, `not`, and `nullable`. A `$ref` to the original keyword must get + // the original schema, not the value that replaced it. it.each([ [ 'a not that a null-only type replaces',