diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 025bda9d30..3c7dd506a7 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2440,6 +2440,120 @@ 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.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 + // 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(&key) + || exported_var_names.contains(&( + resolved_path_str.clone(), + "default".to_string(), + )) + { + 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 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..530f506542 --- /dev/null +++ b/crates/perry/tests/issue_6586_namespace_cjs_default_import.rs @@ -0,0 +1,151 @@ +//! 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", "fakecls"], + "allow": { "compilePackages": ["fakeequal", "faketraverse", "fakecls"] } + } +}"#, + ) + .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"); + + // 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"); + std::fs::write( + &entry, + 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)); + +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"); + + 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\nbox: 42\n", + "namespace import of a CJS default function/class must resolve the default export" + ); +}