Skip to content

IR DCE mishandles Let bindings: no unshift after dropping a dead shadowing binder, body scope resolved in reverse #134

Description

@Unisay

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions