Repository: alloy-framework/alloy
Pull request #137 (2025-04-30 20:58:39)

Fix symbol conflicts
Was not queuing the job for default symbol conflict resolution, so it was only seeing the first conflict.

github-actions[bot]: <!--chronus-github-change-commenter-->
All changed packages have been documented.
 - :white_check_mark: `@alloy-js/core`
 - :white_check_mark: `@alloy-js/typescript`

<details>
<summary>Show changes</summary>

### `@alloy-js/core` - _fix_ [✏️](https://github.com/bterlson/alloy/edit/fix-symbol-conflicts/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-31.md?pr=/alloy-framework/alloy/pull/137)
> Fix default symbol conflict resolver not working with more than 2 symbols.
### `@alloy-js/typescript` - _internal_ [✏️](https://github.com/bterlson/alloy/edit/fix-symbol-conflicts/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-40.md?pr=/alloy-framework/alloy/pull/137)
> Add tests
</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/d30636cf-bd2c-468d-8103-212017ec518c)


<details><summary><b>@alloy-js/babel-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-plugin-jsx-dom-expressions</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-jsx-dom-expressions@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/cli</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/cli@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-preset</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-preset@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/core</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/core@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/create</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/create@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/csharp</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/csharp@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/java</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/java@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/json</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/json@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/prettier-plugin-alloy</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/prettier-plugin-alloy@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/rollup-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/rollup-plugin@137
```

</p></details>
      
    

<details><summary><b>@alloy-js/typescript</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/typescript@137
```

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/41452017672"><code>39e60dc</code></a>_


Patch:
diff --git a/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-31.md b/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-31.md
new file mode 100644
index 0000000..09717ab
--- /dev/null
+++ b/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-31.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+  - "@alloy-js/core"
+---
+
+Fix default symbol conflict resolver not working with more than 2 symbols.
\ No newline at end of file
diff --git a/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-40.md b/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-40.md
new file mode 100644
index 0000000..c8602cc
--- /dev/null
+++ b/.chronus/changes/fix-symbol-conflicts-2025-3-30-13-57-40.md
@@ -0,0 +1,7 @@
+---
+changeKind: internal
+packages:
+  - "@alloy-js/typescript"
+---
+
+Add tests
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index e62e988..0c25f8a 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -848,26 +848,19 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       return;
     }
 
-    if (options.nameConflictResolver) {
-      queueJob(
-        deconflictJobForScopeAndName(
-          scope,
-          symbol.name,
-          options.nameConflictResolver,
-        ),
-      );
-    } else {
-      // default disambiguation is first-wins
-      for (let i = 1; i < existingNames.length; i++) {
-        existingNames[i].name = existingNames[i].originalName + "_" + (i + 1);
-      }
-    }
+    queueJob(
+      deconflictJobForScopeAndName(
+        scope,
+        symbol.name,
+        options.nameConflictResolver,
+      ),
+    );
   }
 
   function deconflictJobForScopeAndName(
     scope: OutputScope,
     name: string,
-    handler: NameConflictResolver,
+    handler: NameConflictResolver | undefined,
   ) {
     if (!deconflictJobs.has(scope)) {
       deconflictJobs.set(scope, new Map());
@@ -881,7 +874,11 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       const conflictedSymbols = [...scope.symbols].filter(
         (sym) => sym.name === name,
       );
-      handler(name, conflictedSymbols);
+      if (handler) {
+        handler(name, conflictedSymbols);
+      } else {
+        defaultConflictHandler(name, conflictedSymbols);
+      }
       jobs.delete(name);
     };
 
@@ -889,6 +886,20 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     return job;
   }
 
+  /**
+   * Default conflict handler. This will rename all but the first symbol
+   * to have a suffix of _2, _3, etc.
+   */
+  function defaultConflictHandler(
+    _: string,
+    conflictedSymbols: OutputSymbol[],
+  ) {
+    for (let i = 1; i < conflictedSymbols.length; i++) {
+      conflictedSymbols[i].name =
+        conflictedSymbols[i].originalName + "_" + (i + 1);
+    }
+  }
+
   function getSymbolForRefkey<TSymbol extends OutputSymbol>(
     refkey: Refkey,
   ): Ref<TSymbol | undefined> {


Test patch:
diff --git a/packages/core/test/symbols.test.ts b/packages/core/test/symbols.test.ts
index dbfd4f4..e80934a 100644
--- a/packages/core/test/symbols.test.ts
+++ b/packages/core/test/symbols.test.ts
@@ -49,7 +49,16 @@ it("resolves symbol conflicts", () => {
     scope,
   });
 
+  const s3 = binder.createSymbol({
+    name: "sym",
+    scope,
+  });
+
+  flushJobs();
+
+  expect(_s1.name).toEqual("sym");
   expect(s2.name).toEqual("sym_2");
+  expect(s3.name).toEqual("sym_3");
 });
 
 type ScopeRecords = Record<string, ScopeDescriptor>;
diff --git a/packages/typescript/test/class.test.tsx b/packages/typescript/test/class.test.tsx
index af07c6e..0776d67 100644
--- a/packages/typescript/test/class.test.tsx
+++ b/packages/typescript/test/class.test.tsx
@@ -1,4 +1,4 @@
-import { List, refkey, StatementList } from "@alloy-js/core";
+import { List, Output, refkey, render, StatementList } from "@alloy-js/core";
 import "@alloy-js/core/testing";
 import { d } from "@alloy-js/core/testing";
 import { describe, expect, it } from "vitest";
@@ -162,6 +162,76 @@ describe("instance members", () => {
       }
     `);
   });
+
+  it("doesn't conflict with variables outside the class", () => {
+    const rk = refkey();
+    const res = toSourceText(
+      <List>
+        <ts.VarDeclaration name="one">1;</ts.VarDeclaration>
+        <ts.VarDeclaration name="two">2;</ts.VarDeclaration>
+        <ts.ClassDeclaration name="Foo">
+          <StatementList>
+            <ts.ClassField name="one" refkey={rk}>
+              1
+            </ts.ClassField>
+            <ts.ClassMethod name="two">{rk} + 1</ts.ClassMethod>
+          </StatementList>
+        </ts.ClassDeclaration>
+      </List>,
+    );
+    expect(res).toEqual(d`
+      const one = 1;
+      const two = 2;
+      class Foo {
+        one = 1;
+        two() {
+          this.one + 1
+        };
+      }
+    `);
+  });
+
+  it("doesn't conflict with imports from other files", () => {
+    const rk1 = refkey();
+    const rk2 = refkey();
+    const tree = render(
+      <Output
+        nameConflictResolver={() => {
+          console.log("CONFLIT");
+        }}
+      >
+        <ts.SourceFile path="decl.ts">
+          <StatementList>
+            <ts.VarDeclaration export name="one" refkey={rk1}>
+              1
+            </ts.VarDeclaration>
+            <ts.VarDeclaration export name="two" refkey={rk2}>
+              2
+            </ts.VarDeclaration>
+          </StatementList>
+        </ts.SourceFile>
+        <ts.SourceFile path="ref.ts">
+          <ts.ClassDeclaration name="Foo">
+            <StatementList>
+              <ts.ClassField name="one">{rk1}</ts.ClassField>
+              <ts.ClassMethod name="two">{rk2}</ts.ClassMethod>
+            </StatementList>
+          </ts.ClassDeclaration>
+        </ts.SourceFile>
+      </Output>,
+    );
+
+    expect(tree.contents[1].contents).toEqual(d`
+      import { one, two } from "./decl.js";
+
+      class Foo {
+        one = one;
+        two() {
+          two
+        };
+      }
+    `);
+  });
 });
 
 describe("static members", () => {
diff --git a/packages/typescript/test/imports.test.tsx b/packages/typescript/test/imports.test.tsx
index 804d96f..03568da 100644
--- a/packages/typescript/test/imports.test.tsx
+++ b/packages/typescript/test/imports.test.tsx
@@ -173,6 +173,56 @@ it("works with default and named imports and name conflicts", () => {
   });
 });
 
+it("works with importing the same name many times from different files with the default name conflict resolver", () => {
+  const rk1 = refkey();
+  const rk1i = refkey();
+  const rk2 = refkey();
+  const rk2i = refkey();
+  const rk3 = refkey();
+  const rk3i = refkey();
+
+  const res = render(
+    <Output>
+      <ts.SourceFile path="test1.ts">
+        <ts.VarDeclaration export name="conflict" refkey={rk1}>
+          "hi"
+        </ts.VarDeclaration>
+        <ts.InterfaceDeclaration export name="MyInterface" refkey={rk1i} />
+      </ts.SourceFile>
+      <ts.SourceFile path="test2.ts">
+        <ts.VarDeclaration name="conflict" refkey={rk2}>
+          "hi"
+        </ts.VarDeclaration>
+        <ts.InterfaceDeclaration export name="MyInterface" refkey={rk2i} />
+      </ts.SourceFile>
+      <ts.SourceFile path="test3.ts">
+        <ts.VarDeclaration name="conflict" refkey={rk3}>
+          "hi"
+        </ts.VarDeclaration>
+        <ts.InterfaceDeclaration export name="MyInterface" refkey={rk3i} />
+      </ts.SourceFile>
+      <ts.SourceFile path="test-import.ts">
+        <StatementList>
+          <ts.VarDeclaration name="one" type={rk1i} initializer={rk1} />
+          <ts.VarDeclaration name="two" type={rk2i} initializer={rk2} />
+          <ts.VarDeclaration name="three" type={rk3i} initializer={rk3} />
+        </StatementList>
+      </ts.SourceFile>
+    </Output>,
+  );
+
+  assertFileContents(res, {
+    "test-import.ts": `
+      import { type MyInterface, conflict } from "./test1.js";
+      import { type MyInterface as MyInterface_2, conflict as conflict_2 } from "./test2.js";
+      import { type MyInterface as MyInterface_3, conflict as conflict_3 } from "./test3.js";
+
+      const one: MyInterface = conflict;
+      const two: MyInterface_2 = conflict_2;
+      const three: MyInterface_3 = conflict_3;
+    `,
+  });
+});
 it("works with default and named imports and name conflicts and references in nested scopes", () => {
   const res = render(
     <Output nameConflictResolver={tsNameConflictResolver}>