Skip to content

First-class passes for the IR pipeline: Pass values, invariant checks, deterministic name supply #138

Description

@Unisay

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: irIR / optimizer / DCE / inlinerenhancementNew feature or request

    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