Two defects in the Let handling of Language.PureScript.Backend.IR.DCE, the same bug class as #37 and #56 (De Bruijn index bookkeeping under Note [Sequential scoping of Let bindings]).
Bug 1: dropping a dead Let binder leaves references pointing one binder too far
When dceAnnotatedExp blanks an unused Abs parameter it lowers the body references with unshift (that was the #56 fix). The Let case has the same obligation and skips it: a dead binding is removed from the group, but references with index >= 1 that skipped over it are left as they were.
Repro
-- let x = 1 in (let x = <dead> in x@1) -- x@1 refers to the outer x
eliminateDeadCode $ UberModule [] [] $ pure . (Name "main",) $
lets (Standalone (noAnn, x, literalInt 1) :| [])
(lets (Standalone (noAnn, x, exception "dead") :| [])
(refLocal x 1))
Actual: let x = 1 in x@1. The inner binding is gone but the reference still has index 1, and only one x binder remains. Expected: let x = 1 in x@0.
In the full pipeline the dangling reference survives renameShadowedNames (index 1 with a single binder in scope means no rename) and the Lua codegen rejects it with UnexpectedRefBound, so the compiler crashes on valid input. When more same-name binders remain above the removed one, the reference instead resolves to the wrong binder and the miscompile is silent. Reachable the same way #56 was: inlining shifts a reference past a shadowing binder (creating x@1), and a later pass kills that binder's uses.
Fix: when a binder is dropped from a Let, apply unshift name 0 to the body and to the RHSs of the groupings that follow it in the same Let, mirroring the Abs case.
Bug 2: the scope for the Let body is built in reverse order
adjacencyListForExpr builds the scope the body's references are resolved against with a right fold:
scope' = foldr addToScope scope (listGrouping =<< toList groupings)
addLocalToScope puts the most recently added binder at index 0, so with foldr the first binding of a name lands at index 0. The convention (Note [Sequential scoping of Let bindings]) is the opposite: the body's index 0 picks the last binding, like let*. Every other walker (countFreeRefs, substitute, qualifyTopRefs, renameShadowedNamesInExpr, and the RHS-side adjacencyListForGrouping right next to this code) implements the convention correctly; only the body scope of the DCE pass disagrees.
Repro
-- let x = 1; x = 2 in x@0 -- by convention x@0 is the second binding
eliminateDeadCode $ UberModule [] [] $ pure . (Name "main",) $
lets (Standalone (noAnn, x, literalInt 1) :| [Standalone (noAnn, x, literalInt 2)])
(refLocal x 0)
Actual: let x = 1 in x@0, i.e. reachability marked the first binding live and eliminated the second, so the expression now evaluates to 1 instead of 2. Expected: let x = 2 in x@0.
Same-name siblings in one Let cannot currently be produced from PureScript source (CoreFn conversion uses source names, which are unique per let block, or fresh generated names), so this is a latent landmine rather than a live miscompile, but it directly contradicts the documented convention that issue #37 established as load-bearing.
Fix: the correctly threaded scope already exists one line below; the body should use fst (foldl' adjacencyListForGrouping (scope, mempty) groupings) instead of the separate foldr.
Tests
Red-first regression tests for both belong in test/Language/PureScript/Backend/IR/DCE/Spec.hs next to the existing dceExpression helper; the repros above drop in as-is. Bug 1 additionally deserves an end-to-end case once a source-level reproduction is pinned down (the #56 golden shows the shape to imitate).
Found during a backend audit on 2026-07-02; bug 1 was first noted during the #37 work and now has a confirmed reproduction.
Two defects in the
Lethandling ofLanguage.PureScript.Backend.IR.DCE, the same bug class as #37 and #56 (De Bruijn index bookkeeping under Note [Sequential scoping of Let bindings]).Bug 1: dropping a dead
Letbinder leaves references pointing one binder too farWhen
dceAnnotatedExpblanks an unusedAbsparameter it lowers the body references withunshift(that was the #56 fix). TheLetcase has the same obligation and skips it: a dead binding is removed from the group, but references with index >= 1 that skipped over it are left as they were.Repro
Actual:
let x = 1 in x@1. The inner binding is gone but the reference still has index 1, and only onexbinder remains. Expected:let x = 1 in x@0.In the full pipeline the dangling reference survives
renameShadowedNames(index 1 with a single binder in scope means no rename) and the Lua codegen rejects it withUnexpectedRefBound, so the compiler crashes on valid input. When more same-name binders remain above the removed one, the reference instead resolves to the wrong binder and the miscompile is silent. Reachable the same way #56 was: inlining shifts a reference past a shadowing binder (creatingx@1), and a later pass kills that binder's uses.Fix: when a binder is dropped from a
Let, applyunshift name 0to the body and to the RHSs of the groupings that follow it in the sameLet, mirroring theAbscase.Bug 2: the scope for the
Letbody is built in reverse orderadjacencyListForExprbuilds the scope the body's references are resolved against with a right fold:addLocalToScopeputs the most recently added binder at index 0, so withfoldrthe first binding of a name lands at index 0. The convention (Note [Sequential scoping of Let bindings]) is the opposite: the body's index 0 picks the last binding, likelet*. Every other walker (countFreeRefs,substitute,qualifyTopRefs,renameShadowedNamesInExpr, and the RHS-sideadjacencyListForGroupingright next to this code) implements the convention correctly; only the body scope of the DCE pass disagrees.Repro
Actual:
let x = 1 in x@0, i.e. reachability marked the first binding live and eliminated the second, so the expression now evaluates to 1 instead of 2. Expected:let x = 2 in x@0.Same-name siblings in one
Letcannot currently be produced from PureScript source (CoreFn conversion uses source names, which are unique per let block, or fresh generated names), so this is a latent landmine rather than a live miscompile, but it directly contradicts the documented convention that issue #37 established as load-bearing.Fix: the correctly threaded scope already exists one line below; the body should use
fst (foldl' adjacencyListForGrouping (scope, mempty) groupings)instead of the separatefoldr.Tests
Red-first regression tests for both belong in
test/Language/PureScript/Backend/IR/DCE/Spec.hsnext to the existingdceExpressionhelper; the repros above drop in as-is. Bug 1 additionally deserves an end-to-end case once a source-level reproduction is pinned down (the #56 golden shows the shape to imitate).Found during a backend audit on 2026-07-02; bug 1 was first noted during the #37 work and now has a confirmed reproduction.