Skip to content

import_function_prefixes keyed by exported name too — collides across unrelated imports in the same file #5918

Description

@proggeramlug

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions