Skip to content

export * as ns is missing from the re-exporting module's namespace object (breaks z.coerce / z.iso / z.core / z.locales) #7189

Closed

Description

@proggeramlug

Summary

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

export const alpha = 1;
export function beta() { return 2; }

ns3.ts

export * as deep from "./ns2.ts";
export const gamma = 3;

nsmain.ts

import * as B from "./ns3.ts";
import { deep } from "./ns3.ts";
console.log("namespace-object member :", typeof B.deep);
console.log("named import of same    :", typeof deep, deep && (deep as any).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:

export * as core    from "../core/index.js";
export * as locales from "../locales/index.js";
export * as iso     from "./iso.js";
export * as coerce  from "./coerce.js";

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.

Refs #7154.

Activity

  1. proggeramlug commented on Aug 5, 2026

    @proggeramlug
    ContributorAuthor

    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.

    Found while landing #7470 / #7471.

  2. proggeramlug commented on Aug 10, 2026

    @proggeramlug
    ContributorAuthor

    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:

    perry_hir::Export::ExportAll { source }            => { … }
    perry_hir::Export::ReExport { source, … }          => { … }
    perry_hir::Export::Named { local, exported }       => { … }

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions