From 133ed5f0c2a4e0da61ea5c66ee098269554787e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 09:37:31 +0000 Subject: [PATCH] perf(downgrader): look up a kept $ref's target only for a null anyOf pair in 3.1 to 3.0 #88 resolved and checked the target of every kept schema `$ref`, though only a `$ref` beside the null branch of a two-branch `anyOf` reads the result. `mergeNullable` now looks up that target in the input itself, which is always complete, so the cycle concern goes away too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp --- packages/downgrader/src/shared.ts | 3 ++- packages/downgrader/src/v3.1-to-v3.0.ts | 22 ++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 94bbf72..ef2eccf 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -187,7 +187,8 @@ export function getRef(value: unknown): string | undefined { return isRecord(value) && typeof value.$ref === 'string' ? value.$ref : undefined } -function find(root: unknown, ref: string): unknown { +/** The target of a local JSON Pointer `$ref` in `root`, looked up afresh each time. */ +export function find(root: unknown, ref: string): unknown { if (!ref.startsWith('#')) { return undefined } diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index c10feab..9b6314c 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -8,6 +8,7 @@ import { defineFields, downgrade, DROP, + find, getRef, hasType, HTTP_METHODS, @@ -132,9 +133,6 @@ const twoBranchAnyOfs: Record[] = [] // and a `nullable` already there was written 3.0-style. const NOT_NULLABLE_KEYWORDS = ['$ref', 'allOf', 'anyOf', 'enum', 'not', 'nullable', 'oneOf'] -// `$ref`s kept as written whose targets reject null, for `mergeNullable`. -const NULL_REJECTING_REFS = new WeakSet() - const convertAnyOf = list(convertSchema) const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaType) @@ -280,10 +278,6 @@ function convertSchemaRef(ref: string, ctx: Context): unknown { // 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. const out = { $ref: ref } - // The target as written, since a cycle can meet its conversion unfinished. - if (rejectsNull(resolvePointer(ref, ctx))) { - NULL_REJECTING_REFS.add(out) - } return isLoose(inline(ref, ctx, convertSchema)) ? loosened(out) : out } const out = inline(ref, ctx, convertSchema) @@ -512,7 +506,7 @@ function rejectsNull(schema: unknown): boolean { // that stays, such as a `$ref` or an `enum`, makes the pair a `oneOf` when it // rejects null, as null then matches one branch only. NSwag reads that // `oneOf` as nullable, and the `anyOf` as an empty class. -function mergeNullable(out: Record): void { +function mergeNullable(out: Record, root: unknown): void { const [first, second] = out.anyOf as unknown[] const other = isOnlyNullSchema(first) ? second : isOnlyNullSchema(second) ? first : undefined if (!isRecord(other)) { @@ -525,9 +519,13 @@ function mergeNullable(out: Record): void { ) { delete out.anyOf Object.assign(out, other, { nullable: true }) - } else if (out.oneOf === undefined && (NULL_REJECTING_REFS.has(other) || rejectsNull(other))) { - out.oneOf = out.anyOf - delete out.anyOf + } else if (out.oneOf === undefined) { + // A `$ref` kept as written rejects null when its target does. + const ref = getRef(other) + if (rejectsNull(ref === undefined ? other : find(root, ref))) { + out.oneOf = out.anyOf + delete out.anyOf + } } } @@ -790,7 +788,7 @@ function downgradeV30(root: unknown, convert: Convert): unknown { twoBranchAnyOfs.length = 0 const out = downgrade(root, convert) for (const schema of twoBranchAnyOfs.splice(0)) { - mergeNullable(schema) + mergeNullable(schema, root) } return out }