Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7980-zod-star-reexports.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +1544 to +1548

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make sanitize_member injective.

sanitize_member("$constructor") produces u__24_constructor. The valid plain name u__24_constructor produces the same result through the plain-name path. A module that exports both functions creates colliding wrapper symbols.

Encode plain names and escaped names in disjoint forms, or encode every name with one reversible scheme. Add a regression that exports both names.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/codegen/helpers.rs` around lines 1544 - 1548, Update
sanitize_member so plain names and escaped names always produce distinct,
reversible outputs; specifically ensure "$constructor" cannot collide with the
valid plain name "u__24_constructor". Preserve existing mangling behavior where
possible, and add a regression covering a module that exports both names.

);
let arity = (*param_count).min(16);
let mut wrapper_params: Vec<crate::types::LlvmType> = vec![I64];
Expand Down
173 changes: 2 additions & 171 deletions crates/perry-hir/src/dynamic_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
}

/// 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<BindingOrigin>
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
Expand Down
193 changes: 193 additions & 0 deletions crates/perry-hir/src/dynamic_import/binding_origin.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<BindingOrigin>
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<BindingOrigin>
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,
});
}
Comment on lines +108 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject private bindings in export * branches.

Line 108 accepts any local definition before it proves that the binding is exported. If one star-export source has a private v and another source exports v, the resolver treats both as owners and returns None. The caller then falls back to a getter on the barrel module, which does not emit that getter.

Require a local definition to have an Export::Named entry for that local binding before returning it. Add a regression with two export * sources where only one exports the requested name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/dynamic_import/binding_origin.rs` around lines 108 -
114, Update the local-binding branch in the binding-origin resolver to return a
BindingOrigin only when the local name has a corresponding Export::Named entry;
private local definitions must be ignored during export-star resolution. Add a
regression covering two export-star sources where only one exports the requested
name, preserving resolution to the exporting source.


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<BindingOrigin> = 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
}
Loading
Loading