From ed5e141ce23b6603423bac3f6fb8e636b7bd9e06 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 18 Jul 2026 09:26:53 -0700 Subject: [PATCH 1/2] fix(compile): namespace import of a CJS default-function links its direct call (#6586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A namespace import of a CommonJS module whose `module.exports` value is itself the export (`module.exports = function equal(){}`, no `__esModule` marker) is TypeScript's `esModuleInterop=false` interop: `import * as equal from "fast-deep-equal"` binds `equal` to the whole `require()` result (the default export), so a direct call `equal(a, b)` is a call OF that value. ajv's `lib/compile/resolve.ts` does exactly this for `fast-deep-equal` and `json-schema-traverse`, and `fast-json-stringify` pulls ajv in. Perry's CJS wrap already emits that value under the module's public `default` symbol, and a *default* import of the same module linked and ran correctly — but a *namespace* import never got an `import_function_prefixes` / `import_function_origin_names` entry for its whole-value binding, so the direct call fell through to a bare `equal` extern and the link died with `Undefined symbols: "_equal"` / `"_traverse"`. Wire the namespace local's whole-value binding to the module's `default` symbol (prefix + origin name + var-shaped `imported_vars` classification), exactly like a Default specifier. Consumer-side only; the exporting module already emits the `default` symbol as public. Namespace member reads (`ns.foo`) are keyed per-namespace via `namespace_member_prefixes` and are unaffected, and real ESM namespace imports (no callable default value) are unchanged — verified their whole-value/member reads still match Node. This clears the link wall in front of `fast-json-stringify@7`; the remaining `ReferenceError: module is not defined` (ajv's dual ESM+CJS `lib/*.ts`) is a separate, deeper follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/commands/compile/run_pipeline.rs | 65 +++++++++ ...issue_6586_namespace_cjs_default_import.rs | 129 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 crates/perry/tests/issue_6586_namespace_cjs_default_import.rs diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 025bda9d30..2b9cb507a4 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2440,6 +2440,71 @@ pub fn run_with_parse_cache( }; if let Some(local) = namespace_like_local { namespace_imports.push(local.clone()); + // Issue #6586: a namespace import of a CommonJS module + // whose `module.exports` value is itself the export + // (`module.exports = function equal(){}`, no + // `__esModule` marker) is TypeScript's + // esModuleInterop=false interop — `import * as equal + // from "fast-deep-equal"` binds `equal` to the whole + // `require()` result (the default export), so a DIRECT + // call `equal(a, b)` is a call OF that value. ajv's + // `lib/compile/resolve.ts` does exactly this for + // `fast-deep-equal` and `json-schema-traverse`, and + // fast-json-stringify pulls ajv in. The CJS wrap emits + // the value under the module's `default` symbol, but the + // namespace binding had no `import_function_prefixes` + // entry, so the direct call fell through to a bare + // `equal` extern and the link died with + // `Undefined symbols: "_equal"`. Wire the whole-value + // binding to the `default` export exactly like a Default + // specifier does below (member reads `ns.foo` are keyed + // per-namespace via `namespace_member_prefixes` and are + // unaffected). Only genuine namespace imports of a module + // that actually HAS a `default` export qualify — the + // #4872 default-import-of-a-named-only-barrel case that + // also lands here has no `default` and is skipped. + if matches!(spec, perry_hir::ImportSpecifier::Namespace { .. }) { + if let Some(default_origin_path) = all_module_exports + .get(&resolved_path_str) + .and_then(|exports| exports.get("default")) + .cloned() + { + let default_prefix = compute_module_prefix( + &default_origin_path, + &ctx.project_root, + ); + let default_suffix = all_module_export_origin_names + .get(&resolved_path_str) + .and_then(|m| m.get("default")) + .cloned() + .unwrap_or_else(|| "default".to_string()); + import_function_prefixes + .entry(local.clone()) + .or_insert(default_prefix); + import_function_origin_names + .entry(local.clone()) + .or_insert(default_suffix.clone()); + // A CJS `module.exports = ` becomes a + // var-shaped default: a value binding emitted as + // a zero-arg getter. A direct call must fetch the + // closure via that getter and THEN invoke it with + // the args (`js_closure_callN`), so mark the local + // as an imported var — otherwise the call site + // treats the getter's return value AS the call + // result and `equal(1, 1)` yields the function + // itself instead of `true`. Mirrors the + // Default-import var classification below. + if exported_var_names + .contains(&(default_origin_path.clone(), default_suffix)) + || exported_var_names.contains(&( + resolved_path_str.clone(), + "default".to_string(), + )) + { + imported_vars.insert(local.clone()); + } + } + } // Register all exports from the source module if let Some(exports) = all_module_exports.get(&resolved_path_str) { for (export_name, origin_path) in exports { diff --git a/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs b/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs new file mode 100644 index 0000000000..f3cf7a8608 --- /dev/null +++ b/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs @@ -0,0 +1,129 @@ +//! Regression test for #6586: a namespace import of a CommonJS module whose +//! `module.exports` value is itself the export +//! (`module.exports = function equal(){}`) — TypeScript's +//! `esModuleInterop=false` interop shape — must bind the namespace local to the +//! default export so a DIRECT call of it (`equal(a, b)`) links. +//! +//! This is the wall that blocks `fast-json-stringify` (via `ajv`): ajv's +//! `lib/compile/resolve.ts` does +//! +//! ```ts +//! import * as equal from "fast-deep-equal" +//! import * as traverse from "json-schema-traverse" +//! // ... +//! traverse(schema, {allKeys: true}, (sch) => { /* ... */ }) +//! if (!equal(sch1, sch2)) throw ambiguos(ref) +//! ``` +//! +//! where both deps are pure CJS `module.exports = function`. Pre-fix, the +//! namespace binding had no `import_function_prefixes` entry, so the direct +//! calls fell through to bare `equal` / `traverse` externs and the link died +//! with `Undefined symbols: "_equal", "_traverse"`. A DEFAULT import of the +//! same module (`import equal from "..."`) always linked — the fix routes the +//! namespace whole-value binding to the module's `default` symbol the same way. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn namespace_import_of_cjs_default_function_links_and_calls() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "ns-cjs-default", + "private": true, + "perry": { + "compilePackages": ["fakeequal", "faketraverse"], + "allow": { "compilePackages": ["fakeequal", "faketraverse"] } + } +}"#, + ) + .expect("write consumer package.json"); + + // fast-deep-equal shape: a bare `module.exports = function`. + let equal_pkg = root.join("node_modules").join("fakeequal"); + std::fs::create_dir_all(&equal_pkg).expect("mkdir fakeequal"); + std::fs::write( + equal_pkg.join("package.json"), + r#"{ "name": "fakeequal", "version": "1.0.0", "main": "index.js" }"#, + ) + .expect("write fakeequal package.json"); + std::fs::write( + equal_pkg.join("index.js"), + "'use strict';\nmodule.exports = function equal(a, b) { return a === b; };\n", + ) + .expect("write fakeequal index.js"); + + // json-schema-traverse shape: `module.exports = fn` PLUS a + // `module.exports.default = fn` self-reference (a named `default` on top of + // the CJS default value). + let traverse_pkg = root.join("node_modules").join("faketraverse"); + std::fs::create_dir_all(&traverse_pkg).expect("mkdir faketraverse"); + std::fs::write( + traverse_pkg.join("package.json"), + r#"{ "name": "faketraverse", "version": "1.0.0", "main": "index.js" }"#, + ) + .expect("write faketraverse package.json"); + std::fs::write( + traverse_pkg.join("index.js"), + "'use strict';\nfunction traverse(schema, cb) { cb(schema); return schema.n * 2; }\nmodule.exports = traverse;\nmodule.exports.default = traverse;\n", + ) + .expect("write faketraverse index.js"); + + // Consumer imports both as namespaces and CALLS them directly — the exact + // ajv `resolve.ts` shape. + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import * as equal from "fakeequal"; +import * as traverse from "faketraverse"; + +const eq: boolean = (equal as any)({ a: 1 }, { a: 1 } as any) === false; +console.log("equal:", (equal as any)(1, 1), (equal as any)(1, 2)); + +let seen = 0; +const doubled: number = (traverse as any)({ n: 21 }, (_s: any) => { seen++; }); +console.log("traverse:", doubled, "seen:", seen); +console.log("eq_ref:", eq); +"#, + ) + .expect("write entry"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed (namespace-import-of-CJS-default link wall regressed?)\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + stdout, + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + stdout, "equal: true false\ntraverse: 42 seen: 1\neq_ref: true\n", + "namespace import of a CJS default function must call the default export" + ); +} From 334e8939dc486345f484b5fd5e034dee4a22101b Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 18 Jul 2026 09:43:23 -0700 Subject: [PATCH 2/2] address review: propagate default-export metadata for namespace-bound defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CJS default that is a static function with rest params (`module.exports = function f(a, ...r){}`) or a class (`module.exports = class Foo{}`) is called / instantiated through the direct `perry_fn___default` symbol, so the namespace binding needs the same arity / rest / synthetic-arguments / return- type / async / class / enum metadata the Default specifier already propagates — otherwise a rest-param default mis-bundles its trailing args and a class default has no ImportedClass entry (`new ns()` can't resolve). Key it by the namespace local (the name the consumer's ExternFuncRef carries), mirroring the Default branch. Also extends the regression test with a CJS-default-class shape (`import * as Box; new Box(21).doubled()` → 42, matches Node). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/commands/compile/run_pipeline.rs | 55 ++++++++++++++++++- ...issue_6586_namespace_cjs_default_import.rs | 30 ++++++++-- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 2b9cb507a4..3c7dd506a7 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2480,10 +2480,16 @@ pub fn run_with_parse_cache( .unwrap_or_else(|| "default".to_string()); import_function_prefixes .entry(local.clone()) - .or_insert(default_prefix); + .or_insert(default_prefix.clone()); import_function_origin_names .entry(local.clone()) .or_insert(default_suffix.clone()); + // The metadata maps are keyed by + // (declaring-path, exported-name); the default + // is declared at `default_origin_path` under + // `default_suffix` (== "default" unless a + // re-export renamed it). + let key = (default_origin_path.clone(), default_suffix); // A CJS `module.exports = ` becomes a // var-shaped default: a value binding emitted as // a zero-arg getter. A direct call must fetch the @@ -2494,8 +2500,7 @@ pub fn run_with_parse_cache( // result and `equal(1, 1)` yields the function // itself instead of `true`. Mirrors the // Default-import var classification below. - if exported_var_names - .contains(&(default_origin_path.clone(), default_suffix)) + if exported_var_names.contains(&key) || exported_var_names.contains(&( resolved_path_str.clone(), "default".to_string(), @@ -2503,6 +2508,50 @@ pub fn run_with_parse_cache( { imported_vars.insert(local.clone()); } + // A non-var-shaped default — a `module.exports = + // function foo(){}` static-function or a + // `module.exports = class Foo{}` — is called / + // instantiated through the direct + // `perry_fn___default` symbol, so it needs + // the same arity / rest / synthetic-arguments / + // return-type / async / class / enum metadata the + // Default specifier propagates below. Without it a + // rest-param default mis-bundles its trailing args + // and a class default has no ImportedClass entry + // (so `new ns()` can't resolve). Key everything by + // the namespace LOCAL, the name the consumer's + // ExternFuncRef carries. + if let Some(¶m_count) = exported_func_param_counts.get(&key) { + imported_param_counts.insert(local.clone(), param_count); + } + if exported_func_has_rest.get(&key).copied().unwrap_or(false) { + imported_has_rest.insert(local.clone()); + } + if exported_func_synthetic_arguments.contains(&key) { + imported_synthetic_arguments.insert(local.clone()); + } + if let Some(return_type) = exported_func_return_types.get(&key) { + imported_return_types.insert(local.clone(), return_type.clone()); + } + if exported_async_funcs.contains(&key) { + imported_async_set.insert(local.clone()); + } + if let Some(class) = exported_classes.get(&key) { + let class_prefix = canonical_class_source_prefix( + class, + &class_canonical_path, + &ctx.project_root, + &default_prefix, + ); + imported_classes.push(imported_class_from_hir( + class, + class_prefix, + Some(local.clone()), + )); + } + if let Some(members) = exported_enums.get(&key) { + imported_enums.push((local.clone(), members.clone())); + } } } // Register all exports from the source module diff --git a/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs b/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs index f3cf7a8608..530f506542 100644 --- a/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs +++ b/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs @@ -40,8 +40,8 @@ fn namespace_import_of_cjs_default_function_links_and_calls() { "name": "ns-cjs-default", "private": true, "perry": { - "compilePackages": ["fakeequal", "faketraverse"], - "allow": { "compilePackages": ["fakeequal", "faketraverse"] } + "compilePackages": ["fakeequal", "faketraverse", "fakecls"], + "allow": { "compilePackages": ["fakeequal", "faketraverse", "fakecls"] } } }"#, ) @@ -77,6 +77,23 @@ fn namespace_import_of_cjs_default_function_links_and_calls() { ) .expect("write faketraverse index.js"); + // A CJS default that is a CLASS (`module.exports = class Foo {}`) — + // exercises the class/metadata propagation for the namespace binding so + // `new ns(...)` resolves an ImportedClass entry instead of a phantom + // `perry_fn___default` function wrapper. + let cls_pkg = root.join("node_modules").join("fakecls"); + std::fs::create_dir_all(&cls_pkg).expect("mkdir fakecls"); + std::fs::write( + cls_pkg.join("package.json"), + r#"{ "name": "fakecls", "version": "1.0.0", "main": "index.js" }"#, + ) + .expect("write fakecls package.json"); + std::fs::write( + cls_pkg.join("index.js"), + "'use strict';\nmodule.exports = class Box { constructor(x) { this.x = x; } doubled() { return this.x * 2; } };\n", + ) + .expect("write fakecls index.js"); + // Consumer imports both as namespaces and CALLS them directly — the exact // ajv `resolve.ts` shape. let entry = root.join("main.ts"); @@ -85,6 +102,7 @@ fn namespace_import_of_cjs_default_function_links_and_calls() { r#" import * as equal from "fakeequal"; import * as traverse from "faketraverse"; +import * as Box from "fakecls"; const eq: boolean = (equal as any)({ a: 1 }, { a: 1 } as any) === false; console.log("equal:", (equal as any)(1, 1), (equal as any)(1, 2)); @@ -93,6 +111,10 @@ let seen = 0; const doubled: number = (traverse as any)({ n: 21 }, (_s: any) => { seen++; }); console.log("traverse:", doubled, "seen:", seen); console.log("eq_ref:", eq); + +// `new` through a namespace binding whose CJS default is a class. +const b: any = new (Box as any)(21); +console.log("box:", b.doubled()); "#, ) .expect("write entry"); @@ -123,7 +145,7 @@ console.log("eq_ref:", eq); String::from_utf8_lossy(&run.stderr) ); assert_eq!( - stdout, "equal: true false\ntraverse: 42 seen: 1\neq_ref: true\n", - "namespace import of a CJS default function must call the default export" + stdout, "equal: true false\ntraverse: 42 seen: 1\neq_ref: true\nbox: 42\n", + "namespace import of a CJS default function/class must resolve the default export" ); }