From 6e7770d7cd5d3e8f45befb3eda5d5191eda2d05b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:10:10 +0000 Subject: [PATCH 1/4] Initial plan -- 2.51.2 From 59efc7b34359f7a80beb955de66ce7e0ea2013b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:24:06 +0000 Subject: [PATCH 2/4] Fix duplicate import error when same symbol is imported as both type and value Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com> --- packages/codegen-core/src/planner/planner.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/codegen-core/src/planner/planner.ts b/packages/codegen-core/src/planner/planner.ts index b7874e4d8..e62f7c9a8 100644 --- a/packages/codegen-core/src/planner/planner.ts +++ b/packages/codegen-core/src/planner/planner.ts @@ -266,9 +266,11 @@ export class Planner { const fromFileId = dep.file.id; const importedName = dep.finalName; - const isTypeOnly = isTypeOnlyKind(dep.kind); const kind = dep.importKind; - const key = `${fromFileId}|${importedName}|${kind}|${isTypeOnly}`; + // Exclude isTypeOnly from the key so that type and value imports with the same name + // are merged into a single entry. This prevents duplicate imports when the same symbol + // is imported both as a type and as a value. + const key = `${fromFileId}|${importedName}|${kind}`; let entry = fileMap.get(key); if (!entry) { @@ -292,8 +294,9 @@ export class Planner { symbol: imp, }; fileMap.set(key, entry); - entry.kinds.add(imp.kind); } + // Always add the current kind to track all usages (type vs value) + entry.kinds.add(dep.kind); dependency['~ref'] = entry.symbol; }); -- 2.51.2 From 3f23cefb37a1d3506ce45936078bfccec4918979 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:02:42 +0000 Subject: [PATCH 3/4] Remove unnecessary comments and add planner unit tests for import deduplication Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com> --- .../src/__tests__/planner.test.ts | 275 ++++++++++++++++++ packages/codegen-core/src/planner/planner.ts | 4 - 2 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 packages/codegen-core/src/__tests__/planner.test.ts diff --git a/packages/codegen-core/src/__tests__/planner.test.ts b/packages/codegen-core/src/__tests__/planner.test.ts new file mode 100644 index 000000000..bde303b94 --- /dev/null +++ b/packages/codegen-core/src/__tests__/planner.test.ts @@ -0,0 +1,275 @@ +import type { INode } from '../nodes/node'; +import { Project } from '../project/project'; +import { ref } from '../refs/refs'; +import type { Symbol } from '../symbols/symbol'; +import type { SymbolKind } from '../symbols/types'; + +/** + * Creates a mock node for testing. + */ +const createMockNode = (args: { + dependencies?: Array; + filePath: string; + language?: 'typescript' | 'javascript'; + name: string; + project: Project; + symbolKind?: SymbolKind; +}): { node: INode; symbol: Symbol } => { + const { + dependencies = [], + filePath, + language = 'typescript', + name, + project, + symbolKind = 'var', + } = args; + + const symbol = project.symbols.register({ + exported: true, + getFilePath: () => filePath, + kind: symbolKind, + name, + }); + + const node: INode = { + analyze: (ctx) => { + for (const dep of dependencies) { + ctx.addDependency(ref(dep)); + } + }, + clone () { + return this; + }, + exported: true, + language, + name: ref(name) as INode['name'], + scope: 'value', + symbol, + toAst: () => ({}), + '~brand': 'test-node', + }; + + symbol.setNode(node); + project.nodes.add(node); + + return { node, symbol }; +}; + +describe('Planner imports deduplication', () => { + it('produces a single value import when there is 1 imported symbol as value', () => { + const project = new Project({ root: '/root' }); + + // Create source file with exported symbol + const { symbol: sourceSymbol } = createMockNode({ + filePath: 'source', + name: 'MyValue', + project, + symbolKind: 'var', + }); + + // Create consumer file that imports the source symbol as value + createMockNode({ + dependencies: [sourceSymbol], + filePath: 'consumer', + name: 'Consumer', + project, + }); + + project.plan(); + + const consumerFile = [...project.files.registered()].find((f) => f.name === 'consumer'); + expect(consumerFile).toBeDefined(); + + const imports = consumerFile!.imports; + expect(imports).toHaveLength(1); + expect(imports[0]!.imports).toHaveLength(1); + expect(imports[0]!.imports[0]!.isTypeOnly).toBe(false); + expect(imports[0]!.imports[0]!.localName).toBe('MyValue'); + }); + + it('produces a single type import when there is 1 imported symbol as type', () => { + const project = new Project({ root: '/root' }); + + // Create source file with exported type symbol + const { symbol: sourceSymbol } = createMockNode({ + filePath: 'source', + name: 'MyType', + project, + symbolKind: 'type', + }); + + // Create consumer file that imports the source symbol as type + createMockNode({ + dependencies: [sourceSymbol], + filePath: 'consumer', + name: 'Consumer', + project, + }); + + project.plan(); + + const consumerFile = [...project.files.registered()].find((f) => f.name === 'consumer'); + expect(consumerFile).toBeDefined(); + + const imports = consumerFile!.imports; + expect(imports).toHaveLength(1); + expect(imports[0]!.imports).toHaveLength(1); + expect(imports[0]!.imports[0]!.isTypeOnly).toBe(true); + expect(imports[0]!.imports[0]!.localName).toBe('MyType'); + }); + + it('produces a single value import when there are 2 imported symbols as values with same name', () => { + const project = new Project({ root: '/root' }); + + // Create source file with exported symbol + const { symbol: sourceSymbol } = createMockNode({ + filePath: 'source', + name: 'MyValue', + project, + symbolKind: 'var', + }); + + // Create consumer file that imports the source symbol twice as value + const consumerSymbol = project.symbols.register({ + exported: true, + getFilePath: () => 'consumer', + kind: 'var', + name: 'Consumer', + }); + const consumerNode: INode = { + analyze: (ctx) => { + ctx.addDependency(ref(sourceSymbol)); + ctx.addDependency(ref(sourceSymbol)); + }, + clone () { + return this; + }, + exported: true, + language: 'typescript', + name: ref('Consumer') as INode['name'], + scope: 'value', + symbol: consumerSymbol, + toAst: () => ({}), + '~brand': 'test-node', + }; + consumerSymbol.setNode(consumerNode); + project.nodes.add(consumerNode); + + project.plan(); + + const consumerFile = [...project.files.registered()].find((f) => f.name === 'consumer'); + expect(consumerFile).toBeDefined(); + + const imports = consumerFile!.imports; + expect(imports).toHaveLength(1); + expect(imports[0]!.imports).toHaveLength(1); + expect(imports[0]!.imports[0]!.isTypeOnly).toBe(false); + expect(imports[0]!.imports[0]!.localName).toBe('MyValue'); + }); + + it('produces a single type import when there are 2 imported symbols as types with same name', () => { + const project = new Project({ root: '/root' }); + + // Create source file with exported type symbol + const { symbol: sourceSymbol } = createMockNode({ + filePath: 'source', + name: 'MyType', + project, + symbolKind: 'type', + }); + + // Create consumer file that imports the source symbol twice as type + const consumerSymbol = project.symbols.register({ + exported: true, + getFilePath: () => 'consumer', + kind: 'var', + name: 'Consumer', + }); + const consumerNode: INode = { + analyze: (ctx) => { + ctx.addDependency(ref(sourceSymbol)); + ctx.addDependency(ref(sourceSymbol)); + }, + clone () { + return this; + }, + exported: true, + language: 'typescript', + name: ref('Consumer') as INode['name'], + scope: 'value', + symbol: consumerSymbol, + toAst: () => ({}), + '~brand': 'test-node', + }; + consumerSymbol.setNode(consumerNode); + project.nodes.add(consumerNode); + + project.plan(); + + const consumerFile = [...project.files.registered()].find((f) => f.name === 'consumer'); + expect(consumerFile).toBeDefined(); + + const imports = consumerFile!.imports; + expect(imports).toHaveLength(1); + expect(imports[0]!.imports).toHaveLength(1); + expect(imports[0]!.imports[0]!.isTypeOnly).toBe(true); + expect(imports[0]!.imports[0]!.localName).toBe('MyType'); + }); + + it('produces a single value import when there are 2 imported symbols, 1 as type and 1 as value, with same name', () => { + const project = new Project({ root: '/root' }); + + // Create source file with exported type symbol + const { symbol: typeSymbol } = createMockNode({ + filePath: 'source', + name: 'MySymbol', + project, + symbolKind: 'type', + }); + + // Create source file with exported value symbol with same name + const { symbol: valueSymbol } = createMockNode({ + filePath: 'source', + name: 'MySymbol', + project, + symbolKind: 'var', + }); + + // Create consumer file that imports both symbols (type and value with same name) + const consumerSymbol = project.symbols.register({ + exported: true, + getFilePath: () => 'consumer', + kind: 'var', + name: 'Consumer', + }); + const consumerNode: INode = { + analyze: (ctx) => { + ctx.addDependency(ref(typeSymbol)); + ctx.addDependency(ref(valueSymbol)); + }, + clone () { + return this; + }, + exported: true, + language: 'typescript', + name: ref('Consumer') as INode['name'], + scope: 'value', + symbol: consumerSymbol, + toAst: () => ({}), + '~brand': 'test-node', + }; + consumerSymbol.setNode(consumerNode); + project.nodes.add(consumerNode); + + project.plan(); + + const consumerFile = [...project.files.registered()].find((f) => f.name === 'consumer'); + expect(consumerFile).toBeDefined(); + + const imports = consumerFile!.imports; + expect(imports).toHaveLength(1); + expect(imports[0]!.imports).toHaveLength(1); + expect(imports[0]!.imports[0]!.isTypeOnly).toBe(false); + expect(imports[0]!.imports[0]!.localName).toBe('MySymbol'); + }); +}); diff --git a/packages/codegen-core/src/planner/planner.ts b/packages/codegen-core/src/planner/planner.ts index e62f7c9a8..b0a511268 100644 --- a/packages/codegen-core/src/planner/planner.ts +++ b/packages/codegen-core/src/planner/planner.ts @@ -267,9 +267,6 @@ export class Planner { const fromFileId = dep.file.id; const importedName = dep.finalName; const kind = dep.importKind; - // Exclude isTypeOnly from the key so that type and value imports with the same name - // are merged into a single entry. This prevents duplicate imports when the same symbol - // is imported both as a type and as a value. const key = `${fromFileId}|${importedName}|${kind}`; let entry = fileMap.get(key); @@ -295,7 +292,6 @@ export class Planner { }; fileMap.set(key, entry); } - // Always add the current kind to track all usages (type vs value) entry.kinds.add(dep.kind); dependency['~ref'] = entry.symbol; -- 2.51.2 From 3c08e38b4daa5c1f4829bdfb7f088d73b0e0f1f1 Mon Sep 17 00:00:00 2001 From: Lubos Date: Fri, 30 Jan 2026 22:08:54 +0800 Subject: [PATCH 4/4] Fix duplicate import error for symbols in planner --- .changeset/selfish-seas-add.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/selfish-seas-add.md diff --git a/.changeset/selfish-seas-add.md b/.changeset/selfish-seas-add.md new file mode 100644 index 000000000..179059c83 --- /dev/null +++ b/.changeset/selfish-seas-add.md @@ -0,0 +1,5 @@ +--- +"@hey-api/codegen-core": patch +--- + +**planner**: fix duplicate import when same symbol is imported as both type and value -- 2.51.2