Skip to content

Fix unbound variable references produced by inlining (#37)#43

Merged
Unisay merged 5 commits into
mainfrom
yura/fix-issue-37
Jun 12, 2026
Merged

Fix unbound variable references produced by inlining (#37)#43
Unisay merged 5 commits into
mainfrom
yura/fix-issue-37

Conversation

@Unisay

@Unisay Unisay commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

The IR refers to locals by name plus a De Bruijn-style index, and the scoping rule for Let was inconsistent across the codebase. renameShadowedNames, DCE and the Lua codegen all treat let bindings sequentially: a standalone binding's RHS sees the earlier siblings of the same let, while the binding's own name points at an outer binder. The last part is the subtle bit. Standalone bindings are non-recursive, so a binding's own name is not in scope in its own RHS; if the same name is also bound further out, a self-reference there resolves to that outer binder:

let x = 1          -- outer binder
in  let x = x + 1  -- this `x` is the outer one (1), not the binding itself
    in  x          -- this `x` is the inner one (2)

The index disambiguates which x is meant. shift, substitute and countFreeRefs implemented the opposite rule: they bumped the index for the binding's own name and ignored siblings.

So when the optimizer inlined a non-exported binding whose body contained a let with a sibling-bound reference (Bind1 in the issue), substituting it under a binder with the same name shifted that reference past its binder. DCE then dropped the binder as unused, and the codegen printed the dangling Ref (Local Bind1) 1 as Bind11, a variable that exists nowhere. This is also why #38 could not reproduce the problem: without an export list nothing gets inlined, since every top-level binding becomes an export.

What changed:

  • shift, substitute and countFreeRefs now thread the scope through let groupings left to right (mapAccumL), matching the convention used everywhere else.
  • The codegen no longer renders a local reference with a non-zero index by gluing the index onto the name. After renameShadowedNames such a reference is never valid, so compilation now fails with UnexpectedRefBound instead of emitting broken Lua.
  • Regression coverage: the golden test from test: add regression test for issue #37 undefined variable bug #38 with the export list change from Issue 37: Explicitly export term to reproduce bug #39 applied, unit tests pinning the sequential scoping convention for all three functions, and an optimizer test that inlines through shadowing lets and asserts no reference is left unbound.

No other golden files changed.

Closes #37. Supersedes #38 and #39. Thanks @Renegatto for the repro and for spotting that the export list is what triggers it.

@Unisay Unisay requested a review from Copilot June 12, 2026 10:37
@Unisay Unisay self-assigned this Jun 12, 2026
Unisay added 4 commits June 12, 2026 12:55
Golden test from PR #38 with the explicit export list from PR #39:
with 'module ... (baz) where' the non-exported bindings get inlined
and the generated Lua references an undefined variable Bind11.
The luacheck golden suite fails with W113 until the bug is fixed.
…ountFreeRefs

The IR's authoritative Let scoping convention (implemented by
renameShadowedNames, DCE and Lua codegen) is sequential: in a standalone
binding's RHS the earlier siblings of the same Let are in scope, while
the binding's own name refers to an outer binder. shift, substitute and
countFreeRefs in IR.Types implement the opposite convention, which makes
inlining produce unbound references (issue #37). These tests are red
until the convention is fixed.
…#37)

The IR's Let scoping convention is sequential (let*): in a standalone
binding's RHS the earlier siblings of the same Let are in scope, while
the binding's own name refers to an outer binder. renameShadowedNames,
DCE and the Lua codegen all follow this convention, but shift,
substitute and countFreeRefs implemented the opposite one: they bumped
the index for the binding's own name and ignored earlier siblings.

As a result, inlining a non-exported binding whose body contained a
let with a sibling-bound reference under a binder with the same name
shifted that reference past its binder. DCE then deleted the
"unused" binder and the Lua codegen rendered the dangling
Ref (Local Bind1) 1 as an undefined variable Bind11.

Also harden the codegen: a local reference with a non-zero index after
renameShadowedNames is now a compile-time error (UnexpectedRefBound)
instead of being silently rendered as an undefined Lua variable.
@Unisay Unisay force-pushed the yura/fix-issue-37 branch from c63ba24 to 68e773e Compare June 12, 2026 10:56

Copilot AI 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…in Linker

Consolidate the let*-scoping convention, previously copy-pasted at three
Let cases in IR.Types and paraphrased across Lua codegen, DCE and two
test specs, into a single GHC-style Note in IR.Types referenced from
every site that implements or depends on it. Add a companion
Note [Locals are uniquely named after renameShadowedNames] in
IR.Optimizer documenting why renameShadowedNames must run last and why
the Lua backend rejects non-zero local indices.

While auditing for the convention, qualifyTopRefs in IR.Linker turned
out to have the same defect issue #37 had: it qualified Let groupings
independently instead of threading the per-name scope left to right, so
a reference bound by an earlier sibling could be mis-qualified to a
top-level binding. Fixed with mapAccumL threading mirroring the
IR.Types functions, plus IR.Linker.Spec unit tests (red before the fix,
green after).
@Unisay Unisay force-pushed the yura/fix-issue-37 branch from 68e773e to 69bd23c Compare June 12, 2026 11:03
@Unisay Unisay marked this pull request as ready for review June 12, 2026 11:08
@Unisay Unisay merged commit 34cc60e into main Jun 12, 2026
1 check passed
@Unisay Unisay deleted the yura/fix-issue-37 branch June 12, 2026 11:08
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.

Undefined variable in compiled code

2 participants