Skip to content

fix(hir): nested local must not reuse a module const's forward-declared id (#1758/#321) - #1815

Merged
proggeramlug merged 1 commit into
mainfrom
worktree-scope-shadow
May 26, 2026
Merged

fix(hir): nested local must not reuse a module const's forward-declared id (#1758/#321)#1815
proggeramlug merged 1 commit into
mainfrom
worktree-scope-shadow

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

A nested local binding could reuse a module-level const/let's
forward-declared LocalId, conflating two distinct variables.

The module-level forward-declaration pass pre-registers a LocalId for each
module-level const/let (so forward references resolve). The var-decl
lowering reused that pre-registered id for any binding of the same name —
including a nested local inside a function body:

const merge = (s, t) => zipWith(s, t);        // forward-refs module zipWith
function helper() { const zipWith = 5; ... }   // local shadow (declared first)
const zipWith = (a, b) => a + b;               // module const

helper's local zipWith consumed the module var's pre-registered id, so the
local and the module binding shared one id; the real zipWith function landed
on a fresh id; and merge's closure — which forward-resolved zipWith to the
pre-registered id — pointed at the wrong (uninitialised) slot →
TypeError: value is not a function. Order-dependent: only triggers when the
local shadow precedes the module const.

Why it matters — unblocks the effect barrel

import { Effect } from "effect" threw value is not a function in
TestContext.ts init. effect's layer.merge = dual(2, (self, that) => zipWith(self, that, ...)) references the module zipWith (exported at
internal/layer.ts:1191), and a local zipWith (L1180, inside another
function) precedes it — exactly this shape.

Localization

Probed pipe args (all defined) → narrowed to layer.merge(a,b) throwing →
merge's body calls zipWith(self,that,cb) (the 3-arg js_closure_call3 in
the backtrace) → HIR showed merge body Call{callee: LocalGet(1)} while the
module zipWith is Let id:6 (id 1 = the shadow). An env-gated define_local
trace confirmed only two zipWith define_locals (both scope_depth=0) — the
nested local made none; it reused.

Fix

Gate the pre_registered_module_vars id-reuse (both the simple-ident and
destructuring-fallback sites in destructuring/var_decl.rs) on module scope
(scope_depth == 0 && inside_block_scope == 0), mirroring define_local's own
module-level tagging. A nested local of the same name now gets a fresh id.

Validation

  • New gap test test_gap_module_const_local_shadow.ts (core shape +
    dual-indirection + independent-nested-local guard) — byte-identical to node.
  • Full 578-test regression sweep (PERRY_NO_AUTO_OPTIMIZE=1): 0
    regressions
    — all 39 failures + 4 hangs fail/hang identically on the
    pre-fix baseline (decorators / crypto / fastify / sqlite / fs / date /
    class-field-layout / mixins — all pre-existing).
  • With this + fix(codegen): namespace-member access of a renamed class export (#1758) #1812 (renamed-class-export, in review), the full effect
    framework works end-to-end, byte-identical to node:
    effect/Effect deep import → 42, effect/Schema init → ok,
    effect/Schema decode → "hello", and the import { Effect } from "effect"
    barrel → 42
    (Effect.runSync(Effect.succeed(42))).

Refs #1758, #321, #1785, #1772, #1791.

…ed id (#1758)

The module-level forward-declaration pass pre-registers a LocalId for each
module-level `const`/`let` so forward references resolve. The var-decl lowering
reused that pre-registered id whenever a binding of the same name was lowered —
including a NESTED local inside a function body. So:

  const merge = (s, t) => zipWith(s, t);          // forward-refs module zipWith
  function helper() { const zipWith = 5; ... }     // local shadow
  const zipWith = (a, b) => a + b;                 // module const

`helper`'s local `zipWith` consumed the module var's pre-registered id, so the
local and the module binding shared one id; the real `zipWith` function landed
on a fresh id; and `merge`'s closure — which forward-resolved `zipWith` to the
pre-registered id — pointed at the wrong (uninitialised) slot →
`TypeError: value is not a function`. (Order-dependent: only triggers when a
local shadow precedes the module const.)

This blocked the entire `import { Effect } from "effect"` barrel: effect's
`layer.merge = dual(2, (self, that) => zipWith(self, that, ...))` references the
module `zipWith` (exported at internal/layer.ts:1191), and a local `zipWith`
(L1180, inside another function) precedes it — so `TestContext.ts` module init
threw.

Fix: gate the `pre_registered_module_vars` id-reuse (both the simple-ident and
destructuring-fallback sites in `destructuring/var_decl.rs`) on MODULE scope
(`scope_depth == 0 && inside_block_scope == 0`), mirroring `define_local`'s own
module-level tagging. A nested local of the same name now gets a fresh id.

With this, `import { Effect } from "effect"; Effect.runSync(Effect.succeed(42))`
prints `42` byte-identical to node — the full effect framework now works
end-to-end (deep import, Schema init, Schema decode, barrel). New gap test
`test_gap_module_const_local_shadow.ts` (core shape + dual-indirection +
independent-nested-local guard), byte-identical to node.

Refs #1758, #321, #1785, #1772, #1791.
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