# 0049 — new-form variadic `..name: []Type` defined in an imported module crashes LLVM emit ## Symptom A pack-fn declared with the **new** variadic syntax `..name: []Type` (the form the FFI plan migrates to, replacing the legacy `name: ..Type`) crashes LLVM IR emission with a null-operand `LLVMBuildExtractValue` inside `emitStrCmp` when: 1. The pack-fn lives in an **imported** module (e.g. `library/modules/std.sx`). 2. The caller is in a **different** module than the definition. The same function written with the legacy `name: ..Type` syntax compiles and runs cleanly. The same new-form definition placed **locally** in the caller's module (or shadowing the imported name) also compiles cleanly. The two together — new form + import boundary — are what trip the emit. ``` Segmentation fault at address 0x0 ???:?:?: 0x... in __ZN4llvm5Value11setNameImplERKNS_5TwineE (.../libLLVM.dylib) ???:?:?: 0x... in _LLVMBuildExtractValue (.../libLLVM.dylib) /Users/agra/projects/sx/src/ir/emit_llvm.zig:3570:48: in emitStrCmp (sx) const rhs_ptr = c.LLVMBuildExtractValue(b, rhs, 0, "str.rp"); ^ /Users/agra/projects/sx/src/ir/emit_llvm.zig:1805:45: in emitInst (sx) .str_eq => |bin| self.emitStrCmp(bin, true), ``` So an emitted `.str_eq` op has a `rhs` Ref that resolves to a null LLVM Value. The `.str_eq` is somewhere downstream of the new-form pack-fn's monomorphisation — most likely an `any_to_string` / format-side string comparison that the migrated call path threads back. The `rhs` was either never materialised in the imported-mono's IR, or was registered against a stale function/module slot that `emit_llvm` resolves to null. ## Reproduction Modify `library/modules/std.sx`: ```sx - path_join :: (parts: ..string) -> string { + path_join :: (..parts: []string) -> string { ``` Body unchanged. Then: ```sx // repro.sx #import "modules/std.sx"; main :: () { p := path_join("a", "b"); print("{}\n", p); } ``` `zig build && ./zig-out/bin/sx run repro.sx` → segfault as above. Negative controls (all compile and run fine): ```sx // Same body, OLD form — works: path_join :: (parts: ..string) -> string { ... } // New form but defined LOCALLY in the caller: path_join :: (..parts: []string) -> string { ... } #import "modules/std.sx"; // imported anyway, just no path_join call main :: () { p := path_join("a", "b"); ... } // New form with `$`-prefixed pack name — works EITHER locally or imported: path_join :: (..$parts: []string) -> string { ... } ``` So the bug is specifically: - new-form variadic (`..name: []Type`) - WITHOUT the `$` prefix on `name` - defined in a module that gets imported (not in the caller's own file) Suite state when the bug first surfaced: commit `0ede097` (master, 2026-05-27, just after the issue-0048 fix landed and the suite was green at 213/213). The only delta on top is the `path_join :: (parts: ..string)` → `path_join :: (..parts: []string)` edit in `library/modules/std.sx`. ## Investigation prompt The FFI plan migrates all stdlib variadic decls from the legacy form to the new `..name: []Type` form (`path_join`, `format`, `print`, plus the extern `open` decl, plus the example fixtures). Per the FFI cadence rule the migration is supposed to be a mechanical textual change with identical semantics. This bug blocks that. The fault location (`emitStrCmp` line 3570 with null `rhs`) is the crash point, not the root cause. The root cause is one of: 1. **Cross-module pack-fn monomorphisation** — the new-form path in `monomorphizePackFn` registers the mono'd function in the current module, but if the mono'd body uses a Ref that resolves through a stale module/function context, the LLVM pass through `emit_llvm.functions[fid]` lookup hands back a null. Compare the new-form mono path to the legacy `name: ..Type` mono path side-by-side — look for any place the latter threads the caller's module ID / FuncId but the former forgets to. 2. **Synthesised slot names** — the new-form pack-fn body uses synthesised `__pack__` idents for per-position arg substitution (per CHECKPOINT-FFI step-2b). If these are re-emitted in the caller's IR against the imported function's body without re-resolving against the caller's scope, they'd appear as undef in the final LLVM pass. 3. **The `$` workaround as a hint** — the bug disappears when the pack name is `..$parts: []string`. Inside the parser / `isPackFn` discriminator, the `$` prefix routes the function through the heterogeneous-pack mono path; the new-form WITHOUT `$` likely routes through a near-but-not-identical path. Diff the two routes — what does the `$` version do that the no-`$` version skips when the call crosses an import? Where to start: - `src/ir/lower.zig` — `monomorphizePackFn` (around line 8460), `materialisePackSlice` (around line 8261), `buildPackSliceValue` (around line 8225). Trace which gets called for the new-form no-`$` path. - `src/ir/lower.zig:lowerPackFnCall` — call-site mono dispatch. Look for a `$`-prefix branch. - `src/parser.zig:parseParam` (variadic handling) — confirm what AST shape the two forms produce. The new form sets `is_variadic = true` AND `is_comptime = false` (no `$`); the new form WITH `$` sets both true. The mono path probably gates on `is_comptime`. Verification step: After the fix, run with the path_join edit re-applied: ```sh git diff library/modules/std.sx # confirm new form lands ./zig-out/bin/sx run examples/121-ios-sim-bundle.sx bash tests/run_examples.sh ``` Both should be green, no segfault. A regression test goes in `examples/NNN-new-form-variadic-cross-module.sx` once the fix lands. The migration of `path_join`, `format`, `print`, and `open` then proceeds. ## Why this matters The FFI plan calls for the legacy `name: ..Type` form to be dropped entirely (`current/CHECKPOINT-FFI.md` references the `'args: ..Any' is in the plan to change to '..args: []Any'` migration). Every stdlib consumer plus the chess game needs the new form. With this bug, stdlib can't be migrated — moving any stdlib variadic to the new form breaks every program that imports it. The migration is gated on this fix.