From eefbc91b7249f6b5231f7eabc70e24dc08371254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 17 Jun 2026 15:05:36 +0200 Subject: [PATCH 1/2] fix(cjs-wrap): function-nested class shadows outer same-named local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A function-nested `class X {}` whose name collides with an outer same-named local (the cjs_wrap `export const X = _cjs.X` re-export binding) failed to shadow it, so an in-scope `exports.X = X` resolved `X` through lookup_local to the circular module-scope const → undefined. A nested `function X(){}` already define_local's its name and shadows correctly; mirror that for classes by binding the name to a ClassRef. Fixes ajv `require('./code').Name === undefined` (new code_1.Name throws 'undefined is not a constructor'). --- crates/perry-hir/src/lower_decl/body_stmt.rs | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index cc7e068b8..98bcd4942 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -344,6 +344,34 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Date: Wed, 17 Jun 2026 15:20:12 +0200 Subject: [PATCH 2/2] test(cjs-wrap): kept-in-IIFE class named export visible on require() End-to-end regression for fix/ajv-not-constructor: a compilePackages package with a function-nested `class Name` (kept in the IIFE by the #5251 guard) exported via `exports.Name = Name` must surface on `require('./code').Name`. Guards both the class case (the bug) and the function case in the identical shape (the asymmetry). --- tests/test_cjs_nested_class_named_export.sh | 113 ++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100755 tests/test_cjs_nested_class_named_export.sh diff --git a/tests/test_cjs_nested_class_named_export.sh b/tests/test_cjs_nested_class_named_export.sh new file mode 100755 index 000000000..e5261ee07 --- /dev/null +++ b/tests/test_cjs_nested_class_named_export.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Regression (fix/ajv-not-constructor): a compiled CJS package whose class is +# kept inside the cjs_wrap IIFE (#5251 hoist guard — the class body references +# the injected `exports`) must still surface its `exports.X = X` named export to +# `require('./code').X`. The HIR lowering for a function-nested `class X {}` now +# defines a scope-local binding shadowing the outer same-named re-export const, +# matching how a nested `function X(){}` already behaves. +# +# Pre-fix: `require('./code').Name` was `undefined` (the in-IIFE `exports.Name = +# Name` resolved `Name` to the circular module-scope `const Name = _cjs.Name`), +# so ajv's `new code_1.Name(...)` threw "undefined is not a constructor". +# The function variant in the identical shape always worked — this guards both. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PERRY="$SCRIPT_DIR/../target/release/perry" +[ ! -f "$PERRY" ] && PERRY="$SCRIPT_DIR/../target/debug/perry" +if [ ! -f "$PERRY" ]; then + echo "SKIP: perry binary not found (build with cargo build --release)" + exit 0 +fi +if ! command -v cc >/dev/null 2>&1; then + echo "SKIP: cc not available" + exit 0 +fi + +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +PKG="$TMPDIR/node_modules/pk" +mkdir -p "$PKG" + +# code.js — the kept-in-IIFE shape: `class Name` whose ctor reads the injected +# `exports`, then `exports.Name = Name`. The #5251 guard keeps the class inside +# the IIFE; the named export must still reach the module's external exports. +cat > "$PKG/code.js" << 'EOF' +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Name = void 0; +exports.IDENTIFIER = /^[a-z]+$/i; +class Name { constructor(s){ if(!exports.IDENTIFIER.test(s)) throw new Error("bad"); this.str = s; } } +exports.Name = Name; +EOF + +# func.js — the FUNCTION variant of the identical shape (the asymmetry to guard +# against re-breaking): a function kept in the IIFE exported the same way. +cat > "$PKG/func.js" << 'EOF' +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Make = void 0; +exports.PREFIX = "v"; +function Make(s){ this.tag = exports.PREFIX + s; } +exports.Make = Make; +EOF + +# scope.js — cross-module consumer that constructs the kept-in-IIFE class at +# module-init time (mirrors ajv's scope.js `new code_1.Name("const")`). +cat > "$PKG/scope.js" << 'EOF' +"use strict"; +const code_1 = require("./code"); +const func_1 = require("./func"); +exports.varKinds = { const: new code_1.Name("const") }; +exports.made = new func_1.Make("x"); +EOF + +cat > "$PKG/index.js" << 'EOF' +"use strict"; +const code_1 = require("./code"); +const func_1 = require("./func"); +const scope_1 = require("./scope"); +module.exports = function(){ + return "Name=" + typeof code_1.Name + + " Make=" + typeof func_1.Make + + " kind=" + String(scope_1.varKinds.const) + + " made=" + scope_1.made.tag; +}; +EOF + +cat > "$PKG/package.json" << 'EOF' +{ "name":"pk","version":"1.0.0","main":"index.js" } +EOF + +cat > "$TMPDIR/package.json" << 'EOF' +{ "type":"module","private":true,"perry":{"compilePackages":["*"],"allow":{"compilePackages":["*"]}} } +EOF + +cat > "$TMPDIR/main.ts" << 'EOF' +import pk from "pk"; +console.log(pk()); +EOF + +cd "$TMPDIR" +COMPILE_OUTPUT=$(PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" main.ts -o test_bin --no-cache 2>&1) || { + echo "FAIL: compile error" + echo "$COMPILE_OUTPUT" | tail -20 + exit 1 +} + +RUN_OUTPUT=$(./test_bin 2>&1) +# Matches `node main.ts`: the class & function both export as functions, and the +# cross-module `new code_1.Name(...)` / `new func_1.Make(...)` succeed. +EXPECTED="Name=function Make=function kind=[object Object] made=vx" + +if [ "$RUN_OUTPUT" = "$EXPECTED" ]; then + echo "PASS" + exit 0 +fi + +echo "FAIL: kept-in-IIFE class named export not visible on require()" +echo "Expected: $EXPECTED" +echo "Got: $RUN_OUTPUT" +exit 1