From e73c2bf27a5ba765b0311f0692054e4b53a871ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 18:28:08 +0200 Subject: [PATCH 1/2] fix(codegen): resolve reexports through star barrels --- crates/perry-codegen/src/codegen/helpers.rs | 6 +- crates/perry-hir/src/dynamic_import.rs | 173 +--------------- .../src/dynamic_import/binding_origin.rs | 193 ++++++++++++++++++ crates/perry-hir/src/dynamic_import/tests.rs | 65 ++++++ crates/perry-hir/src/lower/module_decl.rs | 6 + test-files/_helpers/issue_7964_barrel.ts | 1 + test-files/_helpers/issue_7964_bridge.ts | 1 + test-files/_helpers/issue_7964_leaf.ts | 12 ++ test-files/_helpers/issue_7964_top.ts | 3 + .../test_gap_export_star_variable_reexport.ts | 7 + 10 files changed, 295 insertions(+), 172 deletions(-) create mode 100644 crates/perry-hir/src/dynamic_import/binding_origin.rs create mode 100644 test-files/_helpers/issue_7964_barrel.ts create mode 100644 test-files/_helpers/issue_7964_bridge.ts create mode 100644 test-files/_helpers/issue_7964_leaf.ts create mode 100644 test-files/_helpers/issue_7964_top.ts create mode 100644 test-files/test_gap_export_star_variable_reexport.ts diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 72bdeaa5f1..8e47d7eb26 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1541,7 +1541,11 @@ pub(super) fn emit_namespace_populator( let wrapper_name = format!( "__perry_wrap_perry_fn_{}__{}", source_prefix, - sanitize(source_local) + // Function bodies and their closure wrappers use the + // injective function-name mangler. Plain names are + // unchanged; `$constructor` and similar exports must + // not be collapsed to `_constructor` here (#7964). + sanitize_member(source_local) ); let arity = (*param_count).min(16); let mut wrapper_params: Vec = vec![I64]; diff --git a/crates/perry-hir/src/dynamic_import.rs b/crates/perry-hir/src/dynamic_import.rs index e450fff0c3..0c89b8cd24 100644 --- a/crates/perry-hir/src/dynamic_import.rs +++ b/crates/perry-hir/src/dynamic_import.rs @@ -27,7 +27,9 @@ use std::collections::{HashMap, HashSet}; /// to. Over-cap produces a compile error per D2 (issue #100). pub const DYNAMIC_IMPORT_PATH_CAP: usize = 64; +mod binding_origin; mod visitors; +use binding_origin::{resolve_binding_origin, BindingOrigin}; pub use visitors::{ for_each_dynamic_import, for_each_dynamic_import_mut, for_each_worker_new, for_each_worker_new_mut, @@ -227,177 +229,6 @@ fn flatten_into<'a, F>( } } -/// #6304: where an exported name's value actually lives, after following -/// import bindings and re-export hops through the module graph. -struct BindingOrigin { - /// Module that owns the binding. - source_module: String, - /// The name the binding has *in* `source_module`. - source_local: String, - /// `Some(m)` when the binding is the module namespace of `m` rather than - /// a plain value (`import * as X` / `export * as X`). - namespace_of: Option, -} - -/// True when `module` actually *defines* `name` (as opposed to merely -/// importing or re-exporting it). A definition stops origin resolution. -fn defines_local_binding(module: &Module, name: &str) -> bool { - module.functions.iter().any(|f| f.name == name) - || module.classes.iter().any(|c| c.name == name) - || module.globals.iter().any(|g| g.name == name) - || module.enums.iter().any(|e| e.name == name) -} - -/// The import binding (if any) that `name` refers to in `module`. -/// -/// Native / builtin imports (`import { readFile } from "fs"`) are deliberately -/// excluded: their source is not a compiled module in the graph, so redirecting -/// an export to them would name a module that has no HIR and no -/// `perry_fn_*` symbols. Those keep the pre-existing local-lookup behaviour. -fn find_import_binding(module: &Module, name: &str) -> Option<(String, ImportBindingKind)> { - for import in &module.imports { - if import.type_only || import.is_native { - continue; - } - for spec in &import.specifiers { - match spec { - crate::ir::ImportSpecifier::Named { imported, local } if local == name => { - return Some(( - import.source.clone(), - ImportBindingKind::Value(imported.clone()), - )); - } - crate::ir::ImportSpecifier::Default { local } if local == name => { - return Some(( - import.source.clone(), - ImportBindingKind::Value("default".to_string()), - )); - } - crate::ir::ImportSpecifier::Namespace { local } if local == name => { - return Some((import.source.clone(), ImportBindingKind::Namespace)); - } - _ => {} - } - } - } - None -} - -enum ImportBindingKind { - /// A plain value binding; the payload is the name in the source module. - Value(String), - /// The whole module namespace of the import's source. - Namespace, -} - -/// #6304: resolve `(module_name, local)` to the module that actually defines -/// the binding, following import bindings and `ReExport` / `NamespaceReExport` -/// hops. -/// -/// Returns `None` when nothing could be followed — either `module_name` already -/// defines the binding, or the chain leaves the compiled-module graph (a native -/// import, or a source we have no HIR for). Callers then keep their pre-existing -/// default, so this is strictly a refinement: it can only move an entry CLOSER -/// to a real definition, never invent one. -/// -/// Cycle-safe: a `(module, name)` pair already visited terminates the walk, so a -/// self-referential barrel (`export * as Token from "./selfns"` inside -/// `selfns.ts`) cannot loop forever. -fn resolve_binding_origin<'a, F>( - start_module: &str, - start_local: &str, - lookup: &F, -) -> Option -where - F: Fn(&str) -> Option<&'a Module>, -{ - let mut module_name = start_module.to_string(); - let mut local = start_local.to_string(); - let mut seen: HashSet<(String, String)> = HashSet::new(); - // Only report an origin once we've actually moved somewhere new; otherwise - // the caller's existing default already names the right module. - let mut moved = false; - - loop { - if !seen.insert((module_name.clone(), local.clone())) { - break; - } - let Some(module) = lookup(&module_name) else { - break; - }; - // A real definition here — this is the owner. - if defines_local_binding(module, &local) { - break; - } - - // `import { x } from "src"; export { x }` — hop to `src`. - if let Some((source, kind)) = find_import_binding(module, &local) { - if lookup(&source).is_none() { - break; - } - match kind { - ImportBindingKind::Value(imported) => { - module_name = source; - local = imported; - moved = true; - continue; - } - ImportBindingKind::Namespace => { - return Some(BindingOrigin { - source_module: source.clone(), - source_local: String::new(), - namespace_of: Some(source), - }); - } - } - } - - // `export { x } from "src"` / `export * as X from "src"` — hop through - // the re-export. Lets a chain of barrels (or bundler chunks that - // re-export one another) reach the ultimate owner. - let mut hopped = false; - for export in &module.exports { - match export { - Export::ReExport { - source, - imported, - exported, - } if *exported == local => { - if lookup(source).is_none() { - break; - } - module_name = source.clone(); - local = imported.clone(); - moved = true; - hopped = true; - break; - } - Export::NamespaceReExport { source, name } if *name == local => { - if lookup(source).is_none() { - break; - } - return Some(BindingOrigin { - source_module: source.clone(), - source_local: String::new(), - namespace_of: Some(source.clone()), - }); - } - _ => {} - } - } - if hopped { - continue; - } - break; - } - - moved.then(|| BindingOrigin { - source_module: module_name, - source_local: local, - namespace_of: None, - }) -} - /// Issue #100 / #1725 / #1674: collect every `Stmt::Let { init: Some(_), .. }` /// reachable in the module into a `local_id → init_expr` map — the module-init /// body, every function / method / constructor body, and (descending) nested diff --git a/crates/perry-hir/src/dynamic_import/binding_origin.rs b/crates/perry-hir/src/dynamic_import/binding_origin.rs new file mode 100644 index 0000000000..2abeb3ac81 --- /dev/null +++ b/crates/perry-hir/src/dynamic_import/binding_origin.rs @@ -0,0 +1,193 @@ +//! Resolve namespace entries to the module that owns each exported binding. + +use crate::ir::{Export, ImportSpecifier, Module, Stmt}; +use std::collections::HashSet; + +/// #6304: where an exported name's value actually lives, after following +/// import bindings and re-export hops through the module graph. +pub(super) struct BindingOrigin { + /// Module that owns the binding. + pub(super) source_module: String, + /// The name the binding has *in* `source_module`. + pub(super) source_local: String, + /// `Some(m)` when the binding is the module namespace of `m` rather than + /// a plain value (`import * as X` / `export * as X`). + pub(super) namespace_of: Option, +} + +/// True when `module` actually defines `name`, as opposed to importing or +/// re-exporting it. A definition stops origin resolution. +fn defines_local_binding(module: &Module, name: &str) -> bool { + module.functions.iter().any(|f| f.name == name) + || module.classes.iter().any(|c| c.name == name) + || module.globals.iter().any(|g| g.name == name) + || module.enums.iter().any(|e| e.name == name) + // Module-scoped `const` / `let` declarations live as direct `Stmt::Let` + // entries in `Module::init`, not in `Module::globals`. They are still + // real local definitions and must stop a re-export origin walk. + || module.init.iter().any(|stmt| { + matches!(stmt, Stmt::Let { name: local, .. } if local == name) + }) +} + +/// The import binding, if any, that `name` refers to in `module`. +/// Native imports are excluded because their source has no compiled HIR owner. +fn find_import_binding(module: &Module, name: &str) -> Option<(String, ImportBindingKind)> { + for import in &module.imports { + if import.type_only || import.is_native { + continue; + } + for spec in &import.specifiers { + match spec { + ImportSpecifier::Named { imported, local } if local == name => { + return Some(( + import.source.clone(), + ImportBindingKind::Value(imported.clone()), + )); + } + ImportSpecifier::Default { local } if local == name => { + return Some(( + import.source.clone(), + ImportBindingKind::Value("default".to_string()), + )); + } + ImportSpecifier::Namespace { local } if local == name => { + return Some((import.source.clone(), ImportBindingKind::Namespace)); + } + _ => {} + } + } + } + None +} + +enum ImportBindingKind { + Value(String), + Namespace, +} + +/// Resolve `(module_name, local)` to the module that actually defines the +/// binding. Returns `None` when the chain does not move or leaves the compiled +/// module graph, preserving the caller's existing one-hop fallback. +pub(super) fn resolve_binding_origin<'a, F>( + start_module: &str, + start_local: &str, + lookup: &F, +) -> Option +where + F: Fn(&str) -> Option<&'a Module>, +{ + let mut seen = HashSet::new(); + let origin = resolve_exported_binding(start_module, start_local, lookup, &mut seen)?; + (origin.source_module != start_module + || origin.source_local != start_local + || origin.namespace_of.is_some()) + .then_some(origin) +} + +/// Resolve one exported binding to its owner, including an `export *` barrel. +/// +/// #7964: `leaf: export const v`, `barrel: export * from leaf`, and +/// `bridge: export { v } from barrel` must resolve to `leaf`, because the pure +/// barrel does not emit a `perry_fn_barrel__v` getter. Each star branch gets its +/// own cycle set; distinct successful owners are ambiguous and do not resolve. +fn resolve_exported_binding<'a, F>( + module_name: &str, + local: &str, + lookup: &F, + seen: &mut HashSet<(String, String)>, +) -> Option +where + F: Fn(&str) -> Option<&'a Module>, +{ + if !seen.insert((module_name.to_string(), local.to_string())) { + return None; + } + let module = lookup(module_name)?; + + if defines_local_binding(module, local) { + return Some(BindingOrigin { + source_module: module_name.to_string(), + source_local: local.to_string(), + namespace_of: None, + }); + } + + if let Some((source, kind)) = find_import_binding(module, local) { + if lookup(&source).is_some() { + return match kind { + ImportBindingKind::Value(imported) => { + resolve_exported_binding(&source, &imported, lookup, seen) + } + ImportBindingKind::Namespace => Some(BindingOrigin { + source_module: source.clone(), + source_local: String::new(), + namespace_of: Some(source), + }), + }; + } + } + + // `const _null = ...; export { _null as null }` maps the export name back + // to the local binding before the walk continues. Zod exports both `null` + // and `undefined` this way. + for export in &module.exports { + if let Export::Named { + local: source, + exported, + } = export + { + if exported == local && source != local { + return resolve_exported_binding(module_name, source, lookup, seen); + } + } + } + + // Explicit cross-module exports take precedence over star exports. + for export in &module.exports { + match export { + Export::ReExport { + source, + imported, + exported, + } if exported == local && lookup(source).is_some() => { + return resolve_exported_binding(source, imported, lookup, seen); + } + Export::NamespaceReExport { source, name } + if name == local && lookup(source).is_some() => + { + return Some(BindingOrigin { + source_module: source.clone(), + source_local: String::new(), + namespace_of: Some(source.clone()), + }); + } + _ => {} + } + } + + if local == "default" { + return None; + } + + let mut resolved: Option = None; + for export in &module.exports { + let Export::ExportAll { source } = export else { + continue; + }; + let mut branch_seen = seen.clone(); + let Some(candidate) = resolve_exported_binding(source, local, lookup, &mut branch_seen) + else { + continue; + }; + if resolved.as_ref().is_some_and(|prior| { + prior.source_module != candidate.source_module + || prior.source_local != candidate.source_local + || prior.namespace_of != candidate.namespace_of + }) { + return None; + } + resolved = Some(candidate); + } + resolved +} diff --git a/crates/perry-hir/src/dynamic_import/tests.rs b/crates/perry-hir/src/dynamic_import/tests.rs index 604df46647..fcfe16add6 100644 --- a/crates/perry-hir/src/dynamic_import/tests.rs +++ b/crates/perry-hir/src/dynamic_import/tests.rs @@ -1082,6 +1082,71 @@ fn flatten_reexport_chain_reaches_ultimate_owner() { assert_eq!(flat[0].source_local, "run"); } +#[test] +fn flatten_explicit_reexport_through_export_all_reaches_ultimate_owner() { + // #7964 — zod's source graph has this exact mixed chain: + // + // core.ts: export const NEVER = ... + // core/index: export * from "./core.js" + // external.ts: export { NEVER } from "../core/index.js" + // + // `external.ts` is then itself materialized as a namespace. Stopping at + // core/index makes codegen ask that pure barrel for a getter it never + // emits, so the dependency corpus fails at link time. + let mut leaf = Module::new("leaf"); + leaf.init.push(Stmt::Let { + id: 1, + name: "NEVER".into(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Number(1.0)), + }); + leaf.exports.push(Export::Named { + local: "NEVER".into(), + exported: "NEVER".into(), + }); + leaf.init.push(Stmt::Let { + id: 2, + name: "_null".into(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Number(2.0)), + }); + leaf.exports.push(Export::Named { + local: "_null".into(), + exported: "null".into(), + }); + let mut barrel = Module::new("barrel"); + barrel.exports.push(Export::ExportAll { + source: "leaf".into(), + }); + let mut bridge = Module::new("bridge"); + bridge.exports.push(Export::ReExport { + source: "barrel".into(), + imported: "NEVER".into(), + exported: "NEVER".into(), + }); + bridge.exports.push(Export::ReExport { + source: "barrel".into(), + imported: "null".into(), + exported: "null".into(), + }); + let map = std::collections::HashMap::from([ + ("leaf".to_string(), leaf), + ("barrel".to_string(), barrel), + ("bridge".to_string(), bridge), + ]); + let lookup = |s: &str| map.get(s); + let flat = flatten_exports("bridge", &lookup); + assert_eq!(flat.len(), 2); + assert_eq!(flat[0].name, "NEVER"); + assert_eq!(flat[0].source_module, "leaf"); + assert_eq!(flat[0].source_local, "NEVER"); + assert_eq!(flat[1].name, "null"); + assert_eq!(flat[1].source_module, "leaf"); + assert_eq!(flat[1].source_local, "_null"); +} + #[test] fn flatten_local_definition_still_wins_over_same_named_import() { // A module that DEFINES the name it exports must keep pointing at itself — diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index b31ffc253b..407a3e72f8 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1601,6 +1601,12 @@ pub(crate) fn lower_module_decl( | Expr::BigInt(_) | Expr::Null | Expr::Undefined + // #7964: renamed RegExp literals are values too. + // Zod exports `_null as null` and `_undefined as + // undefined`; omitting these from exported_objects + // leaves the namespace populator calling getters + // that the producer never emits. + | Expr::RegExp { .. } // Refs #420 (drizzle): `const entityKind = Symbol.for(...)` // followed by `export { entityKind }` must register the // local as an exported variable so importing modules diff --git a/test-files/_helpers/issue_7964_barrel.ts b/test-files/_helpers/issue_7964_barrel.ts new file mode 100644 index 0000000000..bca0ae887b --- /dev/null +++ b/test-files/_helpers/issue_7964_barrel.ts @@ -0,0 +1 @@ +export * from "./issue_7964_leaf.ts"; diff --git a/test-files/_helpers/issue_7964_bridge.ts b/test-files/_helpers/issue_7964_bridge.ts new file mode 100644 index 0000000000..ec97032f6d --- /dev/null +++ b/test-files/_helpers/issue_7964_bridge.ts @@ -0,0 +1 @@ +export { NEVER, $brand, null, undefined, $constructor } from "./issue_7964_barrel.ts"; diff --git a/test-files/_helpers/issue_7964_leaf.ts b/test-files/_helpers/issue_7964_leaf.ts new file mode 100644 index 0000000000..3a3d774d01 --- /dev/null +++ b/test-files/_helpers/issue_7964_leaf.ts @@ -0,0 +1,12 @@ +export const NEVER = Object.freeze({ status: "aborted" }); +export const $brand = Symbol("brand"); + +const _null = /^null$/i; +export { _null as null }; + +const _undefined = /^undefined$/i; +export { _undefined as undefined }; + +export function $constructor(): number { + return 7; +} diff --git a/test-files/_helpers/issue_7964_top.ts b/test-files/_helpers/issue_7964_top.ts new file mode 100644 index 0000000000..b10cf4aff3 --- /dev/null +++ b/test-files/_helpers/issue_7964_top.ts @@ -0,0 +1,3 @@ +import * as z from "./issue_7964_bridge.ts"; + +export { z }; diff --git a/test-files/test_gap_export_star_variable_reexport.ts b/test-files/test_gap_export_star_variable_reexport.ts new file mode 100644 index 0000000000..3c5bb4b725 --- /dev/null +++ b/test-files/test_gap_export_star_variable_reexport.ts @@ -0,0 +1,7 @@ +import { z } from "./_helpers/issue_7964_top.ts"; + +console.log(z.NEVER.status); +console.log(typeof z.$brand); +console.log(z.null.test("NULL")); +console.log(z.undefined.test("undefined")); +console.log(z.$constructor()); From b6ef52b06872328fee806b274d40f6cf982bda52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 18:42:06 +0200 Subject: [PATCH 2/2] docs(changelog): note star re-export fix --- changelog.d/7980-zod-star-reexports.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7980-zod-star-reexports.md diff --git a/changelog.d/7980-zod-star-reexports.md b/changelog.d/7980-zod-star-reexports.md new file mode 100644 index 0000000000..5c1bf7fe8f --- /dev/null +++ b/changelog.d/7980-zod-star-reexports.md @@ -0,0 +1 @@ +Fixed namespace materialization through mixed `export *` and explicit re-export chains, including renamed RegExp values and `$`-prefixed functions, so the pinned Zod dependency corpus links again.