Summary
crates/perry/src/commands/compile/run_pipeline.rs builds import_function_prefixes: HashMap<String, String> (imported name → source module prefix, consumed by ExternFuncRef codegen to form perry_fn_<prefix>__<name>) by inserting both the imported (origin) name and the local alias for every Named specifier:
import_function_prefixes
.insert(exported_name.clone(), effective_prefix.clone());
if local_name != exported_name {
import_function_prefixes
.insert(local_name.clone(), effective_prefix.clone());
}
Per the #35/#321 comment right above this in the same function, a renamed (local != exported) named import's Expr::ExternFuncRef in the HIR always carries the local name (unique per import site) — so the exported_name insert is only ever meaningful in the no-rename case (local == exported). But it runs unconditionally, so when two different import statements in the same file rename to different locals from modules whose origin export happens to share a bare name (very common with short, minifier-style names like a, b, c), the second one silently overwrites the first's entry — even though the collision has nothing to do with local-name uniqueness, which the HIR's ExternFuncRef contract otherwise guarantees.
Minimal repro
// mod_a.ts
function u(x: number) { return x * 2; }
export { u as a };
// mod_b.ts
function fallback() { return 99; }
export { fallback as c };
// consumer.ts
import { a as t } from "./mod_a";
import { c as a } from "./mod_b"; // local alias "a" -> mod_b's "c"
function useThem(n: number) {
if (n <= 0) return a(); // should call mod_b's fallback()
return t(n);
}
export { useThem as a };
// main.ts
import { a as fn } from "./consumer";
console.log(fn(5));
console.log(fn(0));
This 3-file version does NOT reproduce it (still under investigation why the exact trigger needs a 4th hop) — the confirmed minimal repro needs a 4-file chain matching real-world minified output shape (found via remeda's dist/chunk-*.js build artifacts):
// chunk_D6FCK2GA.ts
function u(o, n, a) { let t = r => o(r, ...n); return a === void 0 ? t : Object.assign(t, { lazy: a, lazyArgs: n }); }
export { u as a };
// chunk_ANXBDSUI.ts
var e = { done: true, hasNext: false }, s = { done: false, hasNext: false }, a = () => e, o = t => ({ hasNext: true, next: t, done: false });
export { s as a, a as b, o as c };
// chunk_WIMGWYZL.ts
import { a as t } from "./chunk_D6FCK2GA";
function u(r, n, o) { let a = r.length - n.length; if (a === 0) return r(...n); if (a === 1) return t(r, n, o); throw new Error("Wrong number of arguments"); }
export { u as a };
// chunk_WMCGP7PY.ts
import { a as n, c as a } from "./chunk_ANXBDSUI"; // local "a" -> ANXBDSUI's "c"
import { a as t } from "./chunk_WIMGWYZL"; // exported name "a" (from WIMGWYZL) OVERWRITES the entry keyed "a" above
function s(...e) { return t(p, e, o); }
var p = (e, r) => (r < 0 ? [...e] : e.slice(r));
function o(e) { if (e <= 0) return a; let r = e; return i => (r > 0 ? ((r -= 1), n) : { done: false, hasNext: true, next: i }); }
export { s as a };
// main.ts
import { a as dropLastN } from "./chunk_WMCGP7PY";
console.log(dropLastN([1, 2, 3, 4, 5], 2));
console.log(dropLastN([1, 2, 3, 4, 5], -1));
$ perry compile main.ts -o out
Undefined symbols for architecture arm64:
"_perry_fn_chunk_WIMGWYZL_ts__c", referenced from:
___perry_wrap_perry_fn_chunk_WMCGP7PY_ts__o in chunk_WMCGP7PY_ts.o
chunk_WIMGWYZL.ts doesn't export anything called c — only chunk_ANXBDSUI.ts does. The wrapper for o (an internal, non-exported closure in chunk_WMCGP7PY.ts that reads the local a, i.e. chunk_ANXBDSUI's c) resolved against the wrong module entirely, because chunk_WMCGP7PY.ts's second import ({ a as t } from "./chunk_WIMGWYZL") inserted import_function_prefixes["a"] = "chunk_WIMGWYZL_ts", clobbering the earlier (correct) import_function_prefixes["a"] = "chunk_ANXBDSUI_ts" that came from the first import's c as a specifier.
Found via
Real-world source compile of opencode (sst/opencode) with perry.compilePackages: ["*"] — remeda's real, unmodified dist/chunk-*.js build output has this exact shape (esbuild's chunk-splitting produces lots of single/double-letter re-exports, and this collision pattern recurs across the whole file — nearly every remeda function ultimately imports these particular chunks).
Fix
PR incoming — only insert under exported_name in the no-rename case (local_name == exported_name); the aliased case only needs the local_name key, since that's what ExternFuncRef actually carries per #35/#321. This keeps every key in the map unique per file (local identifiers can't collide with each other within one file; export names from different origin modules can and do).
Summary
crates/perry/src/commands/compile/run_pipeline.rsbuildsimport_function_prefixes: HashMap<String, String>(imported name → source module prefix, consumed byExternFuncRefcodegen to formperry_fn_<prefix>__<name>) by inserting both the imported (origin) name and the local alias for everyNamedspecifier:import_function_prefixes .insert(exported_name.clone(), effective_prefix.clone()); if local_name != exported_name { import_function_prefixes .insert(local_name.clone(), effective_prefix.clone()); }Per the #35/#321 comment right above this in the same function, a renamed (
local != exported) named import'sExpr::ExternFuncRefin the HIR always carries the local name (unique per import site) — so theexported_nameinsert is only ever meaningful in the no-rename case (local == exported). But it runs unconditionally, so when two different import statements in the same file rename to different locals from modules whose origin export happens to share a bare name (very common with short, minifier-style names likea,b,c), the second one silently overwrites the first's entry — even though the collision has nothing to do with local-name uniqueness, which the HIR's ExternFuncRef contract otherwise guarantees.Minimal repro
This 3-file version does NOT reproduce it (still under investigation why the exact trigger needs a 4th hop) — the confirmed minimal repro needs a 4-file chain matching real-world minified output shape (found via
remeda'sdist/chunk-*.jsbuild artifacts):chunk_WIMGWYZL.tsdoesn't export anything calledc— onlychunk_ANXBDSUI.tsdoes. The wrapper foro(an internal, non-exported closure inchunk_WMCGP7PY.tsthat reads the locala, i.e.chunk_ANXBDSUI'sc) resolved against the wrong module entirely, becausechunk_WMCGP7PY.ts's second import ({ a as t } from "./chunk_WIMGWYZL") insertedimport_function_prefixes["a"] = "chunk_WIMGWYZL_ts", clobbering the earlier (correct)import_function_prefixes["a"] = "chunk_ANXBDSUI_ts"that came from the first import'sc as aspecifier.Found via
Real-world source compile of
opencode(sst/opencode) withperry.compilePackages: ["*"]—remeda's real, unmodifieddist/chunk-*.jsbuild output has this exact shape (esbuild's chunk-splitting produces lots of single/double-letter re-exports, and this collision pattern recurs across the whole file — nearly every remeda function ultimately imports these particular chunks).Fix
PR incoming — only insert under
exported_namein the no-rename case (local_name == exported_name); the aliased case only needs thelocal_namekey, since that's whatExternFuncRefactually carries per #35/#321. This keeps every key in the map unique per file (local identifiers can't collide with each other within one file; export names from different origin modules can and do).