From e9f4aa49531efb7536f08f0c392e9d511376a0fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:06:34 +0000 Subject: [PATCH 1/2] fix(downgrader): inline refs whose target is an alias to an external file `isInlinable` judged a ref by the last link of its alias chain instead of by the ref's own target. When that chain ended in an external file (or a missing target), a ref into a removed or shifted part was kept as written: - 3.1 -> 3.0: `/pets: {$ref: '#/components/pathItems/Pets'}` with `Pets: {$ref: './paths/pets.yaml'}` kept pointing at the removed `components.pathItems`. - `$defs` entries that alias external files were left dangling the same way. - In a list that lost an entry, the kept ref silently resolved to the entry that shifted into its slot (e.g. a `secret` header instead of `Limit`). Base the check on the ref's own target. Keep refs whose alias chain loops as written, as before: they have no target to inline, and following them would never end in `mergeRef`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011uRMv7RDjXFSBo71sjcefJ --- packages/downgrader/src/shared.test.ts | 25 ++++++++++++++ packages/downgrader/src/shared.ts | 5 ++- packages/downgrader/src/v3.1-to-v3.0.test.ts | 35 ++++++++++++++++++++ packages/downgrader/src/v3.2-to-v3.1.test.ts | 23 +++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 38a6da2..05b7124 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -618,6 +618,31 @@ describe('downgrade', () => { }) }) + it('inlines references whose target is itself a reference that cannot be followed', () => { + expect(convertDocument({ + items: [{ drop: true }, { $ref: './item.yaml' }, { value: 'next' }], + named: { + external: { $ref: '#/removed/external' }, + missing: { $ref: '#/removed/missing' }, + shifted: { $ref: '#/items/1' }, + }, + removed: { external: { $ref: 'other.json#/a' }, missing: { $ref: '#/removed/gone' } }, + }).out).toEqual({ + items: [{ $ref: './item.yaml' }, { value: 'next' }], + named: { + external: { $ref: 'other.json#/a' }, + missing: { $ref: '#/removed/gone' }, + shifted: { $ref: './item.yaml' }, + }, + }) + }) + + it('keeps a reference into a removed loop of aliases as written', () => { + const items = [{ $ref: '#/removed/a' }] + const removed = { a: { $ref: '#/removed/b' }, b: { $ref: '#/removed/a' } } + expect(convertDocument({ items, removed }, ['#/removed/']).out).toEqual({ items }) + }) + it('leaves missing, external, anchor, and malformed references as written', () => { const items = [{ $ref: '#/missing' }, { $ref: 'other.json#/a' }, { $ref: '#anchor' }, { $ref: '#/%E0%A4%A' }] expect(convertDocument({ items }).out).toEqual({ items }) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 81767bc..ba72cf2 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -417,9 +417,8 @@ export function downgrade(root: unknown, convert: Convert, removed: readonly str return end } const isInlinable = (ref: string): boolean => { - const end = aliasEnd(ref) - const target = end === undefined ? undefined : resolveRef(end) - return isRecord(target) || typeof target === 'boolean' + const target = resolveRef(ref) + return (isRecord(target) || typeof target === 'boolean') && aliasEnd(ref) !== undefined } const dangling = new Set() const isRemovedPart = (ref: string): boolean => removed.some(prefix => ref.startsWith(prefix)) diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 0d11cce..df0d9f3 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -202,6 +202,24 @@ describe('downgradeSpecV31ToV30', () => { ).toEqual({ '/a': { ...inlined, description: 'alias', summary: 'Own' } }) }) + it('inlines an entry that references an external file', () => { + expect( + convertWithPathItems( + { + '/pets': { $ref: '#/components/pathItems/Pets' }, + '/pets/{id}': { $ref: '#/components/pathItems/Pet', summary: 'Own' }, + }, + { + Pet: { $ref: './paths/pet.yaml' }, + Pets: { $ref: './paths/pets.yaml' }, + }, + ).paths, + ).toEqual({ + '/pets': { $ref: './paths/pets.yaml' }, + '/pets/{id}': { $ref: './paths/pet.yaml', summary: 'Own' }, + }) + }) + it('inlines references inside callbacks', () => { expect( convertWithPathItems( @@ -2214,6 +2232,23 @@ describe('downgradeSchemaV31ToV30', () => { expect(convertSchema({ $defs: { a: { type: 'string' } }, items: { $ref: '#/$defs/a' }, type: 'array' })).toEqual({ items: { type: 'string' }, type: 'array' }) }) + it('inlines $refs into $defs entries that reference an external file', () => { + expect(convertSchema({ + $defs: { pet: { $ref: './schemas/pet.yaml' } }, + properties: { pet: { $ref: '#/$defs/pet' } }, + })).toEqual({ properties: { pet: { $ref: './schemas/pet.yaml' } } }) + expect(dig(convertSpec({ + components: { + schemas: { + Owner: { + $defs: { pet: { $ref: './schemas/pet.yaml' } }, + properties: { pet: { $ref: '#/components/schemas/Owner/$defs/pet' } }, + }, + }, + }, + }), 'components', 'schemas')).toEqual({ Owner: { properties: { pet: { $ref: './schemas/pet.yaml' } } } }) + }) + it('inlines a $ref to items removed beside prefixItems instead of the items placeholder', () => { expect(convertSchema({ properties: { diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index 12cdafc..6f3a6e0 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -1649,6 +1649,29 @@ describe('downgradeSpecV32ToV31', () => { ]) }) + it('inlines references into a shifted parameter list entry that references an external file', () => { + const result = convertSpec({ + paths: { + '/a': { + get: { + parameters: [ + { in: 'querystring', name: 'qs' }, + { $ref: './parameters/limit.yaml' }, + { in: 'header', name: 'secret' }, + ], + responses: {}, + }, + }, + '/b': { get: { parameters: [{ $ref: '#/paths/~1a/get/parameters/1' }], responses: {} } }, + }, + }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ + { $ref: './parameters/limit.yaml' }, + { in: 'header', name: 'secret' }, + ]) + expect(dig(result, 'paths', '/b', 'get', 'parameters')).toEqual([{ $ref: './parameters/limit.yaml' }]) + }) + it('removes parameter and header references that resolve to removed ones through any pointer', () => { expect( convertSpec({ From 21dc45ee0efa426b3fb93fa9c3855ab54d28621e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:44:42 +0000 Subject: [PATCH 2/2] test(downgrader): trim regression tests for refs to external aliases Keep one case per converter path instead of several that exercise the same engine branch: drop the duplicate `$defs` assertion and the second path item entry, fold the shifted external parameter into the existing shifted-list test, and merge the removed-loop case into the existing alias-loop test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011uRMv7RDjXFSBo71sjcefJ --- packages/downgrader/src/shared.test.ts | 32 +++++--------------- packages/downgrader/src/v3.1-to-v3.0.test.ts | 25 ++------------- packages/downgrader/src/v3.2-to-v3.1.test.ts | 29 +++--------------- 3 files changed, 16 insertions(+), 70 deletions(-) diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 05b7124..20de578 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -618,29 +618,11 @@ describe('downgrade', () => { }) }) - it('inlines references whose target is itself a reference that cannot be followed', () => { + it('inlines a reference whose target is itself an external reference', () => { expect(convertDocument({ - items: [{ drop: true }, { $ref: './item.yaml' }, { value: 'next' }], - named: { - external: { $ref: '#/removed/external' }, - missing: { $ref: '#/removed/missing' }, - shifted: { $ref: '#/items/1' }, - }, - removed: { external: { $ref: 'other.json#/a' }, missing: { $ref: '#/removed/gone' } }, - }).out).toEqual({ - items: [{ $ref: './item.yaml' }, { value: 'next' }], - named: { - external: { $ref: 'other.json#/a' }, - missing: { $ref: '#/removed/gone' }, - shifted: { $ref: './item.yaml' }, - }, - }) - }) - - it('keeps a reference into a removed loop of aliases as written', () => { - const items = [{ $ref: '#/removed/a' }] - const removed = { a: { $ref: '#/removed/b' }, b: { $ref: '#/removed/a' } } - expect(convertDocument({ items, removed }, ['#/removed/']).out).toEqual({ items }) + items: [{ $ref: '#/removed/a' }], + removed: { a: { $ref: 'other.json#/a' } }, + }).out).toEqual({ items: [{ $ref: 'other.json#/a' }] }) }) it('leaves missing, external, anchor, and malformed references as written', () => { @@ -694,9 +676,11 @@ describe('downgrade', () => { }) }) - it('keeps a loop of aliases as written', () => { + it('keeps a loop of aliases as written, even in a removed part', () => { + const items = [{ $ref: '#/named/a' }, { $ref: '#/removed/a' }] const named = { a: { $ref: '#/named/b' }, b: { $ref: '#/named/a' } } - expect(convertDocument({ items: [{ $ref: '#/named/a' }], named }).out).toEqual({ items: [{ $ref: '#/named/a' }], named }) + const removed = { a: { $ref: '#/removed/b' }, b: { $ref: '#/removed/a' } } + expect(convertDocument({ items, named, removed }, ['#/removed/']).out).toEqual({ items, named }) }) it('removes references to removed targets, cascading through aliases', () => { diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index df0d9f3..37ea2ca 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -205,19 +205,10 @@ describe('downgradeSpecV31ToV30', () => { it('inlines an entry that references an external file', () => { expect( convertWithPathItems( - { - '/pets': { $ref: '#/components/pathItems/Pets' }, - '/pets/{id}': { $ref: '#/components/pathItems/Pet', summary: 'Own' }, - }, - { - Pet: { $ref: './paths/pet.yaml' }, - Pets: { $ref: './paths/pets.yaml' }, - }, + { '/a': { $ref: '#/components/pathItems/External', summary: 'Own' } }, + { External: { $ref: './paths/a.yaml' } }, ).paths, - ).toEqual({ - '/pets': { $ref: './paths/pets.yaml' }, - '/pets/{id}': { $ref: './paths/pet.yaml', summary: 'Own' }, - }) + ).toEqual({ '/a': { $ref: './paths/a.yaml', summary: 'Own' } }) }) it('inlines references inside callbacks', () => { @@ -2237,16 +2228,6 @@ describe('downgradeSchemaV31ToV30', () => { $defs: { pet: { $ref: './schemas/pet.yaml' } }, properties: { pet: { $ref: '#/$defs/pet' } }, })).toEqual({ properties: { pet: { $ref: './schemas/pet.yaml' } } }) - expect(dig(convertSpec({ - components: { - schemas: { - Owner: { - $defs: { pet: { $ref: './schemas/pet.yaml' } }, - properties: { pet: { $ref: '#/components/schemas/Owner/$defs/pet' } }, - }, - }, - }, - }), 'components', 'schemas')).toEqual({ Owner: { properties: { pet: { $ref: './schemas/pet.yaml' } } } }) }) it('inlines a $ref to items removed beside prefixItems instead of the items placeholder', () => { diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index 6f3a6e0..6f8e7bd 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -1621,8 +1621,9 @@ describe('downgradeSpecV32ToV31', () => { const result = convertSpec({ components: { parameters: { + External: { $ref: '#/paths/~1a/get/parameters/1' }, Kept: { $ref: '#/paths/~1b/get/parameters/0' }, - Shifted: { $ref: '#/paths/~1a/get/parameters/1' }, + Shifted: { $ref: '#/paths/~1a/get/parameters/2' }, }, }, paths: { @@ -1630,6 +1631,7 @@ describe('downgradeSpecV32ToV31', () => { get: { parameters: [ { in: 'querystring', name: 'qs' }, + { $ref: './parameters/limit.yaml' }, { in: 'query', name: 'b' }, ], responses: {}, @@ -1640,36 +1642,15 @@ describe('downgradeSpecV32ToV31', () => { }) expect(result.components).toEqual({ parameters: { + External: { $ref: './parameters/limit.yaml' }, Kept: { $ref: '#/paths/~1b/get/parameters/0' }, Shifted: { in: 'query', name: 'b' }, }, }) - expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ - { in: 'query', name: 'b' }, - ]) - }) - - it('inlines references into a shifted parameter list entry that references an external file', () => { - const result = convertSpec({ - paths: { - '/a': { - get: { - parameters: [ - { in: 'querystring', name: 'qs' }, - { $ref: './parameters/limit.yaml' }, - { in: 'header', name: 'secret' }, - ], - responses: {}, - }, - }, - '/b': { get: { parameters: [{ $ref: '#/paths/~1a/get/parameters/1' }], responses: {} } }, - }, - }) expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ { $ref: './parameters/limit.yaml' }, - { in: 'header', name: 'secret' }, + { in: 'query', name: 'b' }, ]) - expect(dig(result, 'paths', '/b', 'get', 'parameters')).toEqual([{ $ref: './parameters/limit.yaml' }]) }) it('removes parameter and header references that resolve to removed ones through any pointer', () => {