diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index ed4419eb6d..52dc41e7e3 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -2439,10 +2439,11 @@ pub fn run_with_parse_cache( // (`export { default as foo }`) needs the // codegen to call `perry_fn___default` // when the consumer writes `ns.foo()`. - if let Some(origin_name) = all_module_export_origin_names + let resolved_origin_name = all_module_export_origin_names .get(&resolved_path_str) .and_then(|m| m.get(export_name)) - { + .cloned(); + if let Some(ref origin_name) = resolved_origin_name { if origin_name != export_name { import_function_origin_names .insert(export_name.clone(), origin_name.clone()); @@ -2478,7 +2479,30 @@ pub fn run_with_parse_cache( // the closure with `args`. Mirrors the // named-import branch at the var-detection // arm below. - if exported_var_names.contains(&key) { + // + // Issue #4841: when the namespace member is a + // re-export of a CJS submodule's `default` + // (`import sfy from './sfy'; export { sfy }`, + // where `./sfy` is `module.exports = function`), + // the origin module records the var under its + // "default" suffix — NOT the consumer-visible + // member name. Probe both keys (mirrors the + // named-import arm) so the var-vs-function + // classification fires; otherwise `ns.sfy` takes + // the function path and wraps the default getter + // in a singleton closure, so `ns.sfy(args)` + // RETURNS the function value instead of being it + // (Stripe's `qs.stringify(...)` returned the qs + // function ⇒ `.replace is not a function`). + let origin_key_under_origin_name = resolved_origin_name + .as_ref() + .map(|n| (origin_path.clone(), n.clone())); + if exported_var_names.contains(&key) + || origin_key_under_origin_name + .as_ref() + .map(|k| exported_var_names.contains(k)) + .unwrap_or(false) + { imported_vars.insert(export_name.clone()); } if let Some(class) = exported_classes.get(&key) { diff --git a/crates/perry/tests/issue_4841_namespace_cjs_reexport.rs b/crates/perry/tests/issue_4841_namespace_cjs_reexport.rs new file mode 100644 index 0000000000..702722b174 --- /dev/null +++ b/crates/perry/tests/issue_4841_namespace_cjs_reexport.rs @@ -0,0 +1,132 @@ +//! Regression test for #4841: `import * as ns from ""` where a +//! namespace member is a re-export of a CommonJS submodule's `default` +//! (`var sfy = require('./sfy'); module.exports = { sfy }`, and `./sfy` is +//! `module.exports = function (...) {...}`). +//! +//! The submodule records its export under the synthetic `"default"` suffix — +//! not the consumer-visible member name `sfy`. The namespace-import +//! var-vs-function classifier keyed only on `(origin_path, "sfy")`, missed the +//! `(origin_path, "default")` entry, and so classified `sfy` as a FUNCTION. +//! That routed `ns.sfy` through the singleton-closure wrap of the default +//! getter, so `ns.sfy(args)` RETURNED the function value instead of being it +//! — `typeof ns.sfy(...) === "function"`, length 0. +//! +//! This was the real root cause of the Stripe SDK failure: Stripe's +//! `utils.js` does `import * as qs from 'qs'` and `qs.stringify(...).replace(...)`. +//! `qs.stringify(...)` returned the qs stringify *function* (not a string), so +//! `.replace` was undefined → `TypeError: replace is not a function`. +//! +//! Fix (crates/perry/src/commands/compile.rs): the namespace arm now probes +//! both `(origin_path, member)` and `(origin_path, origin_name)` against +//! `exported_var_names`, mirroring the named-import arm. With the member +//! correctly classified as a var, `ns.sfy` reads the getter's value. + +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_reexport_resolves_member_value() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + // Consumer package.json: native-compile the `mini` package. + std::fs::write( + root.join("package.json"), + r#"{ + "name": "ns-cjs-reexport", + "type": "module", + "perry": { + "compilePackages": ["mini"], + "allow": { "compilePackages": ["mini"] } + } +}"#, + ) + .expect("write consumer package.json"); + + // node_modules/mini — a CJS barrel that re-exports a function submodule + // and an object submodule (mirrors qs/lib/index.js's shape). + let mini = root.join("node_modules").join("mini"); + std::fs::create_dir_all(&mini).expect("mkdir mini"); + std::fs::write( + mini.join("package.json"), + r#"{ "name": "mini", "version": "1.0.0", "main": "index.js" }"#, + ) + .expect("write mini package.json"); + std::fs::write( + mini.join("index.js"), + r#"'use strict'; +var sfy = require('./sfy'); +var helper = require('./helper'); +module.exports = { sfy: sfy, helper: helper }; +"#, + ) + .expect("write index.js"); + // module.exports is a FUNCTION (the qs.stringify shape). + std::fs::write( + mini.join("sfy.js"), + r#"'use strict'; +module.exports = function (object, opts) { return "S:" + object; }; +"#, + ) + .expect("write sfy.js"); + // module.exports is an OBJECT. + std::fs::write( + mini.join("helper.js"), + r#"'use strict'; +module.exports = { ALPHA: 1, doit: function doit(x) { return "H:" + x; } }; +"#, + ) + .expect("write helper.js"); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import * as m from "mini"; +const ns: any = m; +// The function re-export must BE the function, not a wrapper that returns it. +console.log(typeof ns.sfy, ns.sfy.length, ns.sfy("X", {})); +// The object re-export must BE the object, not a function. +console.log(typeof ns.helper, JSON.stringify(ns.helper)); +// Chained `.replace` on a string result — the exact Stripe failure shape. +const q: string = ns.sfy("ab"); +console.log(q.replace(/:/g, "=")); +"#, + ) + .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\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"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + let stdout = String::from_utf8_lossy(&run.stdout); + assert_eq!( + stdout, "function 2 S:X\nobject {\"ALPHA\":1}\nS=ab\n", + "namespace member of a CJS default re-export must resolve to the \ + underlying value (function / object), not a wrapper closure" + ); +}