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
11 changes: 11 additions & 0 deletions crates/perry-hir/src/ir/decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ pub struct Import {
/// is left `Deferred` unless some other (top-level) edge reaches it; the
/// require shim triggers the target's `__init` on first `require()` call.
pub is_deferred_require: bool,
/// Issue #5257: this import was synthesized by the CJS→ESM wrap from a
/// `require('S')` — i.e. `import _req_N from 'S'` (or an adopted alias /
/// `_lazyreq_N`). Under CommonJS, `require('S')` returns the module's
/// *exports object* (its namespace), so a default-import shape here must
/// NOT be held to Node's static-ESM "does not provide an export named
/// 'default'" rule when the target is a named-only / CJS module: the
/// default-export gate skips these and codegen routes the local through
/// the namespace machinery (member reads resolve per-export, a whole-value
/// read materializes the exports object). Genuine user `import X from
/// 'pkg'` (this flag `false`) still errors like Node.
pub is_adopted_require: bool,
}

/// Import specifier
Expand Down
1 change: 1 addition & 0 deletions crates/perry-hir/src/lower/module_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ pub(crate) fn lower_module_decl(
is_dynamic: false,
is_dynamic_target: false,
is_deferred_require: false,
is_adopted_require: false,
});
}
ast::ModuleDecl::ExportDecl(export) => {
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-hir/src/stable_hash/module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ impl SH for Import {
is_dynamic,
is_dynamic_target,
is_deferred_require,
is_adopted_require,
} = self;
source.hash(h);
specifiers.hash(h);
Expand All @@ -141,6 +142,7 @@ impl SH for Import {
is_dynamic.hash(h);
is_dynamic_target.hash(h);
is_deferred_require.hash(h);
is_adopted_require.hash(h);
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/perry-hir/src/stable_hash/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ fn module_metadata_affects_hash() {
is_dynamic: false,
is_dynamic_target: false,
is_deferred_require: false,
is_adopted_require: false,
});
assert_ne!(base_hash, hash_module(&m_imp));

Expand Down
1 change: 1 addition & 0 deletions crates/perry-transform/src/inline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ pub fn inline_functions(
is_dynamic: false,
is_dynamic_target: false,
is_deferred_require: false,
is_adopted_require: false,
});
}
}
Expand Down
7 changes: 7 additions & 0 deletions crates/perry/src/commands/compile/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,12 @@ pub(super) fn enforce_package_default_exports(ctx: &mut CompilationContext) -> R
if import.type_only
|| import.is_dynamic
|| import.is_native
// Issue #5257: an adopted `require('S')` (CJS wrap synthesized
// `import _req_N from 'S'`) is NOT a static-ESM default import —
// `require()` hands back the exports object, so a named-only / CJS
// target is valid and must route through the namespace machinery
// instead of failing Node's "no default export" rule.
|| import.is_adopted_require
|| import.module_kind != perry_hir::ModuleKind::NativeCompiled
|| !is_bare_package_specifier(&import.source)
{
Expand Down Expand Up @@ -506,6 +512,7 @@ mod js_runtime_gate_tests {
is_dynamic: false,
is_dynamic_target: false,
is_deferred_require: false,
is_adopted_require: false,
});

let mut package = empty_module("pkg");
Expand Down
6 changes: 6 additions & 0 deletions crates/perry/src/commands/compile/collect_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,7 @@ fn collect_module_one(
is_dynamic: true,
is_dynamic_target: false,
is_deferred_require: false,
is_adopted_require: false,
});
}

Expand Down Expand Up @@ -1422,6 +1423,11 @@ fn collect_module_one(
if is_lazy {
import.is_deferred_require = true;
}
// #5257: every import here was synthesized from a `require('S')`,
// which under CJS returns the exports object — so a no-`default`
// target must route through the namespace machinery (#4872), not
// trip the static-ESM default gate. Tag so the gate skips them.
import.is_adopted_require = true;
}
}

Expand Down
247 changes: 247 additions & 0 deletions crates/perry/tests/issue_5257_require_adopt_no_default_namespace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
//! Regression test for #5257: a `const x = require('pkg')` (or
//! `const { a } = require('pkg')`) that the CJS→ESM wrap adopts as a DEFAULT
//! import (`import _req_N from 'pkg'`) must NOT be rejected by the static-ESM
//! "does not provide an export named 'default'" gate when `pkg` is a
//! named-only / CJS module.
//!
//! Under CommonJS, `require('pkg')` returns the module's *exports object*
//! (its namespace), so `isexe.sync(...)` / `const { sync } = require('isexe')`
//! work against the named exports even though there is no `default`. Before
//! the fix, the package-default-export gate (bootstrap.rs) bailed the whole
//! build with:
//!
//! Error: The requested package 'isexe' does not provide an export named
//! 'default' (imported as '_req_0' in .../which/lib/index.js).
//!
//! This blocked cross-spawn, which, execa, and joi. The fix tags every import
//! synthesized by the CJS wrap (`is_adopted_require`) and exempts it from the
//! default gate; codegen already routes a no-`default` default import through
//! the namespace machinery (#4872), so member reads / destructuring resolve
//! per-export.
//!
//! The minimized shape mirrors the real `isexe` trigger exactly: a
//! `"type":"module"` package whose `exports` map's `require` condition
//! resolves to a bundled `__esModule` CJS with named-only exports (no
//! default), consumed across a package boundary via `require("foo").bar()`
//! and via destructuring `const { bar } = require("foo")`.

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

#[test]
fn adopted_require_of_named_only_package_binds_namespace_and_runs() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();

std::fs::write(
root.join("package.json"),
r#"{
"name": "require-adopt-no-default",
"type": "module",
"perry": {
"compilePackages": ["foo", "consumer"],
"allow": { "compilePackages": ["foo", "consumer"] }
}
}"#,
)
.expect("write consumer package.json");

// Package `foo`: a `"type":"module"` package whose `exports` map routes
// `require` to a bundled `__esModule` CJS with named-only exports (no
// `default`), and `import` to an ESM with named-only exports. This is the
// exact `isexe` shape that produced #5257.
let foo = root.join("node_modules").join("foo");
std::fs::create_dir_all(&foo).expect("mkdir foo");
std::fs::write(
foo.join("package.json"),
r#"{
"name": "foo",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"require": "./cjs.js",
"import": "./esm.js"
}
}
}"#,
)
.expect("write foo package.json");
std::fs::write(
foo.join("cjs.js"),
r#""use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.bar = void 0;
function bar() { return 42; }
exports.bar = bar;
exports.baz = function baz() { return 7; };
"#,
)
.expect("write foo cjs.js");
std::fs::write(
foo.join("esm.js"),
"export const bar = () => 42;\nexport const baz = () => 7;\n",
)
.expect("write foo esm.js");

// Package `consumer`: a CJS module that pulls `foo` in BOTH adopted-require
// shapes — whole-value alias member access and destructuring.
let consumer = root.join("node_modules").join("consumer");
std::fs::create_dir_all(&consumer).expect("mkdir consumer");
std::fs::write(
consumer.join("package.json"),
r#"{ "name": "consumer", "version": "1.0.0", "main": "index.js" }"#,
)
.expect("write consumer package.json");
std::fs::write(
consumer.join("index.js"),
r#""use strict";
const foo = require("foo");
const { bar, baz } = require("foo");
module.exports.total = function total() {
return foo.bar() + bar() + baz();
};
"#,
)
.expect("write consumer index.js");

let entry = root.join("main.ts");
std::fs::write(
&entry,
r#"
import { total } from "consumer";
console.log(total());
"#,
)
.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 (default-export gate regressed?)\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);
// The specific #5257 symptom must be gone.
let combined = format!(
"{}{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);
assert!(
!combined.contains("does not provide an export named 'default'"),
"the no-default gate must not fire for an adopted require:\n{combined}"
);

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, "91\n",
"adopted require of a named-only package must bind the exports object \
(foo.bar()=42 + bar()=42 + baz()=7)"
);
}

#[test]
fn module_exports_fn_default_require_still_works() {
// Regression guard: a CJS package whose whole module IS a function
// (`module.exports = fn`) — i.e. a real `default`-shaped export — must
// still adopt as a callable default, not get broken by the namespace
// fallback.
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();

std::fs::write(
root.join("package.json"),
r#"{
"name": "require-default-fn",
"type": "module",
"perry": {
"compilePackages": ["fnpkg", "user"],
"allow": { "compilePackages": ["fnpkg", "user"] }
}
}"#,
)
.expect("write package.json");

let fnpkg = root.join("node_modules").join("fnpkg");
std::fs::create_dir_all(&fnpkg).expect("mkdir fnpkg");
std::fs::write(
fnpkg.join("package.json"),
r#"{ "name": "fnpkg", "version": "1.0.0", "main": "index.js" }"#,
)
.expect("write fnpkg package.json");
std::fs::write(
fnpkg.join("index.js"),
"\"use strict\";\nmodule.exports = function add(a, b) { return a + b; };\n",
)
.expect("write fnpkg index.js");

let user = root.join("node_modules").join("user");
std::fs::create_dir_all(&user).expect("mkdir user");
std::fs::write(
user.join("package.json"),
r#"{ "name": "user", "version": "1.0.0", "main": "index.js" }"#,
)
.expect("write user package.json");
std::fs::write(
user.join("index.js"),
"\"use strict\";\nconst add = require(\"fnpkg\");\nmodule.exports.run = function run() { return add(40, 2); };\n",
)
.expect("write user index.js");

let entry = root.join("main.ts");
std::fs::write(
&entry,
"import { run } from \"user\";\nconsole.log(run());\n",
)
.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: {:?}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stderr)
);
assert_eq!(
String::from_utf8_lossy(&run.stdout),
"42\n",
"module.exports = fn must still adopt as a callable default"
);
}
Loading