First-class passes for the IR pipeline: Pass values, invariant checks, deterministic name supply#149
Merged
Merged
Conversation
'Rewritten Recurse' descends into the rewritten node's children without re-applying the rule to the node itself. When every binding of a Let is dead and the node collapses to its body, a body that is itself a Let escaped the DCE rule: its dead bindings were kept, while the parameters of lambdas inside them were blanked (their ids are unreachable), leaving unbound local references in the intermediate IR. A later optimizer iteration always dropped the residue, so generated code was unaffected; the per-pass invariant checks built for #138 surfaced the bug on the golden corpus (Golden.LongWriterBind, Control.Monad.Writer.Trans). Process a collapsed-into Let directly in the rule (the collapse can cascade), so its dead bindings are eliminated in the same pass.
…138) The IR pipeline becomes a list of Step values interpreted by runners: a Pass carries its invariant contract (passRequires/passEnsures), and the Step layer (RunPass | RunFixpoint) lets the checked runner lint every pass boundary including every fixpoint iteration — an intermediate violation masked by a later iteration is exactly what it exists to catch. Components: - IR.Supply: deterministic counter-based name supply threaded through passRun; flattenDeepBinds ports its internal counter to it (names are bit-identical: nothing draws before it, the counter starts at 0). - IR.Linter: well-scopedness check generalized from the Optimizer spec's unboundLocals reference implementation; walks bindings, foreigns, and exports, and treats the runtime lazy factory as bound by the runtime (a fifth site of the PSLUA_runtime_lazy coupling). - IR.Pass: Invariant, Pass, Step, PassCheckFailure, and the three runners (plain, checked, traced); idempotently moves here. - The golden harness compiles through the checked runner, so every golden module doubles as a scope-invariant test of the pipeline. - The CLI gains a --lint-ir debug flag plumbed through compileModules. Behavior-identical: zero golden churn (guarded by the kont0/tmp0 names baked into the Long*Bind goldens).
There was a problem hiding this comment.
Pull request overview
This PR refactors the PureScript→Lua backend’s IR optimization pipeline from ad-hoc function composition into an explicit, interpretable sequence of first-class passes with mechanically checked invariants. It adds an IR linter, threads a deterministic name supply through name-minting passes, wires invariant-checking into the golden test harness (and an opt-in CLI flag), and includes a DCE fix uncovered by the new per-pass checking.
Changes:
- Introduce
IR.Pass/Steppipeline runners (plain, checked, traced) and re-expressoptimizedUberModuleas a pass pipeline (withoptimizedUberModuleChecked). - Add
IR.Linterwell-scopedness checks (including the runtime-lazy exemption) and run them at every pass boundary in the golden harness /--lint-ir. - Add
IR.Supplyand portFlattenDeepBindsto use the deterministic supply; fix a DCE nested-Letedge case and add regression tests/spec coverage.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
test/Main.hs |
Registers new IR linter/pass spec suites in the test runner. |
test/Language/PureScript/Backend/Lua/Golden/Spec.hs |
Switches goldens to compile IR via the checked optimizer runner (per-pass invariant checking). |
test/Language/PureScript/Backend/IR/Pass/Spec.hs |
Adds tests for checked runner failure reporting and fixpoint semantics. |
test/Language/PureScript/Backend/IR/Optimizer/Spec.hs |
Replaces ad-hoc scope checking with centralized linter checks. |
test/Language/PureScript/Backend/IR/Linter/Spec.hs |
Adds linter-specific specs (sites, runtime-lazy exemption, Let scoping rules). |
test/Language/PureScript/Backend/IR/DCE/Spec.hs |
Adds regression test covering the nested-Let DCE escape case. |
pslua.cabal |
Exposes new library modules and includes new specs in the test suite. |
lib/Language/PureScript/Names.hs |
Documents the additional runtime-lazy coupling site in the new linter. |
lib/Language/PureScript/Backend/IR/Supply.hs |
Adds deterministic counter-based name supply for IR passes. |
lib/Language/PureScript/Backend/IR/Pass.hs |
Adds pass/step definitions, invariant checking, tracing runner, and monadic fixpoint helper. |
lib/Language/PureScript/Backend/IR/Optimizer.hs |
Refactors optimizer pipeline into first-class steps; adds checked runner entrypoint. |
lib/Language/PureScript/Backend/IR/Linter.hs |
Implements module-level linting and unboundLocals well-scopedness check. |
lib/Language/PureScript/Backend/IR/FlattenDeepBinds.hs |
Ports flattening to use the shared deterministic supply (flattenDeepBindsM). |
lib/Language/PureScript/Backend/IR/DCE.hs |
Fixes DCE rewrite behavior for nested Let revealed by per-pass checking. |
lib/Language/PureScript/Backend.hs |
Adds lint-ir parameter and plumbs checked optimization into compilation. |
exe/Main.hs |
Adds CLI-side error handling for PassCheckFailure. |
exe/Cli.hs |
Adds --lint-ir flag. |
changelog.d/20260703_121000_unisay_dce_nested_let.md |
Changelog entry for the DCE nested-Let fix. |
changelog.d/20260703_120000_unisay_pass_pipeline.md |
Changelog entry for the first-class pass pipeline and --lint-ir. |
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.
Closes #138.
What
optimizedUberModulewas plain function composition, with the pipeline's invariants living in comments and Notes. This PR makes the pipeline a list of interpreted steps, each pass a value carrying its contract:IR.Pass:Pass(name,passRun :: UberModule -> SupplyM UberModule,passRequires/passEnsures :: Set Invariant) and aSteplayer (RunPass | RunFixpoint). Three runners interpret a[Step]: plain (runSteps), checked (runStepsChecked), and traced (runStepsTraced, per-pass IR snapshots for debugging). TheSteplayer exists so the checked runner can lint every pass of every fixpoint iteration: a violation on an intermediate iteration that a later iteration masks would be invisible to a fixpoint combinator returning an opaquePass. That is not hypothetical, see below.IR.Linter:lintUberModulegeneralizes theunboundLocalsreference implementation that lived in the Optimizer spec. It walks bindings, foreigns, and exports, and treats the runtime lazy factory as bound by the runtime (documented as a fifth site of thePSLUA_runtime_lazycoupling Note).IR.Supply: a deterministic counter-based name supply threaded throughpassRun.flattenDeepBindsports its internal counter to it; minted$kontN/$tmpNnames are bit-identical because nothing draws from the supply before that pass and the counter starts at 0. The module documents why the DCE node-id counter andrenameShadowedNamesmust stay off the shared supply.--lint-irdebug flag plumbed throughcompileModules.Behavior-identical by construction: the fixpoint iterates the optimize/DCE composition and compares after the full sequence, exactly like the old
idempotently. Zero golden churn, guarded by thekont0/tmp0names baked into the Long*Bind goldens.The bug the checked runner caught on day one
Turning on per-pass linting in the golden harness immediately went red on Golden.LongWriterBind: after the first
dcepass,Control.Monad.Writer.Trans.applyWriterTcontained unboundv3/v4references, healed by the next fixpoint iteration. Root cause:Rewritten Recursedescends into the rewritten node's children without re-applying the rule, so when a fully deadLetcollapses to its body and the body is itself aLet, the innerLetescapes the DCE rule. Its dead bindings are kept while the parameters of lambdas inside them get blanked (their ids are unreachable), leaving dangling references. Latent only, because the fixpoint's next iteration always dropped the residue, but that is accidental robustness. Fixed in the first commit (red unit test first), so every commit of this branch keeps the suite green. The bug predates #134.This is the motivation for the whole issue playing out on the spot: the 2026-07-02 audit traced six scoping bugs to invariants that lived in comments, and the first mechanical check of those invariants found a seventh within minutes of existing.
Verification
cabal test all: 330 examples, 0 failures;git statusclean intest/ps/output(zero golden churn).IR.Linter(module sites, runtime-lazy exemption, sequential Let scoping cases, property overGen.scopedExp) andIR.Pass(checked runner reports the offending pass and phase, catches an intermediate fixpoint violation that the plain runner converges past, fixpoint semantics property-tested againstidempotently).pslua --lint-ircompiles the test project and the produced Lua runs (ok!);--helpshows the flag.fourmoluandhlintclean on the touched files.