Repository navigation
An import alias that collides with another import's exported name binds to the wrong module during module init #10286
Description
Parent: #10107 (OpenCode v1.18.30 native bring-up).
Summary
When a module has two aliased imports, and the local alias of one is the same identifier as the exported name of the other's source module, perry binds the alias to the wrong module — but only for calls made during module initialization. The call gets the other module's value, which is typically not a function, so it throws TypeError: value is not a function with no callee name.
This is the shape minifiers emit constantly. Short aliases like t, e, n get reused across import statements in the same file, so any minified dependency that calls an imported function at module scope can hit it.
Repro (4 files)
idx.js
var cat = { br: 1 }
function extend(o) { Object.assign(cat, o) }
function getCat() { return cat }
export { getCat, extend, cat }leafT.mjs — exports a binding literally named t
const t = { kind: "obj-not-a-function" }
export { t }consO.mjs — imports t under the alias e, and extend under the alias t
import { t as e } from "./leafT.mjs"
import { extend as t } from "./idx.js"
function n() { t({ spinner: e }) }
n()
export { n as reg }mainO.ts
import { getCat } from "./idx.js"
import { reg } from "./consO.mjs"
console.log("typeof:", typeof reg, "catalogue:", JSON.stringify(getCat()))bun 1.3.14 and node:
typeof: function catalogue: {"br":1,"spinner":{"kind":"obj-not-a-function"}}
perry 0.5.1571 (+ PRs 10279/10280/10282/10283), perry compile mainO.ts --platform bun:
TypeError: value is not a function
at <anonymous>
t inside consO.mjs resolves to leafT's t — the object — instead of idx's extend.
Control matrix
All four controls pass under perry, which pins the trigger to the conjunction of three conditions:
| variant | change from the repro | perry |
|---|---|---|
| repro | — | fails |
| P | alias extend as q instead of as t |
passes |
| Q | leafT exports z instead of t |
passes |
| R | the leafT import moved out of this module |
passes |
| S | leafT imported without an alias, extend as u |
passes |
| T | identical, but the call happens after init rather than during it | passes |
So all three are required: a local alias colliding with another import's exported name, both imports in the same module, and the call executing during module initialization. T is the interesting one — the same binding is correct once initialization is over, which points at init-time resolution rather than a permanently wrong symbol.
Where to look
crates/perry/src/commands/compile/run_pipeline.rs builds import_function_prefixes / import_function_origin_names keyed by identifier. There is already a comment there (marked "Issue #", so apparently never filed) describing a sibling collision:
exported_nameis whatever the ORIGIN module happens to call it, so it can collide with an unrelated LOCAL alias elsewhere in the same file. Concrete repro:import { a as n, c as a } from "./x"; import { a as t } from "./y"
That fix only inserts the aliased case under local_name. This repro is aliased on both sides, so it should already be keyed by local name — meaning the collision is somewhere else on the init path, not in that map. Same family as #9847/#9857 (name-keyed instead of local-id-keyed registration).
Why it matters
This is the last blocker for OpenCode's terminal interface under perry. The real case is opentui-spinner@0.0.7/dist/solid.mjs, four minified statements:
import{t as e}from"./src-DjeqLSfu.mjs";import{extend as t}from"@opentui/solid/components";function n(){t({spinner:e})}n();export{n as registerSpinner};extend is a plain function declaration in @opentui/solid. A module-body trace of the subgraph under both engines shows perry running 17 modules against bun's 147, stopping immediately after @opentui/solid/components.js — the module right before this one. The at n / at n frames in the original stack are this file's minified n.
Activity
proggeramlug commentedon Sep 15, 2026
Fix in PR #10288: key
imported_varsby the local name, matching the treatmentimport_function_prefixesalready had 120 lines above. The probe that reads the alias directly now reportsfunctionwhere it reportedundefined, and all six control variants still pass.test(compile): pin the import-alias collision and its control (#10286)
fix(compile): key imported_vars by the local name, not the origin exp…