You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
export * as ns is missing from the re-exporting module's namespace object (breaks z.coerce / z.iso / z.core / z.locales) #7189
export * as ns from "./m.ts" re-exports correctly as a named binding but is missing from the re-exporting module's namespace object. import * as B from "./b.ts" sees B.ns === undefined and Object.keys(B) omits it entirely, while import { ns } from "./b.ts" works.
Deterministic, GC-independent, reproduces at d8be5f4a2 in the default configuration (PERRY_NO_AUTO_OPTIMIZE=1, perry-dev build, macOS arm64).
Minimal repro (3 files)
ns2.ts
exportconstalpha=1;exportfunctionbeta(){return2;}
ns3.ts
export*asdeepfrom"./ns2.ts";exportconstgamma=3;
nsmain.ts
import*asBfrom"./ns3.ts";import{deep}from"./ns3.ts";console.log("namespace-object member :",typeofB.deep);console.log("named import of same :",typeofdeep,deep&&(deepasany).alpha);console.log("keys of B :",JSON.stringify(Object.keys(B).sort()));console.log("B.gamma :",B.gamma);
node 26.5.1
perry d8be5f4a2
typeof B.deep
object
undefined
typeof deep, deep.alpha
object 1
object 1 ✅
Object.keys(B)
["deep","gamma"]
["gamma"]
B.gamma
3
3 ✅
So the binding exists — it just never lands on the namespace object built for import * as.
Why it matters: zod
zod's src/index.ts is import * as z from "./v4/classic/external.js"; export { z };, and external.ts re-exports four sub-namespaces exactly this way:
All four are undefined under perry, so every consumer of these breaks:
import{z}from"zod";z.coerce.number().safeParse("42");// TypeError: Cannot read properties of undefined (reading 'number')z.iso.datetime().safeParse("...");// TypeError: Cannot read properties of undefined (reading 'datetime')
z.core and z.locales are likewise undefined. Interestingly Object.keys(z).length is 238 under both node and perry, so perry is contributing four other keys in their place — worth checking whether the namespace-object builder is mis-keying rather than merely dropping.
Environment
zod 4.4.3 via perry.compilePackages, Node 26.5.1 (.node-version pin), perry @ d8be5f4a2, macOS arm64. Found while investigating #7154.
Localized: the static and dynamic namespace paths are two implementations, and only the dynamic one handles export * as
Reproduced verbatim on Windows x86-64 at 0f9bb6cc5 (perry-dev), node 26.5.1 — so this is not platform-specific:
namespace-object member : undefined (node: object)
named import of same : object 1 ✅
keys of B : ["gamma"] (node: ["deep","gamma"])
B.gamma : 3 ✅
Root cause. Export flattening is already correct. flatten_into in perry-hir/src/dynamic_import.rs has a proper Export::NamespaceReExport arm that emits a FlatExport carrying nested_namespace_of: Some(source) — the field that exists precisely to say "this member is a module namespace, not a plain value".
The problem is downstream: nested_namespace_of has exactly one consumer in the whole tree.
$ grep -rn "nested_namespace_of" crates/ --include=*.rs
crates/perry/src/commands/compile/run_pipeline.rs:1936 <- the only read
crates/perry-hir/src/dynamic_import.rs:{89,180,208,223} <- definition + writes
crates/perry-hir/src/dynamic_import/tests.rs:{1027,1159}
Line 1936 sits inside the builder for per_module_namespace_entries, which is the dynamic-import (import("./m")) namespace path — it feeds NamespaceEntry → emit_namespace_populator → js_create_namespace. Static import * as B from "./ns3.ts" is built by a different mechanism (the namespace_imports / member-resolution machinery around run_pipeline.rs:2445-2600), which never goes through resolve_module_exports and therefore has no NamespaceReExport case at all.
That explains every row of the table exactly: gamma is a plain Export::Named and resolves; deep exists as a binding (so import { deep } works, and it is reachable) but is never enumerated into the static namespace object, so both B.deep and Object.keys(B) miss it.
This is the same shape as the two prototype-resolution paths called out in CLAUDE.md — two implementations of one concept that disagree — so the durable fix is to make the static path consume the same flattened FlatExport list (honoring nested_namespace_of) rather than to add a second NamespaceReExport special-case beside it.
Suggested acceptance coverage. A Object.keys(B) assertion, not just typeof B.deep — the value-resolution and enumeration sides fail independently, and a fix that only wires member access would still leave Object.keys wrong (which is what breaks zod's z.core/z.locales barrel iteration).
Not shipping a PR for this: routing the static path through the flattener is a real refactor of the namespace machinery and I could not verify it to the standard the rest of this work was held to. Diagnosis is complete and reproducible; the repro in the issue body works as written.
Reproduced verbatim on main @ db44b31b7 (perry-dev, PERRY_NO_AUTO_OPTIMIZE=1) — typeof B.deep is undefined, Object.keys(B) is ["gamma"], and the named import of the same binding is fine. Root-caused; posting the mechanism because the fix is bigger than the symptom suggests and the machinery for it already exists on a sibling path.
Where the binding is lost
crates/perry/src/commands/compile/run_pipeline.rs builds all_module_exports (module → export-name → origin path). Its own comment says what it is for: "This is used for namespace imports (import * as X from './module') to resolve all exports". It is also what Object.keys(ns) reflects.
The re-export flattening pass over that map (~lines 1070–1210) matches exactly three Export variants:
Export::NamespaceReExport { source, name } — the variant export * as deep from "./ns2.ts" lowers to — is not in that match. It is handled in init_order.rs, reachability.rs, collect_modules.rs and bootstrap.rs, so the module enters the graph and the named import resolves; it is only the namespace-surface map that never learns the name. Hence the exact split in the report: import { deep } works, B.deep does not, and Object.keys agrees with the map rather than with the module.
Why adding one match arm is not the whole fix
The other three arms map an export name to a symbol in an origin module. A namespace re-export's value is not a symbol — it is a namespace object for the source module. So the static path needs a value kind it does not currently have.
The dynamic-import path already has one. perry_hir::flatten_exports returns nested_namespace_of, and run_pipeline.rs (~line 1936) turns that into perry_codegen::NamespaceEntryKind::NestedNamespace { source_prefix }. But per_module_namespace_entries is populated only for target_path in &dyn_target_paths, and reaches codegen as namespace_entries, whose own comment states it is "empty unless this module is a dynamic-import target". Static import * as instead resolves members at compile time through namespace_member_prefixes / import_function_prefixes, which is why B.gamma works by name resolution and why a member with no symbol has nowhere to go.
So the shape of the fix is: teach the static namespace surface the same nested-namespace value the dynamic path already models — either by extending dyn_target_paths/namespace_entries to cover static namespace-import targets, or by giving the static resolver a nested-namespace kind of its own — plus the missing NamespaceReExport arm so the name appears in all_module_exports at all (without which Object.keys stays wrong even if member access is fixed).
I have not implemented it: this is the most special-cased region of the compiler (that single match carries issue-specific carve-outs for #4872, #678, #5924, #684 among others), and a wrong change there breaks module resolution broadly rather than locally. Filing the mechanism so whoever takes it starts from the two concrete call sites rather than from the symptom.
Activity
proggeramlug commentedon Aug 5, 2026
Localized: the static and dynamic namespace paths are two implementations, and only the dynamic one handles
export * asReproduced verbatim on Windows x86-64 at
0f9bb6cc5(perry-dev), node 26.5.1 — so this is not platform-specific:Root cause. Export flattening is already correct.
flatten_intoinperry-hir/src/dynamic_import.rshas a properExport::NamespaceReExportarm that emits aFlatExportcarryingnested_namespace_of: Some(source)— the field that exists precisely to say "this member is a module namespace, not a plain value".The problem is downstream:
nested_namespace_ofhas exactly one consumer in the whole tree.Line 1936 sits inside the builder for
per_module_namespace_entries, which is the dynamic-import (import("./m")) namespace path — it feedsNamespaceEntry→emit_namespace_populator→js_create_namespace. Staticimport * as B from "./ns3.ts"is built by a different mechanism (thenamespace_imports/ member-resolution machinery aroundrun_pipeline.rs:2445-2600), which never goes throughresolve_module_exportsand therefore has noNamespaceReExportcase at all.That explains every row of the table exactly:
gammais a plainExport::Namedand resolves;deepexists as a binding (soimport { deep }works, and it is reachable) but is never enumerated into the static namespace object, so bothB.deepandObject.keys(B)miss it.This is the same shape as the two prototype-resolution paths called out in CLAUDE.md — two implementations of one concept that disagree — so the durable fix is to make the static path consume the same flattened
FlatExportlist (honoringnested_namespace_of) rather than to add a secondNamespaceReExportspecial-case beside it.Suggested acceptance coverage. A
Object.keys(B)assertion, not justtypeof B.deep— the value-resolution and enumeration sides fail independently, and a fix that only wires member access would still leaveObject.keyswrong (which is what breaks zod'sz.core/z.localesbarrel iteration).Not shipping a PR for this: routing the static path through the flattener is a real refactor of the namespace machinery and I could not verify it to the standard the rest of this work was held to. Diagnosis is complete and reproducible; the repro in the issue body works as written.
Found while landing #7470 / #7471.
proggeramlug commentedon Aug 10, 2026
Reproduced verbatim on
main@db44b31b7(perry-dev,PERRY_NO_AUTO_OPTIMIZE=1) —typeof B.deepisundefined,Object.keys(B)is["gamma"], and the named import of the same binding is fine. Root-caused; posting the mechanism because the fix is bigger than the symptom suggests and the machinery for it already exists on a sibling path.Where the binding is lost
crates/perry/src/commands/compile/run_pipeline.rsbuildsall_module_exports(module → export-name → origin path). Its own comment says what it is for: "This is used for namespace imports (import * as X from './module') to resolve all exports". It is also whatObject.keys(ns)reflects.The re-export flattening pass over that map (~lines 1070–1210) matches exactly three
Exportvariants:Export::NamespaceReExport { source, name }— the variantexport * as deep from "./ns2.ts"lowers to — is not in that match. It is handled ininit_order.rs,reachability.rs,collect_modules.rsandbootstrap.rs, so the module enters the graph and the named import resolves; it is only the namespace-surface map that never learns the name. Hence the exact split in the report:import { deep }works,B.deepdoes not, andObject.keysagrees with the map rather than with the module.Why adding one match arm is not the whole fix
The other three arms map an export name to a symbol in an origin module. A namespace re-export's value is not a symbol — it is a namespace object for the source module. So the static path needs a value kind it does not currently have.
The dynamic-import path already has one.
perry_hir::flatten_exportsreturnsnested_namespace_of, andrun_pipeline.rs(~line 1936) turns that intoperry_codegen::NamespaceEntryKind::NestedNamespace { source_prefix }. Butper_module_namespace_entriesis populated onlyfor target_path in &dyn_target_paths, and reaches codegen asnamespace_entries, whose own comment states it is "empty unless this module is a dynamic-import target". Staticimport * asinstead resolves members at compile time throughnamespace_member_prefixes/import_function_prefixes, which is whyB.gammaworks by name resolution and why a member with no symbol has nowhere to go.So the shape of the fix is: teach the static namespace surface the same nested-namespace value the dynamic path already models — either by extending
dyn_target_paths/namespace_entriesto cover static namespace-import targets, or by giving the static resolver a nested-namespace kind of its own — plus the missingNamespaceReExportarm so the name appears inall_module_exportsat all (without whichObject.keysstays wrong even if member access is fixed).I have not implemented it: this is the most special-cased region of the compiler (that single match carries issue-specific carve-outs for #4872, #678, #5924, #684 among others), and a wrong change there breaks module resolution broadly rather than locally. Filing the mechanism so whoever takes it starts from the two concrete call sites rather than from the symptom.
fix(codegen): put export-star-as aliases on the namespace object (#7189…