From d61d2757915e3dfedf44c202a970037d75bd22ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 15 May 2026 09:09:34 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(codegen):=20#678=20=E2=80=94=20re-expor?= =?UTF-8?q?t=20rename=20resolves=20to=20origin=20export=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a TypeScript module imports a name that traverses a re-export rename — `import { Box } from "ink"` where `ink/build/index.js` does `export { default as Box } from './components/Box.js'` — the codegen was forming the cross-module extern as `perry_fn___Box`. The origin module emits the symbol under its own export name (`default`), so the linker failed with `Undefined symbols: _perry_fn_..._Box`. Every compile-package that shapes its barrel as `export { default as } from "./.js"` (ink, hono adapters, drizzle subpackages, react sub-bundles, ...) hit this on any named import. The fix tracks the *origin name* alongside the origin path across re-export chains and threads it through codegen so every `perry_fn___` construction site picks the right suffix: * New `import_function_origin_names: HashMap` on `CompileOptions` parallels `import_function_prefixes`. Entries are inserted only when origin_name != consumer_name (sparse map, identity fallback at the call site). * The CLI driver's per-module loop builds the map by consulting `all_module_export_origin_names` (a parallel to `all_module_exports` populated during the export-propagation loop). Named, ReExport, ExportAll, and NamespaceReExport branches all surface deeper origin names through any number of transitive hops. * `import_origin_suffix()` helper in `perry-codegen` returns the override-or-identity suffix. Every `perry_fn___` construction site (lower_call.rs, expr.rs ExternFuncRef + namespace member dispatch, codegen.rs FuncRef-as-value wrapper emission) now routes through it. * `imported_vars` classification probes BOTH `(origin, exported_name)` AND `(origin, origin_name)` so a `const X = ...; export default X` re-exported under a different name still gets recognized as a variable and routed through the closure-getter call path instead of being treated as a direct function call. * Object cache key includes the new map so two builds with the same modules but different re-export shapes don't share cached `.o`. Validation: * New regression fixture `test-files/test_issue_678_reexport_default.ts` + `test-files/fixtures/issue_678_pkg/` exercises all three shapes (default-as-rename of a const closure, default-as-rename of a function declaration, named-as-rename of a function declaration); byte-for-byte parity with `node --experimental-strip-types`. * `cargo test --release -p perry -p perry-codegen` — 157 + 29 tests pass. * `/tmp/run_gap_tests.sh` — 34/36 (same two pre-existing failures: `test_gap_console_methods`, `test_gap_regexp_advanced`). * Existing `test_issue_310_namespace_reexport.ts` still passes (the ExportAll/NamespaceReExport branches keep working). * The minimal ink repro from #678 now resolves Box / render correctly; remaining link failures (Text — `export default function`-body not lowered, react.development.js compile error, ink/devtools namespace global) are pre-existing bugs in separate code paths, untouched by this PR and not introduced by it. Refs: #678 --- crates/perry-codegen/src/codegen.rs | 36 +++- crates/perry-codegen/src/expr.rs | 57 +++++- crates/perry-codegen/src/lower_call.rs | 18 +- crates/perry/src/commands/compile.rs | 191 +++++++++++++++++- .../src/commands/compile/object_cache.rs | 18 ++ test-files/fixtures/issue_678_pkg/index.ts | 9 + .../fixtures/issue_678_pkg/sub/inner.ts | 26 +++ test-files/test_issue_678_reexport_default.ts | 32 +++ 8 files changed, 373 insertions(+), 14 deletions(-) create mode 100644 test-files/fixtures/issue_678_pkg/index.ts create mode 100644 test-files/fixtures/issue_678_pkg/sub/inner.ts create mode 100644 test-files/test_issue_678_reexport_default.ts diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 0328f945..ea4cace1 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -82,6 +82,19 @@ pub struct CompileOptions { /// `perry_fn___`. Built by the CLI driver /// from each module's `hir.imports` table. pub import_function_prefixes: std::collections::HashMap, + /// Issue #678: for imports that traverse a re-export rename + /// (e.g. `export { default as render } from './render.js'`), maps the + /// consumer-visible name (`render`) to the actual export name in the + /// origin module (`default`). Used by every `perry_fn___` + /// symbol-construction site so the suffix matches what the origin + /// module actually emits. Absent entries (the common case) mean the + /// name in origin matches the consumer's imported name; callers should + /// treat a missing entry as identity. Without this, `import { Box } + /// from "ink"` lowered to `perry_fn___Box` but the + /// origin module emitted `perry_fn___default` (Box.js + /// has `const Box = ...; export default Box`), and the linker failed + /// with `Undefined symbols: _perry_fn_..._Box`. + pub import_function_origin_names: std::collections::HashMap, /// Issue #680: per-namespace member resolution. Keyed by /// `(namespace_local_name, member_name)` → `source_prefix`. Used by /// the namespace-member access lowering paths in `expr.rs` and @@ -384,6 +397,10 @@ pub(crate) struct CrossModuleCtx { pub local_async_funcs: std::collections::HashSet, pub type_aliases: std::collections::HashMap, pub imported_func_param_counts: std::collections::HashMap, + /// Issue #678: see `CompileOptions::import_function_origin_names`. + /// Cloned from the same field so codegen helpers reachable via + /// `CrossModuleCtx` can resolve the origin name without an extra arg. + pub import_function_origin_names: std::collections::HashMap, /// Issue #608 — imported function names whose source-side signature /// has a trailing `...rest` parameter. Used by the cross-module call /// site in `lower_call.rs` to pack trailing args into a rest array. @@ -1183,6 +1200,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> local_async_funcs, type_aliases: opts.type_aliases, imported_func_param_counts: opts.imported_func_param_counts, + import_function_origin_names: opts.import_function_origin_names.clone(), imported_func_has_rest: opts.imported_func_has_rest, imported_func_return_types: opts.imported_func_return_types, method_param_counts, @@ -2716,7 +2734,17 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> llmod.add_internal_constant(&global_name, "{ ptr, i32, i32 }", &init); continue; } - let target_name = format!("perry_fn_{}__{}", source_prefix, name); + // Issue #678: when a re-export rename routes this name to an + // origin export with a different suffix (`export { default as + // render }`), call into the origin's real symbol — `perry_fn_< + // src>__default`, not `perry_fn___render`. The local + // wrapper still uses the consumer-visible name so this + // module's own callers can find it. + let origin_suffix = crate::expr::import_origin_suffix( + &cross_module.import_function_origin_names, + name, + ); + let target_name = format!("perry_fn_{}__{}", source_prefix, origin_suffix); // Look up the param count from the import metadata. Fall back // to 0 if missing — emits a no-arg wrapper, which is wrong // for nonzero-arity functions but won't break compilation. @@ -3146,6 +3174,7 @@ fn compile_function( methods, module_globals, import_function_prefixes, + import_function_origin_names: &cross_module.import_function_origin_names, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -3522,6 +3551,7 @@ fn compile_closure( methods, module_globals, import_function_prefixes, + import_function_origin_names: &cross_module.import_function_origin_names, closure_captures, current_closure_ptr: Some("%this_closure".to_string()), enums, @@ -3757,6 +3787,7 @@ fn compile_method( methods, module_globals, import_function_prefixes, + import_function_origin_names: &cross_module.import_function_origin_names, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -4226,6 +4257,7 @@ fn compile_module_entry( methods, module_globals, import_function_prefixes, + import_function_origin_names: &cross_module.import_function_origin_names, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -4580,6 +4612,7 @@ fn compile_module_entry( methods, module_globals, import_function_prefixes, + import_function_origin_names: &cross_module.import_function_origin_names, closure_captures: HashMap::new(), current_closure_ptr: None, enums, @@ -5321,6 +5354,7 @@ fn compile_static_method( methods, module_globals, import_function_prefixes, + import_function_origin_names: &cross_module.import_function_origin_names, closure_captures: HashMap::new(), current_closure_ptr: None, enums, diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index abcda626..a4e93ab3 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -48,6 +48,24 @@ pub(crate) fn nanbox_bigint_inline(blk: &mut LlBlock, ptr_i64: &str) -> String { blk.bitcast_i64_to_double(&tagged) } +/// Issue #678: resolve the actual symbol-suffix for an imported name. +/// +/// Re-export renames like `export { default as render } from './render.js'` +/// mean the consumer sees `render` while the origin module emits +/// `perry_fn___default`. The `import_function_origin_names` map +/// records this rename so every `perry_fn___` construction +/// site can pick the right suffix instead of the consumer-visible name. +/// +/// Returns the override when present, falling back to `name` (the common +/// case where origin name == consumer name). Used by every codegen +/// path that builds a `perry_fn___` extern symbol. +pub(crate) fn import_origin_suffix<'a>( + origin_names: &'a std::collections::HashMap, + name: &'a str, +) -> &'a str { + origin_names.get(name).map(String::as_str).unwrap_or(name) +} + /// If `callee` is a `new`-target whose class name is statically /// known, return that name. Used by the `Expr::NewDynamic` lowering /// to reroute statically-resolvable shapes to the regular `lower_new` @@ -222,6 +240,15 @@ pub(crate) struct FnCtx<'a> { /// `ExternFuncRef` lowering in `lower_call` to generate scoped /// cross-module calls. pub import_function_prefixes: &'a std::collections::HashMap, + /// Issue #678: Imported function name → original export name in the + /// origin module. Set when the import traverses a re-export rename + /// (`export { default as render } from './render.js'`). Looked up at + /// every `perry_fn___` construction site to + /// pick the right suffix. Absent entries (the common case) mean the + /// origin name matches the consumer's imported name; callers should + /// treat a missing entry as identity by calling + /// `import_origin_suffix(import_function_origin_names, name)`. + pub import_function_origin_names: &'a std::collections::HashMap, /// Closure capture map: when lowering inside a closure body, this /// holds `LocalId → capture_index`. `LocalGet`/`LocalSet`/`Update` /// of an id in this map routes through the runtime @@ -3663,12 +3690,18 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // body. The body only runs later when the consumer // actually calls `HashMap.keySet(self)`, by which time // both modules have finished `__init`. + // Issue #678: re-export renames mean the suffix in the + // origin module differs from the consumer-visible name. + let origin_suffix = + import_origin_suffix(ctx.import_function_origin_names, property); if ctx.imported_vars.contains(property) { - let getter = format!("perry_fn_{}__{}", source_prefix, property); + let getter = + format!("perry_fn_{}__{}", source_prefix, origin_suffix); ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); return Ok(ctx.block().call(DOUBLE, &getter, &[])); } - let target_name = format!("perry_fn_{}__{}", source_prefix, property); + let target_name = + format!("perry_fn_{}__{}", source_prefix, origin_suffix); let wrap_name = format!("__perry_wrap_{}", target_name); let param_count = ctx .imported_func_param_counts @@ -3699,7 +3732,11 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // object stored in the module's export global. if let Expr::ExternFuncRef { name, .. } = object.as_ref() { if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { - let getter = format!("perry_fn_{}__{}", source_prefix, name); + // Issue #678: re-export renames mean the suffix in the + // origin module differs from the consumer-visible name. + let origin_suffix = + import_origin_suffix(ctx.import_function_origin_names, name); + let getter = format!("perry_fn_{}__{}", source_prefix, origin_suffix); ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); let obj_val = ctx.block().call(DOUBLE, &getter, &[]); // Now do property access on the actual object. @@ -6060,7 +6097,11 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if ctx.namespace_imports.contains(class_name) { if let Some(source_prefix) = ctx.import_function_prefixes.get(method_name).cloned() { - let fn_name = format!("perry_fn_{}__{}", source_prefix, method_name); + // Issue #678: namespace member resolved through a re-export + // rename uses the origin name as the symbol suffix. + let origin_suffix = + import_origin_suffix(ctx.import_function_origin_names, method_name); + let fn_name = format!("perry_fn_{}__{}", source_prefix, origin_suffix); let mut lowered: Vec = Vec::with_capacity(args.len()); for a in args { lowered.push(lower_expr(ctx, a)?); @@ -11164,12 +11205,16 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(double_literal(f64::from_bits(bits))); } if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { + // Issue #678: re-export renames mean the origin's symbol uses + // the *origin* name as the suffix, not the consumer-visible one. + let origin_suffix = + import_origin_suffix(ctx.import_function_origin_names, name); // Imported VARIABLES (exported consts/lets) need to be // called through their getter to fetch the value, not // wrapped as closures. Without this, `let v = HONE_VERSION` // creates a closure wrapper instead of the actual string. if ctx.imported_vars.contains(name) { - let fname = format!("perry_fn_{}__{}", source_prefix, name); + let fname = format!("perry_fn_{}__{}", source_prefix, origin_suffix); ctx.pending_declares.push((fname.clone(), DOUBLE, vec![])); return Ok(ctx.block().call(DOUBLE, &fname, &[])); } @@ -11200,7 +11245,7 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `__perry_extern_closure___` global is no // longer referenced and link-time DCE strips it. // Refs #645 deeper followup / #488 drizzle-sqlite. - let target_name = format!("perry_fn_{}__{}", source_prefix, name); + let target_name = format!("perry_fn_{}__{}", source_prefix, origin_suffix); let wrap_name = format!("__perry_wrap_{}", target_name); // Declare the source's wrapper so LLVM accepts the // `@` reference. Signature mirrors the diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index 4f1f1711..8e6b6880 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -560,7 +560,14 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R .cloned() .or_else(|| ctx.import_function_prefixes.get(property).cloned()) { - let symbol = format!("perry_fn_{}__{}", source_prefix, property); + // Issue #678: re-exported names (e.g. `export { default as + // render }`) emit `perry_fn___default` in the origin — + // resolve the actual origin suffix before forming the symbol. + let origin_suffix = crate::expr::import_origin_suffix( + ctx.import_function_origin_names, + property, + ); + let symbol = format!("perry_fn_{}__{}", source_prefix, origin_suffix); if ctx.imported_vars.contains(property) { // Var-shaped export: fetch closure via zero-arg // getter, then closure-call with the user args. @@ -1077,7 +1084,14 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R return Ok(ctx.block().call(DOUBLE, name, &arg_slices)); } }; - let fname = format!("perry_fn_{}__{}", source_prefix, name); + // Issue #678: re-export rename (`export { default as render } from + // './render.js'`) means the origin module emits the symbol under + // the *origin* name (`default`), not the consumer-visible name + // (`render`). Look up the actual origin suffix before forming the + // extern. + let origin_suffix = + crate::expr::import_origin_suffix(ctx.import_function_origin_names, name); + let fname = format!("perry_fn_{}__{}", source_prefix, origin_suffix); // Issue #493 followup: when the imported binding is a VARIABLE // holding a closure value (e.g. `var mergePath = (b, s, ...r) => …` // exported from another module), `perry_fn___` is the diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index e2837f7f..ef2528dc 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -2250,6 +2250,16 @@ pub fn run_with_parse_cache( // Build a map of all exports from all modules: module_path -> HashMap // This is used for namespace imports (`import * as X from './module'`) to resolve all exports let mut all_module_exports: BTreeMap> = BTreeMap::new(); + // Issue #678: parallel map carrying the *origin name* alongside the + // origin path. When `ink/build/index.js` says `export { default as + // render } from './render.js'`, `all_module_exports[ink_path]["render"] + // = render_js_path` and `all_module_export_origin_names[ink_path] + // ["render"] = "default"`. The codegen consumer of an import that + // resolves through this chain forms `perry_fn___default` + // instead of `perry_fn___render` — without it the linker + // fails on the missing `_perry_fn___render` symbol. + let mut all_module_export_origin_names: BTreeMap> = + BTreeMap::new(); for (path, hir_module) in &ctx.native_modules { let path_str = path.to_string_lossy().to_string(); let exports = all_module_exports @@ -2288,7 +2298,13 @@ pub fn run_with_parse_cache( // Propagate exports through ExportAll and ReExport chains loop { - let mut new_export_entries: Vec<(String, String, String)> = Vec::new(); // (module_path, export_name, origin_path) + // (module_path, export_name, origin_path, origin_name_in_origin). + // The fourth tuple element drives Issue #678's per-export + // origin-name map: when a re-export renames a name across a hop + // (`export { default as render } from './render.js'`), the + // consumer must use the *origin* name (`default`) as the symbol + // suffix, not the consumer-visible one (`render`). + let mut new_export_entries: Vec<(String, String, String, String)> = Vec::new(); for (path, hir_module) in &ctx.native_modules { let path_str = path.to_string_lossy().to_string(); for export in &hir_module.exports { @@ -2309,10 +2325,23 @@ pub fn run_with_parse_cache( .map(|e| e.contains_key(name)) .unwrap_or(false); if !already_exists { + // `export * from "src"` doesn't + // rename — origin_name == export_name. + // But if `src` itself remapped this + // name (e.g. `export { default as + // foo } from './x.js'`), propagate + // the deeper origin name across this + // transitive hop. + let deep_origin_name = all_module_export_origin_names + .get(&source_path_str) + .and_then(|m| m.get(name)) + .cloned() + .unwrap_or_else(|| name.clone()); new_export_entries.push(( path_str.clone(), name.clone(), origin.clone(), + deep_origin_name, )); } } @@ -2340,10 +2369,23 @@ pub fn run_with_parse_cache( .map(|v| v == origin) .unwrap_or(false); if !already_correct { + // Walk one more hop: if `src` itself + // remapped `imported` to a deeper + // origin name (`src` did its own + // `export { default as imported } + // from "..."`), record THAT deeper + // name so the consumer's symbol-suffix + // resolution skips both hops. + let deep_origin_name = all_module_export_origin_names + .get(&source_path_str) + .and_then(|m| m.get(imported)) + .cloned() + .unwrap_or_else(|| imported.clone()); new_export_entries.push(( path_str.clone(), exported.clone(), origin.clone(), + deep_origin_name, )); } } @@ -2385,10 +2427,19 @@ pub fn run_with_parse_cache( .map(|v| v == origin) .unwrap_or(false); if !already_correct { + let deep_origin_name = + all_module_export_origin_names + .get(&source_path_str) + .and_then(|m| m.get(&imported_name)) + .cloned() + .unwrap_or_else(|| { + imported_name.clone() + }); new_export_entries.push(( path_str.clone(), exported.clone(), origin.clone(), + deep_origin_name, )); } } @@ -2405,11 +2456,22 @@ pub fn run_with_parse_cache( if new_export_entries.is_empty() { break; } - for (module_path, name, origin) in new_export_entries { + for (module_path, name, origin, origin_name) in new_export_entries { all_module_exports - .entry(module_path) + .entry(module_path.clone()) .or_insert_with(BTreeMap::new) - .insert(name, origin); + .insert(name.clone(), origin); + // Only record the origin-name entry when it actually differs + // from the export name (the common identity case is implicit — + // the codegen helper falls back to the imported name when no + // entry is present). This keeps the map sparse and easy to + // reason about. + if origin_name != name { + all_module_export_origin_names + .entry(module_path) + .or_insert_with(BTreeMap::new) + .insert(name, origin_name); + } } } @@ -3273,6 +3335,17 @@ pub fn run_with_parse_cache( // to generate `perry_fn___`. let mut import_function_prefixes: std::collections::HashMap = std::collections::HashMap::new(); + // Issue #678: parallel to `import_function_prefixes`. When the + // import traverses a re-export rename (`export { default as render + // } from './render.js'`), the consumer sees `render` but the + // origin module emits the symbol with its own export name + // (`default`). This map captures the consumer-name → origin-name + // override so every `perry_fn___` construction site + // can pick the right suffix. Absent entries (the common case) + // mean no rename — the consumer name is the origin name. + let mut import_function_origin_names: + std::collections::HashMap = + std::collections::HashMap::new(); // Issue #680: per-namespace member resolution. Disambiguates // `random.make` vs `tracer.make` when multiple namespaces // export the same member name. Keyed by `(namespace_local, @@ -3310,7 +3383,20 @@ pub fn run_with_parse_cache( // `typeof fsp === "boolean"`. Registering here lets the // catch-all route through `js_unresolved_namespace_stub` // (typeof "object", missing properties → undefined). + // + // Issue #684: skip WHOLE-DECL type-only imports + // (`import type * as X from "..."`). They're erased at + // runtime — the local binding never appears in any + // value-position expression, so registering it as a + // namespace would only widen the per-namespace member + // map below. Per-specifier type-only (`import { type Foo, + // bar }`) is still handled because the same import has + // value specifiers; the whole-decl flag is the one that + // makes the entire import a no-op. for import in &hir_module.imports { + if import.type_only { + continue; + } for spec in &import.specifiers { if let perry_hir::ImportSpecifier::Namespace { local } = spec { if !namespace_imports.contains(local) { @@ -3324,6 +3410,34 @@ pub fn run_with_parse_cache( if import.module_kind != perry_hir::ModuleKind::NativeCompiled { continue; } + // Issue #684: skip WHOLE-DECL type-only imports + // (`import type * as X`, `import type { Foo }`). They + // contribute zero runtime state — neither the namespace + // binding nor the named members ever appear in a + // value-position expression after type erasure. Pre-fix + // the loop below treated them like value imports and + // registered every export of the source module into + // `import_function_prefixes` / `namespace_member_prefixes`, + // which collided with later named-import registrations: + // effect's `ParseResult.ts` has both + // `import { TaggedError } from "./Data.js"` + // `import type * as Schema from "./Schema.js"` + // Schema.ts also exports `TaggedError`, so the type-only + // loop iteration registered `TaggedError → Schema_ts` + // into `import_function_prefixes`. If Schema.ts was + // processed AFTER Data.ts (HashMap iteration order is + // unstable), the Schema entry won — and top-level + // `class ParseError extends TaggedError("ParseError")` + // dispatched into Schema.ts's `TaggedError` instead of + // Data.ts's. Worse, Schema.ts is type-only so it isn't + // in `module_init_deps` either, meaning its backing + // global was still 0.0 — `js_closure_call1(0.0, ...)` + // threw `TypeError: value is not a function` during + // `ParseResult.ts__init`. Closes #684 (companion to + // #680's `module_init_deps` filter at L3234). + if import.type_only { + continue; + } let resolved_path = match &import.resolved_path { Some(p) => p, None => continue, @@ -3350,6 +3464,21 @@ pub fn run_with_parse_cache( compute_module_prefix(origin_path, &ctx.project_root); import_function_prefixes .insert(export_name.clone(), origin_prefix.clone()); + // Issue #678: surface origin-name overrides + // for namespace-imported members too. A + // member reached via a re-export rename + // (`export { default as foo }`) needs the + // codegen to call `perry_fn___default` + // when the consumer writes `ns.foo()`. + if let Some(origin_name) = all_module_export_origin_names + .get(&resolved_path_str) + .and_then(|m| m.get(export_name)) + { + if origin_name != export_name { + import_function_origin_names + .insert(export_name.clone(), origin_name.clone()); + } + } // Issue #680: also register under the // per-namespace key so `random.make` and // `tracer.make` can be disambiguated. @@ -3500,6 +3629,17 @@ pub fn run_with_parse_cache( compute_module_prefix(origin_path, &ctx.project_root); import_function_prefixes .insert(export_name.clone(), origin_prefix.clone()); + // Issue #678: surface origin-name overrides + // for the NamespaceReExport branch too. + if let Some(origin_name) = all_module_export_origin_names + .get(&ns_target_str) + .and_then(|m| m.get(export_name)) + { + if origin_name != export_name { + import_function_origin_names + .insert(export_name.clone(), origin_name.clone()); + } + } let key = (origin_path.clone(), export_name.clone()); if let Some(¶m_count) = exported_func_param_counts.get(&key) @@ -3619,6 +3759,30 @@ pub fn run_with_parse_cache( .insert(local_name.clone(), effective_prefix.clone()); } + // Issue #678: if the import chain renames through a + // re-export (`export { default as render } from + // './render.js'`), the symbol in the origin module + // is `perry_fn___default`, not + // `perry_fn___render`. Surface the deeper + // origin name via `import_function_origin_names` so + // the codegen can pick the right suffix when forming + // the extern symbol. The map is sparse — entries are + // only inserted when origin_name != exported_name. + let resolved_origin_name = all_module_export_origin_names + .get(&resolved_path_str) + .and_then(|m| m.get(&exported_name)) + .cloned(); + if let Some(ref origin_name) = resolved_origin_name { + if origin_name != &exported_name { + import_function_origin_names + .insert(exported_name.clone(), origin_name.clone()); + if local_name != exported_name { + import_function_origin_names + .insert(local_name.clone(), origin_name.clone()); + } + } + } + // Imported variables (not functions) — ExternFuncRef-as-value // should call the getter, not wrap as closure. Look up by the // ORIGIN path (where the `Let X = ...` actually lives), not @@ -3629,7 +3793,23 @@ pub fn run_with_parse_cache( // return value AS the call result — pgTable("users", cols) // returned the closure handle (typeof === "function") with no // pgTable body actually invoked. - if exported_var_names.contains(&origin_key) { + // + // Issue #678 followup: when a re-export rename routes + // the import through `export default `, the origin + // module's `exported_objects` carries the synthetic + // "default" entry (the only thing exported at that + // shape) — not the consumer-visible name. Probe both + // keys so the var-vs-function classification fires + // even when re-export renaming is in play. + let origin_key_under_origin_name = resolved_origin_name + .as_ref() + .map(|n| (origin_path.clone(), n.clone())); + if exported_var_names.contains(&origin_key) + || origin_key_under_origin_name + .as_ref() + .map(|k| exported_var_names.contains(k)) + .unwrap_or(false) + { imported_vars.insert(exported_name.clone()); if local_name != exported_name { imported_vars.insert(local_name.clone()); @@ -4369,6 +4549,7 @@ pub fn run_with_parse_cache( is_entry_module: is_entry, non_entry_module_prefixes, import_function_prefixes, + import_function_origin_names, namespace_member_prefixes, emit_ir_only: bitcode_link, namespace_imports, diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 4562201d..3bc289d5 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -255,6 +255,23 @@ pub fn compute_object_cache_key( h.field("import_fn_prefixes", &s); } + // Issue #678: include the origin-name overrides in the cache key. + // Without this, two builds where the same module imports the same + // names but with different re-export shapes (e.g. a downstream + // package renamed its barrel exports) would share a cached `.o` and + // silently emit the stale symbol suffix. + { + let mut v: Vec<(&String, &String)> = + opts.import_function_origin_names.iter().collect(); + v.sort_by(|a, b| a.0.cmp(b.0)); + let s: String = v + .iter() + .map(|(k, vv)| format!("{}={}", k, vv)) + .collect::>() + .join(","); + h.field("import_fn_origin_names", &s); + } + // Imported classes — sort by name. Serialize every field that codegen // reads so a changed constructor arity or new method on a re-exported // class invalidates consumers. @@ -553,6 +570,7 @@ mod object_cache_tests { is_entry_module: false, non_entry_module_prefixes: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), emit_ir_only: false, namespace_imports: Vec::new(), diff --git a/test-files/fixtures/issue_678_pkg/index.ts b/test-files/fixtures/issue_678_pkg/index.ts new file mode 100644 index 00000000..02556cb4 --- /dev/null +++ b/test-files/fixtures/issue_678_pkg/index.ts @@ -0,0 +1,9 @@ +// Re-exporter barrel for issue #678. The `default as renamed` shape is +// the canonical npm-barrel pattern (ink does `export { default as Box } +// from './components/Box.js'`, etc.). Pre-fix the consumer's codegen +// formed `perry_fn___renamedArrow` and `perry_fn___renamedPlain` +// even though the origin module emits the symbol under `default` / +// `plain`. + +export { default as renamedArrow } from "./sub/inner.ts"; +export { plain as renamedPlain } from "./sub/inner.ts"; diff --git a/test-files/fixtures/issue_678_pkg/sub/inner.ts b/test-files/fixtures/issue_678_pkg/sub/inner.ts new file mode 100644 index 00000000..9a3f29ba --- /dev/null +++ b/test-files/fixtures/issue_678_pkg/sub/inner.ts @@ -0,0 +1,26 @@ +// Origin module for the issue #678 regression fixture. +// +// Three export shapes the symbol-suffix resolver has to round-trip +// through `export { default as X } from "./inner.ts"`: +// +// 1. `const arrow = (x) => x + 1; export default arrow;` +// Origin emits `perry_fn___default` as the zero-arg getter; +// consumer must classify the import as `imported_vars` so the call +// site goes through `js_closure_callN` after fetching the value. +// +// 2. `export default function namedFn(x) { return x * 2; }` +// Origin emits `perry_fn___default` AND `perry_fn___namedFn`; +// consumer must call `__default` (not `__namedFn`) per ES spec when +// the importing module names the binding via the re-export rename. +// +// 3. `export function plain(x) { return x + 100; }` +// Plain function export — sanity check that non-rename imports still +// resolve to `perry_fn___plain` correctly with the new +// origin-name lookup in place. + +const arrow = (x: number): number => x + 1; +export default arrow; + +export function plain(x: number): number { + return x + 100; +} diff --git a/test-files/test_issue_678_reexport_default.ts b/test-files/test_issue_678_reexport_default.ts new file mode 100644 index 00000000..14b24a08 --- /dev/null +++ b/test-files/test_issue_678_reexport_default.ts @@ -0,0 +1,32 @@ +// Issue #678: consumer imports through a `export { default as X } from +// "./inner.ts"` re-export rename. Pre-fix the codegen formed +// `perry_fn___renamedArrow` even though `inner.ts` emits the +// symbol under `default`, and the linker failed with `Undefined +// symbols: _perry_fn_..._renamedArrow`. +// +// The fix tracks the origin export name across the re-export chain so +// every `perry_fn___` construction site picks the correct +// suffix; this file exercises the call-site, the value-as-arg site, and +// the plain non-rename path. + +import { renamedArrow, renamedPlain } from "./fixtures/issue_678_pkg/index.ts"; + +// Call site through the rename — origin export is `default` (a const +// arrow), consumer wrote `renamedArrow`. +console.log("arrow-call:", renamedArrow(41)); + +// Value-as-arg shape: hand the imported binding to another function so +// the codegen takes the `js_closure_alloc_singleton(@__perry_wrap_*)` +// path. Without the origin-name plumb-through, the wrapper symbol +// referenced here would also point at `__renamedArrow` and link-fail. +function apply(fn: (n: number) => number, value: number): number { + return fn(value); +} +console.log("arrow-as-value:", apply(renamedArrow, 99)); + +// Sanity check the non-default rename path: `export { plain as +// renamedPlain }` still resolves to `perry_fn___plain` rather +// than `__renamedPlain`. +console.log("plain-call:", renamedPlain(5)); + +console.log("done"); From 175bad1674dd21ef67352e395ecda64258d63de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 15 May 2026 16:49:51 +0200 Subject: [PATCH 2/2] style: cargo fmt #678 re-export-rename codegen sites --- crates/perry-codegen/src/codegen.rs | 6 ++---- crates/perry-codegen/src/expr.rs | 9 +++------ crates/perry/src/commands/compile/object_cache.rs | 3 +-- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 1f6d20ab..5ac05a5d 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -2740,10 +2740,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // src>__default`, not `perry_fn___render`. The local // wrapper still uses the consumer-visible name so this // module's own callers can find it. - let origin_suffix = crate::expr::import_origin_suffix( - &cross_module.import_function_origin_names, - name, - ); + let origin_suffix = + crate::expr::import_origin_suffix(&cross_module.import_function_origin_names, name); let target_name = format!("perry_fn_{}__{}", source_prefix, origin_suffix); // Look up the param count from the import metadata. Fall back // to 0 if missing — emits a no-arg wrapper, which is wrong diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index a4e93ab3..1ec7366f 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -3695,13 +3695,11 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let origin_suffix = import_origin_suffix(ctx.import_function_origin_names, property); if ctx.imported_vars.contains(property) { - let getter = - format!("perry_fn_{}__{}", source_prefix, origin_suffix); + let getter = format!("perry_fn_{}__{}", source_prefix, origin_suffix); ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); return Ok(ctx.block().call(DOUBLE, &getter, &[])); } - let target_name = - format!("perry_fn_{}__{}", source_prefix, origin_suffix); + let target_name = format!("perry_fn_{}__{}", source_prefix, origin_suffix); let wrap_name = format!("__perry_wrap_{}", target_name); let param_count = ctx .imported_func_param_counts @@ -11207,8 +11205,7 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(source_prefix) = ctx.import_function_prefixes.get(name).cloned() { // Issue #678: re-export renames mean the origin's symbol uses // the *origin* name as the suffix, not the consumer-visible one. - let origin_suffix = - import_origin_suffix(ctx.import_function_origin_names, name); + let origin_suffix = import_origin_suffix(ctx.import_function_origin_names, name); // Imported VARIABLES (exported consts/lets) need to be // called through their getter to fetch the value, not // wrapped as closures. Without this, `let v = HONE_VERSION` diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 3bc289d5..139cb667 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -261,8 +261,7 @@ pub fn compute_object_cache_key( // package renamed its barrel exports) would share a cached `.o` and // silently emit the stale symbol suffix. { - let mut v: Vec<(&String, &String)> = - opts.import_function_origin_names.iter().collect(); + let mut v: Vec<(&String, &String)> = opts.import_function_origin_names.iter().collect(); v.sort_by(|a, b| a.0.cmp(b.0)); let s: String = v .iter()