0%

Section 03: Type-Directed Desugar + Validation (DOMINANT)

Goal

See frontmatter and proposal Phases 3-4 for desugaring and validation. This section fixes BUG-04-129.

Phase-placement invariant — ABSOLUTE

The desugar runs in ori_types (type checker), not ori_canon, per typeck.md §EX-17 and `canon.md §2 rows 3-4. AIMS must see the post-desugar pure-reassignment IR.

A canon-phase desugar would let CanExpr::Assign{target: Index/Field} reach AIMS, assigning logical ownership/COW events to the mutation expression instead of the updated reassignment. Canon-phase and ownership-lowering-phase desugars are forbidden.

Implementation Sketch

After type inference resolves the AssignTarget root and receiver types, walk the chain right-to-left. Each index step wraps the inner value in receiver.updated(key: k, value: inner), while each field step wraps it in { ...receiver, field: inner }.

Intermediate reads use index, and the whole target becomes the pure reassignment root = <wrapped>. Parse-time compound expansion composes with this rewrite.

Side-effecting index/key expressions are hoisted to per-step let temporaries so they evaluate exactly once.

Typeck-phase desugar mechanism (arena-grounded — NOT a canon-phase rewrite)

The desugar runs in ori_types and produces a rewritten expression tree that ori_canon::lower::expr (compiler_repo/compiler/ori_canon/src/lower/expr.rs) lowers as the pure-reassignment form, INSTEAD of lowering ExprKind::Assign { target: AssignTarget, value } as-is into CanExpr::Assign (the current Target-only path per canon.md §2 rows 3-4 notes). The ExprArena is append-allocatable during inference — ori_ir::arena::ExprArena::alloc_expr(&mut self, Expr) -> ExprId (compiler_repo/compiler/ori_ir/src/arena/mod.rs:202) mints fresh ExprIds; the desugar consumes the resolved root/step receiver types (already computed by infer_assign_target in compiler_repo/compiler/ori_types/src/infer/expr/operators/assignment.rs) and allocates synthetic nodes for the rewritten target:

  • Index step → a synthetic ExprKind::MethodCall/Call node resolving IndexSet::updated(self: receiver, key: k, value: inner) against receiver’s inferred type (existing trait-dispatch path resolves the impl; [T]/{K:V}/[T, max N] impls ship in section-01). Non-final reads on the same step resolve via Index (read).
  • Field step → a synthetic struct-spread ExprKind ({ ...receiver, field: inner }) resolving receiver’s struct type and asserting field membership.
  • Root reassignment → the chain collapses to ExprKind::Assign { target: <root Ident>, value: <wrapped> } (a plain mutable-binding reassignment), so ori_canon lowers it through the EXISTING reassignment path and AIMS sees root = root.updated(...) / root = {...root, f:v} — the post-desugar pure-reassignment IR the phase-placement invariant requires.
  • Side-effect hoisting → each side-effecting index/key sub-expression (x[f()]) is bound once to a synthetic per-step let temporary (allocated via alloc_expr/alloc_stmt) wrapping the reassignment in a block, so f() evaluates exactly once across read-copy and write-copy (the load-bearing arr[f()] += 1 case).

Invariant + grounding:

  • The desugar stays in ori_types per canon.md §7.1 Invariant 5 (unified model) — AIMS never sees a CanExpr::Assign { target: Index/Field }.
  • A canon-phase or ARC-phase desugar is BANNED — the rejected “allow desugaring in ori_canon” alternative would let pre-desugar IR reach AIMS and assign ownership/COW events to the mutation expression instead of the reassignment.
  • Grounding: typeck.md §EX-17 (desugar contract) + canon.md §2 rows 3-4 (Target-only → shipped here at the type-checker phase).

03.1 Type-Directed Desugar + IndexSet Resolution + Validation + Diagnostics

Intelligence Reconnaissance

2026-06-01 — feature-mode scaffold; proposal is the research artifact. Desugar contract: read .claude/rules/typeck.md §EX-17 + canon.md §2 rows 3-4 directly (not graph-indexed).

  • scripts/intel-query.sh file-symbols "ori_canon/src/lower/expr" --repo ori — where ExprKind::Assign{target,value} lowers as-is today (the E4003 path) [ori:ori_canon/src/lower/expr.rs].
  • scripts/intel-query.sh callers "index_assignment_not_supported" --repo ori — the E6080 eval guard to retire [ori:ori_eval/src/interpreter/can_eval/control_flow.rs].
  • Verify the assignment-checking module against the shipped tree [ori:ori_types/src/check/bodies/]ori_types/src/infer/methods/ does NOT exist; impl-method body checking lives under check/bodies/ (provenance: roadmap §15D supersession recorded in index.md).

Spec References

  • Proposal §Desugaring (all cases), §Type Checking Rules, §Error Cases, §Implementation Note (type-directed).
  • typeck.md §EX-17 (the desugar contract: list[i]=v → list.updated(...), state.f=v → {...state, f:v}, Spec §13.6.2 index assignment).
  • canon.md §2 rows 3-4 (Target-only → shipped here, type-checker phase).
  • BUG-04-129 (the symptom this closes).

Tests

tests/spec/expressions/{index_assignment_simple,map_assignment,field_assignment,nested_index_assignment,mixed_chain_assignment,compound_index_assignment}.ori + tests/compile-fail/index_assignment_errors.ori + Rust unit tests in ori_types.

  • TDD-first: failing matrix BEFORE implementation — target shape (x[i] / x.f / x.f[i] / x[i].f / x.f.g[i].h) × root binding (mutable / $-immutable / parameter / loop var) × operator (= / += / -= / *= / /= / %= / **=) × index expr (literal / variable / side-effecting f()) × backend (interpreter / LLVM). Each cell positive + negative pin. Side-effect cell (load-bearing): arr[f()] += 1 with a dbg!-counted f() — reject any desugar that double-evaluates.

  • Index-step desugar: recv[k] = vrecv = recv.updated(key: k, value: v) in ori_types (resolve IndexSet<Key,Value> on recv’s type). Hoist k to a temporary when side-effecting.

    • Ori Tests: tests/spec/expressions/index_assignment_simple.ori, tests/spec/expressions/map_assignment.ori
  • Field-step desugar: recv.f = vrecv = { ...recv, f: v } (resolve recv’s struct type; field must exist). No trait needed.

    • Ori Tests: tests/spec/expressions/field_assignment.ori
  • Nested + mixed chains: right-to-left wrapping per proposal §Mixed Chains; intermediate reads via index. Cover grid[x][y][z]=v, state.items[i]=x, list[i].name=x, game.levels[i].enemies[j].hp=0.

    • Ori Tests: tests/spec/expressions/nested_index_assignment.ori, tests/spec/expressions/mixed_chain_assignment.ori
  • Compound assignment: verify two-step desugar (list[i] += 1list[i] = list[i] + 1list = list.updated(...)) for all compound ops × all target forms; single evaluation of the target/index expressions.

    • Ori Tests: tests/spec/expressions/compound_index_assignment.ori
  • Validation + diagnostics (proposal §Type Checking Rules + §Error Cases): root-binding mutability (reject $/param/loop-var); field membership; Index (non-final) + IndexSet (final) resolution with agreeing Value types; RHS assignability; key-type match. Allocate E-codes in ori_diagnostic (structured construction, never format! strings, per diagnostic.md); each error gets a #compile_fail pin.

    • Root-mutability gate — parameter coverage (load-bearing): the existing infer_assign_target (compiler_repo/compiler/ori_types/src/infer/expr/operators/assignment.rs) rejects ONLY engine.env().is_mutable(name) == Some(false). is_mutable (compiler_repo/compiler/ori_types/src/infer/env/mod.rs:121) returns None for PARAMETERS — they bind via plain env.bind() (mutable: None per env/mod.rs:99; e.g. check/bodies/functions.rs:58, infer/expr/lambdas.rs:49), NOT bind_with_mutability. So == Some(false) is FALSE for a parameter root and reassignment through it slips past the immutable-root rejection silently. LOOP VARIABLES, by contrast, bind via bind_patternbind_with_mutability(*name, ty, Mutability::Immutable) (infer/expr/sequences.rs:344, reached from control_flow/loops.rs:88) → is_mutable == Some(false), so a loop-var root is ALREADY rejected by the existing == Some(false) check (NOT a gap; verified against the shipped tree). The mission requires “Root binding must be mutable (non-$, not a parameter, not a loop variable)”. This work item MUST reject any root whose is_mutable(name) != Some(true) (i.e. reject BOTH Some(false) — the let $x + loop-var cases already caught — AND None — the parameter case currently slipping past) for the assignment-target path — the validation MUST NOT silently pass on is_mutable == None.
    • WHERE: ori_types/src/check/ (assignment-target checking; impl-method/desugar entry — verify exact module against the shipped tree, NOT a guessed path) + ori_types/src/infer/expr/operators/assignment.rs (the is_mutable gate) + ori_diagnostic/src/error_code/mod.rs.
    • Ori Tests: tests/compile-fail/index_assignment_errors.ori (immutable $-root, parameter root, loop-var root, str-no-IndexSet, no-field, value-mismatch, key-mismatch). The parameter-root pin fails today (silent pass on is_mutable == None) and MUST reject after this item. The loop-var-root pin ALREADY rejects today (is_mutable == Some(false) via bind_with_mutability(Immutable)) — it is a regression-guard that this item must keep green, NOT a currently-failing case.
  • Dual-execution parity + BUG-04-129 close: interpreter == LLVM for every desugared form; ori build repro.ori + ori run repro.ori both correct (E4003 + E6080 gone). Remove the now-unreachable E4003 (ori_arc) + E6080 (ori_eval) guards OR convert to internal-invariant assertions (no valid program reaches them). ORI_CHECK_LEAKS=1 zero leaks.

  • Verify: full matrix green debug + release, both backends; ./test-all.sh.

Verification Layers (per .claude/rules/layer-coverage.md §1 — addressed-or-justified)

Each pin below names a concrete probe + expected reading; every applicable matrix layer is COVERED or JUSTIFIED-N/A. These are the verifiable criteria the blind-spot review folded in (the deliverable’s L9/L10/L12 + guard-removal + phase-placement were unencoded).

  • L1 Parse — N/A: parser AssignTarget shipped in section-02; section-03 consumes it, adds no grammar surface.
  • L2 Typecheck — COVERED: desugar + validation land here. Probe: Rust unit tests in ori_types exercising infer_assign_target + the synthetic-node rewrite (index/field/nested/mixed/compound; immutable/$/param/loop-var rejection); tests/compile-fail/index_assignment_errors.ori. Expected: each form desugars; each error rejects with its E-code.
  • L3 Canonicalize — COVERED: assert ori_canon::lower::expr (compiler_repo/compiler/ori_canon/src/lower/expr.rs) lowers the desugared root-reassignment through the existing reassignment path. Phase-placement negative pin (load-bearing): NO CanExpr::Assign { target: Index/Field } and NO residual AssignTarget reaches ori_canon/AIMS post-desugar (the cross-phase contract this section satisfies per canon.md §7.1 Invariant 5). Probe (load-bearing automated pin): a Rust canon-phase unit test (ori_canon tests) asserting the canonical IR of each desugared form carries only CanExpr::Assign { target: Ident } + updated/spread call nodes — NO CanExpr::Assign { target: Index/Field } and NO residual AssignTarget survive (there is no ORI_DUMP_AFTER_CANON flag; the assertion is a test, not a dump). Manual inspection: ORI_DUMP_AFTER_TYPECK=1 on each desugared form confirms the post-desugar typed IR is the rewritten reassignment, not an AssignTarget. The E4003 ori_arc guard (compiler/ori_arc/src/lower/control_flow/mod.rs, the CanExpr::Index/CanExpr::Field arms) is never hit by a valid program. Expected: zero index/field assignment targets survive to ARC lowering.
  • L4 AIMS — COVERED: AIMS sees pure reassignment and freezes the correct COW/ownership facts. Probe: inspect the post-AIMS artifact on a 001_100_doors-shaped repro. Expected: no spurious ownership events on the mutation expression; static-unique and dynamic-sharing cases are distinguished without a physical counter assumption.
  • L5 Interpreter (eval) — COVERED: ori run of every desugared form. Probe: ./target/debug/ori test --verbose over tests/spec/expressions/{index_assignment_simple,map_assignment,field_assignment,nested_index_assignment,mixed_chain_assignment,compound_index_assignment}.ori. Expected: correct value; E6080 index_assignment_not_supported guard (compiler/ori_eval/src/interpreter/can_eval/control_flow.rs) unreachable.
  • L6 LLVM codegen — debug — COVERED: ori test --backend=llvm (debug) over the spec corpus. Expected: identical results to eval.
  • L7 LLVM codegen — release — COVERED: ./target/release/ori test --backend=llvm (FastISel path differs from debug). Expected: green.
  • L8 AOT — COVERED: ori build repro.ori then run the binary. Expected: correct value and no spurious E4003; this removes BUG-04-129’s AOT symptom.
  • L9 Dual-execution parity — COVERED: interpreter ≡ LLVM for every desugared form. Probe: dual-exec-verify.sh --test-only tests/spec/expressions/ over the index/field-assignment corpus + the arr[f()] += 1 eval-once count-pin. Expected: identical observable behavior; f() evaluates exactly once on both backends.
  • L10 Resource check — COVERED: run exact logical ownership-event parity plus each admitted executor’s leak/resource probe over the desugared-form corpus. ORI_CHECK_LEAKS=1 and ORI_TRACE_RC=1 remain the current compiled ARC adapter probes, not universal AIMS evidence.
  • L11 Spec conformance — COVERED: tests/spec/expressions/** index/field-assignment cases pin Spec §13.6.1/§13.6.2 behavior (corpus authored by this section; owned by section-05 per the documented FILE_CONTENTION split).
  • L12 Production entry point — COVERED: run ori build repro.ori and ori run repro.ori on BUG-04-129’s real index/field-assignment reproduction. Require the correct value with neither E4003 nor E6080; this is a production CLI gate, not a seam-injected unit test.
  • Guard removal-or-convert pin: the E4003 (ori_arc) + E6080 (ori_eval) guards are REMOVED (now unreachable) OR converted to internal-invariant assertions per the overview’s defense-in-depth decision — assert no valid post-desugar program reaches either guard (the L3 phase-placement negative pin proves unreachability).
  • ori_types Rust desugar unit tests pin: the synthetic-node rewrite has Rust-level unit coverage in ori_types (not only .ori spec tests) asserting the produced updated/spread/reassignment node shape per target form.