From e78ce75bcc63374cd9ec4178bfe18a2e09ca128d Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Mon, 13 Apr 2026 20:37:11 +0300 Subject: [PATCH 1/9] feat: warn when duplicate plugins are specified Emit a console warning when the same plugin name appears multiple times in the plugins array. The last configuration still wins, but users now get a diagnostic pointing to the root cause of unexpected behavior. Closes #3600 --- .changeset/warn-duplicate-plugins.md | 5 +++++ packages/openapi-ts/src/config/plugins.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 .changeset/warn-duplicate-plugins.md diff --git a/.changeset/warn-duplicate-plugins.md b/.changeset/warn-duplicate-plugins.md new file mode 100644 index 0000000000..9e890c6358 --- /dev/null +++ b/.changeset/warn-duplicate-plugins.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +Warn when the same plugin is specified multiple times in the plugins array diff --git a/packages/openapi-ts/src/config/plugins.ts b/packages/openapi-ts/src/config/plugins.ts index b239403e9c..0a19292508 100644 --- a/packages/openapi-ts/src/config/plugins.ts +++ b/packages/openapi-ts/src/config/plugins.ts @@ -1,5 +1,6 @@ import type { AnyPluginName, PluginContext, PluginNames } from '@hey-api/shared'; import { dependencyFactory, valueToObject } from '@hey-api/shared'; +import colors from 'ansi-colors'; import { defaultPluginConfigs } from '../plugins/config'; import type { Config, UserConfig } from './types'; @@ -146,15 +147,30 @@ export function getPlugins({ } } + const seenPlugins = new Set(); + const userPlugins = definedPlugins .map((plugin) => { if (typeof plugin === 'string') { + if (seenPlugins.has(plugin)) { + console.warn( + `⚙️ ${colors.yellow('Warning:')} Duplicate plugin ${colors.cyan(`"${plugin}"`)} detected. The last configuration will be used.`, + ); + } + seenPlugins.add(plugin); return plugin; } const pluginName = plugin.name; if (pluginName) { + if (seenPlugins.has(pluginName)) { + console.warn( + `⚙️ ${colors.yellow('Warning:')} Duplicate plugin ${colors.cyan(`"${pluginName}"`)} detected. The last configuration will be used.`, + ); + } + seenPlugins.add(pluginName); + // @ts-expect-error if (plugin.handler) { // @ts-expect-error From 4bc269f87858ec857668620ca746c145c5b443b7 Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Mon, 13 Apr 2026 20:47:18 +0300 Subject: [PATCH 2/9] refactor: extract duplicate plugin warning into helper and improve wording --- packages/openapi-ts/src/config/plugins.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/openapi-ts/src/config/plugins.ts b/packages/openapi-ts/src/config/plugins.ts index 0a19292508..dea8618501 100644 --- a/packages/openapi-ts/src/config/plugins.ts +++ b/packages/openapi-ts/src/config/plugins.ts @@ -149,13 +149,16 @@ export function getPlugins({ const seenPlugins = new Set(); + const warnDuplicatePlugin = (name: string) => + console.warn( + `⚙️ ${colors.yellow('Warning:')} Duplicate plugin ${colors.cyan(`"${name}"`)} detected. Only the last occurrence will take effect.`, + ); + const userPlugins = definedPlugins .map((plugin) => { if (typeof plugin === 'string') { if (seenPlugins.has(plugin)) { - console.warn( - `⚙️ ${colors.yellow('Warning:')} Duplicate plugin ${colors.cyan(`"${plugin}"`)} detected. The last configuration will be used.`, - ); + warnDuplicatePlugin(plugin); } seenPlugins.add(plugin); return plugin; @@ -165,9 +168,7 @@ export function getPlugins({ if (pluginName) { if (seenPlugins.has(pluginName)) { - console.warn( - `⚙️ ${colors.yellow('Warning:')} Duplicate plugin ${colors.cyan(`"${pluginName}"`)} detected. The last configuration will be used.`, - ); + warnDuplicatePlugin(pluginName); } seenPlugins.add(pluginName); From 80f67f21b6fdea928aaf6c32274343866f0e7108 Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Mon, 13 Apr 2026 20:50:31 +0300 Subject: [PATCH 3/9] test: add test for duplicate plugin warning --- .../openapi-ts/src/__tests__/index.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/openapi-ts/src/__tests__/index.test.ts b/packages/openapi-ts/src/__tests__/index.test.ts index 18a386dedc..f66a5ce507 100644 --- a/packages/openapi-ts/src/__tests__/index.test.ts +++ b/packages/openapi-ts/src/__tests__/index.test.ts @@ -286,6 +286,32 @@ describe('createClient', () => { expect(results).toHaveLength(4); }); + it('warns when duplicate plugins are specified', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + dryRun: true, + input: { + info: { title: 'duplicate-plugin-test', version: '1.0.0' }, + openapi: '3.1.0', + }, + logs: { + level: 'silent', + }, + output: 'output', + plugins: [ + { name: '@hey-api/typescript' }, + { name: '@hey-api/typescript' }, + ], + }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Duplicate plugin'), + ); + + warnSpy.mockRestore(); + }); + it('executes @angular/common HttpRequest builder path', async () => { const results = await createClient({ dryRun: true, From f8d9acd0376b4c21dc0d68d7c2bbca87daad3623 Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Mon, 13 Apr 2026 20:56:05 +0300 Subject: [PATCH 4/9] test: assert warning includes plugin name --- packages/openapi-ts/src/__tests__/index.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/openapi-ts/src/__tests__/index.test.ts b/packages/openapi-ts/src/__tests__/index.test.ts index f66a5ce507..271174dd9a 100644 --- a/packages/openapi-ts/src/__tests__/index.test.ts +++ b/packages/openapi-ts/src/__tests__/index.test.ts @@ -308,6 +308,9 @@ describe('createClient', () => { expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Duplicate plugin'), ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"@hey-api/typescript"'), + ); warnSpy.mockRestore(); }); From adcfc471a97f47e81680d0a2b0fb951c1e0a28b8 Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Tue, 14 Apr 2026 15:27:48 +0300 Subject: [PATCH 5/9] feat: only warn on duplicate plugins with conflicting options --- .../openapi-ts/src/__tests__/index.test.ts | 136 +++++++++++++++--- packages/openapi-ts/src/config/plugins.ts | 39 +++-- 2 files changed, 146 insertions(+), 29 deletions(-) diff --git a/packages/openapi-ts/src/__tests__/index.test.ts b/packages/openapi-ts/src/__tests__/index.test.ts index 271174dd9a..73f9f105e2 100644 --- a/packages/openapi-ts/src/__tests__/index.test.ts +++ b/packages/openapi-ts/src/__tests__/index.test.ts @@ -286,33 +286,133 @@ describe('createClient', () => { expect(results).toHaveLength(4); }); - it('warns when duplicate plugins are specified', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - dryRun: true, + describe('duplicate plugin warnings', () => { + const baseConfig = { + dryRun: true as const, input: { info: { title: 'duplicate-plugin-test', version: '1.0.0' }, - openapi: '3.1.0', + openapi: '3.1.0' as const, }, logs: { - level: 'silent', + level: 'silent' as const, }, output: 'output', - plugins: [ - { name: '@hey-api/typescript' }, - { name: '@hey-api/typescript' }, - ], + }; + + const conflictWarnings = (warnSpy: ReturnType) => + warnSpy.mock.calls.filter( + (args: unknown[]) => typeof args[0] === 'string' && args[0].includes('conflicting options'), + ); + + it('warns when the same plugin is specified with conflicting options', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: [ + { case: 'PascalCase', name: '@hey-api/typescript' }, + { case: 'camelCase', name: '@hey-api/typescript' }, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"@hey-api/typescript"')); + + warnSpy.mockRestore(); + }); + + it('does not warn when the same plugin is specified twice as a string', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: ['@hey-api/typescript', '@hey-api/typescript'], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(0); + + warnSpy.mockRestore(); + }); + + it('does not warn when a string and an object with only name are specified', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: ['@hey-api/typescript', { name: '@hey-api/typescript' }], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(0); + + warnSpy.mockRestore(); + }); + + it('does not warn when two identical object configurations are specified', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: [ + { case: 'PascalCase', name: '@hey-api/typescript' }, + { case: 'PascalCase', name: '@hey-api/typescript' }, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(0); + + warnSpy.mockRestore(); + }); + + it('does not warn when objects differ only in key order', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: [ + { case: 'PascalCase', name: '@hey-api/typescript' }, + { name: '@hey-api/typescript', case: 'PascalCase' }, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(0); + + warnSpy.mockRestore(); + }); + + it('does not warn when nested object configs differ only in key order', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: [ + { + definitions: { case: 'PascalCase', name: 'foo' }, + name: '@hey-api/typescript', + }, + { + name: '@hey-api/typescript', + definitions: { name: 'foo', case: 'PascalCase' }, + }, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(0); + + warnSpy.mockRestore(); }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('Duplicate plugin'), - ); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('"@hey-api/typescript"'), - ); + it('warns when an object adds extra config compared to a string entry', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - warnSpy.mockRestore(); + await createClient({ + ...baseConfig, + plugins: ['@hey-api/typescript', { case: 'PascalCase', name: '@hey-api/typescript' }], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(1); + + warnSpy.mockRestore(); + }); }); it('executes @angular/common HttpRequest builder path', async () => { diff --git a/packages/openapi-ts/src/config/plugins.ts b/packages/openapi-ts/src/config/plugins.ts index dea8618501..af4a1d197b 100644 --- a/packages/openapi-ts/src/config/plugins.ts +++ b/packages/openapi-ts/src/config/plugins.ts @@ -147,30 +147,47 @@ export function getPlugins({ } } - const seenPlugins = new Set(); + const seenPlugins = new Map(); + + const stableStringify = (value: unknown): string => + JSON.stringify(value, (_, v) => + v && typeof v === 'object' && !Array.isArray(v) + ? Object.fromEntries( + Object.entries(v as Record).sort(([a], [b]) => a.localeCompare(b)), + ) + : v, + ); - const warnDuplicatePlugin = (name: string) => + const warnConflictingPlugin = (name: string) => console.warn( - `⚙️ ${colors.yellow('Warning:')} Duplicate plugin ${colors.cyan(`"${name}"`)} detected. Only the last occurrence will take effect.`, + `⚙️ ${colors.yellow('Warning:')} Plugin ${colors.cyan(`"${name}"`)} is configured more than once with conflicting options. Only the last occurrence will take effect.`, ); + const checkDuplicate = (name: string, config: Record) => { + const serialized = stableStringify(config); + const previous = seenPlugins.get(name); + if (previous !== undefined && previous !== serialized) { + warnConflictingPlugin(name); + } + seenPlugins.set(name, serialized); + }; + const userPlugins = definedPlugins .map((plugin) => { if (typeof plugin === 'string') { - if (seenPlugins.has(plugin)) { - warnDuplicatePlugin(plugin); - } - seenPlugins.add(plugin); + checkDuplicate(plugin, {}); return plugin; } const pluginName = plugin.name; if (pluginName) { - if (seenPlugins.has(pluginName)) { - warnDuplicatePlugin(pluginName); - } - seenPlugins.add(pluginName); + const config = Object.fromEntries( + Object.entries(plugin as unknown as Record).filter( + ([key]) => key !== 'name', + ), + ); + checkDuplicate(pluginName, config); // @ts-expect-error if (plugin.handler) { From 41215a2c711f269905dc55b3386ade99b6f0f010 Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Tue, 14 Apr 2026 17:18:32 +0300 Subject: [PATCH 6/9] feat: use log.warn from codegen-core and handle function values in dedup --- .../openapi-ts/src/__tests__/index.test.ts | 39 +++++++++++++++++++ packages/openapi-ts/src/config/plugins.ts | 31 ++++++++------- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/openapi-ts/src/__tests__/index.test.ts b/packages/openapi-ts/src/__tests__/index.test.ts index 73f9f105e2..fc417ba550 100644 --- a/packages/openapi-ts/src/__tests__/index.test.ts +++ b/packages/openapi-ts/src/__tests__/index.test.ts @@ -413,6 +413,45 @@ describe('createClient', () => { warnSpy.mockRestore(); }); + + it('does not warn when function-valued options have identical source', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const transform = (s: string) => s.toUpperCase(); + + await createClient({ + ...baseConfig, + plugins: [ + { definitions: { name: transform }, name: '@hey-api/typescript' }, + { definitions: { name: transform }, name: '@hey-api/typescript' }, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(0); + + warnSpy.mockRestore(); + }); + + it('warns when function-valued options differ', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: [ + { + definitions: { name: (s: string) => s.toUpperCase() }, + name: '@hey-api/typescript', + }, + { + definitions: { name: (s: string) => s.toLowerCase() }, + name: '@hey-api/typescript', + }, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(1); + + warnSpy.mockRestore(); + }); }); it('executes @angular/common HttpRequest builder path', async () => { diff --git a/packages/openapi-ts/src/config/plugins.ts b/packages/openapi-ts/src/config/plugins.ts index af4a1d197b..a03617bfd0 100644 --- a/packages/openapi-ts/src/config/plugins.ts +++ b/packages/openapi-ts/src/config/plugins.ts @@ -1,6 +1,6 @@ +import { log } from '@hey-api/codegen-core'; import type { AnyPluginName, PluginContext, PluginNames } from '@hey-api/shared'; import { dependencyFactory, valueToObject } from '@hey-api/shared'; -import colors from 'ansi-colors'; import { defaultPluginConfigs } from '../plugins/config'; import type { Config, UserConfig } from './types'; @@ -150,24 +150,27 @@ export function getPlugins({ const seenPlugins = new Map(); const stableStringify = (value: unknown): string => - JSON.stringify(value, (_, v) => - v && typeof v === 'object' && !Array.isArray(v) - ? Object.fromEntries( - Object.entries(v as Record).sort(([a], [b]) => a.localeCompare(b)), - ) - : v, - ); - - const warnConflictingPlugin = (name: string) => - console.warn( - `⚙️ ${colors.yellow('Warning:')} Plugin ${colors.cyan(`"${name}"`)} is configured more than once with conflicting options. Only the last occurrence will take effect.`, - ); + JSON.stringify(value, (_, v) => { + if (typeof v === 'function') { + return `[function:${(v as () => unknown).toString()}]`; + } + if (v && typeof v === 'object' && !Array.isArray(v)) { + return Object.fromEntries( + Object.entries(v as Record).sort(([a], [b]) => + a.localeCompare(b), + ), + ); + } + return v; + }); const checkDuplicate = (name: string, config: Record) => { const serialized = stableStringify(config); const previous = seenPlugins.get(name); if (previous !== undefined && previous !== serialized) { - warnConflictingPlugin(name); + log.warn( + `Plugin "${name}" is configured more than once with conflicting options. Only the last occurrence will take effect.`, + ); } seenPlugins.set(name, serialized); }; From 9f9cf09ed663c15e40bcd1072619e7763f49ad99 Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Tue, 14 Apr 2026 17:40:22 +0300 Subject: [PATCH 7/9] test: cover array-valued plugin options in dedup --- .../openapi-ts/src/__tests__/index.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/openapi-ts/src/__tests__/index.test.ts b/packages/openapi-ts/src/__tests__/index.test.ts index fc417ba550..d66e1550bb 100644 --- a/packages/openapi-ts/src/__tests__/index.test.ts +++ b/packages/openapi-ts/src/__tests__/index.test.ts @@ -414,6 +414,50 @@ describe('createClient', () => { warnSpy.mockRestore(); }); + it('does not warn when array-valued options differ only in element key order', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: [ + { + items: [{ from: 'foo', name: 'bar' }], + name: '@hey-api/typescript', + } as never, + { + items: [{ name: 'bar', from: 'foo' }], + name: '@hey-api/typescript', + } as never, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(0); + + warnSpy.mockRestore(); + }); + + it('warns when array-valued options differ in element order', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await createClient({ + ...baseConfig, + plugins: [ + { + items: [{ from: 'a', name: 'x' }, { from: 'b', name: 'y' }], + name: '@hey-api/typescript', + } as never, + { + items: [{ from: 'b', name: 'y' }, { from: 'a', name: 'x' }], + name: '@hey-api/typescript', + } as never, + ], + }); + + expect(conflictWarnings(warnSpy)).toHaveLength(1); + + warnSpy.mockRestore(); + }); + it('does not warn when function-valued options have identical source', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const transform = (s: string) => s.toUpperCase(); From 3ff8f76e4c602875c0a256097f8c5d41f7e2486b Mon Sep 17 00:00:00 2001 From: inas sarhan Date: Tue, 14 Apr 2026 17:59:03 +0300 Subject: [PATCH 8/9] refactor: extract warnDuplicatePlugins to @hey-api/shared and apply in openapi-python --- packages/openapi-python/src/config/plugins.ts | 8 ++- packages/openapi-ts/src/config/plugins.ts | 43 +++------------ packages/shared/src/index.ts | 1 + packages/shared/src/plugins/duplicate.ts | 54 +++++++++++++++++++ 4 files changed, 68 insertions(+), 38 deletions(-) create mode 100644 packages/shared/src/plugins/duplicate.ts diff --git a/packages/openapi-python/src/config/plugins.ts b/packages/openapi-python/src/config/plugins.ts index 2bda621e6c..b7b07a4c53 100644 --- a/packages/openapi-python/src/config/plugins.ts +++ b/packages/openapi-python/src/config/plugins.ts @@ -1,5 +1,9 @@ import type { AnyPluginName, PluginContext, PluginNames } from '@hey-api/shared'; -import { dependencyFactory, valueToObject } from '@hey-api/shared'; +import { + dependencyFactory, + valueToObject, + warnDuplicatePlugins, +} from '@hey-api/shared'; import { defaultPluginConfigs } from '../plugins/config'; import type { Config, UserConfig } from './types'; @@ -143,6 +147,8 @@ export function getPlugins({ } } + warnDuplicatePlugins(definedPlugins as ReadonlyArray); + const userPlugins = definedPlugins .map((plugin) => { if (typeof plugin === 'string') { diff --git a/packages/openapi-ts/src/config/plugins.ts b/packages/openapi-ts/src/config/plugins.ts index a03617bfd0..036d3051ed 100644 --- a/packages/openapi-ts/src/config/plugins.ts +++ b/packages/openapi-ts/src/config/plugins.ts @@ -1,6 +1,9 @@ -import { log } from '@hey-api/codegen-core'; import type { AnyPluginName, PluginContext, PluginNames } from '@hey-api/shared'; -import { dependencyFactory, valueToObject } from '@hey-api/shared'; +import { + dependencyFactory, + valueToObject, + warnDuplicatePlugins, +} from '@hey-api/shared'; import { defaultPluginConfigs } from '../plugins/config'; import type { Config, UserConfig } from './types'; @@ -147,51 +150,17 @@ export function getPlugins({ } } - const seenPlugins = new Map(); - - const stableStringify = (value: unknown): string => - JSON.stringify(value, (_, v) => { - if (typeof v === 'function') { - return `[function:${(v as () => unknown).toString()}]`; - } - if (v && typeof v === 'object' && !Array.isArray(v)) { - return Object.fromEntries( - Object.entries(v as Record).sort(([a], [b]) => - a.localeCompare(b), - ), - ); - } - return v; - }); - - const checkDuplicate = (name: string, config: Record) => { - const serialized = stableStringify(config); - const previous = seenPlugins.get(name); - if (previous !== undefined && previous !== serialized) { - log.warn( - `Plugin "${name}" is configured more than once with conflicting options. Only the last occurrence will take effect.`, - ); - } - seenPlugins.set(name, serialized); - }; + warnDuplicatePlugins(definedPlugins as ReadonlyArray); const userPlugins = definedPlugins .map((plugin) => { if (typeof plugin === 'string') { - checkDuplicate(plugin, {}); return plugin; } const pluginName = plugin.name; if (pluginName) { - const config = Object.fromEntries( - Object.entries(plugin as unknown as Record).filter( - ([key]) => key !== 'name', - ), - ); - checkDuplicate(pluginName, config); - // @ts-expect-error if (plugin.handler) { // @ts-expect-error diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c42c978052..0a190f3664 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -97,6 +97,7 @@ export type { OpenApiSchemaObject, } from './openApi/types'; export type { GetNameContext, Hooks } from './parser/hooks'; +export { warnDuplicatePlugins } from './plugins/duplicate'; export type { SchemaWithType } from './plugins/shared/types/schema'; export { definePluginConfig, mappers } from './plugins/shared/utils/config'; export type { PluginInstanceTypes } from './plugins/shared/utils/instance'; diff --git a/packages/shared/src/plugins/duplicate.ts b/packages/shared/src/plugins/duplicate.ts new file mode 100644 index 0000000000..36df836579 --- /dev/null +++ b/packages/shared/src/plugins/duplicate.ts @@ -0,0 +1,54 @@ +import { log } from '@hey-api/codegen-core'; + +type PluginEntry = string | ({ name: string } & Record); + +const stableStringify = (value: unknown): string => + JSON.stringify(value, (_, v) => { + if (typeof v === 'function') { + return `[function:${(v as () => unknown).toString()}]`; + } + if (v && typeof v === 'object' && !Array.isArray(v)) { + return Object.fromEntries( + Object.entries(v as Record).sort(([a], [b]) => + a.localeCompare(b), + ), + ); + } + return v; + }); + +export const warnDuplicatePlugins = ( + plugins: ReadonlyArray, +): void => { + const seen = new Map(); + + for (const plugin of plugins) { + if (typeof plugin === 'string') { + const previous = seen.get(plugin); + if (previous !== undefined && previous !== '{}') { + log.warn( + `Plugin "${plugin}" is configured more than once with conflicting options. Only the last occurrence will take effect.`, + ); + } + seen.set(plugin, '{}'); + continue; + } + + const name = plugin.name; + if (!name) continue; + + const config = Object.fromEntries( + Object.entries(plugin as Record).filter( + ([key]) => key !== 'name', + ), + ); + const serialized = stableStringify(config); + const previous = seen.get(name); + if (previous !== undefined && previous !== serialized) { + log.warn( + `Plugin "${name}" is configured more than once with conflicting options. Only the last occurrence will take effect.`, + ); + } + seen.set(name, serialized); + } +}; From 403b4427b779193c0e8b798baf7385263a9b9e54 Mon Sep 17 00:00:00 2001 From: Lubos Date: Wed, 15 Apr 2026 06:08:10 +0200 Subject: [PATCH 9/9] chore: clean up types --- .changeset/warn-duplicate-plugins.md | 3 +- packages/openapi-python/src/config/plugins.ts | 4 +- .../openapi-ts/src/__tests__/index.test.ts | 212 ------------------ packages/openapi-ts/src/config/plugins.ts | 4 +- packages/shared/src/index.ts | 2 +- .../src/plugins/__tests__/duplicate.test.ts | 193 ++++++++++++++++ packages/shared/src/plugins/duplicate.ts | 68 +++--- 7 files changed, 239 insertions(+), 247 deletions(-) create mode 100644 packages/shared/src/plugins/__tests__/duplicate.test.ts diff --git a/.changeset/warn-duplicate-plugins.md b/.changeset/warn-duplicate-plugins.md index 9e890c6358..7515c112f7 100644 --- a/.changeset/warn-duplicate-plugins.md +++ b/.changeset/warn-duplicate-plugins.md @@ -1,5 +1,6 @@ --- "@hey-api/openapi-ts": patch +"@hey-api/shared": patch --- -Warn when the same plugin is specified multiple times in the plugins array +**config**: warn on duplicated plugin configurations diff --git a/packages/openapi-python/src/config/plugins.ts b/packages/openapi-python/src/config/plugins.ts index b7b07a4c53..eb2211d230 100644 --- a/packages/openapi-python/src/config/plugins.ts +++ b/packages/openapi-python/src/config/plugins.ts @@ -2,7 +2,7 @@ import type { AnyPluginName, PluginContext, PluginNames } from '@hey-api/shared' import { dependencyFactory, valueToObject, - warnDuplicatePlugins, + warnOnConflictingDuplicatePlugins, } from '@hey-api/shared'; import { defaultPluginConfigs } from '../plugins/config'; @@ -147,7 +147,7 @@ export function getPlugins({ } } - warnDuplicatePlugins(definedPlugins as ReadonlyArray); + warnOnConflictingDuplicatePlugins(definedPlugins); const userPlugins = definedPlugins .map((plugin) => { diff --git a/packages/openapi-ts/src/__tests__/index.test.ts b/packages/openapi-ts/src/__tests__/index.test.ts index d66e1550bb..18a386dedc 100644 --- a/packages/openapi-ts/src/__tests__/index.test.ts +++ b/packages/openapi-ts/src/__tests__/index.test.ts @@ -286,218 +286,6 @@ describe('createClient', () => { expect(results).toHaveLength(4); }); - describe('duplicate plugin warnings', () => { - const baseConfig = { - dryRun: true as const, - input: { - info: { title: 'duplicate-plugin-test', version: '1.0.0' }, - openapi: '3.1.0' as const, - }, - logs: { - level: 'silent' as const, - }, - output: 'output', - }; - - const conflictWarnings = (warnSpy: ReturnType) => - warnSpy.mock.calls.filter( - (args: unknown[]) => typeof args[0] === 'string' && args[0].includes('conflicting options'), - ); - - it('warns when the same plugin is specified with conflicting options', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: [ - { case: 'PascalCase', name: '@hey-api/typescript' }, - { case: 'camelCase', name: '@hey-api/typescript' }, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(1); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"@hey-api/typescript"')); - - warnSpy.mockRestore(); - }); - - it('does not warn when the same plugin is specified twice as a string', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: ['@hey-api/typescript', '@hey-api/typescript'], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(0); - - warnSpy.mockRestore(); - }); - - it('does not warn when a string and an object with only name are specified', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: ['@hey-api/typescript', { name: '@hey-api/typescript' }], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(0); - - warnSpy.mockRestore(); - }); - - it('does not warn when two identical object configurations are specified', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: [ - { case: 'PascalCase', name: '@hey-api/typescript' }, - { case: 'PascalCase', name: '@hey-api/typescript' }, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(0); - - warnSpy.mockRestore(); - }); - - it('does not warn when objects differ only in key order', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: [ - { case: 'PascalCase', name: '@hey-api/typescript' }, - { name: '@hey-api/typescript', case: 'PascalCase' }, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(0); - - warnSpy.mockRestore(); - }); - - it('does not warn when nested object configs differ only in key order', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: [ - { - definitions: { case: 'PascalCase', name: 'foo' }, - name: '@hey-api/typescript', - }, - { - name: '@hey-api/typescript', - definitions: { name: 'foo', case: 'PascalCase' }, - }, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(0); - - warnSpy.mockRestore(); - }); - - it('warns when an object adds extra config compared to a string entry', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: ['@hey-api/typescript', { case: 'PascalCase', name: '@hey-api/typescript' }], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(1); - - warnSpy.mockRestore(); - }); - - it('does not warn when array-valued options differ only in element key order', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: [ - { - items: [{ from: 'foo', name: 'bar' }], - name: '@hey-api/typescript', - } as never, - { - items: [{ name: 'bar', from: 'foo' }], - name: '@hey-api/typescript', - } as never, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(0); - - warnSpy.mockRestore(); - }); - - it('warns when array-valued options differ in element order', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: [ - { - items: [{ from: 'a', name: 'x' }, { from: 'b', name: 'y' }], - name: '@hey-api/typescript', - } as never, - { - items: [{ from: 'b', name: 'y' }, { from: 'a', name: 'x' }], - name: '@hey-api/typescript', - } as never, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(1); - - warnSpy.mockRestore(); - }); - - it('does not warn when function-valued options have identical source', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const transform = (s: string) => s.toUpperCase(); - - await createClient({ - ...baseConfig, - plugins: [ - { definitions: { name: transform }, name: '@hey-api/typescript' }, - { definitions: { name: transform }, name: '@hey-api/typescript' }, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(0); - - warnSpy.mockRestore(); - }); - - it('warns when function-valued options differ', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await createClient({ - ...baseConfig, - plugins: [ - { - definitions: { name: (s: string) => s.toUpperCase() }, - name: '@hey-api/typescript', - }, - { - definitions: { name: (s: string) => s.toLowerCase() }, - name: '@hey-api/typescript', - }, - ], - }); - - expect(conflictWarnings(warnSpy)).toHaveLength(1); - - warnSpy.mockRestore(); - }); - }); - it('executes @angular/common HttpRequest builder path', async () => { const results = await createClient({ dryRun: true, diff --git a/packages/openapi-ts/src/config/plugins.ts b/packages/openapi-ts/src/config/plugins.ts index 036d3051ed..1a45f5f2fd 100644 --- a/packages/openapi-ts/src/config/plugins.ts +++ b/packages/openapi-ts/src/config/plugins.ts @@ -2,7 +2,7 @@ import type { AnyPluginName, PluginContext, PluginNames } from '@hey-api/shared' import { dependencyFactory, valueToObject, - warnDuplicatePlugins, + warnOnConflictingDuplicatePlugins, } from '@hey-api/shared'; import { defaultPluginConfigs } from '../plugins/config'; @@ -150,7 +150,7 @@ export function getPlugins({ } } - warnDuplicatePlugins(definedPlugins as ReadonlyArray); + warnOnConflictingDuplicatePlugins(definedPlugins); const userPlugins = definedPlugins .map((plugin) => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4c37e3709a..dc6eecc088 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -99,7 +99,7 @@ export type { OpenApiSchemaObject, } from './openApi/types'; export type { GetNameContext, Hooks } from './parser/hooks'; -export { warnDuplicatePlugins } from './plugins/duplicate'; +export { warnOnConflictingDuplicatePlugins } from './plugins/duplicate'; export type { SchemaWithType } from './plugins/shared/types/schema'; export { definePluginConfig, mappers } from './plugins/shared/utils/config'; export type { PluginInstanceTypes } from './plugins/shared/utils/instance'; diff --git a/packages/shared/src/plugins/__tests__/duplicate.test.ts b/packages/shared/src/plugins/__tests__/duplicate.test.ts new file mode 100644 index 0000000000..fffc0e8f3a --- /dev/null +++ b/packages/shared/src/plugins/__tests__/duplicate.test.ts @@ -0,0 +1,193 @@ +import { log } from '@hey-api/codegen-core'; + +import { warnOnConflictingDuplicatePlugins } from '../duplicate'; + +describe('warnOnConflictingDuplicatePlugins', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const warningMessage = + 'Plugin "@hey-api/client-fetch" is configured multiple times. Only the last instance will take effect.'; + + it('does not warn for duplicate string plugins', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins(['@hey-api/client-fetch', '@hey-api/client-fetch']); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn for duplicate plugins with identical config in different key order', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + { + foo: 'bar', + name: '@hey-api/client-fetch', + output: 'sdk', + }, + { + output: 'sdk', + // eslint-disable-next-line sort-keys-fix/sort-keys-fix + foo: 'bar', + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when a string and an object with only name are specified', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins(['@hey-api/client-fetch', { name: '@hey-api/client-fetch' }]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn for duplicate plugins with identical object config', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + { + foo: 'bar', + name: '@hey-api/client-fetch', + }, + { + foo: 'bar', + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when nested object configs differ only in key order', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + { + definitions: { case: 'PascalCase', name: 'foo' }, + name: '@hey-api/client-fetch', + }, + { + // eslint-disable-next-line sort-keys-fix/sort-keys-fix + definitions: { name: 'foo', case: 'PascalCase' }, + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns for duplicate plugins with conflicting config', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + { + foo: 'bar', + name: '@hey-api/client-fetch', + }, + { + foo: 'baz', + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith(warningMessage); + }); + + it('warns when a string plugin conflicts with an object plugin of the same name', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + '@hey-api/client-fetch', + { + name: '@hey-api/client-fetch', + output: 'sdk', + }, + ]); + + expect(warnSpy).toHaveBeenCalledOnce(); + }); + + it('does not warn when array-valued options differ only in element key order', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + { + items: [{ from: 'foo', name: 'bar' }], + name: '@hey-api/client-fetch', + }, + { + // eslint-disable-next-line sort-keys-fix/sort-keys-fix + items: [{ name: 'bar', from: 'foo' }], + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns when array-valued options differ in element order', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + { + items: [ + { from: 'a', name: 'x' }, + { from: 'b', name: 'y' }, + ], + name: '@hey-api/client-fetch', + }, + { + items: [ + { from: 'b', name: 'y' }, + { from: 'a', name: 'x' }, + ], + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith(warningMessage); + }); + + it('does not warn when function-valued options have identical source', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + const transform = (value: string) => value.toUpperCase(); + + warnOnConflictingDuplicatePlugins([ + { + definitions: { name: transform }, + name: '@hey-api/client-fetch', + }, + { + definitions: { name: transform }, + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns when function-valued options differ', () => { + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + warnOnConflictingDuplicatePlugins([ + { + definitions: { name: (value: string) => value.toUpperCase() }, + name: '@hey-api/client-fetch', + }, + { + definitions: { name: (value: string) => value.toLowerCase() }, + name: '@hey-api/client-fetch', + }, + ]); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy).toHaveBeenCalledWith(warningMessage); + }); +}); diff --git a/packages/shared/src/plugins/duplicate.ts b/packages/shared/src/plugins/duplicate.ts index 36df836579..f969639c65 100644 --- a/packages/shared/src/plugins/duplicate.ts +++ b/packages/shared/src/plugins/duplicate.ts @@ -1,54 +1,64 @@ import { log } from '@hey-api/codegen-core'; -type PluginEntry = string | ({ name: string } & Record); +import type { PluginNames } from './types'; -const stableStringify = (value: unknown): string => - JSON.stringify(value, (_, v) => { +type PluginConfig = { + name: PluginNames; +}; + +type PluginDefinition = PluginNames | TConfig; + +function stableStringify(value: unknown): string { + return JSON.stringify(value, (_, v) => { if (typeof v === 'function') { return `[function:${(v as () => unknown).toString()}]`; } if (v && typeof v === 'object' && !Array.isArray(v)) { return Object.fromEntries( - Object.entries(v as Record).sort(([a], [b]) => - a.localeCompare(b), - ), + Object.entries(v as Record).sort(([a], [b]) => a.localeCompare(b)), ); } return v; }); +} + +function normalizePluginEntry( + plugin: PluginDefinition, +): { + name: PluginNames; + serialized: string; +} { + if (typeof plugin === 'string') { + return { + name: plugin, + serialized: '{}', + }; + } + + const { name, ...config } = plugin; -export const warnDuplicatePlugins = ( - plugins: ReadonlyArray, -): void => { + return { + name, + serialized: stableStringify(config), + }; +} + +export function warnOnConflictingDuplicatePlugins( + plugins: ReadonlyArray>, +): void { const seen = new Map(); for (const plugin of plugins) { - if (typeof plugin === 'string') { - const previous = seen.get(plugin); - if (previous !== undefined && previous !== '{}') { - log.warn( - `Plugin "${plugin}" is configured more than once with conflicting options. Only the last occurrence will take effect.`, - ); - } - seen.set(plugin, '{}'); - continue; - } - - const name = plugin.name; + const { name, serialized } = normalizePluginEntry(plugin); if (!name) continue; - const config = Object.fromEntries( - Object.entries(plugin as Record).filter( - ([key]) => key !== 'name', - ), - ); - const serialized = stableStringify(config); const previous = seen.get(name); if (previous !== undefined && previous !== serialized) { log.warn( - `Plugin "${name}" is configured more than once with conflicting options. Only the last occurrence will take effect.`, + `Plugin "${name}" is configured multiple times. Only the last instance will take effect.`, ); } + seen.set(name, serialized); } -}; +}