Summary
crates/perry-codegen/src/codegen/artifacts.rs has two existing wrapper-emission loops (closing #836/#837) that walk hir.exports and emit __perry_wrap_perry_fn_<module>__<name> closure-wrapper symbols for exports that need one — but both loops explicitly pattern-match only Export::Named { local, exported }:
let perry_hir::Export::Named { local, exported } = export else { continue; };
perry_hir::ir::decl::Export has two other variants that can equally end up needing a wrapper — ReExport { source, imported, exported } (export { X } from "mod") and NamespaceReExport { source, name } (export * as X from "mod") — and neither loop handles them. When a namespace-shaped value (e.g. an export * as Foo from "./foo" self-reference) flows through a further export { Foo } from "./that-module" re-export, and a consumer accesses it via Foo.method(...) (lowered to StaticMethodCall), codegen expects a __perry_wrap_perry_fn_<reexporting-module>__Foo symbol that is never emitted anywhere — link failure.
Minimal repro
// selfns.ts
export * as Token from "./selfns"
const CHARS_PER_TOKEN = 4
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))
// reexport_selfns.ts
export { Token, estimate } from "./selfns"
// main_selfns.ts
import { Token, estimate } from "./reexport_selfns"
console.log(Token.estimate("hello world"))
console.log(estimate("hello world"))
$ perry compile main_selfns.ts -o out
Undefined symbols for architecture arm64:
"___perry_wrap_perry_fn_reexport_selfns_ts__Token", referenced from:
_main in main_selfns_ts.o
ld: symbol(s) not found for architecture arm64
Removing the indirection (importing directly from ./selfns instead of through ./reexport_selfns) compiles and runs fine — confirming the bug is specifically about the wrapper not surviving a ReExport/NamespaceReExport hop, not about namespace re-exports in general.
Found via
Real-world source compile of opencode (sst/opencode) with perry.compilePackages: ["*"]. Confirmed on 12+ distinct barrel files across the app (Effect Schema/Context.Tag values re-exported across workspace package boundaries) plus in zod's v4/index.ts (export * from "./classic/index.js", re-exporting z) and effect's NodeSocket.ts (export * as NodeWS from "ws"). App-level workaround (rewrite export { X } from "mod" to import { X } from "mod"; export { X }) fixes every instance tried, but this is exactly the kind of thing real-world barrel-file-heavy TypeScript codebases do pervasively, so it's likely one of the more impactful compiler-side fixes available right now.
Suggested fix direction
Extend the two loops around crates/perry-codegen/src/codegen/artifacts.rs:768 and :887 (or add a third, analogous loop) to also handle Export::ReExport and Export::NamespaceReExport, emitting a forwarding wrapper that calls through to the resolved source module's own wrapper/accessor — recursing through further re-export chains if necessary (the repro above is a 2-hop chain: main → reexport_selfns → selfns, and selfns's NamespaceReExport of itself is also presumably unhandled — likely needs its own wrapper emission first, with ReExport's wrapper then forwarding to it).
Not attempting a fix in this issue — flagging with a precise, minimal, isolated repro (compiles/fails in seconds, no dependencies) for whoever picks it up, since a correct fix needs to handle the cross-module forwarding chain carefully rather than being a blind mechanical addition.
Summary
crates/perry-codegen/src/codegen/artifacts.rshas two existing wrapper-emission loops (closing #836/#837) that walkhir.exportsand emit__perry_wrap_perry_fn_<module>__<name>closure-wrapper symbols for exports that need one — but both loops explicitly pattern-match onlyExport::Named { local, exported }:perry_hir::ir::decl::Exporthas two other variants that can equally end up needing a wrapper —ReExport { source, imported, exported }(export { X } from "mod") andNamespaceReExport { source, name }(export * as X from "mod") — and neither loop handles them. When a namespace-shaped value (e.g. anexport * as Foo from "./foo"self-reference) flows through a furtherexport { Foo } from "./that-module"re-export, and a consumer accesses it viaFoo.method(...)(lowered toStaticMethodCall), codegen expects a__perry_wrap_perry_fn_<reexporting-module>__Foosymbol that is never emitted anywhere — link failure.Minimal repro
Removing the indirection (importing directly from
./selfnsinstead of through./reexport_selfns) compiles and runs fine — confirming the bug is specifically about the wrapper not surviving aReExport/NamespaceReExporthop, not about namespace re-exports in general.Found via
Real-world source compile of
opencode(sst/opencode) withperry.compilePackages: ["*"]. Confirmed on 12+ distinct barrel files across the app (EffectSchema/Context.Tagvalues re-exported across workspace package boundaries) plus inzod'sv4/index.ts(export * from "./classic/index.js", re-exportingz) andeffect'sNodeSocket.ts(export * as NodeWS from "ws"). App-level workaround (rewriteexport { X } from "mod"toimport { X } from "mod"; export { X }) fixes every instance tried, but this is exactly the kind of thing real-world barrel-file-heavy TypeScript codebases do pervasively, so it's likely one of the more impactful compiler-side fixes available right now.Suggested fix direction
Extend the two loops around
crates/perry-codegen/src/codegen/artifacts.rs:768and:887(or add a third, analogous loop) to also handleExport::ReExportandExport::NamespaceReExport, emitting a forwarding wrapper that calls through to the resolved source module's own wrapper/accessor — recursing through further re-export chains if necessary (the repro above is a 2-hop chain:main→reexport_selfns→selfns, andselfns'sNamespaceReExportof itself is also presumably unhandled — likely needs its own wrapper emission first, withReExport's wrapper then forwarding to it).Not attempting a fix in this issue — flagging with a precise, minimal, isolated repro (compiles/fails in seconds, no dependencies) for whoever picks it up, since a correct fix needs to handle the cross-module forwarding chain carefully rather than being a blind mechanical addition.