optimizedUberModule is currently plain function composition, and the pipeline's invariants (which pass must run before which, what each pass assumes about names and scoping) live in comments and Notes. The Plutus compiler shows the alternative: a pass is a value that carries its contract, and the pipeline runner enforces the contract mechanically. This issue introduces that structure for pslua's IR pipeline. It is a pure refactor: no codegen change, no golden churn.
Design
data Invariant = WellScoped | ... -- extended by the GUC work later
data Pass = Pass
{ passName ∷ Text
, passRun ∷ UberModule → SupplyM UberModule
, passRequires ∷ Set Invariant
, passEnsures ∷ Set Invariant
}
Deliberately smaller than the Plutus Pass: no typechecking hooks, no error provenance, just the contract and the transformation.
Components:
- Pipeline runner: sequences
Pass values; in the test harness (and behind a debug flag in the CLI) it checks passRequires before and passEnsures after each pass via the linter, failing loudly with the offending pass's name. Provides a fixpoint combinator with the same semantics as today's idempotently (whole-module Eq; switching to a change flag is a separate, later step) and optional per-pass IR dumps for debugging.
- Linter:
lintUberModule ∷ UberModule → [Violation] in lib, starting with well-scopedness — generalize unboundLocals from test/Language/PureScript/Backend/IR/Optimizer/Spec.hs, which already implements the reference scope-checking logic against Note [Sequential scoping of Let bindings].
- Deterministic name supply:
SupplyM (counter-based, stable traversal order) threaded through passRun. Generated names end up in golden files, so freshness must be reproducible across runs; RepM.generateName is the prototype. Passes that need no fresh names simply don't draw from it.
- Porting: existing stages (
eliminateDeadCode, the optimizer rewrite blocks, mergeForeignsIntoBindings, renameShadowedNames, magicDo, flattenDeepBinds) become Pass values with their current ordering, so the composed pipeline is behavior-identical.
Motivation
The immediate consumer is the naming redesign (follow-up issue): moving the pipeline to globally unique local names needs a place where invariants are declared and mechanically checked at pass boundaries, a supply for freshening, and per-pass visibility during the migration. But the structure pays for itself independently: the 2026-07-02 backend audit traced six scoping bugs (#37, #56, #133, #134) to invariants that lived in comments and had to be re-implemented correctly by every traversal; a linter that runs after every pass in the test suite turns that class of mistake from a silent miscompile into a red test naming the culprit.
Blocks the GUC naming redesign issue. Independent of #133/#134 (those only gate the GUC switch itself).
optimizedUberModuleis currently plain function composition, and the pipeline's invariants (which pass must run before which, what each pass assumes about names and scoping) live in comments and Notes. The Plutus compiler shows the alternative: a pass is a value that carries its contract, and the pipeline runner enforces the contract mechanically. This issue introduces that structure for pslua's IR pipeline. It is a pure refactor: no codegen change, no golden churn.Design
Deliberately smaller than the Plutus
Pass: no typechecking hooks, no error provenance, just the contract and the transformation.Components:
Passvalues; in the test harness (and behind a debug flag in the CLI) it checkspassRequiresbefore andpassEnsuresafter each pass via the linter, failing loudly with the offending pass's name. Provides a fixpoint combinator with the same semantics as today'sidempotently(whole-moduleEq; switching to a change flag is a separate, later step) and optional per-pass IR dumps for debugging.lintUberModule ∷ UberModule → [Violation]inlib, starting with well-scopedness — generalizeunboundLocalsfromtest/Language/PureScript/Backend/IR/Optimizer/Spec.hs, which already implements the reference scope-checking logic against Note [Sequential scoping of Let bindings].SupplyM(counter-based, stable traversal order) threaded throughpassRun. Generated names end up in golden files, so freshness must be reproducible across runs;RepM.generateNameis the prototype. Passes that need no fresh names simply don't draw from it.eliminateDeadCode, the optimizer rewrite blocks,mergeForeignsIntoBindings,renameShadowedNames,magicDo,flattenDeepBinds) becomePassvalues with their current ordering, so the composed pipeline is behavior-identical.Motivation
The immediate consumer is the naming redesign (follow-up issue): moving the pipeline to globally unique local names needs a place where invariants are declared and mechanically checked at pass boundaries, a supply for freshening, and per-pass visibility during the migration. But the structure pays for itself independently: the 2026-07-02 backend audit traced six scoping bugs (#37, #56, #133, #134) to invariants that lived in comments and had to be re-implemented correctly by every traversal; a linter that runs after every pass in the test suite turns that class of mistake from a silent miscompile into a red test naming the culprit.
Blocks the GUC naming redesign issue. Independent of #133/#134 (those only gate the GUC switch itself).