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
114 changes: 114 additions & 0 deletions crates/perry/src/commands/compile/run_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <expr>` 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_<mod>__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(&param_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 {
Expand Down
151 changes: 151 additions & 0 deletions crates/perry/tests/issue_6586_namespace_cjs_default_import.rs
Original file line number Diff line number Diff line change
@@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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_<mod>__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"
);
}
Loading