From 8a2052640664ae7a1c196590c0b2e662010a500b Mon Sep 17 00:00:00 2001 From: nonchan7720 Date: Tue, 14 Jul 2026 01:40:33 +0900 Subject: [PATCH 1/9] fix: join extension property --- .../cli/src/commands/join/utils/collect-paths.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/cli/src/commands/join/utils/collect-paths.ts b/packages/cli/src/commands/join/utils/collect-paths.ts index 3a7ef2d4d6..dd958d5b63 100644 --- a/packages/cli/src/commands/join/utils/collect-paths.ts +++ b/packages/cli/src/commands/join/utils/collect-paths.ts @@ -72,6 +72,7 @@ export function collectPaths({ collectPathStringFields(pathItem, path, field); } } + collectExtensionProperty(pathItem, path); } } @@ -228,6 +229,19 @@ export function collectPaths({ ); } } + + function collectExtensionProperty(pathItem: Oas3PathItem, path: string | number) { + // x-* + const extensions = pathItem as Record; + for (const key of Object.keys(extensions).filter((key) => key.startsWith('x-'))) { + if (!joinedDef.paths[path].hasOwnProperty(key)) { + joinedDef.paths[path][key] = extensions[key]; + } + if (!potentialConflicts.paths[path].hasOwnProperty(key)) { + potentialConflicts.paths[path][key] = extensions[key]; + } + } + } } function isServersEqual(serverOne: Oas3Server, serverTwo: Oas3Server) { From 9c2f8247491ad55595eafaca5616d72b110d3b0e Mon Sep 17 00:00:00 2001 From: nonchan7720 Date: Tue, 14 Jul 2026 01:59:11 +0900 Subject: [PATCH 2/9] fix collectPathExtension --- .../src/commands/join/utils/collect-paths.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/join/utils/collect-paths.ts b/packages/cli/src/commands/join/utils/collect-paths.ts index dd958d5b63..0d3a498f93 100644 --- a/packages/cli/src/commands/join/utils/collect-paths.ts +++ b/packages/cli/src/commands/join/utils/collect-paths.ts @@ -70,9 +70,10 @@ export function collectPaths({ } if (typeof pathItem[field] === 'string') { collectPathStringFields(pathItem, path, field); + } else if (field.startsWith('x-') && typeof pathItem[field] !== 'string') { + collectPathExtension(pathItem, path, field); } } - collectExtensionProperty(pathItem, path); } } @@ -230,16 +231,16 @@ export function collectPaths({ } } - function collectExtensionProperty(pathItem: Oas3PathItem, path: string | number) { + function collectPathExtension(pathItem: Oas3PathItem, path: string | number, field: string) { // x-* - const extensions = pathItem as Record; - for (const key of Object.keys(extensions).filter((key) => key.startsWith('x-'))) { - if (!joinedDef.paths[path].hasOwnProperty(key)) { - joinedDef.paths[path][key] = extensions[key]; - } - if (!potentialConflicts.paths[path].hasOwnProperty(key)) { - potentialConflicts.paths[path][key] = extensions[key]; - } + const value = (pathItem as Record)[field]; + const joinedPathItem = joinedDef.paths[path]; + if (!joinedPathItem.hasOwnProperty(field)) { + joinedPathItem[field] = value; + return; + } + if (!dequal(joinedPathItem[field], value)) { + logger.warn(`warning: different ${field} values in ${path}\n`); } } } From 489b31e2c7fb8eb438f9a7842333fdd08c440ead Mon Sep 17 00:00:00 2001 From: nonchan7720 Date: Tue, 14 Jul 2026 02:11:01 +0900 Subject: [PATCH 3/9] test: cover non-string x-* path extension merging in join --- .../cli/src/__tests__/commands/join.test.ts | 70 +++++++++++++++++++ .../src/__tests__/fixtures/join/documents.ts | 57 +++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts index bf8dea26bc..0490fe2698 100644 --- a/packages/cli/src/__tests__/commands/join.test.ts +++ b/packages/cli/src/__tests__/commands/join.test.ts @@ -3,6 +3,7 @@ import { detectSpec, getTotals, loadConfig, + logger, type Document, BaseResolver, } from '@redocly/openapi-core'; @@ -24,6 +25,9 @@ import { thirdDocument, serverAndPaths, anotherServerAndPaths, + pathWithObjectExtension, + anotherPathWithSameObjectExtension, + anotherPathWithDifferentObjectExtension, } from '../fixtures/join/documents.js'; describe('handleJoin', () => { @@ -466,6 +470,72 @@ describe('handleJoin', () => { }); }); + describe('path extensions', () => { + it('should merge non-string x-* path extension when values are the same', async () => { + vi.mocked(detectSpec).mockReturnValue('oas3_0'); + const warnSpy = vi.spyOn(logger, 'warn'); + vi.spyOn(BaseResolver.prototype, 'resolveDocument') + .mockReset() + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-a' }, + parsed: pathWithObjectExtension, + } as Document) + ) + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-b' }, + parsed: anotherPathWithSameObjectExtension, + } as Document) + ); + + await handleJoin({ + argv: { + apis: ['a.yaml', 'b.yaml'], + }, + config: configFixture, + version: 'cli-version', + }); + + const joinedDef = writeToFileByExtensionSpy.mock.calls[0][0]; + expect(joinedDef.paths['/foo']['x-metadata']).toEqual({ owner: 'team-a' }); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('different x-metadata values') + ); + }); + + it('should warn when non-string x-* path extension values differ', async () => { + vi.mocked(detectSpec).mockReturnValue('oas3_0'); + const warnSpy = vi.spyOn(logger, 'warn'); + vi.spyOn(BaseResolver.prototype, 'resolveDocument') + .mockReset() + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-a' }, + parsed: pathWithObjectExtension, + } as Document) + ) + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-b' }, + parsed: anotherPathWithDifferentObjectExtension, + } as Document) + ); + + await handleJoin({ + argv: { + apis: ['a.yaml', 'b.yaml'], + }, + config: configFixture, + version: 'cli-version', + }); + + const joinedDef = writeToFileByExtensionSpy.mock.calls[0][0]; + expect(joinedDef.paths['/foo']['x-metadata']).toEqual({ owner: 'team-a' }); + expect(warnSpy).toHaveBeenCalledWith('warning: different x-metadata values in /foo\n'); + }); + }); + describe('replace$Refs', () => { it('should prefix discriminator mapping refs when schema name contains prefix substring', () => { const doc = { diff --git a/packages/cli/src/__tests__/fixtures/join/documents.ts b/packages/cli/src/__tests__/fixtures/join/documents.ts index 0da63a113d..a1be85aa0b 100644 --- a/packages/cli/src/__tests__/fixtures/join/documents.ts +++ b/packages/cli/src/__tests__/fixtures/join/documents.ts @@ -162,3 +162,60 @@ export const anotherServerAndPaths = { }, }, }; + +export const pathWithObjectExtension = { + openapi: '3.0.0', + info: { + title: 'First API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/first', + }, + ], + paths: { + '/foo': { + 'x-metadata': { owner: 'team-a' }, + get: { + summary: 'Get Foo', + }, + }, + }, +}; + +export const anotherPathWithSameObjectExtension = { + openapi: '3.0.0', + info: { + title: 'Second API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/second', + }, + ], + paths: { + '/foo': { + 'x-metadata': { owner: 'team-a' }, + }, + }, +}; + +export const anotherPathWithDifferentObjectExtension = { + openapi: '3.0.0', + info: { + title: 'Second API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/second', + }, + ], + paths: { + '/foo': { + 'x-metadata': { owner: 'team-b' }, + }, + }, +}; From bc76fd1c04ce1c0ff9d787374bb47dd69dd12716 Mon Sep 17 00:00:00 2001 From: nonchan <50233291+nonchan7720@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:15:24 +0900 Subject: [PATCH 4/9] Clarify fix for join extension property Clarified the fix for join extension property to specify that it addresses non-string x-* path extension properties. --- .changeset/dull-adults-punch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dull-adults-punch.md diff --git a/.changeset/dull-adults-punch.md b/.changeset/dull-adults-punch.md new file mode 100644 index 0000000000..0eefd0e871 --- /dev/null +++ b/.changeset/dull-adults-punch.md @@ -0,0 +1,5 @@ +--- +"@redocly/cli": patch +--- + +Fix join dropping non-string x-* path extension properties. From 310638c63baaf0eacaf5d6f0270149da6e5eed73 Mon Sep 17 00:00:00 2001 From: nonchan <50233291+nonchan7720@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:24:07 +0900 Subject: [PATCH 5/9] Update .changeset/dull-adults-punch.md Co-authored-by: Viktor Sydor <31951646+kanoru3101@users.noreply.github.com> --- .changeset/dull-adults-punch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dull-adults-punch.md b/.changeset/dull-adults-punch.md index 0eefd0e871..d3c74cf061 100644 --- a/.changeset/dull-adults-punch.md +++ b/.changeset/dull-adults-punch.md @@ -2,4 +2,4 @@ "@redocly/cli": patch --- -Fix join dropping non-string x-* path extension properties. +Fixed an issue where the `join` command silently dropped path-level `x-*` extensions with non-string values. From 229f223237f76842524239873b259b33728f0146 Mon Sep 17 00:00:00 2001 From: nonchan7720 Date: Sat, 18 Jul 2026 00:30:41 +0900 Subject: [PATCH 6/9] test: cover x-* path extension merge when objects have different keys --- .../cli/src/__tests__/commands/join.test.ts | 32 +++++++++++++++++++ .../src/__tests__/fixtures/join/documents.ts | 18 +++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts index 0490fe2698..94301d08bd 100644 --- a/packages/cli/src/__tests__/commands/join.test.ts +++ b/packages/cli/src/__tests__/commands/join.test.ts @@ -28,6 +28,7 @@ import { pathWithObjectExtension, anotherPathWithSameObjectExtension, anotherPathWithDifferentObjectExtension, + anotherPathWithDifferentKeyObjectExtension, } from '../fixtures/join/documents.js'; describe('handleJoin', () => { @@ -534,6 +535,37 @@ describe('handleJoin', () => { expect(joinedDef.paths['/foo']['x-metadata']).toEqual({ owner: 'team-a' }); expect(warnSpy).toHaveBeenCalledWith('warning: different x-metadata values in /foo\n'); }); + + it('should warn when non-string x-* path extension objects have different keys', async () => { + vi.mocked(detectSpec).mockReturnValue('oas3_0'); + const warnSpy = vi.spyOn(logger, 'warn'); + vi.spyOn(BaseResolver.prototype, 'resolveDocument') + .mockReset() + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-a' }, + parsed: pathWithObjectExtension, + } as Document) + ) + .mockImplementationOnce(() => + Promise.resolve({ + source: { absoluteRef: 'ref-b' }, + parsed: anotherPathWithDifferentKeyObjectExtension, + } as Document) + ); + + await handleJoin({ + argv: { + apis: ['a.yaml', 'b.yaml'], + }, + config: configFixture, + version: 'cli-version', + }); + + const joinedDef = writeToFileByExtensionSpy.mock.calls[0][0]; + expect(joinedDef.paths['/foo']['x-metadata']).toEqual({ owner: 'team-a' }); + expect(warnSpy).toHaveBeenCalledWith('warning: different x-metadata values in /foo\n'); + }); }); describe('replace$Refs', () => { diff --git a/packages/cli/src/__tests__/fixtures/join/documents.ts b/packages/cli/src/__tests__/fixtures/join/documents.ts index a1be85aa0b..40d076aee8 100644 --- a/packages/cli/src/__tests__/fixtures/join/documents.ts +++ b/packages/cli/src/__tests__/fixtures/join/documents.ts @@ -219,3 +219,21 @@ export const anotherPathWithDifferentObjectExtension = { }, }, }; + +export const anotherPathWithDifferentKeyObjectExtension = { + openapi: '3.0.0', + info: { + title: 'Second API', + version: '1.0.0', + }, + servers: [ + { + url: 'https://foo.com/api/v1/second', + }, + ], + paths: { + '/foo': { + 'x-metadata': { team: 'x' }, + }, + }, +}; From c3edfd1d56ec55cde8e0c08fa60dd7acaba3dcc7 Mon Sep 17 00:00:00 2001 From: nonchan7720 Date: Sat, 18 Jul 2026 00:36:27 +0900 Subject: [PATCH 7/9] refactor: merge collectPathExtension into collectPathStringFields --- .../src/commands/join/utils/collect-paths.ts | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/join/utils/collect-paths.ts b/packages/cli/src/commands/join/utils/collect-paths.ts index 0d3a498f93..b48d419fd5 100644 --- a/packages/cli/src/commands/join/utils/collect-paths.ts +++ b/packages/cli/src/commands/join/utils/collect-paths.ts @@ -68,10 +68,11 @@ export function collectPaths({ if (field === 'parameters') { collectPathParameters(pathItem, path); } - if (typeof pathItem[field] === 'string') { + if ( + typeof pathItem[field] === 'string' || + (field.startsWith('x-') && typeof pathItem[field] !== 'string') + ) { collectPathStringFields(pathItem, path, field); - } else if (field.startsWith('x-') && typeof pathItem[field] !== 'string') { - collectPathExtension(pathItem, path, field); } } } @@ -85,7 +86,7 @@ export function collectPaths({ const fieldValue = pathItem[field]; if ( joinedDef.paths[path].hasOwnProperty(field) && - joinedDef.paths[path][field] !== fieldValue + !dequal(joinedDef.paths[path][field], fieldValue) ) { logger.warn(`warning: different ${field} values in ${path}\n`); return; @@ -230,19 +231,6 @@ export function collectPaths({ ); } } - - function collectPathExtension(pathItem: Oas3PathItem, path: string | number, field: string) { - // x-* - const value = (pathItem as Record)[field]; - const joinedPathItem = joinedDef.paths[path]; - if (!joinedPathItem.hasOwnProperty(field)) { - joinedPathItem[field] = value; - return; - } - if (!dequal(joinedPathItem[field], value)) { - logger.warn(`warning: different ${field} values in ${path}\n`); - } - } } function isServersEqual(serverOne: Oas3Server, serverTwo: Oas3Server) { From 5b7a1cc4c5213961be74beef689ecc31da79e371 Mon Sep 17 00:00:00 2001 From: nonchan7720 Date: Sat, 18 Jul 2026 10:50:52 +0900 Subject: [PATCH 8/9] refactor: simplify x-* field condition in collectPaths --- packages/cli/src/commands/join/utils/collect-paths.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/src/commands/join/utils/collect-paths.ts b/packages/cli/src/commands/join/utils/collect-paths.ts index b48d419fd5..923d4c0956 100644 --- a/packages/cli/src/commands/join/utils/collect-paths.ts +++ b/packages/cli/src/commands/join/utils/collect-paths.ts @@ -68,10 +68,7 @@ export function collectPaths({ if (field === 'parameters') { collectPathParameters(pathItem, path); } - if ( - typeof pathItem[field] === 'string' || - (field.startsWith('x-') && typeof pathItem[field] !== 'string') - ) { + if (typeof pathItem[field] === 'string' || field.startsWith('x-')) { collectPathStringFields(pathItem, path, field); } } From b593d94ecf5de456dc5e8e9b2f88e3d2db821557 Mon Sep 17 00:00:00 2001 From: nonchan <50233291+nonchan7720@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:20:03 +0900 Subject: [PATCH 9/9] Update .changeset/dull-adults-punch.md Co-authored-by: Viktor Sydor <31951646+kanoru3101@users.noreply.github.com> --- .changeset/dull-adults-punch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dull-adults-punch.md b/.changeset/dull-adults-punch.md index d3c74cf061..5dd3637b26 100644 --- a/.changeset/dull-adults-punch.md +++ b/.changeset/dull-adults-punch.md @@ -1,5 +1,5 @@ --- -"@redocly/cli": patch +'@redocly/cli': patch --- Fixed an issue where the `join` command silently dropped path-level `x-*` extensions with non-string values.