Skip to content

fix(cjs-wrap): function-nested class named export lost (ajv 'undefined is not a constructor')#5325

Merged
proggeramlug merged 2 commits into
mainfrom
fix/ajv-not-constructor
Jun 17, 2026
Merged

fix(cjs-wrap): function-nested class named export lost (ajv 'undefined is not a constructor')#5325
proggeramlug merged 2 commits into
mainfrom
fix/ajv-not-constructor

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Root

In a compiled CJS package (`perry.compilePackages`), a function-nested `class X {}` whose name collides with an outer same-named local failed to shadow that local, so an in-scope read of `X` resolved to the wrong binding.

Concretely: the #5251 hoist guard correctly keeps a class inside the wrapping IIFE when its body references the injected `exports`/`module`/`require` (ajv's `codegen/code.js`: `class Name { constructor(s){ if(!exports.IDENTIFIER.test(s)) ... } }`). The cjs_wrap then emits a module-scope re-export binding `export const Name = _cjs.Name;` (= local `Name`). When the IIFE body runs `exports.Name = Name` (recording the module's named export), the identifier `Name` was resolved via `lookup_local` — which walked past the (absent) in-IIFE binding up to that module-scope `const Name = _cjs.Name`. That is a circular self-read → `undefined`. So `require('./code').Name` was `undefined`, and ajv's `scope.js` `new code_1.Name(...)` threw `TypeError: undefined is not a constructor` (at `compile/codegen/scope.js:17`).

Class-vs-function asymmetry: a nested `function X(){}` already `define_local`s its name (`lower_nested_fn_decl`) and shadows the outer const, so the identical shape exported fine. A nested `class X {}` only registered into the global class table and created no scope-local binding — the gap.

Fix

`crates/perry-hir/src/lower_decl/body_stmt.rs` — when lowering a function-nested class whose name has a pre-existing outer local, define a scope-local binding and emit `Stmt::Let { name, init: ClassRef(name) }` (the same shape `var C = class {…}` lowers to, already recorded by codegen in `local_class_aliases`). This shadows the outer re-export const, mirroring the function path. Gated on a pre-existing outer binding so packages without a collision are byte-for-byte unaffected.

Minimal repro (before/after)

CJS package `pk` with `code.js` (`class Name`; `exports.Name = Name`), `scope.js` (`new require('./code').Name(...)`), `index.js` (re-exports), consumed by `import pk from "pk"`:

  • node: `Name=function kind=[object Object]`
  • perry before: `TypeError: undefined is not a constructor`
  • perry after: `Name=function kind=[object Object]` ✅ (== node)

Swapping `class Name` → `function Name` made perry succeed pre-fix (the asymmetry). A new regression test `tests/test_cjs_nested_class_named_export.sh` exercises both the class case (the bug) and the function case (the guard) end-to-end.

ajv (before/after)

  • before: `TypeError: undefined is not a constructor` (codegen/scope.js)
  • after: advances past it; next wall is `ReferenceError: Assign is not defined` (separate, not chased)

Validation

  • perry-hir: 160+ tests pass; cjs_wrap unit suite: 87 pass.
  • Lint gates: `cargo fmt --check`, `check_file_size.sh`, `gc_store_site_inventory.py`, `addr_class_inventory.py` all pass.
  • secret-tests corpus: 53/59 OK (89%) — identical pass set to baseline, zero regressions (ajv's error category advanced from `not-a-constructor` → `not-defined`).

Note for maintainer

Per contributor convention, this PR does not touch `[workspace.package] version` (Cargo.toml), the `Current Version:` line (CLAUDE.md), `CHANGELOG.md`, or `Cargo.lock` — please fold the version bump + changelog entry at merge.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed variable scoping behavior for nested class declarations that share a name with an outer binding, ensuring correct identifier resolution and proper class accessibility within nested scopes.
  • Tests

    • Added regression test to verify proper CommonJS named-export visibility for nested class declarations and their correct instantiation.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

In lower_body_stmt, a shadowing fix emits a Stmt::Let binding initialized from Expr::ClassRef when a function-nested class name collides with an existing outer local. A new shell regression test validates that the resulting CJS named export remains a callable constructor.

Changes

Nested class name shadowing fix

Layer / File(s) Summary
HIR shadowing logic for nested class name collision
crates/perry-hir/src/lower_decl/body_stmt.rs
When lowering a function-nested class, lookup_local is called on the class name; on collision, a new Type::Any local is defined and a Stmt::Let initialized from Expr::ClassRef(class_name) is emitted so subsequent identifier reads resolve to the class value instead of the outer binding.
CJS nested class named export regression test
tests/test_cjs_nested_class_named_export.sh
New Bash script builds a temporary CJS npm fixture with an IIFE-guarded class Name and a parallel function export, compiles via the perry binary, runs the output, and asserts require('./code').Name and require('./func').Make are callable constructors; skips if required tooling is absent.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • PerryTS/perry#5110: Both PRs modify the same function-nested class declaration path in lower_body_stmt, with that PR emitting static field/block initialization statements and this one adding a shadowing Stmt::Let for the class name.

Poem

🐇 A class hid behind a name already claimed,
so I slipped in a Let and the binding was tamed.
No outer shadow shall trick the scope's eye—
ClassRef shines bright where old locals would lie.
Hop hop, the test passes, the exports ring true!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the bug fix: a function-nested class named export that was lost due to CJS wrapping, causing the 'undefined is not a constructor' error from ajv.
Description check ✅ Passed The description provides a comprehensive Root section explaining the bug, a detailed Fix section describing the solution, validation results, and explicitly states that version/changelog updates are deferred per convention. All key information is present and well-organized.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ajv-not-constructor

Comment @coderabbitai help to get the list of available commands and usage tips.

Ralph Küpper added 2 commits June 17, 2026 15:22
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').
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).
@proggeramlug proggeramlug force-pushed the fix/ajv-not-constructor branch from 7ae1f8c to ee20121 Compare June 17, 2026 13:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_cjs_nested_class_named_export.sh`:
- Around line 100-113: The script may exit prematurely due to `set -e` when
`./test_bin` exits with a non-zero status, preventing the failure diagnostics
from being printed. Modify the RUN_OUTPUT command execution to gracefully handle
non-zero exit codes from `./test_bin` so the script continues to execute and
print the Expected/Got output for debugging purposes. This can be done by either
temporarily disabling the exit-on-error behavior before running the test
command, or by appending `|| true` to the command to suppress the exit code
propagation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ec9a79e-35f5-4d49-9e2b-7b9d77ccfd28

📥 Commits

Reviewing files that changed from the base of the PR and between 6d5718e and ee20121.

📒 Files selected for processing (2)
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • tests/test_cjs_nested_class_named_export.sh

Comment on lines +100 to +113
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard runtime execution so failures still print diagnostics.

At Line 100, set -e can terminate the script before the Expected/Got failure output runs. Add explicit handling around ./test_bin so runtime regressions remain debuggable.

Proposed fix
-RUN_OUTPUT=$(./test_bin 2>&1)
+RUN_OUTPUT=$(./test_bin 2>&1) || {
+  echo "FAIL: runtime error"
+  echo "$RUN_OUTPUT" | tail -20
+  exit 1
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
RUN_OUTPUT=$(./test_bin 2>&1) || {
echo "FAIL: runtime error"
echo "$RUN_OUTPUT" | tail -20
exit 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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cjs_nested_class_named_export.sh` around lines 100 - 113, The
script may exit prematurely due to `set -e` when `./test_bin` exits with a
non-zero status, preventing the failure diagnostics from being printed. Modify
the RUN_OUTPUT command execution to gracefully handle non-zero exit codes from
`./test_bin` so the script continues to execute and print the Expected/Got
output for debugging purposes. This can be done by either temporarily disabling
the exit-on-error behavior before running the test command, or by appending `||
true` to the command to suppress the exit code propagation.

@proggeramlug proggeramlug merged commit b60cebe into main Jun 17, 2026
14 of 15 checks passed
@proggeramlug proggeramlug deleted the fix/ajv-not-constructor branch June 17, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant