Skip to content

Next.js 16 standalone app-router bring-up — walls 1–5 (codegen/runtime/HIR fixes)#5438

Merged
proggeramlug merged 50 commits into
mainfrom
feat/nextjs-wall-46
Jun 19, 2026
Merged

Next.js 16 standalone app-router bring-up — walls 1–5 (codegen/runtime/HIR fixes)#5438
proggeramlug merged 50 commits into
mainfrom
feat/nextjs-wall-46

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Next.js 16 standalone app-router bring-up — codegen/runtime/HIR fixes

This branch carries the Next.js app-router compilation fixes (walls 1–5 of the bring-up). The render goes from an immediate SIGSEGV crash to booting and running deep into the request pipeline (past IncrementalCache construction when the remaining wall is worked around).

Key fixes (non-exhaustive)

  • fix(runtime): resolve (don't construct) a class-object on a 0-arg value-call (9970fbbe7) + the construct-path companion — wall 2/4 (value is not a function / undefined.get).
  • fix(fs): readFileSync throws Node-shaped ENOENT on failed read (not empty string) (af8c832b0) — wall 3.
  • fix(hir): forward captured enclosing locals to ctor for directly-constructed anonymous class expressions (6c41417ff) — wall 5.
  • fix(transform): scan class field inits + extends in compute_max_local_id (cbfd6d097) — LocalId collision, parallel to compilePackages: hono app.request() resolves to undefined (reading 'status') #5143.
  • fix(hir): scope-aware native-module shadowing (58d8c77b2); fix(runtime): js_object_get_index_polymorphic primitive-receiver SIGSEGV guard (0ba3b22fc); fix(nextjs): walls 1-4 (29425f555); plus a series of HIR scope/shadowing/hoisting and runtime descriptor fixes.

Remaining blocker (filed separately)

The 6th wall is #5437 — a deferred/adopted require binding captured by value (as an unresolved thunk) by a class constructor (IncrementalCachenew uw.SharedCacheControls). Root cause + candidate fixes + a deterministic full-bundle repro are in that issue. It is not addressed here.

Notes for merge

  • Version/CHANGELOG metadata intentionally left for the maintainer to fold at merge time (per the project's contributor-PR convention).
  • Branch is behind main by a few commits; rebase or 3-way merge at your discretion (linear history / squash-or-rebase enforced).

Summary by CodeRabbit

Release Notes

  • New Features
    • Added Next.js .next/server/** deferred module initialization via path-based runtime registration, including on-demand JSON disk loading.
  • Bug Fixes
    • Fixed super() / inherited constructor dispatch for dynamic parents.
    • Improved constructor rest-parameter handling and constructor replay.
    • Fixed stale accessor/descriptor usage after object reuse; corrected JSON replacer this binding and improved filesystem read error reporting.
  • Documentation
    • Added a draft write-up of a CJS lazy-require runtime issue investigation.
  • Chores
    • Updated related lowering/runtime behavior and refreshed affected tests.

Ralph Küpper added 30 commits June 17, 2026 06:33
…dle target (v0.5.1173)

Next.js patchSetHeaderWithCookieSupport calls
Object.defineProperty(res, PATCHED_SET_HEADER, {value:true}). Perry models the
http ServerResponse as a native HANDLE (small pointer-tagged id), not a heap
object, so js_object_define_property threw 'called on non-object', aborting the
request (HTTP 500). Localized via backtrace: define_property <- patch_set_header
<- Server.handleRequestImpl.

FIX: a native-handle target no longer throws. String-key data descriptors route
through the handle property-set dispatch (handle[key] round-trips); symbol keys /
accessors degrade to a best-effort no-op (the framework patch is idempotent).
Returns the target instead of throwing. Verified on real Next.js: defineProperty
TypeError gone, request advances to a deeper invariant (separate wall).
…parent ctor (v0.5.1174)

class NodeNextRequest extends _index.BaseNextRequest: super() was a no-op, so
BaseNextRequest's ctor (sets this.url/this.method) never ran → req.url undefined
→ 'Invariant: url can not be undefined'. The named member-extends HIR arm set
only static extends_name and relied on the parent being in codegen's class table
(module-order-dependent); only the .default arm routed through extends_expr.

FIX: route named member-extends through extends_expr unconditionally (like
.default + the unknown-Ident arm) so super() runs the parent ctor at runtime via
js_fetch_or_value_super (tolerates native/closure/classref/builtin parents).
Static extends/extends_name kept when resolvable; colliding-name native case
(class Agent extends http.Agent) still handled by the parent_name==name arm.
Verified real Next.js: BaseNextRequest ctor runs, req.url/method resolve,
invariant gone, request advances into routing/render.
…ijacked on user-class instances (v0.5.1175)
…module parent dropped construction args

A no-own-ctor class with heritage inherits the spec default ctor
constructor(...args){super(...args)}. When the parent is a cross-module
class resolved at runtime as a dynamic parent (class Child extends
_mod.Parent {} -> extends_expr + js_register_class_parent_dynamic), the
synthesized standalone ctor dropped every construction arg, so the parent
ctor ran with garbage params. Next.js route matchers (PagesRouteMatcher,
AppPageRouteMatcher, AppRouteRouteMatcher) never received their definition
-> RouteMatcher(definition) left this.definition undefined -> reload()
threw 'Cannot read properties of undefined (reading pathname)' -> HTTP 500.

- artifacts.rs: synthesized_ctor_param_count helper resolves the forwarding
  arity from the nearest ancestor ctor; falls back to a fixed band (8) when
  a non-native cross-module parent's arity is unavailable (auto-optimize
  compiles nested modules with empty imported_classes). Native parents keep 0.
- string_pool.rs: register CLASS_CONSTRUCTORS total_params from the same
  helper (ctor_arity_overrides) so the registered arity matches the emitted
  forwarding-ctor signature (was 0 for no-own-ctor classes).
- method.rs: route a dynamic-parent (extends_expr) synthesized super through
  js_fetch_or_value_super instead of the wrong-prefix inline static call.
… on class instances + runtime require(.json)

Wall 52: `.match(arg)` was folded to String.prototype.match based only on the
ARG being possibly-regex, never checking the RECEIVER is a string. A class
instance with a user `match(pathname)` method (Next.js RouteMatcher) called
through a shared call site `m.match(p)` (m: any) compiled to
js_string_match(m_as_string, p), reinterpreting the instance pointer as a
string and returning null — so DefaultRouteMatcherManager never matched the
App-Router root '/'. Fix: only fold to StringMatch/StringMatchAll when the
receiver is statically a string; Any/class receivers fall through to the
runtime dispatcher (which handles string .match via is_string() and class
methods). Mirrors the wall-50 normalize fix.

Wall 53: runtime `require(absolutePath.json)` (Next.js loads manifests via
`require(this.middlewareManifestPath)`) threw MODULE_NOT_FOUND — the CJS
wrapper's require only handles compile-time-static specifiers. Added a .json
disk fallback: new js_require_json_disk runtime fn (readFileSync + JSON.parse,
throws MODULE_NOT_FOUND when absent) reached via a __perry_require_json_disk
intrinsic (globals.rs -> __perry_runtime NativeMethodCall -> codegen FFI),
called from the wrapper require for absolute .json paths. .js/.node runtime
require stays unsupported (would need eval).
…e registry (Next.js wall 54, part 1)

Next.js/turbopack load page + chunk modules via a runtime `require(path)`
computed from a manifest, not a static specifier. Perry resolves static
requires at compile time, so a runtime absolute-path require threw
MODULE_NOT_FOUND even for modules it had compiled.

Adds a path->module-exports registry: every compiled CJS module self-registers
its exports under its canonical absolute source path at init (cjs wrapper tail),
and the wrapper's require resolves an unmatched absolute path through the
registry before the .json disk read / MODULE_NOT_FOUND throw.

- runtime: js_register_path_module / js_require_path_module (canonicalized key)
- intrinsics: __perry_register_path_module / __perry_require_path_module
  (globals.rs -> __perry_runtime NativeMethodCall -> codegen FFI)
- wrap.rs: self-register at module init + path lookup in require

Verified: a module statically required (compiled) is also resolvable via
require(absolutePath.js) with identical exports ref, matching node. This is the
foundation; compile-time discovery of the dynamically-required module set
(part 2) is still needed for the page modules to be in the registry.
`is_in_node_modules` used `canonical.contains("node_modules")` — a substring
match that misclassifies a non-node_modules .js whose FILENAME contains
"node_modules" (e.g. turbopack's bundled chunks
`.next/server/chunks/ssr/node_modules_next_dist_…._.js`) as a node_modules JS
module, force-routing it to the removed V8 runtime -> hard 'JavaScript runtime
(V8) support has been removed' build error. Check for a real `node_modules/`
directory component instead so such files compile natively (Next.js wall 54
groundwork: lets the turbopack server chunks AOT-compile).
…t eagerly at module init

A class declared inside a function/arrow body (e.g. turbopack chunk
factories) had its `static x = ...` field initializers emitted into the
enclosing MODULE's init, so they ran eagerly when the chunk module was
loaded — even though the factory closure was never called. React-SSR
turbopack chunks have nested classes whose static initializers run
arbitrary code (`static #a = this.EMPTY = new z(...)`); running them
eagerly at module load threw `TypeError: Cannot convert undefined or
null to object` while loading a 209KB chunk.

Add `Class.is_nested` (set when lowered at scope_depth>0 / inside a
block scope) and skip nested classes in init_static_fields_early/late.
The function-body decl path (body_stmt.rs) already emits static-field
init into the function body for non-pre-hoisted classes, so they now
initialize lazily when the declaration is evaluated — matching JS.
…server page/chunk modules by path

A Next.js standalone server loads its page/route/chunk modules from
`<entry_dir>/.next/server/**` via a path computed at request time
(`require(getPagePath(...))`, turbopack `R.c("chunkpath")`) — never via a
static import — so the import walk never reached them and they were not
AOT-compiled. The server reached request handling but every render threw
`Error: Cannot find module '.next/server/app/page.js'`.

Discovery: when the entry sits beside a `.next/server` directory,
collect_modules seeds every `.next/server/**/*.js` as an additional
compile root (75 modules in the demo; 669 -> 747). They self-register
under their absolute path at init (part 1's
`__perry_register_path_module`).

Lazy loading: the `.next` modules stay Deferred (eager-initing turbopack
chunks at startup runs React-SSR code before the server is ready). The
entry's `main` records each module's `<prefix>__init` address by path
(new `js_register_path_init` + `MODULE_PATH_INIT_REGISTRY`); the first
runtime `require(absolutePath)` whose exports aren't registered yet
triggers that init lazily (which self-registers exports and may
recursively require its chunk dependencies in `R.c()` order). All
registry locks are released before calling init to allow re-entry.

With this the server boots and the page-load path fires correctly on
request (verified: the trigger reaches `loadComponents` -> `requirePage`
-> the page module's init). Executing the turbopack chunk/page code
itself is the next wall (a SIGSEGV in the generated React-SSR code).

Gated entirely on the `.next/server` sibling directory, so non-Next.js
builds are byte-identical. Runtime additions are additive.
… it to undefined

SetAdd wrote js_set_add's return value back to the variable's storage to
"see the possibly-realloc'd pointer" — but js_set_add ALWAYS returns the
same SetHeader pointer (ensure_capacity reallocs only the internal
elements buffer, never the header). The writeback was vestigial (copied
from the array-push realloc pattern) and actively wrong for a
boxed/mutable closure capture: it called js_closure_set_capture_f64,
overwriting the capture SLOT (which holds a box pointer) with the Set
value, so the next read dereferenced the Set-as-box and got undefined.

Repro (CJS module, the Next.js turbopack runtime shape):
  const loaded = new Set();
  function lrp(p){ loaded.add(p); }          // loaded captured by closure
  module.exports = (s) => ({ c: (p) => lrp(p) });
  // caller: const R = require(mod)('x'); R.c('a'); R.c('b');
After the first R.c, `loaded` read as undefined; the second R.c
SIGSEGV'd in js_set_add. This silently cleared the turbopack runtime's
module-level `loadedChunks` Set, crashing Next.js page/chunk loading.

Fix: drop the writeback entirely — the set is mutated in place and the
header pointer is stable; GC moves are handled by root rewriting of the
variable slot, not here. Map.set was already correct (no writeback).
`{ 900(e,t,r){} }` — an object-literal method shorthand with a numeric
key — fell through `lower_method_prop`'s key match to the `_ => Ok(None)`
arm, so the method was dropped entirely: `obj[900]` returned undefined
and the key never appeared in `Object.keys`. `{ 900: function(){} }`
(numeric key, value form) and `{ "900"(){} }` (string-keyed shorthand)
both worked, so this only hit the numeric-key + shorthand combination.

webpack/turbopack module-factory tables use exactly this shape
(`var r = { 900(e,t,r){…}, 781(e){…} }` then `r[900].call(…)`), so a
bundle's numeric-keyed factories vanished — e.g. Next.js's
`app-page-turbo.runtime.prod.js` entry `a(900)`.

Add the `PropName::Num` arm mirroring the KeyValue / closed-shape paths
(`number_to_js_key`), so the key stringifies per spec (`{900(){}}` has
own key "900").
… per-object flag

`PROPERTY_DESCRIPTORS` / `ACCESSOR_DESCRIPTORS` are keyed by raw object
address. When an object is freed and its address is reused by a fresh
allocation, a stale `(addr, key)` descriptor entry from the previous
tenant would be read back for the new object — reporting a plain `{}`'s
property as a getter-only accessor or `writable: false` and falsely
throwing "Cannot assign to read only property" on an ordinary write.

Each descriptor install already sets `OBJ_FLAG_HAS_DESCRIPTORS` in the
object's GcHeader (via `note_descriptor_target`, for `GC_TYPE_OBJECT`),
and a fresh allocation's `_reserved` is zeroed. Gate the hot get/set
descriptor lookups on a new `object_has_descriptors()` helper so a fresh
object with the flag clear skips the stale side-table read entirely:

- `field_set_by_name`: accessor short-circuit + per-property writable check
- `field_get_set`: plain-object accessor get/set short-circuits
- `proxy::own_set_descriptor`: the [[Set]] walk used once a descriptor on
  Object.prototype disables the plain-object fast path process-wide
  (closures keep consulting the side tables — they don't carry the flag).

Addresses the stale-descriptor-on-address-reuse class of false read-only
throws.
…ained member-init

A simple-ident var bound to a chained member-assignment whose RHS is an
object literal miscompiles in the full-bundle context: the constructed
object's own field reads back as 0 when the construction flows directly
into both the member store and the binding. A directly-bound object
literal (`var x = {exports:{}}`) is fine, so hoist the construction to
its own `Let` and feed the member-set and the binding from that temp —
a semantics-preserving rewrite of `var i = n[e] = {exports:{}}` into
`var tmp = {exports:{}}; n[e] = tmp; var i = tmp;`.

This unblocks Next.js's app-page-turbo webpack runtime: its
`__webpack_require__` does `var i = cache[e] = {exports:{}}` then passes
`i.exports` to the module factory; the corrupted `exports = 0` made
React's `exports.Fragment = …` throw "Cannot assign to read only
property 'Fragment'". Together with the descriptor-flag fix
(prior commit) the page module now loads past the Fragment wall.
The is_nested field on perry_hir::Class (nested-class eager-static-init fix)
was missed on the inliner's test-helper Class literal, breaking
`cargo test -p perry-transform`. Add is_nested: false.
A fresh var/let/const NAME = ... binding inherited a native-instance tag
(FormData/Response/Headers/http.Server/...) that an UNRELATED earlier
binding of the same NAME registered. ctx.native_instances is module-global,
keyed by name, last-match-wins; a minified webpack bundle that binds a local
i via new FormData() in one factory and reuses var i = { exports:{} } as the
require-cache object in another leaked the FormData tag into the second i.
The plain read i.exports then lowered to a FormData NativeMethodCall (no such
member -> folds to 0), so React's exports.Fragment = ... ran against 0 and
threw "Cannot assign to read only property 'Fragment'" -- the long-standing
Next.js app-page-turbo wall.

Fix: push a tombstone (empty module) for the name before the native-instance
registration checks in the simple-ident var-decl path; a genuinely-native
init re-registers after it (last-match-wins keeps the real tag), and
lookup_native_instance resolves a tombstoned name to None so the read/call
lowers as an ordinary property access. The tombstone lives in the
scope-truncated native_instances vec, so it does not leak past the binding's
function.

Validated: Next.js app-page-turbo bundle no longer throws the Fragment error;
native http server (createServer/listen/close/address) and EventEmitter still
dispatch correctly; perry-hir tests pass.
lower_fn_decl did `lookup_func(name).unwrap_or_else(|| fresh_func())` but
the fresh_func() branch never called register_func, so a function
declaration whose name was not pre-hoisted (common for deeply-nested decls
inside closures, e.g. minified webpack-bundled modules) had its own name
unresolvable in its body. A self-reference or member read on the function
name then lowered to js_global_get_or_throw_unresolved -> "ReferenceError:
<name> is not defined". Register the name when it was not already
pre-registered; the registration lives in the enclosing scope's
function table (scope-truncated at exit), so it is visible for recursion
and sibling references without leaking. perry-hir tests pass.
lower_method_prop lowered object-method bodies via lower_block_stmt (a plain
block: no function-declaration name pre-registration), while every other
function-body path (lower_fn_decl/lower_nested_fn_decl/lower_fn_expr) uses
lower_fn_body_block_stmt, which Phase-1-hoists nested function-declaration
names so forward references resolve. Minified webpack bundles emit module
factories as object METHOD shorthand (`{"./mod"(e,t,r){ ...; r.d(t,{x:()=>o});
... function o(){} }}`), so the export getter `()=>o` is a forward reference
to a `function o` declared later in the same method body. Without Phase-1
hoisting that reference lowered to an unresolved global -> "ReferenceError: o
is not defined" on the Next.js page-module load. Route method/accessor bodies
through lower_fn_body_block_stmt (both call sites). perry-hir tests pass.
A `var` declared LATER in a function body but referenced by a closure created
EARLIER must be captured by reference (the closure sees the later assignment).
predefine_var_bindings_in_function_body already hoists+boxes such `var`s, but
their box was never added to the function-entry prealloc set — only let/const
forward-captures (Phase 1.6) were. So the earlier closure captured a
TAG_UNDEFINED snapshot instead of the live box, and saw `undefined` after the
`var` was assigned. This is the webpack/turbopack ESM re-export shape used in
Next.js' react-server.node.js: `r.d(t,{x:()=>n.x}); var n=r("…")` — the export
getter `()=>n.x` is created before `var n` is assigned. Extend
pre_register_forward_captured_lets to also add forward-captured `var` ids to
forward_boxed_ids (prealloc). Repro: `function f(){const g=()=>n; var n=42;
return g()}` returned undefined, now 42. perry-hir tests pass.
Phase-1 function-declaration hoisting in lower_fn_body_block_stmt reused ANY
existing local of the same name via lookup_local, which searches every scope.
A nested `function a` therefore reused an OUTER-scope `a` binding's local/box
instead of shadowing it with a fresh one. When the outer binding is a
closure-captured variable, the nested declaration overwrites the captured box
at runtime: in the Next.js app-page turbopack bundle the webpack chunk's
`function a` require is captured by an inner IIFE, and react-dom's module-local
`function a` (React's prod error formatter) reused that same box — so after
react-dom was required, `a("…/superstruct/index.cjs")` invoked React's error
formatter instead of the require, returning the string "Minified React error
#…/superstruct/index.cjs". That string flowed through `a.n` and a later 0-arg
call threw "value is not a function" during app-page module init (HTTP 500).
Add lookup_local_in_current_scope (locals at/after the current enter_scope
mark) and use it for the hoisting reuse decision so a same-named outer local is
shadowed (fresh local + box) per JS scoping. A sibling `var`/`function` in the
SAME body still merges. perry-hir tests pass; var-forward-capture + nested
re-export repros unaffected.
Identifier resolution checked lookup_local (which searches every scope) before
lookup_class, so a nested `class a` whose name also exists as a binding in an
OUTER scope resolved to that outer binding instead of the class. In the Next.js
app-page turbopack bundle a webpack chunk-level `a` (`a=()=>{}`, undefined when
the module factory runs at init time) is captured into p-timeout's module,
which declares `class a extends Error` (its TimeoutError) and exports it via
`e.exports.TimeoutError=a` — that read resolved to the outer `undefined`, so
`new r.TimeoutError` threw "undefined is not a constructor". When the name is a
class declared in the current function body (forward_class_names, lexically
saved/restored per body) and there is no current-scope local of that name,
resolve the identifier to a ClassRef so it shadows the outer local. A sibling
param/var/let in the same scope still wins. perry-hir tests pass; class-shadow
repro fixed.
The Fragment fix (e3665b330) tombstoned a leaked native-instance tag when a
VAR-DECL rebinds a name that an earlier `new FormData()`/`new Response()` (etc.)
in another factory registered (native_instances is module-global, name-keyed,
last-match-wins). Function PARAMETERS were not covered: a minified
`function(e){…}` whose param `e` collides with an earlier `e = new Response()`
inherited the stale `"e"→native` tag, so `e.<method>` lowered to a
NativeMethodCall on the stale module → folded named reads to 0. In the Next.js
app-page bundle superstruct's `enums(e){ let r=e.map(x=>a(x)).join(); … }` saw
`e.map`/`e.length`/`e.constructor` ALL read 0 (while `e[0]` and
`Array.prototype.map.call(e)` worked — the array was fully valid) → `e.map(…)`
went through number-method dispatch → 0 → `.join` on 0 → "(number).join is not
a function" during app-page module init. Add `shadow_native_instance_if_present`
and call it at every parameter binding site (fn decl / fn expr / arrow / object
method / class method / private method / nested fn decl) so a param shadows any
live same-named native-instance tag, exactly like a var-decl. perry-hir tests
pass (158); class-shadow + superstruct repros unaffected.
…odule

Class references are name-keyed (`Expr::New { class_name }` / `ClassRef(name)`
resolve by name at codegen), and the body-stmt dedup skips a `class X` whose
name is already registered. In a minified single-module bundle (Next.js
app-page) many DISTINCT classes share a name across nested webpack/ncc factory
scopes — `class s`x3, `class a`x5, etc. superstruct's `Struct` (`class s`, 6
members) was the 3rd `class s`, so the dedup SKIPPED it and `enums()`'s
`new s({...})` bound to an unrelated `class s` (16 members) whose ctor read a
property on undefined -> "Cannot read properties of undefined (reading 'b')"
during app-page module init.

Add scope-local class-name aliasing: when a function body (or function-
expression body) declares `class X` while an outer/prior `class X` is already
registered, rename this body's X to a unique `X$<n>` and record `X -> X$<n>` in
a `class_renames` map (saved/restored per body, mirroring `forward_class_names`)
so the declaration and every in-body reference (`new X`, `ClassRef`, dedup)
bind to the lexically-correct class. Detection runs in BOTH Phase-1.5 class
scans — `lower_fn_body_block_stmt` AND `lower_fn_expr` (the cjs/ncc
`(function(e){…class s{…}…})(t)` IIFE path, where Struct lives). Functions are
unaffected (already id-based via `FuncRef(FuncId)`); non-colliding classes are
unaffected (alias is identity), so blast radius is limited to duplicate-named
classes that were previously broken. perry-hir tests pass (158); superstruct +
class-shadow repros pass; app-page bundle advances past the wall.
…a real handle

Next.js' standalone server sets globalThis.AsyncLocalStorage = AsyncLocalStorage
(node-environment-baseline.js) then constructs storage via
new maybeGlobalAsyncLocalStorage() (async-local-storage.js). The dynamic callee
misses the static `new AsyncLocalStorage()` codegen arm, so js_new_function_construct
fell through to a class_id=0 empty object whose .getStore read back undefined ->
`TypeError: getStore is not a function` at server startup (io-utils.js io()).

Add an async_hooks arm to js_new_function_construct mirroring the #4995
EventEmitter dispatch: a new JS_NATIVE_ASYNC_HOOKS_CONSTRUCT pointer, registered
by perry-stdlib at startup, routes a bound async_hooks.AsyncLocalStorage /
AsyncResource ctor value to the real stdlib/runtime handle constructor.

Also bind AsyncLocalStorage method-VALUE reads (als.getStore / als.run as values,
not zero-arg calls) for statically-typed receivers (expr_member.rs arm +
dispatch_async_local_storage_property) so the read returns the callable bound
method rather than invoking getStore() with no args.

Verified: server now passes the getStore startup wall (advances to a later
new-Function wall in depd/send); static and dynamic ALS run/getStore match Node.
…le time

depd's `wrapfunction` (bundled in `send` -> Next.js) builds its deprecation
wrapper with a runtime-constructed body, so the eval classifier bucketed it
RuntimeUnknown and (in defer mode, PERRY_ALLOW_EVAL) compiled it to a
throw-on-call value. `send` invokes the wrapper eagerly at module init, so the
Next.js standalone server crashed at startup (`new Function() cannot run in an
ahead-of-time compiled binary`) before printing its banner.

The runtime `js_function_ctor_from_strings` already recognizes this exact
template and returns the wrapped fn. Recognize the same shape at HIR-lowering
time (5 verbatim param-name string-literal args + the three body marker
substrings collected from the concatenation skeleton) and let it PROCEED to that
recognizer instead of deferring. Any other runtime-unknown body still defers.
NO general eval/interpreter.

Verified: with this + the async-hooks dynamic-new fix, the Next.js server now
boots and prints its banner (advances to a later path-dispatch wall).
…ty read

The CJS-require shim lowers `require("path")` to
`PropertyGet { NativeModuleRef("path"), "default" }` (a property read), not a
bare `NativeModuleRef`. The devirtualized native-module dispatch bucket is
installed (js_nm_install_<mod>) only by the bare-NativeModuleRef value lowering
in static_field_meta, so the require-then-`.default.<method>()` shape never
registered the bucket: nm_dispatch_lookup fell to its None arm and every method
call returned undefined (Next.js' `_path.default.join(...)` -> undefined ->
distDir/buildIdPath undefined -> fs ERR_INVALID_ARG_TYPE at server startup).
Properties (path.sep) worked because they resolve via the constants path, not
the method dispatcher.

Emit nm_install_symbol(module) in the NativeModuleRef property-read arm too,
mirroring the bare-ref path. Idempotent at runtime; also keeps the bucket's
handlers alive against the auto-optimize dead-strip.

Verified: `require("path")`-only (no static import) .default.join now returns
the joined path; the Next.js server advances past the filesystem-setup wall.
Per ECMA-262 SerializeJSONProperty, JSON.stringify(value, replacer) must call
the replacer with `this` = the holder (the containing object/array; for the
root, the `{ "": value }` wrapper). Perry's call_replacer invoked the closure
via js_closure_call2 with no receiver, so `this` was wrong for every key
(verified: a 4-key object gave root/isA/wrapper all false vs Node's
wrapper/root/isA/root). Replacers that depend on the holder broke — notably
React's Flight reply encoder keys its dedup Maps by `this`
(referenceMap.get(this)), and `this[key] instanceof Date` checks.

Set the implicit `this` to the holder around the replacer call (mirrors the
reviver path's internalize_json_property), threading the holder through
call_replacer: the object/array being walked for nested keys, and a freshly
built `{ "": value }` wrapper (GC-safe, like apply_reviver) for the root.

Verified byte-identical to Node across the JSON.stringify suite (replacer
this-binding, array/function replacers, pretty-print, Map-cycle replacer).
…rototype, not self

js_object_get_prototype_of fell through to its `return obj_value` self-prototype
fallback for native-module namespace objects (the `{__module__}`-tagged
NATIVE_MODULE_CLASS_ID object that `require("path")` etc. produce), so
Object.getPrototypeOf(mod) === mod. Turbopack's `interopEsm` walks the import's
prototype chain — `for (cur = raw; !LEAF_PROTOTYPES.includes(cur); cur =
getProto(cur))` — to mirror getters for every inherited key; a self-returning
getProto makes that loop never advance, so it allocates export getters
(`createGetter` = `()=>obj[key]`) forever. That was the Next.js 16 standalone
startup runaway: after the banner the server span at 100% CPU, RSS climbing
~27MB/s, never reaching `✓ Ready`.

Return %Object.prototype% (a LEAF_PROTOTYPE) for NATIVE_MODULE_CLASS_ID objects
in both heap-pointer branches so the chain walk terminates. Verified: the
native-module proto chain now ends at Object.prototype→null (no self-loop), and
the Next.js standalone server boots to a stable 193MB RSS and serves requests
(now a normal HTTP 500 `cacheSignal is not a function` render error, not a hang).
…osure_ptr guard, rest-param construct ABI, url string-method gating

- native/mod.rs + native_call_method.rs + stdlib_ffi.rs: null-safe native-method fallback (cacheSignal e.indexOf 0.0-sentinel mis-classification)
- closure/dynamic_props.rs: is_closure_ptr heap-range guard (SIGSEGV on mis-boxed 0x400000000)
- string_pool.rs + class_constructors.rs x2: register class-ctor rest params in CLOSURE_REST_REGISTRY + pack rest array before positional caps in both replay paths (member-new super-spread 0x400000000)
- expr_member.rs + native_module.rs: skip unimplemented-module gate for String.prototype methods on module-named locals (url.endsWith)

wall#5 (readFileSync ENOENT) NOT included — Perry can't propagate the runtime throw through Next's manifest loader without crashing; needs deeper async-exception work.
# Conflicts:
#	crates/perry-hir/src/lower/context.rs
#	crates/perry-hir/src/lower_decl/block.rs
Ralph Küpper added 13 commits June 18, 2026 10:12
…ndex to undefined, not SIGSEGV

NaN-boxed receivers whose tag isn't POINTER (0x7FFD) or STRING (0x7FFF) are
primitives (INT32 0x7FFE, BIGINT 0x7FFA, undefined/null/bool 0x7FFC). Indexing
them is `undefined` per JS (`(983055)[0] === undefined`). Previously the int
payload was treated as a heap pointer and the GcHeader at raw-8 was read → wild
deref → SIGSEGV (Next.js app-page-turbo render crash on a NaN-boxed-int
receiver 0xf000f). Reject non-pointer/string NaN-boxed receivers up front
(cross-platform); also guard the raw-pointer path with is_valid_obj_ptr.
… longer mis-resolve to node modules)

native_modules_index is module-global + first-match-wins with no scope
tracking, so a local binding (param or `const`) named the same as a
registered native module (`url`, `util`, …) resolved to the node MODULE
everywhere — firing the unimplemented-API gate on its ordinary methods.
Next.js app-page-turbo hit this en masse: 88x `url.push`, 84x `util.destroy`,
and the render-blocking `url.o` deferred throw (a local `o` arrow, a local
`url` array, undici's own `util`, etc.).

Add a per-scope module-shadow stack mirroring the native-INSTANCE shadowing
(shadow_native_instance / truncate): push at param + var-decl binding sites,
truncate in exit_scope. The var-decl case is conditional — it does NOT shadow
a decl whose init is `require('<node-core>')` (the real module binding).
Drops the app-page-turbo unimplemented-API gates 640 -> 28 and clears the
url.o render throw.
# Conflicts:
#	crates/perry-runtime/src/module_require.rs
…_id (LocalId parallel to #5143)

compute_max_func_id scans class field/static-field initializers and the
extends expression (the #5143 FuncId fix), but the parallel LocalId scan
in compute_max_local_id did not — a field-initializer closure's param/body
LocalIds were invisible, so the generator/async transform could synthesize
state/done/sent LocalIds that COLLIDE with a field-init local and corrupt
unrelated codegen (e.g. a module-binding/capture read resolving wrong). A
too-high max is always safe; a missed id collides. Adds a regression test.
…nstead of throwing

A class object (OBJECT_TYPE_CLASS) reaching js_native_call_value — e.g.
`new s.RequestCookies(headers)` where the dynamic callee resolves (through a
webpack lazy-export getter) to a class object but the construct site lowered to
a call — hit `func_ptr.is_null()` and threw `value is not a function`. The
construct path (js_new_function_construct) already handles class objects; the
call path did not. Route a class object in the call path to construction (its
only sensible meaning). Unblocks the Next.js app-page-turbo render past the
RequestCookies wall.
…ue-call

Refines the prior class-object-in-call-path fix. In the Next.js app-page-turbo
bundle, `new s.RequestCookies(headers)` lowers as `new (s.RequestCookies())(headers)`:
the member callee evaluates as a 0-arg value-call and the OUTER `new` carries the
args. Constructing at that 0-arg inner call (prior behavior) built the instance
with no args -> the constructor read 'cookie' off undefined headers
('Cannot read properties of undefined (reading get)'). Instead, return the class
object on a 0-arg value-call so the outer `new` constructs it with the real args;
a value-call WITH args still constructs directly. Clears both the RequestCookies
value-not-fn (W2) and undefined.get (W4) walls in the Next.js render.
…mpty string)

A failed readFileSync returned an empty string instead of throwing. Callers that
try/catch a missing file then fall back (e.g. Next.js loadManifest returns {}
when an optional manifest like subresource-integrity-manifest.json is absent)
never saw the error — the empty string flowed into JSON.parse('') and threw a
misleading 'Unexpected end of JSON input'. Throw a Node-shaped fs error
(code/errno/syscall/path) — a real catchable JS exception, not the null-deref the
prior empty-string workaround guarded against — so try/catch behaves as node.
…tructed anonymous class expressions

`new class { m() { return outer } }()` registered a synthesized class whose
constructor takes one param per captured enclosing-scope local (method bodies
rewritten to read `this.__perry_cap_<id>`), but the directly-constructed
anonymous form passed only the user's args to the New — cap params received
`undefined`, so methods reading a captured local saw `undefined`. Mirror the
named-class `new C()` path: append each capture as `LocalGet(id)`. Refs Next.js
bundled tracer (getActiveScopeSpan -> trace.getSpan on undefined destructured trace).
# Conflicts:
#	crates/perry-hir/src/lower/context.rs
#	crates/perry-hir/src/lower/expr_call/regex_string.rs
#	crates/perry-hir/src/lower/lowering_context.rs
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements Next.js "wall 54 (part 2)" deferred-module path-based lazy initialization end-to-end, adds Class.is_nested to defer nested-class static field initialization to class evaluation time, introduces scope-aware native instance/module shadowing and class rename disambiguation in the HIR lowering context, fixes constructor arity and rest-parameter codegen for inherited classes, adds AsyncLocalStorage dynamic construction dispatch, adds per-object descriptor address-reuse gating, and fixes numerous runtime and lowering correctness issues.

Changes

Next.js Wall 54 + Nested Statics + Shadowing + Ctor Rest + AsyncLocalStorage + Descriptor Gating + Runtime Fixes

Layer / File(s) Summary
Class.is_nested HIR field: declaration, hashing, specialization, test fixtures
crates/perry-hir/src/ir/decl.rs, crates/perry-hir/src/stable_hash/decls.rs, crates/perry-hir/src/stable_hash/tests.rs, crates/perry-hir/src/monomorph/specialize.rs, crates/perry-codegen/src/codegen/mod.rs, crates/perry-hir/src/lower/context.rs, crates/perry-hir/src/lower/module_decl.rs, crates/perry-codegen/tests/*, crates/perry-transform/src/inline/mod.rs
Adds documented is_nested: bool to Class HIR struct; propagates through stable hashing, monomorphization, imported-stub construction, namespace lowering, and all test fixture literals.
Nested class static field timing in codegen and is_nested assignment in lowering
crates/perry-codegen/src/codegen/helpers.rs, crates/perry-hir/src/lower_decl/class_decl.rs, crates/perry-transform/src/generator/id_scan.rs
init_static_fields_early/late skip is_nested classes. lower_class_decl/lower_class_from_ast set is_nested from scope depth. compute_max_local_id scans class field closures; new test added.
LoweringContext: native shadowing fields, class rename state, and new helpers
crates/perry-hir/src/lower/lowering_context.rs, crates/perry-hir/src/lower/context.rs
Adds scope_module_shadow_marks, module_shadow_stack, class_renames, next_class_rename_id fields. Implements resolve_class_name, maybe_rename_colliding_class, lookup_local_in_current_scope, shadow_native_module_if_present, shadow_native_instance_if_present, tombstone-aware lookup_native_instance, and enter_scope/exit_scope shadow-stack save/restore.
Native shadowing + class rename applied across all HIR lowering call sites
crates/perry-hir/src/lower/expr_function.rs, crates/perry-hir/src/lower_decl/fn_decl.rs, crates/perry-hir/src/lower_decl/class_members.rs, crates/perry-hir/src/lower_decl/private_members.rs, crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs, crates/perry-hir/src/lower/expr_object.rs, crates/perry-hir/src/lower/lower_expr.rs, crates/perry-hir/src/lower_decl/block.rs, crates/perry-hir/src/lower_decl/body_stmt.rs, crates/perry-hir/src/destructuring/var_decl.rs
Calls shadow_native_instance_if_present/shadow_native_module_if_present after every parameter define_local across arrow, fn-expr, fn-decl, constructor, method, setter, and catch paths. Applies maybe_rename_colliding_class/resolve_class_name in block, body-stmt, and expr-function class pre-registration. Fixes hoisted-function-declaration local reuse and var forward-capture box allocation.
Cross-module extends_expr and dynamic-parent super dispatch
crates/perry-hir/src/lower_decl/class_decl.rs, crates/perry-codegen/src/codegen/method.rs, crates/perry-hir/src/lower/expr_new.rs
lower_class_decl/lower_class_from_ast unconditionally populate extends_expr for named cross-module extends. Codegen skips static-symbol ctor resolution when extends_expr set; emits js_fetch_or_value_super for dynamic parent. expr_new.rs uses resolve_class_name and forwards synthesized capture locals for anonymous class new.
Synthesized ctor arity, ctor_arity_overrides, and rest-param registry
crates/perry-codegen/src/codegen/artifacts.rs, crates/perry-codegen/src/codegen/string_pool.rs, crates/perry-runtime/src/object/class_constructors.rs
Introduces synthesized_ctor_param_count helper; builds ctor_arity_overrides map and passes it to emit_string_pool. emit_string_pool records ctor_rest_regs and emits js_register_closure_rest. Runtime constructor replay paths correctly bundle trailing args into a JS array at the rest parameter slot.
Next.js wall 54: module collection, CJS wrap, config fields, and init order
crates/perry/src/commands/compile/collect_modules.rs, crates/perry/src/commands/compile/cjs_wrap/wrap.rs, crates/perry/src/commands/compile/init_order.rs, crates/perry-codegen/src/codegen/opts.rs, crates/perry-codegen/src/codegen/mod.rs, crates/perry/src/commands/compile.rs, crates/perry/src/commands/compile/object_cache.rs
Adds is_nextjs_runtime_module predicate and recursive .next/server/**/*.js root seeding. Fixes component-exact node_modules detection. CJS wrap injects __perry_register_path_module self-registration in both emit branches and extends require shim with path-module and JSON disk fallback. Adds nextjs_path_init_modules to CompileOptions/CrossModuleCtx, wired through compile worker.
Next.js wall 54: HIR intrinsic lowering, var hoisting rewrite, codegen entry registration, and runtime path registry
crates/perry-hir/src/lower/expr_call/globals.rs, crates/perry-hir/src/destructuring/var_decl.rs, crates/perry-codegen/src/codegen/entry.rs, crates/perry-codegen/src/runtime_decls/objects.rs, crates/perry-runtime/src/module_require.rs
HIR lowers __perry_require_json_disk, __perry_register_path_module, __perry_require_path_module intrinsics. var_decl.rs hoists the webpack member-assignment pattern. Entry codegen pre-emits string constants and registers <prefix>__init addresses via js_register_path_init in main. Runtime implements path-module and JSON-disk registry (js_register_path_init, js_register_path_module, js_require_path_module, js_require_json_disk).
AsyncLocalStorage dynamic construction and property dispatch
crates/perry-runtime/src/value/tags.rs, crates/perry-runtime/src/value/handle.rs, crates/perry-runtime/src/lib.rs, crates/perry-runtime/src/object/class_registry.rs, crates/perry-stdlib/src/common/dispatch.rs, crates/perry-hir/src/lower/expr_member.rs
Adds JS_NATIVE_ASYNC_HOOKS_CONSTRUCT atomic and js_set_native_async_hooks_construct FFI. Runtime js_new_function_construct dispatches async_hooks constructors through the atomic. Stdlib registers async_hooks_native_construct callback and dispatch_async_local_storage_property for bound method-value reads. HIR lowers selected AsyncLocalStorage properties as PropertyGet.
Per-object descriptor address-reuse gating
crates/perry-runtime/src/object/mod.rs, crates/perry-runtime/src/object/field_get_set.rs, crates/perry-runtime/src/object/field_set_by_name.rs, crates/perry-runtime/src/proxy.rs
Adds object_has_descriptors(obj) reading OBJ_FLAG_HAS_DESCRIPTORS from GcHeader._reserved. All accessor/property descriptor lookups are now additionally gated on this per-object flag to prevent stale descriptor reads after heap address reuse.
JSON.stringify replacer spec-compliant holder this binding
crates/perry-runtime/src/json/replacer.rs
call_replacer gains holder_f64 parameter and sets implicit this to the holder around closure invocation. Adds holder_value and root_holder helpers. All object/array and root replacer calls pass the correct holder.
Miscellaneous runtime and lowering correctness fixes
crates/perry-codegen/src/expr/bigint_set.rs, crates/perry-runtime/src/closure/dispatch.rs, crates/perry-runtime/src/closure/dynamic_props.rs, crates/perry-runtime/src/fs/mod.rs, crates/perry-runtime/src/object/polymorphic_index.rs, crates/perry-runtime/src/object/object_ops.rs, crates/perry-runtime/src/object/native_call_method.rs, crates/perry-codegen/src/lower_call/native/mod.rs, crates/perry-codegen/src/lower_call/property_get.rs, crates/perry-codegen/src/expr/property_get.rs, crates/perry-hir/src/lower/expr_call/array_only_methods.rs, crates/perry-hir/src/lower/expr_call/local_array_methods.rs, crates/perry-hir/src/lower/expr_call/native_module.rs, crates/perry-hir/src/lower/expr_call/regex_string.rs, crates/perry-hir/src/lower/expr_object.rs, crates/perry-runtime/src/value/mod.rs, W6_ISSUE_DRAFT.md
Removes SetAdd storage writeback; adds is_valid_obj_ptr guard to is_closure_ptr; throws Node-shaped fs error on unreadable paths; fixes polymorphic index tag matching and pointer validation; adds HANDLE-receiver branch to defineProperty; returns Object.prototype for native-module namespace objects; adds class-as-callable path in js_native_call_value; adds js_native_call_method_nullsafe and updates unknown-native-method codegen fallback; removes normalize string-only force-routing; adds receiver-type guard for match/matchAll; extends array-method overlapping guards for mutating methods; exempts String.prototype methods from native-module unimplemented-API gate; preserves numeric-keyed object literal methods; adds eager nm_install_symbol for NativeModuleRef property-get; registers fn-decl name immediately on miss; detects depd wrapfunction new Function shape; includes W6_ISSUE_DRAFT.md documenting deferred-require binding problem.

Sequence Diagram(s)

sequenceDiagram
  participant compile_entry
  participant js_register_path_init
  participant Runtime
  participant js_require_path_module
  participant prefix__init
  participant js_register_path_module

  rect rgba(173, 216, 230, 0.5)
    note over compile_entry: Startup: entry module main()
    compile_entry->>js_register_path_init: (path_ptr, byte_len, &prefix__init)
  end

  rect rgba(144, 238, 144, 0.5)
    note over Runtime: Later: require(absolutePath)
    Runtime->>js_require_path_module: path_value (NaN-boxed)
    js_require_path_module->>js_require_path_module: check PATH_MODULES cache
    js_require_path_module->>prefix__init: transmute + call (on cache miss)
    prefix__init->>js_register_path_module: (path_value, module.exports)
    js_require_path_module->>Runtime: return cached exports
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • PerryTS/perry#5125: Both PRs adjust the codegen/runtime handling of dynamic-parent classes—main PR updates codegen's standalone-constructor/super dispatch for dynamically computed extends_expr, depending on runtime dynamic-parent instance/layout and constructor argument handling fixed in the retrieved wall-45 PR.
  • PerryTS/perry#5270: The main PR's HIR scope-resolution updates to LoweringContext local lookup helpers (e.g., scope-aware lookup_local_in_current_scope and shadowing) are directly related to the retrieved PR's LoweringContext.locals refactor from a Vec to an indexed Locals structure that changes how local bindings are looked up.
  • PerryTS/perry#5402: Both PRs extend Perry's module-loading/require paths to resolve module namespaces via <prefix>__init: main PR wires deferred Next.js module init addresses (nextjs_path_init_modulesjs_register_path_init/js_require_path_module), while the retrieved PR adds synchronous computed require(expr) lowering that directly resolves to the compiled namespace by calling <prefix>__init.

Poem

🐇 Hoppity-hop through the .next/server maze,
Each deferred module wakes up with lazy grace.
is_nested flags the class, "wait for your turn!"
The shadow stack guards what the scope cannot discern.
Rest params now bundle, ctor arities align,
And AsyncLocalStorage answers new just fine. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: Next.js 16 standalone app-router bring-up implementation across codegen/runtime/HIR layers with walls 1-5.
Description check ✅ Passed The description comprehensively covers objectives, key fixes organized by wall number, scope of changes (codegen/runtime/HIR), testing expectations, and known limitations. It maps well to the extensive changeset while maintaining clarity.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/nextjs-wall-46

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

@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: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
crates/perry-runtime/src/object/field_get_set.rs (1)

1079-1099: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate the data-descriptor lookup too.

The accessor table is now protected by object_has_descriptors, but the following get_property_attrs lookup is still unconditional. Since PROPERTY_DESCRIPTORS has the same raw-address keying, a reused object address can still make this indexed setter silently drop a write as non-writable.

🐛 Proposed fix
-                if ACCESSORS_IN_USE.with(|c| c.get()) && super::object_has_descriptors(obj as usize) {
+                let has_descriptors = super::object_has_descriptors(obj as usize);
+                if ACCESSORS_IN_USE.with(|c| c.get()) && has_descriptors {
                     if let Some(acc) = get_accessor_descriptor(obj as usize, name) {
                         if acc.set != 0 {
                             let closure = (acc.set & crate::value::POINTER_MASK)
                                 as *const crate::closure::ClosureHeader;
                             if !closure.is_null() {
@@
                         return;
                     }
                 }
-                if let Some(attrs) = get_property_attrs(obj as usize, name) {
-                    if !attrs.writable() {
-                        return;
+                if has_descriptors {
+                    if let Some(attrs) = get_property_attrs(obj as usize, name) {
+                        if !attrs.writable() {
+                            return;
+                        }
                     }
                 }
🤖 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 `@crates/perry-runtime/src/object/field_get_set.rs` around lines 1079 - 1099,
The get_property_attrs call is currently unconditional, but like the accessor
descriptor lookup, the PROPERTY_DESCRIPTORS table uses raw-address keying which
can cause stale data from reused object addresses. Wrap the get_property_attrs
invocation with the same gate condition used for get_accessor_descriptor: check
that ACCESSORS_IN_USE is true and object_has_descriptors returns true for the
object before calling get_property_attrs.
crates/perry-runtime/src/proxy.rs (1)

999-1032: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't skip descriptors for array proxy targets.

object_has_descriptors is only set for GC_TYPE_OBJECT, but this proxy invariant check handles generic targets. An array with an own non-writable/accessor descriptor skips lines 1012-1021 and can fall through to obj_value_has_own_key as writable: true, letting proxy set violate the target descriptor.

🐛 Possible fix direction
-            if header.obj_type == crate::gc::GC_TYPE_OBJECT {
+            if matches!(
+                header.obj_type,
+                crate::gc::GC_TYPE_OBJECT | crate::gc::GC_TYPE_ARRAY
+            ) {
                 let header = header as *const crate::gc::GcHeader as *mut crate::gc::GcHeader;
                 (*header)._reserved |= crate::gc::OBJ_FLAG_HAS_DESCRIPTORS;
             }
🤖 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 `@crates/perry-runtime/src/proxy.rs` around lines 999 - 1032, The condition
checking `object_has_descriptors(obj_ptr)` on line 1012 only covers
GC_TYPE_OBJECT targets and misses array proxy targets that may have their own
non-writable or accessor descriptors. This causes arrays with
non-writable/accessor descriptors to skip the descriptor lookup block and
incorrectly fall through to obj_value_has_own_key which returns writable: true,
violating proxy invariants. Modify the condition to also check descriptor tables
for array targets, or restructure the logic so descriptor checks are performed
regardless of whether the object_has_descriptors flag is set, ensuring both the
accessor descriptor and property attributes lookups are consulted for all proxy
target types before falling through to the obj_value_has_own_key check.
crates/perry-runtime/src/object/field_set_by_name.rs (1)

959-982: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mirror the non-writable check in the wide-key hit path.

When keys_index_lookup hits, this function writes the slot and returns before reaching the new gated PROPERTY_DESCRIPTORS check. A wide object can still overwrite an existing writable: false property once the sidecar index is active.

🐛 Proposed fix
             if let Some(i) = keys_index_lookup(obj, keys, name_bytes, key_hash) {
                 let i = i as usize;
                 if is_frozen {
                     let key_str = key_to_str_for_diag(key);
                     crate::error::throw_immutable_write(0, &key_str);
                 }
+                if PROPERTY_ATTRS_IN_USE.with(|c| c.get())
+                    && super::object_has_descriptors(obj as usize)
+                {
+                    if let Some(ref k) = incoming_key_str {
+                        if let Some(attrs) = get_property_attrs(obj as usize, k) {
+                            if !attrs.writable() {
+                                crate::error::throw_immutable_write(0, k);
+                            }
+                        }
+                    }
+                }
                 if i < alloc_limit {
                     js_object_set_field(obj, i as u32, JSValue::from_bits(value.to_bits()));
                 } else {

Also applies to: 1117-1124

🤖 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 `@crates/perry-runtime/src/object/field_set_by_name.rs` around lines 959 - 982,
The wide-key hit path (when keys_index_lookup returns Some(i)) is missing a
check for non-writable properties via PROPERTY_DESCRIPTORS before writing to the
field. This allows wide objects to overwrite properties with writable: false
once the sidecar index is active. Add a non-writable property descriptor check
in the keys_index_lookup hit path (alongside the existing is_frozen check) to
prevent writing to non-writable properties, and apply the same fix to the
similar code block at lines 1117-1124 as noted in the comment.
crates/perry-hir/src/lower/expr_call/array_only_methods.rs (1)

464-478: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Extend the new mutator bail-out to chained receivers.

Line 1117 now relies on recv_is_class, but the ast::Expr::Call receiver path at Lines 464-478 still excludes push. A call like factory().push(x) can therefore still lower to native array.push_single on a class/object result and reinterpret its header as an ArrayHeader.

Proposed fix
                         let is_overlapping = matches!(
                             method_name,
                             "find"
                                 | "findIndex"
                                 | "findLast"
                                 | "findLastIndex"
                                 | "map"
                                 | "filter"
                                 | "some"
                                 | "every"
                                 | "forEach"
                                 | "reduce"
                                 | "reduceRight"
-                                | "join"
+                                | "join"
+                                | "push"
+                                | "pop"
+                                | "shift"
+                                | "unshift"
                         );

Also applies to: 1109-1117

🤖 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 `@crates/perry-hir/src/lower/expr_call/array_only_methods.rs` around lines 464
- 478, The is_overlapping check in the matches! block starting at line 464
excludes the "push" method, which causes chained receiver calls like
factory().push(x) to incorrectly lower to native array.push_single on
class/object results. Add "push" to the list of method names in the
is_overlapping matches! expression to ensure the mutator bail-out logic is
consistently applied across all call receiver paths, matching the behavior now
implemented at line 1117 that relies on recv_is_class.
crates/perry-hir/src/lower/context.rs (1)

1447-1465: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore native-module shadows when block scopes exit too.

The new shadow stack is only truncated in exit_scope, but block-scoped let/const bindings are removed by pop_block_scope. A block-local const url = ... will keep url hidden from lookup_native_module for the rest of the function after the block exits.

Proposed fix
-    pub(crate) fn push_block_scope(&mut self) -> (usize, usize) {
+    pub(crate) fn push_block_scope(&mut self) -> (usize, usize, usize) {
         self.inside_block_scope += 1;
-        (self.locals.len(), self.functions.len())
+        (
+            self.locals.len(),
+            self.functions.len(),
+            self.module_shadow_stack.len(),
+        )
     }
 
-    pub(crate) fn pop_block_scope(&mut self, mark: (usize, usize)) {
+    pub(crate) fn pop_block_scope(&mut self, mark: (usize, usize, usize)) {
         debug_assert!(
             self.inside_block_scope > 0,
             "pop_block_scope without matching push"
         );
         self.inside_block_scope = self.inside_block_scope.saturating_sub(1);
-        let (locals_mark, functions_mark) = mark;
+        let (locals_mark, functions_mark, module_shadow_mark) = mark;
+        self.module_shadow_stack.truncate(module_shadow_mark);
🤖 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 `@crates/perry-hir/src/lower/context.rs` around lines 1447 - 1465, The
native-module shadow stack is only being restored in the exit_scope method, but
block-scoped let/const bindings are removed by pop_block_scope which does not
perform this restoration. Apply the same shadow stack restoration logic from
exit_scope to the pop_block_scope method - specifically, add code to pop from
scope_module_shadow_marks and truncate the module_shadow_stack to the saved mark
value, ensuring that block-local bindings no longer shadow native module lookups
after the block exits.
crates/perry-hir/src/lower_decl/block.rs (1)

1009-1025: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the resolved class name before re-registering captures.

Line 1010 restores ctx.class_renames, then Line 1024 looks up captures by the raw AST name. For a body class renamed by maybe_rename_colliding_class, captures are recorded under the resolved name, so this pass skips append_new_args_stmt and RegisterClassCaptures.

Proposed fix
     };
     ctx.forward_class_names = saved_forward_class_names;
-    ctx.class_renames = saved_class_renames;
 
     // Re-register capture snapshots for classes declared in this body at
@@
         let mut re_regs: Vec<Stmt> = Vec::new();
         for stmt in &block.stmts {
             if let ast::Stmt::Decl(ast::Decl::Class(class_decl)) = stmt {
-                let cname = class_decl.ident.sym.to_string();
+                let cname = ctx.resolve_class_name(class_decl.ident.sym.as_str());
                 if let Some(captured) = ctx.lookup_class_captures(&cname) {
@@
             }
         }
     }
+    ctx.class_renames = saved_class_renames;
 
     // Undefined-initialised entry slots for hoisted `var`s declared in

Also applies to: 1066-1066

🤖 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 `@crates/perry-hir/src/lower_decl/block.rs` around lines 1009 - 1025, The code
on line 1024 performs a lookup using the raw AST class name (cname derived from
class_decl.ident.sym), but class captures are recorded under the resolved class
name after renaming. Since ctx.class_renames was restored on line 1010, you need
to resolve the class name by checking ctx.class_renames to get the actual
renamed class name, then use that resolved name when calling
ctx.lookup_class_captures instead of the raw AST name. Apply the same fix to
line 1066 which has the identical issue.
🧹 Nitpick comments (5)
crates/perry-stdlib/src/common/dispatch.rs (1)

290-304: ⚡ Quick win

Deduplicate the AsyncLocalStorage method-name allowlist.

The same method set (run/getStore/enterWith/exit/disable) is now maintained in multiple places (method dispatch + property dispatch). A shared helper/const avoids divergence bugs later.

♻️ Suggested refactor
+const ASYNC_LOCAL_STORAGE_METHODS: &[&str] =
+    &["run", "getStore", "enterWith", "exit", "disable"];
+
+#[inline]
+fn is_async_local_storage_method(name: &str) -> bool {
+    ASYNC_LOCAL_STORAGE_METHODS.contains(&name)
+}
+
 unsafe fn dispatch_async_local_storage_method(
     handle: i64,
     method: &str,
     args: &[f64],
 ) -> Option<f64> {
-    if !matches!(
-        method,
-        "run" | "getStore" | "enterWith" | "exit" | "disable"
-    ) {
+    if !is_async_local_storage_method(method) {
         return None;
     }
     ...
 }
 
 unsafe fn dispatch_async_local_storage_property(handle: i64, property: &str) -> Option<f64> {
-    if !matches!(
-        property,
-        "run" | "getStore" | "enterWith" | "exit" | "disable"
-    ) {
+    if !is_async_local_storage_method(property) {
         return None;
     }
     ...
 }
🤖 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 `@crates/perry-stdlib/src/common/dispatch.rs` around lines 290 - 304, The
AsyncLocalStorage method-name allowlist (run, getStore, enterWith, exit,
disable) is duplicated across multiple functions and can diverge over time.
Create a shared constant or helper function that defines this allowed method set
once, and refactor both the dispatch_async_local_storage_method and
dispatch_async_local_storage_property functions to reference this shared
definition instead of maintaining separate copies of the method names.
crates/perry-runtime/src/module_require.rs (1)

261-270: 💤 Low value

Consider a debug assertion on path_len for defensive safety.

The cast path_len as usize from i64 will silently wrap negative values to a huge usize, causing undefined behavior in from_raw_parts. While the safety contract places responsibility on the caller, a debug assertion could catch codegen bugs early in development without release build overhead.

🛡️ Suggested defensive assertion
 pub unsafe extern "C" fn js_register_path_init(path_ptr: *const u8, path_len: i64, init_addr: i64) {
+    debug_assert!(path_len >= 0, "path_len must be non-negative");
     let slice = std::slice::from_raw_parts(path_ptr, path_len as usize);
🤖 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 `@crates/perry-runtime/src/module_require.rs` around lines 261 - 270, In the
js_register_path_init function, add a debug assertion to verify that path_len is
non-negative before it is cast to usize and used in the from_raw_parts call. The
assertion should check that path_len is greater than or equal to 0, placed
before the slice creation to catch potential undefined behavior from negative
values being silently cast to large usize values.
crates/perry-codegen/src/expr/property_get.rs (1)

603-616: 💤 Low value

Duplicate nm_install_symbol call in the common path.

The new install call at lines 614-616 is necessary to cover the process.version early-return path. However, for all other properties, this install call is followed by another identical call at lines 639-641. Since the install function is idempotent (atomic store), this is correct but emits redundant code in the generated IR.

Consider removing the second call at lines 639-641 since the new early call now covers all paths:

♻️ Optional cleanup
                 let prop_len_str = property.len().to_string();
-                // The value read of a native-module callable export (`const f =
-                // util.inherits`) mints a BOUND_METHOD closure that, when invoked
-                // indirectly, dispatches through the per-module `NM_DISPATCH_REGISTRY`
-                // populated by `js_nm_install_<module>()`. The *direct* call form
-                // (`util.inherits(a, b)`) is statically lowered to the runtime extern
-                // and never touches the registry, so a module reached ONLY via this
-                // value-read path would leave the registry empty and the indirect call
-                // would resolve to `undefined` (winston/readable-stream's
-                // `require('inherits')` → `util.inherits` value → `inherits(Sub, Base)`
-                // silently skipped, breaking the ES5 super-chain). Emit the install
-                // here so the value-read path's later dispatch finds the module fn.
-                if let Some(install_sym) = crate::nm_install::nm_install_symbol(module_name) {
-                    ctx.block().call_void(install_sym, &[]);
-                }
                 return Ok(ctx.block().call(
🤖 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 `@crates/perry-codegen/src/expr/property_get.rs` around lines 603 - 616, The
code now contains a duplicate call to crate::nm_install::nm_install_symbol
within the same function. The first call (with the check for Some(install_sym))
handles all code paths including the early return for process.version, making
the second identical call to nm_install_symbol redundant. Locate and remove the
second call to nm_install_symbol that occurs later in the function (around the
location mentioned in the comment at lines 639-641) since the earlier call now
covers all execution paths and the install operation is idempotent, eliminating
the need for the duplicate invocation.
crates/perry-hir/src/stable_hash/tests.rs (1)

282-282: ⚡ Quick win

Add a direct hash regression check for is_nested.

At Line 282, the fixture includes is_nested, but this test still doesn’t verify that flipping only is_nested changes the hash. A small follow-up assertion would lock in the new stable-hash contract.

Suggested test addition
     m_class.classes.push(Class {
         id: 1,
         name: "C".to_string(),
@@
         aliases: vec![],
         is_nested: false,
     });
-    assert_ne!(base_hash, hash_module(&m_class));
+    let class_hash = hash_module(&m_class);
+    assert_ne!(base_hash, class_hash);
+
+    let mut m_class_nested = m_class;
+    m_class_nested.classes[0].is_nested = true;
+    assert_ne!(class_hash, hash_module(&m_class_nested));
🤖 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 `@crates/perry-hir/src/stable_hash/tests.rs` at line 282, In the test at line
282 where the fixture includes `is_nested: false`, add a regression check
assertion after computing the initial hash. Create a second fixture that is
identical except with `is_nested` toggled to true, compute its hash, and add an
assertion verifying that the two hashes are different. This ensures that changes
to the `is_nested` field are properly reflected in the stable hash calculation
and prevents future regressions where this field might accidentally be ignored.
crates/perry-hir/src/destructuring/var_decl.rs (1)

1938-1938: 💤 Low value

Consider using a unique suffix for the temp local name.

The hardcoded name "__nx_member_init" works correctly since define_local allocates unique IDs, but multiple occurrences of this pattern in the same scope would produce locals with identical names in HIR dumps/debug output. A minor improvement would be to append a counter or use ctx.next_local_id() in the name for clearer diagnostics.

This is purely cosmetic and doesn't affect correctness.

🤖 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 `@crates/perry-hir/src/destructuring/var_decl.rs` at line 1938, The hardcoded
local variable name "__nx_member_init" in the ctx.define_local call should be
made unique for better debugging visibility in HIR dumps. Instead of using a
static string, append a unique suffix (such as a counter from the context or
ctx.next_local_id() if available) to the temporary variable name so that
multiple instances of this pattern produce locals with distinguishable names in
debug output, making diagnostics clearer without affecting runtime correctness.
🤖 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 `@crates/perry-codegen/src/codegen/artifacts.rs`:
- Around line 419-428: The constructor emission logic uses extends_name to check
for class heritage, but the ctor_arity_overrides registration uses extends_expr,
causing an inconsistency where the registered total_params may not match the
actual emitted function signature. Identify both the ctor emission site (around
the extends_name check near line 419 in the extends_name branch) and the
ctor_arity_overrides registration site (mentioned as also applying to lines
1648-1675), then ensure both locations use the same heritage predicate (either
extends_name or extends_expr) consistently so that registered total_params and
synthesized forwarding arity remain synchronized.

In `@crates/perry-codegen/src/codegen/helpers.rs`:
- Around line 808-814: The guard checking `c.is_nested` at lines 808-814
prevents nested class static-field initializers from running at module init in
the primary path, but the later `__perry_static_init_` fallback code path does
not have this same guard. Find where the `__perry_static_init_` fallback
processes classes (likely iterating over static blocks or initializers) and
apply the same `is_nested` check to skip nested classes there as well, ensuring
that nested classes with static blocks cannot execute at module init through
either code path.

In `@crates/perry-codegen/src/codegen/method.rs`:
- Around line 503-561: The dynamic parent super call initialization block is
currently nested inside a condition that only checks for extends_name, making it
unreachable for classes with extends_expr but no static parent name. Ensure that
the outer condition wrapping this entire block (which contains the
builtin_parent_runtime check and the class.extends_expr.is_some() check) is
placed such that the dynamic super initialization executes for classes with
extends_expr, allowing the synthesized constructor to properly initialize fields
and call super with the correct parent reference instead of skipping both steps.

In `@crates/perry-codegen/src/codegen/string_pool.rs`:
- Around line 583-590: The position closure in the constructor rest parameter
lookup using position(|p| p.is_rest) incorrectly matches both actual rest
parameters and Perry's synthesized arguments parameter, causing incorrect rest
registration for constructors with both rest parameters and synthetic arguments.
Modify the position closure in the class.constructor.params iteration to add an
additional condition that excludes the synthetic arguments parameter from
matching, ensuring only actual rest parameters are recorded in ctor_rest_regs.
Apply the same fix to the other occurrence mentioned at lines 700-705.

In `@crates/perry-hir/src/lower_decl/block.rs`:
- Around line 85-108: The current implementation in the block that processes
var_decl.decls only collects top-level variable declarations and ignores var
declarations nested within control flow blocks like if/for/try statements. Since
all var declarations are function-scoped and already predefined, nested var
declarations must also be included in the forward-capture prealloc pass to
ensure earlier closures capture the live box rather than an undefined snapshot.
Modify the loop that iterates over var_decl.decls to recursively traverse and
collect var declarations from nested scopes, then add their identifiers to the
same forward_boxed_ids and var_hoisted_ids logic.

In `@crates/perry-hir/src/lower/context.rs`:
- Around line 411-416: The maybe_rename_colliding_class function generates class
aliases in the format X$0 without verifying that the generated alias doesn't
collide with existing registered classes. Modify the function to generate
candidate aliases in a loop, where each iteration checks both lookup_class for
the candidate and the values already in class_renames to ensure no collision
occurs. Continue incrementing the counter until a safe candidate is found that
doesn't collide with any registered or previously renamed class, then insert
that safe candidate into class_renames.

In `@crates/perry-hir/src/lower/expr_function.rs`:
- Around line 229-230: The function-expression parameter shadowing at lines
229-230 (in the context of define_local and shadow_native_instance_if_present)
only shadows native instances but not native module aliases, which can cause
parameter names like `url` or `util` to incorrectly resolve to Node.js modules
instead of the parameter. After the call to
ctx.shadow_native_instance_if_present for the param_name, also add a call to
shadow native module aliases using the appropriate shadow method (similar to
what is done in function-declaration/private/catch paths). Apply this same fix
to the second location mentioned at lines 603-604.

In `@crates/perry-hir/src/lower/expr_object.rs`:
- Around line 243-244: After defining a local parameter binding with
ctx.define_local() in the object method/accessor parameter handling, you need to
shadow both native instances and native module aliases to prevent parameters
from incorrectly resolving through native-module aliases during body lowering.
In addition to the ctx.shadow_native_instance_if_present() call, also add a
corresponding call to shadow native module aliases. This fix needs to be applied
at both locations where object-literal parameters are processed (around the
ctx.define_local() calls for param_name in the method and accessor parameter
handling blocks).

In `@crates/perry-runtime/src/closure/dispatch.rs`:
- Around line 1397-1407: The special case handling for class objects in this
branch (checking func_ptr.is_null() and is_class_object_value) is being applied
to all value-call scenarios, not just new-expression callee resolution. This
causes direct calls like C() to incorrectly return the class object instead of
throwing an error. Remove or refactor this entire conditional block and instead
pass explicit construct/callee-resolution intent information from the lowering
phase so the code can distinguish between actual new-expression callee
resolution and direct function calls to class objects, or route the affected new
sites to js_new_function_construct directly instead of through this generic
dispatch path.

In `@crates/perry-runtime/src/closure/dynamic_props.rs`:
- Around line 322-333: The `is_valid_obj_ptr` guard at line 331 is insufficient
on Linux-like targets because it only performs a broad range check (>= 0x1000)
rather than real heap-membership validation, allowing aligned unmapped addresses
like 0x4_0000_0000 to bypass the check and reach the unsafe magic read at *(ptr
+ 12), causing potential SIGSEGV. Replace or supplement the is_valid_obj_ptr
call with an actual arena or allocation-membership predicate that definitively
verifies the pointer belongs to a valid heap allocation before attempting to
dereference the ClosureHeader.

In `@crates/perry-runtime/src/fs/mod.rs`:
- Around line 303-309: The current code is calling std::fs::read a second time
just to capture an error, which can result in different errors due to filesystem
race conditions and loses the original error details from
read_file_bytes_with_options. Modify the read_file_bytes_with_options function
to return a Result type (Result<Vec<u8>, io::Error>) instead of just the bytes,
then in the error handling path, extract the error directly from that Result and
pass it to build_fs_error_value, eliminating the redundant filesystem call and
preserving the original error information.

In `@crates/perry-runtime/src/json/replacer.rs`:
- Around line 456-462: The root holder object is being created with the
post-toJSON transformed value instead of the original root value. In the
call_replacer invocation with replacer, empty_key_f64, and value_after_to_json
as arguments, change the root_holder parameter to use the original root value
instead of value_after_to_json. This ensures the this context in the replacer
function sees the original value in the root wrapper, while the replacer itself
still operates on the post-toJSON transformed value. Apply this fix to both
occurrences mentioned: the main location around lines 456-462 and the additional
location at lines 1302-1307.

In `@crates/perry-runtime/src/object/class_constructors.rs`:
- Around line 535-554: Replace the `lookup_closure_rest` call to return full
rest metadata instead of just the index, capturing information about whether the
rest slot is ordinary rest, synthetic arguments, or rest-and-arguments. Update
the rest parameter handling logic starting with the loop that populates
final_args and the rest_arr creation to use this metadata to determine whether
arguments should be packed from all call args or from the tail, ensuring the
correct number of arguments are included based on the specific rest type,
particularly when handling synthetic arguments objects.

In `@crates/perry-runtime/src/object/object_ops.rs`:
- Around line 1388-1403: Move the descriptor validation that normally occurs via
validate_property_descriptor to execute before the native handle fast path in
the code block with dispatch and the early return statement. Ensure that invalid
descriptors are caught and validated before the early return of obj_value, and
only allow validated accessor or symbol descriptors to proceed through this fast
path without throwing errors, preventing invalid descriptors from bypassing
validation checks.
- Around line 1371-1380: The handle classification logic in the handle_id
assignment uses a hardcoded boundary check `p < 0x10000` instead of the
canonical handle classifier used elsewhere in the runtime. Replace the
conditional `if p >= 1 && p < 0x10000` with a call to the standard
`addr_class::is_small_handle(p)` function to ensure consistent handle
classification across all runtime paths and properly recognize native
handle-band values.

In `@crates/perry-runtime/src/object/polymorphic_index.rs`:
- Around line 49-52: The match statement in the polymorphic_index function
currently filters tag values with cases for 0x7FFD and 0x7FFF (heap string
pointers) but lacks handling for the inline short-string (SSO) tag before the
default fallback case. Add a new match arm for the short-string tag value that
appears before the underscore catch-all case, which should materialize the
string and route it to the appropriate string indexing logic instead of
returning the undefined value. This ensures SSO strings like "abc" are properly
indexed rather than falling through to the primitive rejection path.
- Around line 78-89: The is_valid_obj_ptr call in the guard before reading the
GC header is insufficient because it only checks a broad address range on
Linux-like targets, allowing small bogus values like 0xf000f to pass through and
cause a wild memory read at raw - GC_HEADER_SIZE. Replace the is_valid_obj_ptr
check with a proper GC or allocation-membership predicate that actually verifies
the pointer belongs to a valid heap allocation, not just checking if it falls
within a broad address range, to prevent small bogus pointers from reaching the
header dereference operation.

---

Outside diff comments:
In `@crates/perry-hir/src/lower_decl/block.rs`:
- Around line 1009-1025: The code on line 1024 performs a lookup using the raw
AST class name (cname derived from class_decl.ident.sym), but class captures are
recorded under the resolved class name after renaming. Since ctx.class_renames
was restored on line 1010, you need to resolve the class name by checking
ctx.class_renames to get the actual renamed class name, then use that resolved
name when calling ctx.lookup_class_captures instead of the raw AST name. Apply
the same fix to line 1066 which has the identical issue.

In `@crates/perry-hir/src/lower/context.rs`:
- Around line 1447-1465: The native-module shadow stack is only being restored
in the exit_scope method, but block-scoped let/const bindings are removed by
pop_block_scope which does not perform this restoration. Apply the same shadow
stack restoration logic from exit_scope to the pop_block_scope method -
specifically, add code to pop from scope_module_shadow_marks and truncate the
module_shadow_stack to the saved mark value, ensuring that block-local bindings
no longer shadow native module lookups after the block exits.

In `@crates/perry-hir/src/lower/expr_call/array_only_methods.rs`:
- Around line 464-478: The is_overlapping check in the matches! block starting
at line 464 excludes the "push" method, which causes chained receiver calls like
factory().push(x) to incorrectly lower to native array.push_single on
class/object results. Add "push" to the list of method names in the
is_overlapping matches! expression to ensure the mutator bail-out logic is
consistently applied across all call receiver paths, matching the behavior now
implemented at line 1117 that relies on recv_is_class.

In `@crates/perry-runtime/src/object/field_get_set.rs`:
- Around line 1079-1099: The get_property_attrs call is currently unconditional,
but like the accessor descriptor lookup, the PROPERTY_DESCRIPTORS table uses
raw-address keying which can cause stale data from reused object addresses. Wrap
the get_property_attrs invocation with the same gate condition used for
get_accessor_descriptor: check that ACCESSORS_IN_USE is true and
object_has_descriptors returns true for the object before calling
get_property_attrs.

In `@crates/perry-runtime/src/object/field_set_by_name.rs`:
- Around line 959-982: The wide-key hit path (when keys_index_lookup returns
Some(i)) is missing a check for non-writable properties via PROPERTY_DESCRIPTORS
before writing to the field. This allows wide objects to overwrite properties
with writable: false once the sidecar index is active. Add a non-writable
property descriptor check in the keys_index_lookup hit path (alongside the
existing is_frozen check) to prevent writing to non-writable properties, and
apply the same fix to the similar code block at lines 1117-1124 as noted in the
comment.

In `@crates/perry-runtime/src/proxy.rs`:
- Around line 999-1032: The condition checking `object_has_descriptors(obj_ptr)`
on line 1012 only covers GC_TYPE_OBJECT targets and misses array proxy targets
that may have their own non-writable or accessor descriptors. This causes arrays
with non-writable/accessor descriptors to skip the descriptor lookup block and
incorrectly fall through to obj_value_has_own_key which returns writable: true,
violating proxy invariants. Modify the condition to also check descriptor tables
for array targets, or restructure the logic so descriptor checks are performed
regardless of whether the object_has_descriptors flag is set, ensuring both the
accessor descriptor and property attributes lookups are consulted for all proxy
target types before falling through to the obj_value_has_own_key check.

---

Nitpick comments:
In `@crates/perry-codegen/src/expr/property_get.rs`:
- Around line 603-616: The code now contains a duplicate call to
crate::nm_install::nm_install_symbol within the same function. The first call
(with the check for Some(install_sym)) handles all code paths including the
early return for process.version, making the second identical call to
nm_install_symbol redundant. Locate and remove the second call to
nm_install_symbol that occurs later in the function (around the location
mentioned in the comment at lines 639-641) since the earlier call now covers all
execution paths and the install operation is idempotent, eliminating the need
for the duplicate invocation.

In `@crates/perry-hir/src/destructuring/var_decl.rs`:
- Line 1938: The hardcoded local variable name "__nx_member_init" in the
ctx.define_local call should be made unique for better debugging visibility in
HIR dumps. Instead of using a static string, append a unique suffix (such as a
counter from the context or ctx.next_local_id() if available) to the temporary
variable name so that multiple instances of this pattern produce locals with
distinguishable names in debug output, making diagnostics clearer without
affecting runtime correctness.

In `@crates/perry-hir/src/stable_hash/tests.rs`:
- Line 282: In the test at line 282 where the fixture includes `is_nested:
false`, add a regression check assertion after computing the initial hash.
Create a second fixture that is identical except with `is_nested` toggled to
true, compute its hash, and add an assertion verifying that the two hashes are
different. This ensures that changes to the `is_nested` field are properly
reflected in the stable hash calculation and prevents future regressions where
this field might accidentally be ignored.

In `@crates/perry-runtime/src/module_require.rs`:
- Around line 261-270: In the js_register_path_init function, add a debug
assertion to verify that path_len is non-negative before it is cast to usize and
used in the from_raw_parts call. The assertion should check that path_len is
greater than or equal to 0, placed before the slice creation to catch potential
undefined behavior from negative values being silently cast to large usize
values.

In `@crates/perry-stdlib/src/common/dispatch.rs`:
- Around line 290-304: The AsyncLocalStorage method-name allowlist (run,
getStore, enterWith, exit, disable) is duplicated across multiple functions and
can diverge over time. Create a shared constant or helper function that defines
this allowed method set once, and refactor both the
dispatch_async_local_storage_method and dispatch_async_local_storage_property
functions to reference this shared definition instead of maintaining separate
copies of the method names.
🪄 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: 1a58d3ca-dab6-4117-ba4c-5cbfb808ef4e

📥 Commits

Reviewing files that changed from the base of the PR and between ffb7ca9 and f98dbbb.

📒 Files selected for processing (75)
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/helpers.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/opts.rs
  • crates/perry-codegen/src/codegen/string_pool.rs
  • crates/perry-codegen/src/collectors/class_accessors.rs
  • crates/perry-codegen/src/collectors/this_as_value.rs
  • crates/perry-codegen/src/expr/bigint_set.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/lower_call/native/mod.rs
  • crates/perry-codegen/src/lower_call/property_get.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs
  • crates/perry-codegen/tests/constructor_recursion.rs
  • crates/perry-codegen/tests/large_object_barriers.rs
  • crates/perry-codegen/tests/macos_bundle_chdir_gate.rs
  • crates/perry-codegen/tests/native_proof_buffer_views.rs
  • crates/perry-codegen/tests/native_proof_regressions.rs
  • crates/perry-codegen/tests/shadow_slot_hygiene.rs
  • crates/perry-codegen/tests/static_symbol_hygiene.rs
  • crates/perry-codegen/tests/typed_feedback.rs
  • crates/perry-codegen/tests/typed_shape_descriptor.rs
  • crates/perry-codegen/tests/typed_shape_descriptors.rs
  • crates/perry-hir/src/destructuring/var_decl.rs
  • crates/perry-hir/src/ir/decl.rs
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_call/array_only_methods.rs
  • crates/perry-hir/src/lower/expr_call/globals.rs
  • crates/perry-hir/src/lower/expr_call/local_array_methods.rs
  • crates/perry-hir/src/lower/expr_call/native_module.rs
  • crates/perry-hir/src/lower/expr_call/regex_string.rs
  • crates/perry-hir/src/lower/expr_function.rs
  • crates/perry-hir/src/lower/expr_member.rs
  • crates/perry-hir/src/lower/expr_new.rs
  • crates/perry-hir/src/lower/expr_object.rs
  • crates/perry-hir/src/lower/lower_expr.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower/module_decl.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs
  • crates/perry-hir/src/lower_decl/class_decl.rs
  • crates/perry-hir/src/lower_decl/class_members.rs
  • crates/perry-hir/src/lower_decl/fn_decl.rs
  • crates/perry-hir/src/lower_decl/private_members.rs
  • crates/perry-hir/src/monomorph/specialize.rs
  • crates/perry-hir/src/stable_hash/decls.rs
  • crates/perry-hir/src/stable_hash/tests.rs
  • crates/perry-runtime/src/closure/dispatch.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/fs/mod.rs
  • crates/perry-runtime/src/json/replacer.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/module_require.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/field_get_set.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/object/object_ops.rs
  • crates/perry-runtime/src/object/polymorphic_index.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/value/handle.rs
  • crates/perry-runtime/src/value/mod.rs
  • crates/perry-runtime/src/value/tags.rs
  • crates/perry-stdlib/src/common/dispatch.rs
  • crates/perry-transform/src/generator/id_scan.rs
  • crates/perry-transform/src/inline/mod.rs
  • crates/perry/src/commands/compile.rs
  • crates/perry/src/commands/compile/cjs_wrap/wrap.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/init_order.rs

Comment on lines 419 to +428
} else if class.extends_name.is_some() {
// Walk ancestors for the first one with a ctor; adopt its
// params (cleared of ids — they'll be fresh).
let mut found_params: Vec<perry_hir::Param> = Vec::new();
let mut cur = class.extends_name.clone();
while let Some(pname) = cur {
// v0.5.760: also consult `opts.imported_classes` for
// cross-module parent ctors. Pre-fix the loop fell
// through to the next ancestor when `class_table`'s
// entry for an imported class returned a stub with
// `constructor: None` (stubs always have None) — even
// though the source module did have a real ctor/effect.
// Result: `class Child extends Parent { x =
// "y" }` (no own ctor, parent in another module) had
// its synthesized ctor with ZERO params, so the user's
// `new Child("arg")` lost the arg before reaching
// Parent_constructor. Explicit zero-arg ctors and
// field-initializer ctors still stop the walk even with
// zero adopted params. Refs #420.
let imported_ctor = opts
.imported_classes
.iter()
.find(|i| i.local_alias.as_deref().unwrap_or(&i.name) == pname.as_str())
.filter(|ic| {
ic.constructor_param_count > 0
|| ic.has_own_constructor
|| ic.has_instance_fields
});
if let Some(pclass) = class_table.get(pname.as_str()) {
if let Some(pctor) = &pclass.constructor {
found_params = pctor.params.clone();
break;
}
if let Some(imported_ctor) = imported_ctor {
for i in 0..imported_ctor.constructor_param_count {
found_params.push(perry_hir::Param {
id: 0xFFFF_0000 + i as u32,
name: format!("__forward_arg{}", i),
ty: perry_types::Type::Any,
default: None,
decorators: Vec::new(),
is_rest: false,
arguments_object: None,
});
}
break;
}
cur = pclass.extends_name.clone();
} else if let Some(stub) = imported_class_stubs.iter().find(|c| c.name == pname)
{
// Imported stub — params not in HIR; use effectful
// ctor metadata as a synthetic count of unnamed args.
if let Some(imported_ctor) = imported_ctor {
for i in 0..imported_ctor.constructor_param_count {
found_params.push(perry_hir::Param {
id: 0xFFFF_0000 + i as u32,
name: format!("__forward_arg{}", i),
ty: perry_types::Type::Any,
default: None,
decorators: Vec::new(),
is_rest: false,
arguments_object: None,
});
}
} else {
cur = stub.extends_name.clone();
continue;
}
break;
} else {
break;
}
}
// No own ctor + heritage → JS spec default ctor
// `constructor(...args) { super(...args) }`. Synthesize forwarding
// params matching the closest ancestor ctor's arity (incl.
// cross-module parents) via the shared helper — its result is
// ALSO what gets registered into CLASS_CONSTRUCTORS below, so the
// emitted signature and the runtime `total_params` always agree
// (Next.js wall 51). The actual `super(...)` is emitted by the
// compile_method post-init step from these params positionally.
let n = synthesized_ctor_param_count(

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 | 🟠 Major | ⚡ Quick win

Use the same heritage predicate for ctor emission and registration.

ctor_arity_overrides now registers an extends_expr-only class with the synthesized forwarding arity, but the actual standalone ctor only gets those params when extends_name is set. That can register a nonzero total_params for a zero-arg LLVM function and drop dynamic parent forwarding.

Proposed fix
-            } else if class.extends_name.is_some() {
+            } else if class.extends_name.is_some() || class.extends_expr.is_some() {
                 // No own ctor + heritage → JS spec default ctor
                 // `constructor(...args) { super(...args) }`. Synthesize forwarding

Also applies to: 1648-1675

🤖 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 `@crates/perry-codegen/src/codegen/artifacts.rs` around lines 419 - 428, The
constructor emission logic uses extends_name to check for class heritage, but
the ctor_arity_overrides registration uses extends_expr, causing an
inconsistency where the registered total_params may not match the actual emitted
function signature. Identify both the ctor emission site (around the
extends_name check near line 419 in the extends_name branch) and the
ctor_arity_overrides registration site (mentioned as also applying to lines
1648-1675), then ensure both locations use the same heritage predicate (either
extends_name or extends_expr) consistently so that registered total_params and
synthesized forwarding arity remain synchronized.

Comment on lines +808 to +814
// Next.js wall 54: a nested class's static-field initializers must run
// when the enclosing function evaluates the class, not at module init.
// Running a side-effectful one eagerly (e.g. `static #a = new Self()`)
// both mistimes it and can crash before user code.
if c.is_nested {
continue;
}

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 | 🟠 Major | ⚡ Quick win

Skip nested classes in the static-block fallback too.

This guard prevents nested static fields from running at module init, but the later __perry_static_init_ fallback still walks nested classes. A nested class with static { ... } that is not already represented in hir.init can still execute at module init and hit the same out-of-scope/local-timing bug.

Proposed fix
     for c in &hir.classes {
+        if c.is_nested {
+            continue;
+        }
         for sm in &c.static_methods {
             if !sm.name.starts_with("__perry_static_init_") {
                 continue;
             }
📝 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
// Next.js wall 54: a nested class's static-field initializers must run
// when the enclosing function evaluates the class, not at module init.
// Running a side-effectful one eagerly (e.g. `static #a = new Self()`)
// both mistimes it and can crash before user code.
if c.is_nested {
continue;
}
for c in &hir.classes {
if c.is_nested {
continue;
}
for sm in &c.static_methods {
if !sm.name.starts_with("__perry_static_init_") {
continue;
}
🤖 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 `@crates/perry-codegen/src/codegen/helpers.rs` around lines 808 - 814, The
guard checking `c.is_nested` at lines 808-814 prevents nested class static-field
initializers from running at module init in the primary path, but the later
`__perry_static_init_` fallback code path does not have this same guard. Find
where the `__perry_static_init_` fallback processes classes (likely iterating
over static blocks or initializers) and apply the same `is_nested` check to skip
nested classes there as well, ensuring that nested classes with static blocks
cannot execute at module init through either code path.

Comment on lines +503 to +561
if builtin_parent_runtime.is_none() && class.extends_expr.is_some() {
if let Some(cid) =
ctx.class_ids.get(&class.name).copied().filter(|c| *c != 0)
{
let undef_lit = crate::nanbox::double_literal(f64::from_bits(
crate::nanbox::TAG_UNDEFINED,
));
let mut lowered_args: Vec<String> =
Vec::with_capacity(method.params.len());
for p in &method.params {
if let Some(slot) = ctx.locals.get(&p.id).cloned() {
lowered_args.push(ctx.block().load(DOUBLE, &slot));
} else {
lowered_args.push(undef_lit.clone());
}
}
let parent_val = ctx.block().call(
DOUBLE,
"js_get_dynamic_parent_value",
&[(crate::types::I32, &cid.to_string())],
);
let (args_ptr, args_len) = if lowered_args.is_empty() {
("null".to_string(), "0".to_string())
} else {
let buf_reg =
ctx.func.alloca_entry_array(DOUBLE, lowered_args.len());
for (i, a_val) in lowered_args.iter().enumerate() {
let slot = ctx.block().gep(
DOUBLE,
&buf_reg,
&[(I64, &format!("{}", i))],
);
ctx.block().store(DOUBLE, a_val, &slot);
}
let ptr_reg = ctx.block().next_reg();
ctx.block().emit_raw(format!(
"{} = getelementptr [{} x double], ptr {}, i64 0, i64 0",
ptr_reg,
lowered_args.len(),
buf_reg
));
(ptr_reg, lowered_args.len().to_string())
};
let this_box = match ctx.this_stack.last().cloned() {
Some(slot) => ctx.block().load(DOUBLE, &slot),
None => undef_lit.clone(),
};
let _ = ctx.block().call(
DOUBLE,
"js_fetch_or_value_super",
&[
(DOUBLE, &parent_val),
(DOUBLE, &this_box),
(crate::types::PTR, &args_ptr),
(I64, &args_len),
],
);
}
}

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 | 🟠 Major | ⚡ Quick win

Make the dynamic-parent path reachable for extends_expr-only classes.

The new dynamic super(...args) block still sits inside a no-own-ctor branch that only checks extends_name, and field initialization also treats extends_expr as a base class. For a dynamic parent with no static name, the synthesized ctor skips super and initializes self fields as if it had no heritage.

Proposed fix
-        let init_mode = if class.extends_name.is_some() {
+        let has_heritage = class.extends_name.is_some() || class.extends_expr.is_some();
+        let init_mode = if has_heritage {
             crate::lower_call::FieldInitMode::AncestorsOnly
         } else {
             crate::lower_call::FieldInitMode::All
         };
@@
-        if class.constructor.is_none() && class.extends_name.is_some() {
+        if class.constructor.is_none() && has_heritage {
🤖 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 `@crates/perry-codegen/src/codegen/method.rs` around lines 503 - 561, The
dynamic parent super call initialization block is currently nested inside a
condition that only checks for extends_name, making it unreachable for classes
with extends_expr but no static parent name. Ensure that the outer condition
wrapping this entire block (which contains the builtin_parent_runtime check and
the class.extends_expr.is_some() check) is placed such that the dynamic super
initialization executes for classes with extends_expr, allowing the synthesized
constructor to properly initialize fields and call super with the correct parent
reference instead of skipping both steps.

Comment on lines +583 to +590
// #wall3: record the rest-param position (in USER params) so the runtime
// bundles trailing args at the dynamic member-new dispatch path.
if let Some(rest_idx) = class
.constructor
.as_ref()
.map(|c| c.params.len() as u32)
.unwrap_or(0);
ctor_triples.push((
cid,
format!("{}__{}_constructor", module_prefix, class_name),
ctor_params,
));
.and_then(|c| c.params.iter().position(|p| p.is_rest))
{
ctor_rest_regs.push((ctor_symbol.clone(), rest_idx));

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 | 🟠 Major | 🏗️ Heavy lift

Preserve synthetic arguments metadata for constructor rest registration.

position(|p| p.is_rest) also matches Perry’s synthesized arguments param (arguments_object.is_some()), but this always emits js_register_closure_rest. Dynamic construction of constructor(a) { arguments } would bundle only the tail after a, not all original call args; constructor(...rest) { arguments } also needs the rest-and-arguments path.

Suggested direction
-        if let Some(rest_idx) = class
-            .constructor
-            .as_ref()
-            .and_then(|c| c.params.iter().position(|p| p.is_rest))
-        {
-            ctor_rest_regs.push((ctor_symbol.clone(), rest_idx));
+        if let Some(constructor) = class.constructor.as_ref() {
+            // Mirror the user-function wrapper split:
+            // - ordinary user rest -> js_register_closure_rest
+            // - synthesized `arguments` -> js_register_closure_synthetic_arguments
+            // - user rest + synthesized `arguments` -> js_register_closure_rest_and_arguments
+            // Store the runtime registration kind alongside `rest_idx`.
         }

Also applies to: 700-705

🤖 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 `@crates/perry-codegen/src/codegen/string_pool.rs` around lines 583 - 590, The
position closure in the constructor rest parameter lookup using position(|p|
p.is_rest) incorrectly matches both actual rest parameters and Perry's
synthesized arguments parameter, causing incorrect rest registration for
constructors with both rest parameters and synthetic arguments. Modify the
position closure in the class.constructor.params iteration to add an additional
condition that excludes the synthetic arguments parameter from matching,
ensuring only actual rest parameters are recorded in ctor_rest_regs. Apply the
same fix to the other occurrence mentioned at lines 700-705.

Comment on lines +85 to +108
} else {
// `var` bindings are already predefined + boxed by
// `predefine_var_bindings_in_function_body`, but their box is
// NOT in the prealloc set. A closure created EARLIER in the body
// that references a `var` declared LATER (`r.d(t,{x:()=>n.x});
// var n=r("…")` — the webpack ESM re-export shape in Next.js'
// react-server.node.js) must capture the *live* box, not a
// TAG_UNDEFINED snapshot. Add forward-captured `var` ids to the
// prealloc set so codegen allocates the box at function entry.
for decl in &var_decl.decls {
let mut binding_idents: Vec<(String, u32)> = Vec::new();
collect_pat_forward_idents(&decl.name, &mut binding_idents);
for (name, _span_lo) in binding_idents {
if !seen_closure_refs.contains(&name) {
continue;
}
if let Some(id) = ctx.lookup_local(&name) {
if !forward_boxed_ids.contains(&id) {
ctx.var_hoisted_ids.insert(id);
forward_boxed_ids.push(id);
}
}
}
}

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 | 🟠 Major | ⚡ Quick win

Include nested var declarations in the forward-capture prealloc pass.

This branch only sees top-level var statements. A later var inside if/for/try is still function-scoped and already predefined, so an earlier closure reading it can still capture an unallocated/undefined box.

Proposed fix
-            } else {
-                // `var` bindings are already predefined + boxed by
-                // `predefine_var_bindings_in_function_body`, but their box is
-                // NOT in the prealloc set. A closure created EARLIER in the body
-                // that references a `var` declared LATER (`r.d(t,{x:()=>n.x});
-                // var n=r("…")` — the webpack ESM re-export shape in Next.js'
-                // react-server.node.js) must capture the *live* box, not a
-                // TAG_UNDEFINED snapshot. Add forward-captured `var` ids to the
-                // prealloc set so codegen allocates the box at function entry.
-                for decl in &var_decl.decls {
-                    let mut binding_idents: Vec<(String, u32)> = Vec::new();
-                    collect_pat_forward_idents(&decl.name, &mut binding_idents);
-                    for (name, _span_lo) in binding_idents {
-                        if !seen_closure_refs.contains(&name) {
-                            continue;
-                        }
-                        if let Some(id) = ctx.lookup_local(&name) {
-                            if !forward_boxed_ids.contains(&id) {
-                                ctx.var_hoisted_ids.insert(id);
-                                forward_boxed_ids.push(id);
-                            }
-                        }
-                    }
-                }
             }
         }
+        // `var` is function-scoped even when it appears inside nested statements.
+        // Use the same recursive collector as the predefine pass so forward
+        // captures of later `if (...) { var n = ... }` / loop declarations are
+        // preallocated too.
+        let mut var_names = Vec::new();
+        collect_var_binding_names_from_stmt(stmt, &mut var_names);
+        var_names.sort();
+        var_names.dedup();
+        for name in var_names {
+            if !seen_closure_refs.contains(&name) {
+                continue;
+            }
+            if let Some(id) = ctx.lookup_local(&name) {
+                if !forward_boxed_ids.contains(&id) {
+                    ctx.var_hoisted_ids.insert(id);
+                    forward_boxed_ids.push(id);
+                }
+            }
+        }
         // Record closures introduced by THIS statement for subsequent decls.
🤖 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 `@crates/perry-hir/src/lower_decl/block.rs` around lines 85 - 108, The current
implementation in the block that processes var_decl.decls only collects
top-level variable declarations and ignores var declarations nested within
control flow blocks like if/for/try statements. Since all var declarations are
function-scoped and already predefined, nested var declarations must also be
included in the forward-capture prealloc pass to ensure earlier closures capture
the live box rather than an undefined snapshot. Modify the loop that iterates
over var_decl.decls to recursively traverse and collect var declarations from
nested scopes, then add their identifiers to the same forward_boxed_ids and
var_hoisted_ids logic.

Comment on lines +535 to +554
let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8)
.map(|ri| ri as usize)
.filter(|ri| *ri < user_params);
if let Some(ri) = rest_idx {
for i in 0..ri {
if !args_ptr.is_null() && i < args_len {
final_args.push(*args_ptr.add(i));
} else {
final_args.push(undef);
}
}
let mut rest_arr = crate::array::js_array_alloc(0);
if !args_ptr.is_null() {
let mut i = ri;
while i < args_len {
rest_arr = crate::array::js_array_push_f64(rest_arr, *args_ptr.add(i));
i += 1;
}
}
final_args.push(crate::value::js_nanbox_pointer(rest_arr as i64));

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 | 🟠 Major | 🏗️ Heavy lift

Replay constructors using the full rest metadata, not only the index.

lookup_closure_rest loses whether the registered slot is ordinary rest, synthetic arguments, or rest-and-arguments. Once constructor symbols can include arguments_object params, this packs arguments from the tail instead of all call args and can omit the extra synthetic slot after a user rest before captures are appended.

Suggested direction
-    let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8)
-        .map(|ri| ri as usize)
-        .filter(|ri| *ri < user_params);
+    // Use the same full rest-kind metadata as dynamic closure dispatch, then
+    // build constructor user args accordingly before appending captures:
+    // - ordinary rest: fixed args + tail array
+    // - synthetic `arguments`: fixed args + full arguments array
+    // - rest-and-arguments: fixed args + tail array + full arguments array

Also applies to: 615-634

🤖 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 `@crates/perry-runtime/src/object/class_constructors.rs` around lines 535 -
554, Replace the `lookup_closure_rest` call to return full rest metadata instead
of just the index, capturing information about whether the rest slot is ordinary
rest, synthetic arguments, or rest-and-arguments. Update the rest parameter
handling logic starting with the loop that populates final_args and the rest_arr
creation to use this metadata to determine whether arguments should be packed
from all call args or from the tail, ensuring the correct number of arguments
are included based on the specific rest type, particularly when handling
synthetic arguments objects.

Comment on lines +1371 to +1380
let handle_id = if jv.is_pointer() {
let p = jv.as_pointer::<u8>() as usize;
if p >= 1 && p < 0x10000 {
Some(p)
} else {
None
}
} else {
None
};

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 | 🟠 Major | ⚡ Quick win

Use the canonical handle classifier here.

The p < 0x10000 cutoff misses native handle-band values that other runtime paths classify with addr_class::is_small_handle(...). Handles at/above 0x10000 will skip this best-effort path and still throw “called on non-object”.

Proposed fix
-                if p >= 1 && p < 0x10000 {
+                if crate::value::addr_class::is_small_handle(p) {
                     Some(p)
                 } else {
                     None
                 }
📝 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
let handle_id = if jv.is_pointer() {
let p = jv.as_pointer::<u8>() as usize;
if p >= 1 && p < 0x10000 {
Some(p)
} else {
None
}
} else {
None
};
let handle_id = if jv.is_pointer() {
let p = jv.as_pointer::<u8>() as usize;
if crate::value::addr_class::is_small_handle(p) {
Some(p)
} else {
None
}
} else {
None
};
🤖 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 `@crates/perry-runtime/src/object/object_ops.rs` around lines 1371 - 1380, The
handle classification logic in the handle_id assignment uses a hardcoded
boundary check `p < 0x10000` instead of the canonical handle classifier used
elsewhere in the runtime. Replace the conditional `if p >= 1 && p < 0x10000`
with a call to the standard `addr_class::is_small_handle(p)` function to ensure
consistent handle classification across all runtime paths and properly recognize
native handle-band values.

Comment on lines +1388 to +1403
let dval = if desc_has_field(descriptor_value, b"value") {
Some(f64::from_bits(
desc_read_field(descriptor_value, b"value").bits(),
))
} else {
None
};
if let Some(v) = dval {
let name_ptr =
(ks as *const u8).add(std::mem::size_of::<crate::StringHeader>());
let name_len = (*ks).byte_len as usize;
dispatch(hid as i64, name_ptr, name_len, v);
}
}
}
return obj_value;

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

Validate the descriptor before the native-handle early return.

This branch returns before the normal descriptor object check and validate_property_descriptor, so invalid descriptors on native handles can be accepted instead of throwing. Move the descriptor validation ahead of the handle fast path, then no-op only validated accessor/symbol descriptors.

🤖 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 `@crates/perry-runtime/src/object/object_ops.rs` around lines 1388 - 1403, Move
the descriptor validation that normally occurs via validate_property_descriptor
to execute before the native handle fast path in the code block with dispatch
and the early return statement. Ensure that invalid descriptors are caught and
validated before the early return of obj_value, and only allow validated
accessor or symbol descriptors to proceed through this fast path without
throwing errors, preventing invalid descriptors from bypassing validation
checks.

Comment on lines +49 to +52
match (obj_handle as u64) >> 48 {
0x7FFD | 0x7FFF => (obj_handle as u64) & 0x0000_FFFF_FFFF_FFFF,
_ => return f64::from_bits(crate::value::TAG_UNDEFINED),
}

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 | 🟠 Major | ⚡ Quick win

Handle inline short strings before rejecting NaN-boxed primitives.

The tag filter accepts heap string pointers (0x7FFF) but rejects the inline short-string tag path. If an SSO string reaches this polymorphic index helper, "abc"[0] returns undefined instead of indexing the string. Add a short-string branch that materializes/routes to the string index path before the primitive fallback.

🤖 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 `@crates/perry-runtime/src/object/polymorphic_index.rs` around lines 49 - 52,
The match statement in the polymorphic_index function currently filters tag
values with cases for 0x7FFD and 0x7FFF (heap string pointers) but lacks
handling for the inline short-string (SSO) tag before the default fallback case.
Add a new match arm for the short-string tag value that appears before the
underscore catch-all case, which should materialize the string and route it to
the appropriate string indexing logic instead of returning the undefined value.
This ensures SSO strings like "abc" are properly indexed rather than falling
through to the primitive rejection path.

Comment on lines +78 to +89
// #wall5-render: `obj[idx]` where `obj` is a mis-boxed / non-heap value
// (e.g. a small bogus pointer like 0xf000f produced upstream) must NOT
// dereference the GcHeader at `raw-8` — that's a wild read → SIGSEGV. The
// `raw < 0x1000` guards above are too weak (0xf000f passes). Typed-array /
// buffer / string receivers were already handled before this point, so a
// value reaching here that isn't a valid arena/old-gen object pointer is
// not indexable → `undefined` (matches JS `(5)[0]` etc.). Mirrors the
// is_closure_ptr heap-range guard (wall #2). Next.js app-page-turbo's
// `u_i_24_6.get` indexed such a value during the app render → crash.
if !crate::value::addr_class::is_valid_obj_ptr(raw as *const u8) {
return f64::from_bits(crate::value::TAG_UNDEFINED);
}

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 | 🔴 Critical | 🏗️ Heavy lift

Do not rely on the broad address-range check before reading the GC header.

Line 87 still accepts small bogus raw values like the documented 0xf000f on Linux-like targets because is_valid_obj_ptr only checks a broad address range there. That means line 95 can still read raw - GC_HEADER_SIZE from unmapped memory. Use a real GC/allocation-membership predicate before the header read.

🤖 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 `@crates/perry-runtime/src/object/polymorphic_index.rs` around lines 78 - 89,
The is_valid_obj_ptr call in the guard before reading the GC header is
insufficient because it only checks a broad address range on Linux-like targets,
allowing small bogus values like 0xf000f to pass through and cause a wild memory
read at raw - GC_HEADER_SIZE. Replace the is_valid_obj_ptr check with a proper
GC or allocation-membership predicate that actually verifies the pointer belongs
to a valid heap allocation, not just checking if it falls within a broad address
range, to prevent small bogus pointers from reaching the header dereference
operation.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-codegen/src/codegen/string_pool.rs (1)

759-766: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Undefined variable blk — compilation error.

The ctor_rest_regs loop references blk.call_void(...) but blk is not defined at this point. All other registration loops were updated to use the chunker pattern (chunker.roll_if_full() + chunker.current_block()), but this loop was missed during the refactoring.

Proposed fix
     ctor_rest_regs.sort_unstable();
     for (ctor_symbol, rest_idx) in ctor_rest_regs {
+        chunker.roll_if_full();
+        let blk = chunker.current_block();
         let func_ref = format!("@{}", ctor_symbol);
         blk.call_void(
             "js_register_closure_rest",
             &[(PTR, &func_ref), (I32, &rest_idx.to_string())],
         );
     }
🤖 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 `@crates/perry-codegen/src/codegen/string_pool.rs` around lines 759 - 766, The
`ctor_rest_regs` loop is attempting to call `blk.call_void(...)` but the
variable `blk` is not defined, causing a compilation error. This loop needs to
be refactored to use the chunker pattern that was applied to other registration
loops. Update the loop to call `chunker.roll_if_full()` before the operation to
ensure the block is ready, then replace the undefined `blk` reference with
`chunker.current_block()` to get the active block from the chunker, matching the
pattern used in the surrounding registration code.
🤖 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.

Outside diff comments:
In `@crates/perry-codegen/src/codegen/string_pool.rs`:
- Around line 759-766: The `ctor_rest_regs` loop is attempting to call
`blk.call_void(...)` but the variable `blk` is not defined, causing a
compilation error. This loop needs to be refactored to use the chunker pattern
that was applied to other registration loops. Update the loop to call
`chunker.roll_if_full()` before the operation to ensure the block is ready, then
replace the undefined `blk` reference with `chunker.current_block()` to get the
active block from the chunker, matching the pattern used in the surrounding
registration code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 020d0f1f-6763-41f9-a240-0a13f0b7b0f2

📥 Commits

Reviewing files that changed from the base of the PR and between f98dbbb and 48034a3.

📒 Files selected for processing (4)
  • crates/perry-codegen/src/codegen/string_pool.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry/src/commands/compile.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/perry/src/commands/compile.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs

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