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
12 changes: 10 additions & 2 deletions crates/perry-hir/src/lower_decl/class_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,16 @@ pub fn validate_class_element_early_errors(class: &ast::Class, class_name: &str)
for member in &class.body {
match member {
// `constructor(){}` (the ordinary one) parses as a dedicated
// Constructor node; count them to catch duplicates.
ast::ClassMember::Constructor(_) => constructor_count += 1,
// Constructor node; count them to catch duplicates. TypeScript
// overload SIGNATURES (`constructor(kind: 'N');` with no body)
// are type-only declarations, not constructors — only the
// implementation (the one with a body) counts (#4872, rxjs's
// `Notification` has 3 overload signatures + 1 implementation).
ast::ClassMember::Constructor(c) => {
if c.body.is_some() {
constructor_count += 1;
}
}
ast::ClassMember::Method(m) => {
let Some(name) = static_prop_name(&m.key) else {
continue;
Expand Down
43 changes: 41 additions & 2 deletions crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,17 @@ pub fn run_with_parse_cache(
if let Some(source_exports) = all_module_exports.get(&source_path_str) {
let current_exports = all_module_exports.get(&path_str);
for (name, origin) in source_exports {
// ESM semantics: `export * from "src"`
// re-exports every named export EXCEPT
// `default`. Leaking it made barrels
// claim a default binding they never
// define, which breaks the #4872
// has-default probe that decides whether
// a default import can bind to
// `perry_fn_<src>__default`.
if name == "default" {
continue;
}
let already_exists = current_exports
.map(|e| e.contains_key(name))
.unwrap_or(false);
Expand Down Expand Up @@ -2423,8 +2434,36 @@ pub fn run_with_parse_cache(
.find(|nl| nl.module == import.source);

for spec in &import.specifiers {
// Handle namespace imports (import * as X)
if let perry_hir::ImportSpecifier::Namespace { local } = spec {
// Handle namespace imports (import * as X).
//
// Issue #4872: a DEFAULT import of a compiled module that
// has NO `default` export gets the same treatment. The
// CJS wrap lowers every `require('X')` to `import _req_N
// from 'X'`; when X resolves to an ESM barrel with only
// named exports (rxjs's src/index.ts, uid's index.mjs) or
// to a type-only interface surface with no exports at all
// (nestjs dist `*.interface.js`), there is no
// `perry_fn_<src>__default` symbol for the consumer to
// bind — the old fall-through registered the local as a
// callable function import and the link died on
// `__perry_wrap_perry_fn_<src>__default`. Node's
// `require(esm)` semantics hand back the module namespace
// object, so route the local through the namespace
// machinery: member reads resolve per-export to origin
// symbols, and a whole-value read materializes the
// namespace object (empty for zero-export modules).
let namespace_like_local: Option<&String> = match spec {
perry_hir::ImportSpecifier::Namespace { local } => Some(local),
perry_hir::ImportSpecifier::Default { local }
if !all_module_exports
.get(&resolved_path_str)
.is_some_and(|exports| exports.contains_key("default")) =>
{
Some(local)
}
_ => None,
};
if let Some(local) = namespace_like_local {
namespace_imports.push(local.clone());
// Register all exports from the source module
if let Some(exports) = all_module_exports.get(&resolved_path_str) {
Expand Down
7 changes: 7 additions & 0 deletions crates/perry/src/commands/compile/cjs_wrap/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ pub(in crate::commands::compile) fn is_commonjs(source: &str) -> bool {
}
source.contains("module.exports")
|| source.contains("exports.")
// Issue #4872: tsc-compiled type-only modules (nestjs dist
// `*.interface.js`) contain ONLY the interop marker
// `Object.defineProperty(exports, "__esModule", { value: true });`
// — no `exports.X =`, no `require(`. Without this arm they fall
// through to the ESM pipeline, where the bare `exports` identifier
// throws a ReferenceError at module init.
|| source.contains("defineProperty(exports,")
|| (source.contains("require(") && !source.contains("import "))
}

Expand Down
25 changes: 25 additions & 0 deletions crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@ pub fn extract_require_specifiers(source: &str) -> Vec<String> {
specs
}

/// Issue #4872: extract `__exportStar(require('SPEC'), exports)` re-export
/// calls — the tsc-emitted CJS lowering of `export * from 'SPEC'`. Matches
/// the bare inline-helper form (`__exportStar(require("./x"), exports)`),
/// the tslib member form (`tslib_1.__exportStar(require("./x"), exports)`),
/// and the comma-sequenced form (`(0, tslib_1.__exportStar)(require("./x"),
/// exports)`). The helper *definition* (`var __exportStar = (this && ...)`)
/// never matches because the pattern requires a `require('...')` literal as
/// the first argument. Order preserved, deduped.
pub fn extract_export_star_specs(source: &str) -> Vec<String> {
let re = regex::Regex::new(
r#"(?:[A-Za-z_$][A-Za-z0-9_$]*\s*\.\s*)?__exportStar\s*\)?\s*\(\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)\s*,\s*exports\s*\)"#,
)
.unwrap();
let mut specs = Vec::new();
for cap in re.captures_iter(source) {
if let Some(m) = cap.get(1) {
let s = m.as_str().to_string();
if !specs.contains(&s) {
specs.push(s);
}
}
}
specs
}

/// Refs #488 drizzle-sqlite: extract `var <alias> = require("<spec>");`
/// declarations from the source as `(alias_name, spec, (start_byte,
/// end_byte))`. The byte range covers the whole matched statement so
Expand Down
4 changes: 3 additions & 1 deletion crates/perry/src/commands/compile/cjs_wrap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ pub(self) use extract_exports::{
extract_exports_from_source, extract_named_exports_from_require,
extract_object_literal_exports_from_require, extract_single_module_exports_assignment,
};
pub(self) use extract_requires::{extract_require_aliases_with_ranges, extract_require_specifiers};
pub(self) use extract_requires::{
extract_export_star_specs, extract_require_aliases_with_ranges, extract_require_specifiers,
};
pub(self) use hoist_classes::{
extract_top_level_class_decls, rewrite_module_exports_class_expression,
};
Expand Down
30 changes: 30 additions & 0 deletions crates/perry/src/commands/compile/cjs_wrap/wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@ pub(in crate::commands::compile) fn wrap_commonjs_for_target(

let mut named_exports = extract_exports_from_source(source);

// Issue #4872: `__exportStar(require('X'), exports)` is tsc's CJS
// lowering of `export * from 'X'` — emit exactly that as a real ESM
// re-export at module scope. The static `export *` lets compile.rs's
// transitive re-export propagation resolve names through multi-level
// barrels to their defining module (nestjs's `@nestjs/common/index.js`
// → `decorators/index.js` → `core/index.js` → `controller.decorator.js`),
// so a consumer's `import { Controller } from '@nestjs/common'` binds
// the origin's symbol instead of link-failing on
// `perry_fn_<common_index_js>__Controller`. The runtime copy inside the
// IIFE still runs, so `_cjs.X` property reads keep working too.
let export_star_specs = extract_export_star_specs(source);

// For trivial re-export wrappers (`module.exports = require('./X')`),
// recursively pull in the target's named exports. Without this,
// react/index.js — which has zero `exports.X =` patterns of its own —
Expand All @@ -186,6 +198,15 @@ pub(in crate::commands::compile) fn wrap_commonjs_for_target(
if !spec.starts_with("./") && !spec.starts_with("../") {
continue;
}
// #4872: specs re-exported via `__exportStar` surface through the
// static `export * from` emitted below — resolving to the ORIGIN
// module's symbols. Pulling the target's textual exports here would
// emit explicit `export const X = _cjs.X;` bindings that shadow the
// star re-export (ESM precedence) and degrade those names back to
// runtime property reads.
if export_star_specs.contains(spec) {
continue;
}
let Some(target) = super::super::resolve::resolve_relative_import_path(spec, source_path)
else {
continue;
Expand Down Expand Up @@ -368,6 +389,14 @@ pub(in crate::commands::compile) fn wrap_commonjs_for_target(
None => "export default _cjs;".to_string(),
};

// #4872: ESM `export * from` declarations for every `__exportStar`
// call detected above.
let export_star_decls = export_star_specs
.iter()
.map(|spec| format!("export * from '{}';", spec))
.collect::<Vec<_>>()
.join("\n");

let wrapped = format!(
r#"{imports}
{import_aliases}
Expand Down Expand Up @@ -478,6 +507,7 @@ const _cjs = (function() {{
{direct_class_exports}
{direct_named_reexports}
{named_export_decls}
{export_star_decls}
"#
);
if std::env::var("PERRY_DEBUG_CJS_WRAP").is_ok() {
Expand Down
163 changes: 163 additions & 0 deletions crates/perry/tests/issue_4872_barrel_default_reexports.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! Regression test for #4872: undefined default-wrapper symbols for
//! re-exported barrel modules (the nestjs link wall).
//!
//! Three shapes, all taken from the `tests/release/packages/nestjs-hello`
//! fixture's failure:
//!
//! 1. A tsc-emitted TYPE-ONLY module whose entire body is
//! `Object.defineProperty(exports, "__esModule", { value: true });`
//! (nestjs dist `*.interface.js`). Pre-fix it wasn't detected as CJS, so
//! it compiled as a zero-export ES module; the consumer's synthetic
//! `require()` still value-read its default import and the link died on
//! `__perry_wrap_perry_fn_<src>__default` (and, once that was fixed, the
//! unwrapped module threw `ReferenceError: exports is not defined` at
//! init).
//!
//! 2. `__exportStar(require("./x"), exports)` barrels (tsc's CJS lowering of
//! `export * from "./x"`), nested two levels deep
//! (`@nestjs/common/index.js` → `decorators/index.js` → leaf). Pre-fix
//! the wrap surfaced no static re-export, so the consumer's named import
//! bound `perry_fn_<barrel>__Controller` — which no object file defines.
//!
//! 3. A default import (synthesized by the CJS wrap from `require(...)`) of
//! an ES module that has ONLY named exports (rxjs `src/index.ts`, uid
//! `dist/index.mjs`). There is no `default` binding to call, so the local
//! now binds the module NAMESPACE (Node `require(esm)` semantics) and
//! member calls resolve per-export to origin symbols.
//!
//! Fixes (see PR for #4872): cjs_wrap emits `export * from '<spec>'` for
//! `__exportStar` calls; `is_commonjs` detects `defineProperty(exports,`;
//! compile.rs routes default imports of no-default modules through the
//! namespace machinery; `export *` propagation no longer leaks `default`.

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

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

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

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

let pkg = root.join("node_modules").join("fakepkg");
std::fs::create_dir_all(&pkg).expect("mkdir fakepkg");
std::fs::write(
pkg.join("package.json"),
r#"{ "name": "fakepkg", "version": "1.0.0", "main": "index.js" }"#,
)
.expect("write fakepkg package.json");

// Shape 1: type-only interface surface — the nestjs `*.interface.js`
// dist output. No exports, no require, just the interop marker.
std::fs::write(
pkg.join("iface.js"),
"\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\n",
)
.expect("write iface.js");

// Shape 3: ES module with ONLY named exports — no default binding.
std::fs::write(
pkg.join("esm-barrel.mjs"),
"export const uid = (n) => \"U:\" + n;\n",
)
.expect("write esm-barrel.mjs");

// Shape 2: two-level `__exportStar` chain down to a concrete leaf.
std::fs::write(
pkg.join("leaf.js"),
r#""use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Controller = void 0;
function Controller() { return "CTRL"; }
exports.Controller = Controller;
"#,
)
.expect("write leaf.js");
std::fs::write(
pkg.join("mid.js"),
r#""use strict";
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) exports[p] = m[p];
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./leaf"), exports);
"#,
)
.expect("write mid.js");

// The package barrel: star re-exports the type-only surface AND the
// mid-level barrel, plus a member call on the no-default ES module.
std::fs::write(
pkg.join("index.js"),
r#""use strict";
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) exports[p] = m[p];
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./iface"), exports);
__exportStar(require("./mid"), exports);
const esm_1 = require("./esm-barrel.mjs");
exports.greet = function greet() { return esm_1.uid(7); };
"#,
)
.expect("write index.js");

let entry = root.join("main.ts");
std::fs::write(
&entry,
r#"
import { greet, Controller } from "fakepkg";
// Shape 3: member call on a default-import of a no-default ES module.
console.log(greet());
// Shape 2: named import resolved through the two-level __exportStar chain.
console.log(Controller());
"#,
)
.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 (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");
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, "U:7\nCTRL\n",
"barrel re-exports must resolve to concrete origin bindings"
);
}
Loading