fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #3635 from hey-api/copilot/fix-symbolonce-bug

fix: symbol() and symbolOnce() correctly deduplicate external symbols by name

authored by

Lubos and committed by
GitHub
86078bf1 c3e497c0

+403 -170
+6
.changeset/rude-rules-fly.md
··· 1 + --- 2 + "@hey-api/openapi-ts": patch 3 + "@hey-api/shared": patch 4 + --- 5 + 6 + **plugin**: fix: `symbolOnce()` method correctly handles multiple symbols with the same metadata
+18
packages/codegen-core/src/__tests__/symbols.test.ts
··· 106 106 const result = r.query(meta({ a: { b: { c: 123 } } })); 107 107 expect(result).toEqual([a]); 108 108 }); 109 + 110 + it('query() returns all symbols sharing the same meta but with different names', () => { 111 + const r = new SymbolRegistry(); 112 + 113 + const sharedMeta = meta({ category: 'external', resource: '@shared/types' }); 114 + 115 + const a = r.register({ meta: sharedMeta, name: 'CustomNumber' }); 116 + const b = r.register({ meta: sharedMeta, name: 'FlakeIdString' }); 117 + 118 + const results = r.query(sharedMeta); 119 + expect(results).toHaveLength(2); 120 + expect(results).toContain(a); 121 + expect(results).toContain(b); 122 + 123 + // filtering by name correctly identifies each symbol independently 124 + expect(results.find((s) => s.name === 'CustomNumber')).toBe(a); 125 + expect(results.find((s) => s.name === 'FlakeIdString')).toBe(b); 126 + }); 109 127 });
-14
packages/openapi-python/src/plugins/@hey-api/sdk/v1/plugin.ts
··· 16 16 plugin.symbol('build_client_params', { 17 17 external: clientModule, 18 18 meta: { 19 - category: 'external', 20 19 resource: 'client.build_client_params', 21 20 tool: client.name, 22 21 }, ··· 24 23 plugin.symbol('Client', { 25 24 external: clientModule, 26 25 meta: { 27 - category: 'external', 28 26 resource: 'client.Client', 29 27 tool: client.name, 30 28 }, ··· 33 31 // functools 34 32 plugin.symbol('cached_property', { 35 33 external: 'functools', 36 - meta: { 37 - category: 'external', 38 - resource: 'functools.cached_property', 39 - }, 40 34 }); 41 35 42 36 // typing 43 37 plugin.symbol('Any', { 44 38 external: 'typing', 45 - meta: { 46 - category: 'external', 47 - resource: 'typing.Any', 48 - }, 49 39 }); 50 40 plugin.symbol('Union', { 51 41 external: 'typing', 52 - meta: { 53 - category: 'external', 54 - resource: 'typing.Union', 55 - }, 56 42 }); 57 43 58 44 const structure = new StructureModel();
-40
packages/openapi-python/src/plugins/pydantic/v2/plugin.ts
··· 7 7 // enum 8 8 plugin.symbol('Enum', { 9 9 external: 'enum', 10 - meta: { 11 - category: 'external', 12 - resource: 'enum.Enum', 13 - }, 14 10 }); 15 11 16 12 // typing 17 13 plugin.symbol('Any', { 18 14 external: 'typing', 19 - meta: { 20 - category: 'external', 21 - resource: 'typing.Any', 22 - }, 23 15 }); 24 16 plugin.symbol('Literal', { 25 17 external: 'typing', 26 - meta: { 27 - category: 'external', 28 - resource: 'typing.Literal', 29 - }, 30 18 }); 31 19 plugin.symbol('NoReturn', { 32 20 external: 'typing', 33 - meta: { 34 - category: 'external', 35 - resource: 'typing.NoReturn', 36 - }, 37 21 }); 38 22 plugin.symbol('Optional', { 39 23 external: 'typing', 40 - meta: { 41 - category: 'external', 42 - resource: 'typing.Optional', 43 - }, 44 24 }); 45 25 plugin.symbol('TypeAlias', { 46 26 external: 'typing', 47 - meta: { 48 - category: 'external', 49 - resource: 'typing.TypeAlias', 50 - }, 51 27 }); 52 28 plugin.symbol('Union', { 53 29 external: 'typing', 54 - meta: { 55 - category: 'external', 56 - resource: 'typing.Union', 57 - }, 58 30 }); 59 31 60 32 // Pydantic 61 33 plugin.symbol('BaseModel', { 62 34 external: 'pydantic', 63 - meta: { 64 - category: 'external', 65 - resource: 'pydantic.BaseModel', 66 - }, 67 35 }); 68 36 plugin.symbol('ConfigDict', { 69 37 external: 'pydantic', 70 - meta: { 71 - category: 'external', 72 - resource: 'pydantic.ConfigDict', 73 - }, 74 38 }); 75 39 plugin.symbol('Field', { 76 40 external: 'pydantic', 77 - meta: { 78 - category: 'external', 79 - resource: 'pydantic.Field', 80 - }, 81 41 }); 82 42 83 43 const processor = createProcessor(plugin);
-16
packages/openapi-ts/src/plugins/@angular/common/plugin.ts
··· 17 17 plugin.symbol('HttpRequest', { 18 18 external: '@angular/common/http', 19 19 kind: 'type', 20 - meta: { 21 - category: 'external', 22 - resource: '@angular/common/http.HttpRequest', 23 - }, 24 20 }); 25 21 plugin.symbol('inject', { 26 22 external: '@angular/core', 27 - meta: { 28 - category: 'external', 29 - resource: '@angular/core.inject', 30 - }, 31 23 }); 32 24 plugin.symbol('Injectable', { 33 25 external: '@angular/core', 34 - meta: { 35 - category: 'external', 36 - resource: '@angular/core.Injectable', 37 - }, 38 26 }); 39 27 plugin.symbol('httpResource', { 40 28 external: '@angular/common/http', 41 - meta: { 42 - category: 'external', 43 - resource: '@angular/common/http.httpResource', 44 - }, 45 29 }); 46 30 47 31 const httpRequestStructure = new StructureModel();
-1
packages/openapi-ts/src/plugins/@hey-api/sdk/shared/typeOptions.ts
··· 19 19 external: clientModule, 20 20 kind: 'type', 21 21 meta: { 22 - category: 'external', 23 22 resource: 'client.Client', 24 23 tool: client.name, 25 24 },
-8
packages/openapi-ts/src/plugins/@hey-api/sdk/v1/plugin.ts
··· 19 19 plugin.symbol('formDataBodySerializer', { 20 20 external: clientModule, 21 21 meta: { 22 - category: 'external', 23 22 resource: 'client.formDataBodySerializer', 24 23 tool: client.name, 25 24 }, ··· 27 26 plugin.symbol('urlSearchParamsBodySerializer', { 28 27 external: clientModule, 29 28 meta: { 30 - category: 'external', 31 29 resource: 'client.urlSearchParamsBodySerializer', 32 30 tool: client.name, 33 31 }, ··· 35 33 plugin.symbol('buildClientParams', { 36 34 external: clientModule, 37 35 meta: { 38 - category: 'external', 39 36 resource: 'client.buildClientParams', 40 37 tool: client.name, 41 38 }, ··· 45 42 external: clientModule, 46 43 kind: 'type', 47 44 meta: { 48 - category: 'external', 49 45 resource: 'client.Composable', 50 46 tool: client.name, 51 47 }, ··· 54 50 if (isAngularClient) { 55 51 plugin.symbol('Injectable', { 56 52 external: '@angular/core', 57 - meta: { 58 - category: 'external', 59 - resource: '@angular/core.Injectable', 60 - }, 61 53 }); 62 54 } 63 55
-4
packages/openapi-ts/src/plugins/@pinia/colada/queryKey.ts
··· 47 47 const clientModule = clientFolderAbsolutePath(getTypedConfig(plugin)); 48 48 const symbolSerializeQueryValue = plugin.symbol('serializeQueryKeyValue', { 49 49 external: clientModule, 50 - meta: { 51 - category: 'external', 52 - resource: `${clientModule}.serializeQueryKeyValue`, 53 - }, 54 50 }); 55 51 56 52 const fn = $.const(symbolCreateQueryKey).assign(
-20
packages/openapi-ts/src/plugins/@pinia/colada/v0/plugin.ts
··· 5 5 export const handlerV0: PiniaColadaPlugin['Handler'] = ({ plugin }) => { 6 6 plugin.symbol('defineQueryOptions', { 7 7 external: plugin.name, 8 - meta: { 9 - category: 'external', 10 - resource: `${plugin.name}.defineQueryOptions`, 11 - }, 12 8 }); 13 9 plugin.symbol('UseMutationOptions', { 14 10 external: plugin.name, 15 11 kind: 'type', 16 - meta: { 17 - category: 'external', 18 - resource: `${plugin.name}.UseMutationOptions`, 19 - }, 20 12 }); 21 13 plugin.symbol('UseQueryOptions', { 22 14 external: plugin.name, 23 15 kind: 'type', 24 - meta: { 25 - category: 'external', 26 - resource: `${plugin.name}.UseQueryOptions`, 27 - }, 28 16 }); 29 17 plugin.symbol('_JSONValue', { 30 18 external: plugin.name, 31 19 kind: 'type', 32 - meta: { 33 - category: 'external', 34 - resource: `${plugin.name}._JSONValue`, 35 - }, 36 20 }); 37 21 plugin.symbol('AxiosError', { 38 22 external: 'axios', 39 23 kind: 'type', 40 - meta: { 41 - category: 'external', 42 - resource: 'axios.AxiosError', 43 - }, 44 24 }); 45 25 46 26 plugin.forEach(
-29
packages/openapi-ts/src/plugins/@tanstack/query-core/v5/plugin.ts
··· 9 9 plugin.symbol('DefaultError', { 10 10 external: plugin.name, 11 11 kind: 'type', 12 - meta: { 13 - category: 'external', 14 - resource: `${plugin.name}.DefaultError`, 15 - }, 16 12 }); 17 13 plugin.symbol('InfiniteData', { 18 14 external: plugin.name, 19 15 kind: 'type', 20 - meta: { 21 - category: 'external', 22 - resource: `${plugin.name}.InfiniteData`, 23 - }, 24 16 }); 25 17 const mutationsType = 26 18 plugin.name === '@tanstack/angular-query-experimental' || ··· 32 24 external: plugin.name, 33 25 kind: 'type', 34 26 meta: { 35 - category: 'external', 36 27 resource: `${plugin.name}.MutationOptions`, 37 28 }, 38 29 }); 39 30 plugin.symbol('infiniteQueryOptions', { 40 31 external: plugin.name, 41 - meta: { 42 - category: 'external', 43 - resource: `${plugin.name}.infiniteQueryOptions`, 44 - }, 45 32 }); 46 33 plugin.symbol('queryOptions', { 47 34 external: plugin.name, 48 - meta: { 49 - category: 'external', 50 - resource: `${plugin.name}.queryOptions`, 51 - }, 52 35 }); 53 36 plugin.symbol('useMutation', { 54 37 external: plugin.name, 55 - meta: { 56 - category: 'external', 57 - resource: `${plugin.name}.useMutation`, 58 - }, 59 38 }); 60 39 plugin.symbol('useQuery', { 61 40 external: plugin.name, 62 - meta: { 63 - category: 'external', 64 - resource: `${plugin.name}.useQuery`, 65 - }, 66 41 }); 67 42 plugin.symbol('AxiosError', { 68 43 external: 'axios', 69 44 kind: 'type', 70 - meta: { 71 - category: 'external', 72 - resource: 'axios.AxiosError', 73 - }, 74 45 }); 75 46 76 47 plugin.forEach(
+1 -8
packages/openapi-ts/src/plugins/arktype/v2/plugin.ts
··· 38 38 39 39 let ast: Partial<Ast> = {}; 40 40 41 - // const z = plugin.referenceSymbol({ 42 - // category: 'external', 43 - // resource: 'arktype.type', 44 - // }); 41 + // const type = plugin.external('arktype.type'); 45 42 46 43 if (schema.$ref) { 47 44 const query: SymbolMeta = { ··· 285 282 export const handlerV2: ArktypePlugin['Handler'] = ({ plugin }) => { 286 283 plugin.symbol('type', { 287 284 external: 'arktype', 288 - meta: { 289 - category: 'external', 290 - resource: 'arktype.type', 291 - }, 292 285 }); 293 286 294 287 plugin.forEach('operation', 'parameter', 'requestBody', 'schema', 'webhook', (event) => {
-4
packages/openapi-ts/src/plugins/orpc/v1/plugin.ts
··· 8 8 export const handlerV1: OrpcPlugin['Handler'] = ({ plugin }) => { 9 9 plugin.symbol('oc', { 10 10 external: '@orpc/contract', 11 - meta: { 12 - category: 'external', 13 - resource: '@orpc/contract.oc', 14 - }, 15 11 }); 16 12 17 13 const structure = new StructureModel();
-4
packages/openapi-ts/src/plugins/swr/v2/plugin.ts
··· 6 6 external: 'swr', 7 7 importKind: 'default', 8 8 kind: 'function', 9 - meta: { 10 - category: 'external', 11 - resource: 'swr', 12 - }, 13 9 }); 14 10 15 11 plugin.forEach(
+1 -1
packages/openapi-ts/src/plugins/swr/v2/useSwr.ts
··· 17 17 return; 18 18 } 19 19 20 - const symbolUseSwr = plugin.external('swr'); 20 + const symbolUseSwr = plugin.external('swr.useSWR'); 21 21 const symbolUseQueryFn = plugin.symbol(applyNaming(operation.id, plugin.config.useSwr)); 22 22 23 23 const awaitSdkFn = $.lazy((ctx) =>
-4
packages/openapi-ts/src/plugins/valibot/v1/plugin.ts
··· 9 9 plugin.symbol('v', { 10 10 external: 'valibot', 11 11 importKind: 'namespace', 12 - meta: { 13 - category: 'external', 14 - resource: 'valibot.v', 15 - }, 16 12 }); 17 13 18 14 const processor = createProcessor(plugin);
-1
packages/openapi-ts/src/plugins/zod/mini/plugin.ts
··· 11 11 external: getZodModule({ plugin }), 12 12 importKind: 'namespace', 13 13 meta: { 14 - category: 'external', 15 14 resource: 'zod.z', 16 15 }, 17 16 });
-1
packages/openapi-ts/src/plugins/zod/v3/plugin.ts
··· 10 10 plugin.symbol('z', { 11 11 external: getZodModule({ plugin }), 12 12 meta: { 13 - category: 'external', 14 13 resource: 'zod.z', 15 14 }, 16 15 });
-1
packages/openapi-ts/src/plugins/zod/v4/plugin.ts
··· 11 11 external: getZodModule({ plugin }), 12 12 importKind: 'namespace', 13 13 meta: { 14 - category: 'external', 15 14 resource: 'zod.z', 16 15 }, 17 16 });
+358
packages/shared/src/plugins/shared/utils/__tests__/instance.test.ts
··· 1 + import { Logger } from '@hey-api/codegen-core'; 2 + 3 + import type { Context } from '../../../../ir/context'; 4 + import { PluginInstance } from '../instance'; 5 + 6 + const createMockContext = (): Context => 7 + ({ 8 + config: { 9 + input: [], 10 + logs: {}, 11 + output: { case: undefined, entryFile: 'index.ts', path: '' }, 12 + parser: { hooks: {} }, 13 + pluginOrder: [], 14 + plugins: {}, 15 + }, 16 + dependencies: {}, 17 + intents: [], 18 + logger: new Logger(), 19 + package: { 20 + dependencies: {}, 21 + name: 'test-plugin', 22 + version: '1.0.0', 23 + }, 24 + plugins: {}, 25 + }) as unknown as Context; 26 + 27 + const createMockGen = () => { 28 + let id = 1; 29 + const symbols = { 30 + get: vi.fn(), 31 + isRegistered: vi.fn(), 32 + query: vi.fn((): any[] => []), 33 + reference: vi.fn((meta) => ({ ...meta, id: 1 })), 34 + register: vi.fn((symbol) => ({ ...symbol, id: id++ })), 35 + registered: vi.fn(() => [].values()), 36 + }; 37 + return { 38 + defaultFileName: 'main', 39 + defaultNameConflictResolver: vi.fn(), 40 + extensions: {}, 41 + files: vi.fn(), 42 + moduleEntryNames: {}, 43 + nameConflictResolvers: {}, 44 + nodes: { 45 + add: vi.fn(), 46 + update: vi.fn(), 47 + }, 48 + plan: vi.fn(), 49 + render: vi.fn(), 50 + symbols, 51 + }; 52 + }; 53 + 54 + describe('PluginInstance.symbol', () => { 55 + it('registers a basic symbol with name and meta', () => { 56 + const gen = createMockGen(); 57 + const context = createMockContext(); 58 + const instance = new PluginInstance({ 59 + config: { exportFromIndex: false }, 60 + context, 61 + dependencies: [], 62 + gen: gen as any, 63 + handler: vi.fn(), 64 + name: '@hey-api/test', 65 + }); 66 + 67 + const result = instance.symbol('Foo', { meta: { category: 'type' } }); 68 + 69 + expect(gen.symbols.register).toHaveBeenCalledWith( 70 + expect.objectContaining({ 71 + meta: expect.objectContaining({ category: 'type', pluginName: '@hey-api/test' }), 72 + name: 'Foo', 73 + }), 74 + ); 75 + expect(result).toBeDefined(); 76 + expect(result.name).toBe('Foo'); 77 + }); 78 + 79 + it('sets default pluginName to "custom" for absolute paths', () => { 80 + const gen = createMockGen(); 81 + const context = createMockContext(); 82 + const instance = new PluginInstance({ 83 + config: {}, 84 + context, 85 + dependencies: [], 86 + gen: gen as any, 87 + handler: vi.fn(), 88 + name: '/absolute/path/plugin', 89 + }); 90 + 91 + instance.symbol('Bar'); 92 + 93 + expect(gen.symbols.register).toHaveBeenCalledWith( 94 + expect.objectContaining({ 95 + meta: expect.objectContaining({ pluginName: 'custom' }), 96 + }), 97 + ); 98 + }); 99 + 100 + it('sets default getExportFromFilePath when not provided', () => { 101 + const gen = createMockGen(); 102 + const context = createMockContext(); 103 + const instance = new PluginInstance({ 104 + config: {}, 105 + context, 106 + dependencies: [], 107 + gen: gen as any, 108 + handler: vi.fn(), 109 + name: '@hey-api/test', 110 + }); 111 + 112 + instance.symbol('Baz'); 113 + 114 + expect(gen.symbols.register).toHaveBeenCalledWith( 115 + expect.objectContaining({ 116 + getExportFromFilePath: expect.any(Function), 117 + }), 118 + ); 119 + }); 120 + 121 + it('sets default getFilePath when not provided', () => { 122 + const gen = createMockGen(); 123 + const context = createMockContext(); 124 + const instance = new PluginInstance({ 125 + config: {}, 126 + context, 127 + dependencies: [], 128 + gen: gen as any, 129 + handler: vi.fn(), 130 + name: '@hey-api/test', 131 + }); 132 + 133 + instance.symbol('Qux'); 134 + 135 + expect(gen.symbols.register).toHaveBeenCalledWith( 136 + expect.objectContaining({ 137 + getFilePath: expect.any(Function), 138 + }), 139 + ); 140 + }); 141 + 142 + it('uses provided getExportFromFilePath when provided', () => { 143 + const gen = createMockGen(); 144 + const context = createMockContext(); 145 + const customFn = vi.fn(() => ['exported']); 146 + const instance = new PluginInstance({ 147 + config: {}, 148 + context, 149 + dependencies: [], 150 + gen: gen as any, 151 + handler: vi.fn(), 152 + name: '@hey-api/test', 153 + }); 154 + 155 + instance.symbol('Test', { getExportFromFilePath: customFn }); 156 + 157 + expect(gen.symbols.register).toHaveBeenCalledWith( 158 + expect.objectContaining({ 159 + getExportFromFilePath: customFn, 160 + }), 161 + ); 162 + }); 163 + 164 + it('uses provided getFilePath when provided', () => { 165 + const gen = createMockGen(); 166 + const context = createMockContext(); 167 + const customFn = vi.fn(() => 'custom/path'); 168 + const instance = new PluginInstance({ 169 + config: {}, 170 + context, 171 + dependencies: [], 172 + gen: gen as any, 173 + handler: vi.fn(), 174 + name: '@hey-api/test', 175 + }); 176 + 177 + instance.symbol('Test', { getFilePath: customFn }); 178 + 179 + expect(gen.symbols.register).toHaveBeenCalledWith( 180 + expect.objectContaining({ 181 + getFilePath: customFn, 182 + }), 183 + ); 184 + }); 185 + 186 + it('deduplicates external symbols', () => { 187 + const existingSymbol = { 188 + id: 1, 189 + meta: { category: 'external', resource: 'lib' }, 190 + name: 'ExternalLib', 191 + } as any; 192 + const gen = createMockGen(); 193 + gen.symbols.query.mockReturnValueOnce([existingSymbol]); 194 + const context = createMockContext(); 195 + const instance = new PluginInstance({ 196 + config: {}, 197 + context, 198 + dependencies: [], 199 + gen: gen as any, 200 + handler: vi.fn(), 201 + name: '@hey-api/test', 202 + }); 203 + 204 + const result = instance.symbol('ExternalLib', { external: 'lib' }); 205 + 206 + expect(gen.symbols.query).toHaveBeenCalledWith({ 207 + category: 'external', 208 + resource: 'lib.ExternalLib', 209 + }); 210 + expect(gen.symbols.register).not.toHaveBeenCalled(); 211 + expect(result).toBe(existingSymbol); 212 + }); 213 + 214 + it('registers new symbol when external symbol not found', () => { 215 + const gen = createMockGen(); 216 + gen.symbols.query.mockReturnValueOnce([]); 217 + const context = createMockContext(); 218 + const instance = new PluginInstance({ 219 + config: {}, 220 + context, 221 + dependencies: [], 222 + gen: gen as any, 223 + handler: vi.fn(), 224 + name: '@hey-api/test', 225 + }); 226 + 227 + instance.symbol('NewExternal', { external: 'lib' }); 228 + 229 + expect(gen.symbols.register).toHaveBeenCalled(); 230 + }); 231 + 232 + it('executes symbol:register:before hook', () => { 233 + const beforeHook = vi.fn(); 234 + const gen = createMockGen(); 235 + const context = createMockContext(); 236 + const instance = new PluginInstance({ 237 + config: { '~hooks': { events: { 'symbol:register:before': beforeHook } } }, 238 + context, 239 + dependencies: [], 240 + gen: gen as any, 241 + handler: vi.fn(), 242 + name: '@hey-api/test', 243 + }); 244 + 245 + instance.symbol('HookTest'); 246 + 247 + expect(beforeHook).toHaveBeenCalledWith({ 248 + plugin: instance, 249 + symbol: expect.objectContaining({ name: 'HookTest' }), 250 + }); 251 + }); 252 + 253 + it('executes symbol:register:after hook', () => { 254 + const afterHook = vi.fn(); 255 + const gen = createMockGen(); 256 + const context = createMockContext(); 257 + const instance = new PluginInstance({ 258 + config: { '~hooks': { events: { 'symbol:register:after': afterHook } } }, 259 + context, 260 + dependencies: [], 261 + gen: gen as any, 262 + handler: vi.fn(), 263 + name: '@hey-api/test', 264 + }); 265 + 266 + instance.symbol('HookTestAfter'); 267 + 268 + expect(afterHook).toHaveBeenCalledWith({ 269 + plugin: instance, 270 + symbol: expect.objectContaining({ name: 'HookTestAfter' }), 271 + }); 272 + }); 273 + }); 274 + 275 + describe('PluginInstance.symbolOnce', () => { 276 + it('returns existing symbol if found by name and meta', () => { 277 + const existingSymbol = { 278 + id: 1, 279 + meta: { category: 'type', pluginName: '@hey-api/test' }, 280 + name: 'ExistingSymbol', 281 + } as any; 282 + const gen = createMockGen(); 283 + gen.symbols.query.mockReturnValueOnce([existingSymbol]); 284 + const context = createMockContext(); 285 + const instance = new PluginInstance({ 286 + config: {}, 287 + context, 288 + dependencies: [], 289 + gen: gen as any, 290 + handler: vi.fn(), 291 + name: '@hey-api/test', 292 + }); 293 + 294 + const result = instance.symbolOnce('ExistingSymbol', { meta: { category: 'type' } }); 295 + 296 + expect(gen.symbols.query).toHaveBeenCalledWith({ category: 'type' }); 297 + expect(gen.symbols.register).not.toHaveBeenCalled(); 298 + expect(result).toBe(existingSymbol); 299 + }); 300 + 301 + it('registers new symbol when not found', () => { 302 + const gen = createMockGen(); 303 + gen.symbols.query.mockReturnValueOnce([]); 304 + const context = createMockContext(); 305 + const instance = new PluginInstance({ 306 + config: {}, 307 + context, 308 + dependencies: [], 309 + gen: gen as any, 310 + handler: vi.fn(), 311 + name: '@hey-api/test', 312 + }); 313 + 314 + instance.symbolOnce('NewSymbol', { meta: { category: 'type' } }); 315 + 316 + expect(gen.symbols.register).toHaveBeenCalled(); 317 + }); 318 + 319 + it('delegates to symbol() for external symbols', () => { 320 + const gen = createMockGen(); 321 + const context = createMockContext(); 322 + const instance = new PluginInstance({ 323 + config: {}, 324 + context, 325 + dependencies: [], 326 + gen: gen as any, 327 + handler: vi.fn(), 328 + name: '@hey-api/test', 329 + }); 330 + 331 + instance.symbolOnce('ExternalSym', { external: 'lib' }); 332 + 333 + expect(gen.symbols.query).toHaveBeenCalledWith({ 334 + category: 'external', 335 + resource: 'lib.ExternalSym', 336 + }); 337 + }); 338 + 339 + it('does not deduplicate when meta is not provided', () => { 340 + const gen = createMockGen(); 341 + gen.symbols.query.mockReturnValue([]); 342 + const context = createMockContext(); 343 + const instance = new PluginInstance({ 344 + config: {}, 345 + context, 346 + dependencies: [], 347 + gen: gen as any, 348 + handler: vi.fn(), 349 + name: '@hey-api/test', 350 + }); 351 + 352 + const result1 = instance.symbolOnce('RepeatableSymbol'); 353 + const result2 = instance.symbolOnce('RepeatableSymbol'); 354 + 355 + expect(result1).not.toBe(result2); 356 + expect(gen.symbols.register).toHaveBeenCalledTimes(2); 357 + }); 358 + });
+19 -14
packages/shared/src/plugins/shared/utils/instance.ts
··· 117 117 118 118 external( 119 119 resource: Required<SymbolMeta>['resource'], 120 - meta?: Omit<SymbolMeta, 'category' | 'resource'>, 120 + meta: Omit<SymbolMeta, 'category' | 'resource'> = {}, 121 121 ): Symbol { 122 122 return this.gen.symbols.reference({ 123 123 ...meta, ··· 372 372 } 373 373 } 374 374 375 - symbol(name: SymbolIn['name'], symbol?: Omit<SymbolIn, 'name'>): Symbol<ResolvedNode> { 375 + symbol(name: SymbolIn['name'], symbol: Omit<SymbolIn, 'name'> = {}): Symbol<ResolvedNode> { 376 + const meta = { ...symbol.meta }; 377 + if (symbol.external) { 378 + if (!meta.category) meta.category = 'external'; 379 + if (!meta.resource) meta.resource = `${symbol.external}.${name}`; 380 + const existing = this.gen.symbols.query(meta).find((s) => s.name === name); 381 + if (existing) return existing; 382 + } 376 383 const symbolIn: SymbolIn = { 377 384 ...symbol, 378 385 meta: { 379 386 pluginName: path.isAbsolute(this.name) ? 'custom' : this.name, 380 - ...symbol?.meta, 387 + ...meta, 381 388 }, 382 389 name, 383 390 }; ··· 399 406 400 407 /** 401 408 * Registers a symbol only if it does not already exist based on the provided 402 - * metadata. This prevents duplicate symbols from being created in the project. 409 + * name and metadata. This prevents duplicate symbols from being created in 410 + * the project. 403 411 */ 404 - symbolOnce(name: SymbolIn['name'], symbol?: Omit<SymbolIn, 'name'>): Symbol { 405 - const meta = { 406 - ...symbol?.meta, 407 - }; 408 - if (symbol?.external) { 409 - meta.category = 'external'; 410 - meta.resource = symbol.external; 412 + symbolOnce(name: SymbolIn['name'], symbol: Omit<SymbolIn, 'name'> = {}): Symbol { 413 + // `.symbol()` will handle the external symbol deduplication 414 + if (symbol.external) return this.symbol(name, symbol); 415 + if (symbol.meta) { 416 + const existing = this.gen.symbols.query(symbol.meta).find((s) => s.name === name); 417 + if (existing) return existing; 411 418 } 412 - const existing = this.querySymbol(meta); 413 - if (existing) return existing; 414 - return this.symbol(name, { ...symbol, meta }); 419 + return this.symbol(name, symbol); 415 420 } 416 421 417 422 private buildEventHooks(): EventHooks {