Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c848f6e
test(downgrader): cover the cut-$ref reset, writeOnly and $ref tags, …
claude Nov 2, 2026
17b0cc9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
90d9ae5
fix(downgrader): leave out a 3.2 security requirement name whose sche…
claude Nov 2, 2026
71b348d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
720cf6e
test(downgrader): cover a 3.2 security requirement name holding undef…
claude Nov 2, 2026
de5d602
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
6c5d811
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
85e0979
test(downgrader): cover a null branch beside enums in a malformed anyOf
claude Nov 2, 2026
dd95369
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
170d121
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
4ea0add
test(downgrader): keep the allOf beside a restricting keyword on a ta…
claude Nov 2, 2026
eef3ee2
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
c7b4e1b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
7531a72
docs(downgrader): a $ref into an anyOf removed for a media-type-only …
claude Nov 2, 2026
4c4b7bf
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
946a4f6
docs(downgrader): a $ref into a removed not dangles
claude Nov 2, 2026
ae2be47
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
69c1aae
docs(downgrader): a $ref into a 3.2 itemSchema that moves into schema…
claude Nov 2, 2026
91533fb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
f4f0c9b
docs(downgrader): a $ref by index past a removed 3.2 querystring para…
claude Nov 2, 2026
74e3b3b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
2360af7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
25fb9ca
fix(downgrader): keep a $ref bare beside only a 3.0-style nullable
claude Nov 2, 2026
5658ff8
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
ffa455e
fix(downgrader): keep a $ref bare beside identifiers 3.0 removes
claude Nov 2, 2026
114cae7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
24cfba2
fix(downgrader): keep null in a loop of nullable $ref pairs
claude Nov 2, 2026
bc1fe15
fix(downgrader): follow nullable pairs beside annotations 3.0 drops
claude Nov 2, 2026
f3cc671
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
47d2d6c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
f7cdda7
fix(downgrader): drop a discriminator that a $ref or type list would …
claude Nov 2, 2026
660afb1
docs(downgrader): note the discriminator dropped beside a $ref or typ…
claude Nov 2, 2026
6718cbd
fix(downgrader): keep a $ref bare beside only a removed discriminator
claude Nov 2, 2026
272eaf2
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
80c69ae
test(downgrader): keep a discriminator beside a $ref and an anyOf or …
claude Nov 2, 2026
a8c2c54
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
c80c90c
fix(downgrader): follow a null pair nested in a null pair around a $r…
claude Nov 2, 2026
e47c94c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
96fe14d
fix(downgrader): stop the nested pair walk at a cycle of objects
claude Nov 2, 2026
7645f9b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
b3e2536
test(downgrader): follow a $ref beside an object cycle in a nested nu…
claude Nov 2, 2026
dbe21f7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
956f651
fix(downgrader): keep a null pair only when its $ref would loop back …
claude Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 36 additions & 42 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ const refTargets = new Set<unknown>()
// holds it, also once the target it was cut at is no longer being inlined.
const cutHolders = new Set<unknown>()

// Pairs of null and a `$ref` that became the `$ref` alone, with their branches.
const collapsedPairs: [schema: Record<string, unknown>, anyOf: unknown[]][] = []

// The 3.0 keywords that apply only to objects, which `convertType` moves into
// the `"object"` branch of a `type` list.
const OBJECT_KEYWORDS = [
Expand Down Expand Up @@ -958,47 +961,6 @@ function admitsNull(schema: unknown): boolean {
)
}

// A `$ref` whose target admits null, which a pair of null and the `$ref`
// becomes. A target that holds such a pair, beside annotations that 3.0 may
// drop, such as `$comment`, can become its own `$ref` in turn, so a loop of
// them, which in 3.1 admits only null, would become `$ref`s that match
// nothing. One that does not admit null keeps its pair. A pair nested in the
// target's `anyOf`, as `anyOf: [{ anyOf: [$ref, null] }, null]`, leads on too.
function refAdmitsNull(ref: string, ctx: Context): boolean {
const seen = new Set<string>()
for (let next: string | undefined = ref; next !== undefined;) {
const target = resolvePointer(next, ctx)
if (!admitsNull(target)) {
return next !== ref
}
if (seen.has(next)) {
return false
}
seen.add(next)
const { anyOf } = target as Record<string, unknown>
const others = Array.isArray(anyOf) ? nonNullBranches(anyOf) : []
next = others.length === 1 ? getRef(others[0]) : undefined
}
return true
}

// Met again, around a cycle of objects, a union adds nothing.
function nonNullBranches(branches: unknown[], seen = new Set<unknown[]>()): unknown[] {
if (seen.has(branches)) {
return []
}
seen.add(branches)
return branches.flatMap((branch) => {
if (acceptsNull(branch)) {
return []
}
const nested = isRecord(branch) ? (branch.anyOf ?? branch.oneOf) : undefined
return Array.isArray(nested) && nested.some(acceptsNull)
? nonNullBranches(nested, seen)
: [branch]
})
}

// A `type` with no `nullable: true` beside it, in 3.1 or 3.0, rejects null.
function rejectsNull(schema: unknown): boolean {
if (!isRecord(schema) || schema.nullable === true) {
Expand Down Expand Up @@ -1219,11 +1181,16 @@ function mergeNullable(
const nullBranch = branches.find((branch) => isNullBranch(branch, out))
out.oneOf = branches[0] === other ? [other, nullBranch] : [nullBranch, other]
delete out.anyOf
} else if (ref !== undefined && Object.keys(out).length === 1 && refAdmitsNull(ref, ctx)) {
} else if (
ref !== undefined &&
Object.keys(out).length === 1 &&
admitsNull(resolvePointer(ref, ctx))
) {
// GitHub writes a nullable reference this way to a schema that already
// admits null, and Zod and Pydantic to a nullable one, which 3.0 writes as
// the `$ref` alone. NSwag reads the pair as an empty class, and
// oapi-codegen as a union.
collapsedPairs.push([out, out.anyOf as unknown[]])
delete out.anyOf
out.$ref = ref
}
Expand Down Expand Up @@ -1769,6 +1736,23 @@ function convertDocument(value: unknown, ctx: Context): unknown {
return convertObject(value, ctx, DOCUMENT_FIELDS, finishDocument)
}

// A chain of bare `$ref`s from `schema` that comes back to it.
function leadsBack(schema: Record<string, unknown>, ctx: Context): boolean {
const seen = new Set<unknown>()
for (let next: unknown = schema; isRecord(next) && Object.keys(next).length === 1;) {
const ref = getRef(next)
if (ref === undefined || seen.has(next)) {
return false
}
seen.add(next)
next = resolvePointer(ref, ctx)
if (next === schema) {
return true
}
}
return false
}

function downgradeV30(root: unknown, convert: Convert): unknown {
try {
const ctx = createContext(root)
Expand All @@ -1787,13 +1771,23 @@ function downgradeV30(root: unknown, convert: Convert): unknown {
}
}
}
// A loop of such `$ref`s, which in 3.1 admits only null, would match
// nothing, so each keeps its pair.
if (collapsedPairs.length > 0) {
const outCtx = createContext(out)
for (const [schema, anyOf] of collapsedPairs.filter(([item]) => leadsBack(item, outCtx))) {
delete schema.$ref
schema.anyOf = anyOf
}
}
return out
} finally {
// Cleared even when conversion throws, so nothing keeps the input alive.
nullableUnions.length = 0
unionTargets.clear()
refTargets.clear()
cutHolders.clear()
collapsedPairs.length = 0
LOOSE.clear()
}
}
Expand Down
28 changes: 17 additions & 11 deletions packages/downgrader/tests/v3.1-to-v3.0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1339,8 +1339,9 @@ describe('downgradeSchemaV31ToV30', () => {
A: { anyOf: [ref('B'), onlyNull] },
B: { anyOf: [ref('A'), onlyNull] },
})
// One that leads into such a loop, without being on it, keeps the `$ref`.
expect(schemas({ A: pair('B'), B: pair('C'), C: pair('B') })).toEqual({
A: { anyOf: [ref('B'), onlyNull] },
A: ref('B'),
B: { anyOf: [ref('C'), onlyNull] },
C: { anyOf: [ref('B'), onlyNull] },
})
Expand Down Expand Up @@ -1370,12 +1371,13 @@ describe('downgradeSchemaV31ToV30', () => {
A: { anyOf: [ref('B'), onlyNull] },
B: { anyOf: [ref('A'), onlyNull] },
})
// A description that 3.0 keeps beside the pair breaks the loop.
expect(schemas({ A: pair('B'), B: { description: 'B', ...pair('A') } })).toEqual({
A: { anyOf: [ref('B'), onlyNull] },
A: ref('B'),
B: { description: 'B', anyOf: [ref('A'), onlyNull] },
})
// So does a pair nested in the target's union.
const nested = { anyOf: [{ anyOf: [ref('A'), onlyNull] }, onlyNull] }
// So does a pair nested in the target's union, which keeps one pair.
const nested = { anyOf: [ref('A'), onlyNull] }
expect(schemas({ A: { anyOf: [pair('A'), { type: 'null' }] } })).toEqual({ A: nested })
expect(schemas({ A: { anyOf: [{ oneOf: pair('A').anyOf }, { type: 'null' }] } })).toEqual({
A: nested,
Expand All @@ -1390,13 +1392,17 @@ describe('downgradeSchemaV31ToV30', () => {
A: ref('B'),
B: { anyOf: [{ not: {} }, ref('A'), onlyNull] },
})
// A cycle of objects ends the walk.
const cyclic: Record<string, any> = { anyOf: [{ type: 'null' }] }
cyclic.anyOf.unshift({ anyOf: [cyclic, { type: 'null' }] })
expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual(ref('B'))
// A $ref beside the cycle is still followed.
cyclic.anyOf[0].anyOf.push(ref('A'))
expect(schemas({ A: pair('B'), B: cyclic })?.A).toEqual({ anyOf: [ref('B'), onlyNull] })
// As does a loop through a nested pair, or a duplicate `$ref`, or null
// written as `const`.
expect(
schemas({
A: pair('B'),
B: { anyOf: [{ anyOf: [ref('A'), { const: null }] }, { type: 'null' }] },
}),
).toEqual({ A: { anyOf: [ref('B'), onlyNull] }, B: { anyOf: [ref('A'), onlyNull] } })
expect(schemas({ A: { anyOf: [ref('A'), ref('A'), { type: 'null' }] } })).toEqual({
A: { anyOf: [ref('A'), onlyNull] },
})
})

it('merges a branch in full where a cycle meets it before its conversion is done', () => {
Expand Down
Loading