fix(hir): nested local must not reuse a module const's forward-declared id (#1758/#321) - #1815
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A nested local binding could reuse a module-level
const/let'sforward-declared
LocalId, conflating two distinct variables.The module-level forward-declaration pass pre-registers a
LocalIdfor eachmodule-level
const/let(so forward references resolve). The var-decllowering reused that pre-registered id for any binding of the same name —
including a nested local inside a function body:
helper's localzipWithconsumed the module var's pre-registered id, so thelocal and the module binding shared one id; the real
zipWithfunction landedon a fresh id; and
merge's closure — which forward-resolvedzipWithto thepre-registered id — pointed at the wrong (uninitialised) slot →
TypeError: value is not a function. Order-dependent: only triggers when thelocal shadow precedes the module const.
Why it matters — unblocks the
effectbarrelimport { Effect } from "effect"threwvalue is not a functioninTestContext.tsinit. effect'slayer.merge = dual(2, (self, that) => zipWith(self, that, ...))references the modulezipWith(exported atinternal/layer.ts:1191), and a localzipWith(L1180, inside anotherfunction) precedes it — exactly this shape.
Localization
Probed pipe args (all defined) → narrowed to
layer.merge(a,b)throwing →merge's body callszipWith(self,that,cb)(the 3-argjs_closure_call3inthe backtrace) → HIR showed
mergebodyCall{callee: LocalGet(1)}while themodule
zipWithisLet id:6(id 1 = the shadow). An env-gateddefine_localtrace confirmed only two
zipWithdefine_locals (bothscope_depth=0) — thenested local made none; it reused.
Fix
Gate the
pre_registered_module_varsid-reuse (both the simple-ident anddestructuring-fallback sites in
destructuring/var_decl.rs) on module scope(
scope_depth == 0 && inside_block_scope == 0), mirroringdefine_local's ownmodule-level tagging. A nested local of the same name now gets a fresh id.
Validation
test_gap_module_const_local_shadow.ts(core shape +dual-indirection + independent-nested-local guard) — byte-identical to node.
PERRY_NO_AUTO_OPTIMIZE=1): 0regressions — 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).
framework works end-to-end, byte-identical to node:
effect/Effectdeep import → 42,effect/Schemainit → ok,effect/Schemadecode → "hello", and theimport { Effect } from "effect"barrel → 42 (
Effect.runSync(Effect.succeed(42))).Refs #1758, #321, #1785, #1772, #1791.