Repository navigation
perry compile silently produces wrong output when import resolves through namespace re-export (export * as X from ...) #310
Description
What happened
When user code does import { X } from "pkg" and the package's entry point re-exports X via the namespace form export * as X from "./X.js", Perry's module collector does not traverse the re-export. The compile succeeds, the binary links cleanly, and every property access on X (e.g. X.someFn(...)) silently returns 0 — Perry's "unknown identifier" lowering fallback. There is no warning that X.someFn was unresolved.
Reproduced with Effect but the bug is general: any npm package whose index.ts uses export * as Foo from "./Foo.js" exhibits the same silent-zero behavior under import { Foo } from "pkg".
What you expected
Either:
- Preferred: traverse the re-export and pull
./X.jsinto the module graph the same wayimport * as X from "pkg/X"does. - At minimum: emit a hard error or loud warning ("namespace re-export
Xfrompkg/index.tsnot traversed; calls onXwill return 0"). Today the user gets a silently-broken binary.
Minimal reproduction
mkdir nsre-zero && cd nsre-zero
cat > package.json <<'JSON'
{
"name": "nsre-zero",
"version": "0.0.0",
"type": "module",
"perry": { "compilePackages": ["effect"] }
}
JSON
bun add effect// test_minimal.ts
import { Effect } from "effect";
const program = Effect.succeed(42);
const result = Effect.runSync(program);
console.log("result:", result);Command:
perry compile test_minimal.ts -o /tmp/test_minimal
/tmp/test_minimalExpected output: result: 42
Actual output: result: 0 — exit 0, no errors, no warnings about Effect being unresolved
Diagnostic output
perry compile -v test_minimal.ts ... surfaces the smoking gun:
Collecting modules...
Compile package: effect
Found 3 module(s): 3 native, 0 JavaScript
Module init order (2 modules):
[0] node_modules/effect/src/Function.ts
[1] node_modules/effect/src/index.ts
Only 2 of Effect's 152 modules were collected. effect/src/Effect.ts (29,102 lines, the actual implementation) was never visited despite being explicitly named in the index.ts re-export. By contrast, import * as Effect from "effect/Effect" correctly pulls 152 modules (then OOMs on a separate Perry-side bug — filed as #309).
Relevant source
effect/src/index.ts line 229:
export * as Effect from "./Effect.js"This is a standard ES2020 namespace re-export and is the recommended pattern for "deep stdlib" packages (Effect, lodash-fp, ramda, etc.) — Perry should support it for compilePackages resolution.
Environment
- Perry version: 0.5.399
- Host OS: macOS 26.4 (arm64, Apple Silicon)
- Target: native
- Installed via: from source
- Effect version: 3.21.2
Notes for triage
- Likely fix site:
crates/perry/src/commands/compile/collect_modules.rsand / orcrates/perry/src/commands/compile/resolve.rs(the v0.5.340 split). Theimport { Foo } from "pkg"code path needs to consult the resolvedpkg's exports forFoo— and whenFoois itself a namespace re-export pointing at another file, recurse into that file's module graph. - A scoped workaround that doesn't fix the silent-zero but stops the false success: when a user-side
PropertyGetlowers to "unknown identifier X" ANDXcame from animport { X } from "pkg"binding, upgrade from "warn-and-lower-to-0" to a hard error. Today's "warn at lowering site, lower to 0" is the right default for genuinely-runtime-resolved globals (Intl,globalThis.foo) but completely wrong for an import that the resolver gave up on.
Why this matters
This is a silent-correctness bug — the binary produces wrong output and exits 0. Users with even modestly large dependency graphs will hit this without realizing it; the failure mode is "my Effect program prints all zeros" not "my Effect program won't compile." That is the worst possible failure mode for a compiler.
Activity
proggeramlug commentedon Apr 30, 2026
Same minimal repro project as #309 (the OOM case). The two failure modes are reachable from the same package install via different import shapes.
proggeramlug commentedon Apr 30, 2026
End-to-end Effect compat is now tracked under #321 — the codegen long tail surfaced once this was resolved is split across #314 / #315 / #316 / #317 / #318 / #319 / #320.
fix(codegen): closes #318 — delegate closure-collector descent to cen…
chore(stdlib): closes #320 — pin ReadableStream regression test (link…
fix(codegen): closes #319 — gate string self-append fast path on allo…
fix(hir): closes PerryTS#310 — traverse `export * as X from ...` name…
fix(hir): re-exporting an import-* namespace via export { ns } keeps …