Repository: alloy-framework/alloy
Pull request #114 (2025-04-16 19:30:20)

Expand `instantiateSymbolInto` to fully mirror instance & static members
**What’s changed**  
- **Dual‑scope support**: No longer restrict to instance‑only. Now handles both `InstanceMemberContainer` and `StaticMemberContainer` flags.  
- **Scope prep**: Always calls `addInstanceMembersToSymbol` & `addStaticMembersToSymbol` so both target scopes exist.  
- **Unified recursion**: Introduces a single `copyMembers` helper that:  
  - Computes and reuses composite `refkey` to prevent duplicates (idempotency).  
  - Marks each clone with `StaticMember` for correct dot‑notation resolution.  
  - Recurses into nested `staticMemberScope` for deep static hierarchies.  
- **Error removal**: Removed the old guard that threw unless source was an instance container.  

**Testing**  
- Added/updated tests to cover:  
  - Initial and late addition of instance members  
  - Initial and late addition of static members  
  - Recursive nesting of static children  
  - Combined instance+static containers  
  - Idempotency on repeated calls.

github-actions[bot]: <!--chronus-github-change-commenter-->
:x: There is undocummented changes. Run `chronus add` to add a changeset or [click here](https://github.com/joheredi/alloy/new/feature/nested-instantiate-symbol?filename=.chronus/changes/feature-nested-instantiate-symbol-2025-3-16-19-30-45.md&value=---%0A%23%20Change%20versionKind%20to%20one%20of%3A%20breaking%2C%20feature%2C%20fix%2C%20internal%0AchangeKind%3A%20fix%0Apackages%3A%0A%20%20-%20%22%40alloy-js%2Fcore%22%0A---%0A%0AExpand%20%60instantiateSymbolInto%60%20to%20fully%20mirror%20instance%C2%A0%26%C2%A0static%20members).

**The following packages have changes but are not documented.**
 - :x:`@alloy-js/core`

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


</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/fa8650f6-aac9-4154-bb85-a7c7c1ab0c93)


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

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

</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@114
```

</p></details>
      
    

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

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

</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@114
```

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</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@114
```

</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@114
```

</p></details>
      
    

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

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

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/40680365154"><code>36af476</code></a>_

bterlson: I believe `instantiateSymbolInto` is broken when source symbols are changed - it likely adds a whole new set of symbols. This should be confirmed with a test, and then fixed (I think by storing the added symbols and removing them in `onCleanup`).

Patch:
diff --git a/.chronus/changes/feature-nested-instantiate-symbol-2025-3-16-19-33-22.md b/.chronus/changes/feature-nested-instantiate-symbol-2025-3-16-19-33-22.md
new file mode 100644
index 0000000..89b71cd
--- /dev/null
+++ b/.chronus/changes/feature-nested-instantiate-symbol-2025-3-16-19-33-22.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+instantiateSymbolInto now mirrors both instance and static members (including nested static hierarchies), and no longer errors on static-only sources.
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index 220c8dd..e62e988 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -9,7 +9,7 @@ import {
 import { useBinder } from "./context/binder.js";
 import { useMemberScope } from "./context/member-scope.js";
 import { useScope } from "./context/scope.js";
-import { effect, memo, untrack } from "./jsx-runtime.js";
+import { effect, memo, onCleanup, untrack } from "./jsx-runtime.js";
 import { refkey, Refkey } from "./refkey.js";
 import { queueJob, QueueJob } from "./scheduler.js";
 export type Metadata = object;
@@ -623,33 +623,77 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
   }
 
   function instantiateSymbolInto(source: OutputSymbol, target: OutputSymbol) {
-    if (~source.flags & OutputSymbolFlags.InstanceMemberContainer) {
-      throw new Error("Can only instantiate symbols with instance members");
+    if (target.staticMemberScope) {
+      return;
     }
 
-    addInstanceMembersToSymbol(target);
+    // Ensure static member scope exists
+    addStaticMembersToSymbol(target);
 
     effect(() => {
-      // rerun when instance member scope changes
-      for (const sym of source.instanceMemberScope!.symbols) {
-        // but don't want to track anything else
-        untrack(() => {
-          // we don't want to trigger this effect when the symbols
-          // we're instantiating into has changed
+      // copy instance members if it's an instance‐container
+      if (source.flags & OutputSymbolFlags.InstanceMemberContainer) {
+        copyMembers(
+          source.instanceMemberScope!.symbols,
+          target,
+          target.staticMemberScope!,
+        );
+      }
 
-          if (target.instanceMemberScope!.symbols.has(sym)) {
-            return;
-          }
+      // copy static members if it's a static‐container
+      if (source.flags & OutputSymbolFlags.StaticMemberContainer) {
+        copyMembers(
+          source.staticMemberScope!.symbols,
+          target,
+          target.staticMemberScope!,
+        );
+      }
+    });
 
-          createSymbol({
-            name: sym.name,
-            scope: target.instanceMemberScope!,
-            refkey: [refkey(target.refkeys[0], sym.refkeys[0])],
-            flags: sym.flags | OutputSymbolFlags.InstanceMember,
+    /**
+     * Recursively copy `symbols` from `sourceSym` into `intoScope` of `targetSym`.
+     * Always marks each instantiation as StaticMember so lookups use dot notation (e.g. Parent.child)
+     * and preserves any StaticMemberContainer flag to auto create newSym.staticMemberScope.
+     */
+    function copyMembers(
+      symbols: Set<OutputSymbol>,
+      targetSym: OutputSymbol,
+      intoScope: OutputScope,
+    ) {
+      for (const srcSym of symbols) {
+        untrack(() => {
+          const wantKey = refkey(targetSym.refkeys[0], srcSym.refkeys[0]);
+
+          // create the new symbol. Preserve StaticMemberContainer if present
+          const newSym = createSymbol({
+            name: srcSym.name,
+            scope: intoScope,
+            refkey: wantKey,
+            flags: srcSym.flags | OutputSymbolFlags.StaticMember,
+          });
+
+          onCleanup(() => {
+            binder.deleteSymbol(newSym);
           });
+
+          // if the source symbol itself was a container of static members,
+          // recurse into the newSym.staticMemberScope that createSymbol just gave us
+          if (
+            srcSym.staticMemberScope &&
+            srcSym.staticMemberScope.symbols.size > 0
+          ) {
+            // ensure we have that scope
+            addStaticMembersToSymbol(newSym);
+
+            copyMembers(
+              srcSym.staticMemberScope.symbols,
+              newSym,
+              newSym.staticMemberScope!,
+            );
+          }
         });
       }
-    });
+    }
   }
 
   function addStaticMembersToSymbol(symbol: OutputSymbol) {


Test patch:
diff --git a/packages/core/test/symbols.test.ts b/packages/core/test/symbols.test.ts
index 4831373..dbfd4f4 100644
--- a/packages/core/test/symbols.test.ts
+++ b/packages/core/test/symbols.test.ts
@@ -301,8 +301,25 @@ describe("instance members", () => {
 });
 
 describe("instantiating members", () => {
-  it("instantiates static symbols", () => {
+  it("instantiates instance members", () => {
     const binder = createOutputBinder();
+
+    /**
+     * The following structure would match code like this:
+     * ```ts
+     * // A class with instance members
+     * class Source {
+     *   instance() {
+     *     print("instance");
+     *   }
+     * }
+     *
+     * // Instantiates into t
+     * var t = new Source();
+     *
+     * t.instance();
+     * ```
+     */
     const {
       symbols: { rootSymbol, instance, instantiation },
     } = createScopeTree(binder, {
@@ -312,7 +329,7 @@ describe("instantiating members", () => {
             flags: OutputSymbolFlags.InstanceMemberContainer,
             instanceMembers: {
               instance: {
-                flags: OutputSymbolFlags.StaticMember,
+                flags: OutputSymbolFlags.InstanceMember,
               },
             },
           },
@@ -323,20 +340,107 @@ describe("instantiating members", () => {
 
     binder.instantiateSymbolInto(rootSymbol, instantiation);
     expect(
-      instantiation.flags & OutputSymbolFlags.InstanceMemberContainer,
+      instantiation.flags & OutputSymbolFlags.StaticMemberContainer,
     ).toBeTruthy();
-    expect(instantiation.instanceMemberScope).toBeDefined();
+    expect(instantiation.staticMemberScope).toBeDefined();
     const expectedRefkey = refkey(
       instantiation.refkeys[0],
       instance.refkeys[0],
     );
     expect(
-      instantiation.instanceMemberScope!.symbolsByRefkey.get(expectedRefkey),
+      instantiation.staticMemberScope!.symbolsByRefkey.get(expectedRefkey),
     ).toBeDefined();
   });
 
-  it("instantiates static symbols that are added after the instantiation", () => {
+  it("doesn't duplicate symbols", () => {
+    const binder = createOutputBinder();
+
+    const {
+      symbols: { rootSymbol, instantiation },
+    } = createScopeTree(binder, {
+      rootScope: {
+        symbols: {
+          rootSymbol: {
+            flags: OutputSymbolFlags.InstanceMemberContainer,
+            instanceMembers: {
+              instance: {
+                flags: OutputSymbolFlags.InstanceMember,
+              },
+            },
+          },
+          instantiation: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(rootSymbol, instantiation);
+    flushJobs();
+    expect(instantiation.staticMemberScope!.symbols.size).toBe(1);
+
+    const lateKey = refkey();
+    // now add a brand‐new static member to source
+    binder.createSymbol({
+      name: "lateChild",
+      scope: rootSymbol.instanceMemberScope!,
+      refkey: lateKey,
+      flags: OutputSymbolFlags.InstanceMember,
+    });
+
+    flushJobs();
+
+    expect(rootSymbol.instanceMemberScope!.symbols.size).toBe(2);
+    expect(instantiation.staticMemberScope!.symbols.size).toBe(2);
+  });
+
+  it("should remove members in instance when source deleted them", () => {
+    const binder = createOutputBinder();
+
+    const {
+      symbols: { rootSymbol, instantiation },
+    } = createScopeTree(binder, {
+      rootScope: {
+        symbols: {
+          rootSymbol: {
+            flags: OutputSymbolFlags.InstanceMemberContainer,
+            instanceMembers: {
+              instance: {
+                flags: OutputSymbolFlags.InstanceMember,
+              },
+            },
+          },
+          instantiation: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(rootSymbol, instantiation);
+    expect(instantiation.staticMemberScope!.symbols.size).toBe(1);
+
+    const lateKey = refkey();
+    // now add a brand‐new static member to source
+    binder.createSymbol({
+      name: "lateChild",
+      scope: rootSymbol.instanceMemberScope!,
+      refkey: lateKey,
+      flags: OutputSymbolFlags.InstanceMember,
+    });
+
+    flushJobs();
+
+    expect(rootSymbol.instanceMemberScope!.symbols.size).toBe(2);
+    expect(instantiation.staticMemberScope!.symbols.size).toBe(2);
+
+    binder.deleteSymbol(
+      rootSymbol.instanceMemberScope!.symbols.values().next().value!,
+    );
+    flushJobs();
+    expect(rootSymbol.instanceMemberScope!.symbols.size).toBe(1);
+    expect(instantiation.staticMemberScope!.symbols.size).toBe(1);
+  });
+
+  it("instantiates instance members added after the instantiation", () => {
     const binder = createOutputBinder();
+
     const {
       symbols: { rootSymbol, instance, instantiation },
     } = createScopeTree(binder, {
@@ -346,7 +450,7 @@ describe("instantiating members", () => {
             flags: OutputSymbolFlags.InstanceMemberContainer,
             instanceMembers: {
               instance: {
-                flags: OutputSymbolFlags.StaticMember,
+                flags: OutputSymbolFlags.InstanceMember,
               },
             },
           },
@@ -358,15 +462,15 @@ describe("instantiating members", () => {
     binder.instantiateSymbolInto(rootSymbol, instantiation);
     flushJobs();
     expect(
-      instantiation.flags & OutputSymbolFlags.InstanceMemberContainer,
+      instantiation.flags & OutputSymbolFlags.StaticMemberContainer,
     ).toBeTruthy();
-    expect(instantiation.instanceMemberScope).toBeDefined();
+    expect(instantiation.staticMemberScope).toBeDefined();
     const expectedRefkey = refkey(
       instantiation.refkeys[0],
       instance.refkeys[0],
     );
     expect(
-      instantiation.instanceMemberScope!.symbolsByRefkey.get(expectedRefkey),
+      instantiation.staticMemberScope!.symbolsByRefkey.get(expectedRefkey),
     ).toBeDefined();
 
     const newInstanceMemberRefkey = refkey();
@@ -382,9 +486,278 @@ describe("instantiating members", () => {
     );
     flushJobs();
     expect(
-      instantiation.instanceMemberScope!.symbolsByRefkey.get(newExpectedRefkey),
+      instantiation.staticMemberScope!.symbolsByRefkey.get(newExpectedRefkey),
+    ).toBeDefined();
+  });
+
+  it("instantiates static symbols for a static container source", () => {
+    const binder = createOutputBinder();
+
+    /**
+     * The following structure would match code like this:
+     * ```ts
+     * // A class with instance members
+     * class Source {
+     *   static child() {
+     *     print("child");
+     *   }
+     * }
+     *
+     *
+     * var printChild = Source.child;
+     *
+     * printChild();
+     * ```
+     */
+    const {
+      symbols: { source, child, target },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          source: {
+            flags: OutputSymbolFlags.StaticMemberContainer,
+            staticMembers: {
+              child: { flags: OutputSymbolFlags.StaticMember },
+            },
+          },
+          target: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(source, target);
+
+    // target must now be a StaticMemberContainer too
+    expect(target.flags & OutputSymbolFlags.StaticMemberContainer).toBeTruthy();
+    expect(target.staticMemberScope).toBeDefined();
+
+    const expectedKey = refkey(target.refkeys[0], child.refkeys[0]);
+    expect(
+      target.staticMemberScope!.symbolsByRefkey.get(expectedKey),
+    ).toBeDefined();
+  });
+
+  it("instantiates static symbols added after instantiation", () => {
+    const binder = createOutputBinder();
+    const lateKey = refkey();
+
+    const {
+      symbols: { source, target },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          source: {
+            flags: OutputSymbolFlags.StaticMemberContainer,
+          },
+          target: {},
+        },
+      },
+    });
+
+    // hook up instantiation
+    binder.instantiateSymbolInto(source, target);
+
+    // now add a brand‐new static member to source
+    const late = binder.createSymbol({
+      name: "lateChild",
+      scope: source.staticMemberScope!,
+      refkey: lateKey,
+      flags: OutputSymbolFlags.StaticMember,
+    });
+
+    flushJobs();
+
+    // it should *automatically* show up on target.staticMemberScope
+    const expectedKey = refkey(target.refkeys[0], late.refkeys[0]);
+    expect(
+      target.staticMemberScope!.symbolsByRefkey.get(expectedKey),
     ).toBeDefined();
   });
+
+  it("recursively instantiates nested static members", () => {
+    const binder = createOutputBinder();
+
+    /**
+     * The following structure would match code like this:
+     * ```ts
+     *   class Source {
+     *     static Level1 = class {
+     *       static level2() { print("deep"); }
+     *     }
+     *   }
+     *
+     *   var target = Source;
+     *
+     *   target.Level1.level2()
+     * ```
+     */
+    const {
+      symbols: { source, level1, level2, target },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          source: {
+            flags: OutputSymbolFlags.StaticMemberContainer,
+            staticMembers: {
+              level1: {
+                flags:
+                  OutputSymbolFlags.StaticMember |
+                  OutputSymbolFlags.StaticMemberContainer,
+                staticMembers: {
+                  level2: { flags: OutputSymbolFlags.StaticMember },
+                },
+              },
+            },
+          },
+          target: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(source, target);
+
+    // level1 should appear under target.staticMemberScope
+    const key1 = refkey(target.refkeys[0], level1.refkeys[0]);
+    const instantiated1 = target.staticMemberScope!.symbolsByRefkey.get(key1)!;
+    expect(instantiated1.name).toBe(level1.name);
+
+    // and level2 should appear under the *child* staticMemberScope of that instantiated level1
+    const childScope = instantiated1.staticMemberScope!;
+    const key2 = refkey(instantiated1.refkeys[0], level2.refkeys[0]);
+    expect(childScope.symbolsByRefkey.get(key2)).toBeDefined();
+  });
+
+  it("copies both instance *and* static members when source has both flags", () => {
+    const binder = createOutputBinder();
+
+    /**
+     * ```ts
+     *   class Source {
+     *     instance() { print("inst"); }
+     *     static s1()  { print("static"); }
+     *   }
+     *
+     *   let t = new Source()
+     *   t.instance()
+     *   t.s1()
+     * ```
+     */
+    const {
+      symbols: { source, inst },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          source: {
+            flags:
+              OutputSymbolFlags.InstanceMemberContainer |
+              OutputSymbolFlags.StaticMemberContainer,
+            instanceMembers: {
+              i1: { flags: OutputSymbolFlags.InstanceMember },
+            },
+            staticMembers: {
+              s1: { flags: OutputSymbolFlags.StaticMember },
+            },
+          },
+          inst: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(source, inst);
+
+    expect(inst.staticMemberScope).toBeDefined();
+    expect(
+      [...inst.staticMemberScope!.symbols].some((s) => s.name === "i1"),
+    ).toBe(true);
+
+    // static side
+    const symbols = [...source.staticMemberScope!.symbols];
+    expect(inst.staticMemberScope).toBeDefined();
+    const sKey = refkey(inst.refkeys[0], symbols[0].refkeys[0]);
+    expect(inst.staticMemberScope!.symbolsByRefkey.has(sKey)).toBe(true);
+  });
+
+  it("is idempotent, calling twice does not duplicate", () => {
+    const binder = createOutputBinder();
+    const {
+      symbols: { source, target },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          source: {
+            flags: OutputSymbolFlags.StaticMemberContainer,
+            staticMembers: {
+              a: { flags: OutputSymbolFlags.StaticMember },
+            },
+          },
+          target: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(source, target);
+    flushJobs();
+    const initialCount = target.staticMemberScope!.symbols.size;
+    binder.instantiateSymbolInto(source, target);
+    flushJobs();
+    expect(target.staticMemberScope!.symbols.size).toBe(initialCount);
+  });
+
+  it("instantiates static children of instance members under the instance scope", () => {
+    const binder = createOutputBinder();
+    /**
+     * ```ts
+     *    class Source {
+     *     instM = class {
+     *       static deep() { print("deep"); }
+     *     }
+     *    }
+     *
+     *   var t = new Source();
+     *   t.instM.deep();
+     * ```
+     */
+    const {
+      symbols: { source, deep, target },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          source: {
+            flags: OutputSymbolFlags.InstanceMemberContainer,
+            instanceMembers: {
+              instM: {
+                flags:
+                  OutputSymbolFlags.InstanceMember |
+                  OutputSymbolFlags.StaticMemberContainer,
+                staticMembers: {
+                  deep: { flags: OutputSymbolFlags.StaticMember },
+                },
+              },
+            },
+          },
+          target: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(source, target);
+
+    // Find the instantiated copy of instM under target.instanceMemberScope
+    const instMSym = [...target.staticMemberScope!.symbols].find(
+      (s) => s.name === "instM",
+    )!;
+
+    // instMSym should have gotten its own staticMemberScope via the StaticMemberContainer flag
+    expect(instMSym.staticMemberScope).toBeDefined();
+
+    // compute the expected key for the deep child:
+    //    (<target>, <instM>)  then (on that)  (<deep original>)
+    const expectedDeepKey = refkey(instMSym.refkeys[0], deep.refkeys[0]);
+
+    expect(
+      instMSym.staticMemberScope!.symbolsByRefkey.has(expectedDeepKey),
+    ).toBe(true);
+  });
 });
 
 describe("symbol name resolution", () => {