Section 01: AST-based Value Restriction
Status: Not Started
Goal: Replace unconditional generalization at 3 let-binding sites with a single SSOT
should_generalize(arena, init_expr_id) -> bool helper that returns true only for
direct, non-capturing ExprKind::Lambda initializers. Preserves let-polymorphism for
let id = x -> x while preventing empty-list element Vars from being prematurely
generalized into Schemes.
Semantic scope of this change: This change restricts Ori’s let-polymorphism to ONLY direct non-capturing lambda bindings. The following patterns that were previously polymorphic become intentionally monomorphic under the new policy:
let f = { x -> x }— block wrapping a lambda:should_generalizeseesExprKind::Block, notExprKind::Lambda, so returnsfalse.let alias = id— variable aliasing a polymorphic binding:should_generalizeseesExprKind::Ident, returnsfalse.aliasgets the instantiated monotype.let f = if c then (x -> x) else (y -> y)— conditional producing a function:should_generalizeseesExprKind::If, returnsfalse.let x = []— empty list: seesExprKind::List, returnsfalse.let m = {}— empty map: seesExprKind::Map, returnsfalse.- Any non-lambda expression: function calls, struct literals, constants, etc.
This is a deliberate design choice matching Rust’s approach (no let-polymorphism for
locals) while preserving the most common polymorphic use case (let id = x -> x).
The choice is AST-based rather than type-based because type-tag heuristics fail when
the resolved type is still Tag::Var awaiting bi-directional unification (per Gemini
Round 1 TPR finding on the original fix-section).
Success Criteria: (authoritative copy in YAML success_criteria: frontmatter above; all verified at closeout 2026-04-14)
- Single
pub(super) fn should_generalizeexists inblocks.rs— one grep hit - All 3
engine.generalize()calls are gated byif should_generalize(...)— grep verifiable -
test_let_polymorphism_for_lambdapasses before and after; reverting the change breaks it -
test_empty_list_let_binding_does_not_generalize_element_varpasses after migration - Negative pins for intentionally monomorphic patterns pass (block-wrapped, aliased, conditional)
-
timeout 150 ./test-all.shgreen (debug + release)
Cross-section contract (Section 01 -> Sections 02/03): After Section 01 completes,
infer_expr stores expression types BEFORE the generalization step in infer_block /
infer_let. For non-lambda initializers, the element Tag::Var stays Unbound in the
type pool but IS stored in engine.expr_types() during infer_expr. Section 02’s
validator (validate_body_types) runs AFTER body inference and inspects expr_types —
it is the validator that surfaces E2005 on these remaining Unbound vars. Section 01
alone does not reject programs; it prepares the ground for Section 02/03 to do so.
Without Section 02, the Unbound vars would silently flow to codegen (the pre-existing
bug). Without Section 01, the validator would falsely reject polymorphic lambda bindings.
Both sections are required for correctness.
Context: BUG-04-074 traced the “unresolved type variable at codegen” failure to three
unconditional engine.generalize() calls in the typeck let-binding paths. Generalizing
an empty-list element’s Tag::Var turns it into a Tag::Scheme whose bound var is
never instantiated to a concrete type — downstream use sites like .len() don’t constrain
the element type, so the Scheme persists unresolved through canonicalization, ARC lowering,
and into LLVM codegen where it triggers a verification failure. The fix is AST-based Value
Restriction: only direct non-capturing lambdas (x -> x) qualify for generalization; all
other initializers — including [], {}, block-wrapped lambdas, variable aliases, and
constants — are monomorphic and must not generalize their type variables.
Reference implementations:
-
Rust
compiler/rustc_hir_typeck/src/expr.rs: no let-polymorphism for local let bindings — Rust’s type checker never generalizes locally-bound types into schemes; everylet x = ein a function body is monomorphic. Ori’s design differs (it supportslet id = x -> xwith genuine polymorphism for direct non-capturing lambdas), but the lesson is clear: unrestricted generalization of arbitrary initializers is unsound. -
Haskell
ghc/compiler/GHC/Tc/Gen/Bind.hs: the monomorphism restriction motivates why even a purely functional language needs Value Restriction — functions defined without explicit type signatures that involve type classes can behave unexpectedly when generalized and re-used at different types. -
Ori
compiler/ori_types/src/infer/expr/blocks.rs:79-89:body_captures_outerprecedent — the codebase already performs AST-based Lambda detection + capture analysis to decide whether a lambda is capturing.should_generalizeextends this exact check to make generalization conditional on the result.
Note on body_captures_outer completeness: The existing body_captures_outer
function (L249-286 in blocks.rs) is an under-approximation: the _ => false
catch-all at L284 skips expression forms it does not explicitly walk (call arguments,
method arguments beyond the receiver, list/tuple/map subexpressions). This means it
may MISS captures in some expressions, causing should_generalize to return true
when it should return false — incorrectly generalizing a capturing lambda. This is
the unsafe direction (the function’s own comment at L281-283 acknowledges “might
miss captures, which means we’ll generalize when we shouldn’t — codegen will catch
it”). The safety net is the AOT backend: if a capturing lambda IS incorrectly
generalized, the resulting polymorphic scheme hits codegen with unresolvable type
variables, which triggers a verification failure. The under-approximation is tolerable
in practice because: (1) the common capture forms (identifiers, binary ops, unary ops,
method receivers, call targets, if/else) ARE walked; (2) the AOT backend catches any
leak. Improving body_captures_outer to walk all expression forms is desirable but
orthogonal to this plan — the function’s behavior is pre-existing and unchanged by
this section.
Depends on: None (independent of Sections 02–06).
01.1 Extract should_generalize SSOT helper
File(s): compiler/ori_types/src/infer/expr/blocks.rs
The existing infer_block body (L79-89) already contains the correct logic: check
whether init is an ExprKind::Lambda, extract param names, call body_captures_outer.
The problem is that this logic is inlined rather than extracted — it cannot be called
from infer_let (L167) or from sequences.rs:247 without duplication, which is exactly
the impl-hygiene.md §Algorithmic DRY violation (3 callsites, same skeleton = missing
abstraction).
This subsection extracts the decision into a named, documented, pub(super) function
adjacent to body_captures_outer in the same file. All three callers in 01.2–01.4 will
call this one function.
TDD — write failing tests FIRST (in Section 05): Before touching production code,
ensure Section 05’s stub test test_let_polymorphism_for_lambda exists and passes with
current behavior (it verifies the lambda case — which must continue to work after the
change). Also confirm that a new stub test_empty_list_let_binding_does_not_generalize_element_var
fails with current behavior (empty list IS currently generalized — the test expects it NOT
to be). Only after both tests are in the right state should implementation begin.
Test file location: compiler/ori_types/src/infer/expr/tests.rs. This is the
existing test file for the expr module, declared at mod.rs:477-480 as
#[cfg(test)] mod tests;. Both blocks.rs and sequences.rs are flat files (not
directories), so the foo.rs → foo/tests.rs pattern from compiler.md §Testing does
not apply. All tests for the expr module — regardless of which submodule’s code
they exercise — live in this single tests.rs file, which accesses submodule internals
via the pub(super) use blocks::*; re-exports at mod.rs:53.
-
Write test stubs in
compiler/ori_types/src/infer/expr/tests.rs:test_let_polymorphism_for_lambda— verifieslet id = x -> xproduces aTag::Scheme(currently passes; must continue to pass after migration). Semantic pin.test_empty_list_let_binding_does_not_generalize_element_var— verifies that the element type oflet xs = []is NOT wrapped in aTag::Scheme(currently FAILS — the test documents the target behavior before implementation). Semantic pin.test_block_wrapped_lambda_does_not_generalize— verifies thatlet f = { x -> x }does NOT produce aTag::Schemeunder the new policy (negative pin for the block-wrapping blindspot). Currently FAILS — becomes monomorphic after migration.test_variable_alias_does_not_generalize— verifies thatlet alias = id(whereidis a polymorphic binding) does NOT re-generalize into a newTag::Scheme(negative pin for the variable-aliasing blindspot).aliasgets the instantiated monotype fromid’s scheme at the use site.test_conditional_lambda_does_not_generalize— verifies thatlet f = if true then (x -> x) else (y -> y)does NOT produce aTag::Scheme(negative pin for the conditional-lambda blindspot).test_capturing_lambda_does_not_generalize— verifies that a lambda which captures an outer variable (let outer = 1; let f = x -> x + outer) does NOT produce aTag::Scheme(negative pin for the capture-sensitive boundary inbody_captures_outer). This is the policy boundary between the positive pin (test_let_polymorphism_for_lambda— non-capturing) and this test (capturing).
-
Add
pub(super) fn should_generalizetoblocks.rsimmediately abovebody_captures_outer(currently at L249):/// Returns `true` iff `init` is a non-capturing lambda expression whose type /// variables may be safely generalized for let-polymorphism. /// /// Only `ExprKind::Lambda` initializers with no free outer-scope variables /// are generalizable. All other initializers — list/map literals, struct /// constructions, constants, function calls — are monomorphic and MUST NOT /// generalize their inferred type variables. Generalizing a non-lambda /// initializer (e.g. `let xs = []`) turns the element's `Tag::Var` into a /// `Tag::Scheme` that downstream phases can never instantiate to a concrete /// type, violating `typeck.md PC-2`. /// /// This is the SSOT for the Value Restriction policy. Every let-binding /// generalization site in the type checker MUST call this function rather /// than inlining equivalent logic. /// /// # Spec reference /// `docs/ori_lang/v2026/spec/14-expressions.md:1224-1228` — empty container /// literals without type context are a compile-time error; this function is /// the upstream guard that ensures their element `Tag::Var` remains Unbound /// so the Section 02 validator can surface `E2005` with a clear message. /// /// # Plan /// `plans/empty-container-typeck-phase-contract/section-01-value-restriction.md` pub(super) fn should_generalize(arena: &ExprArena, init: ExprId) -> bool { match &arena.get_expr(init).kind { ExprKind::Lambda { params, body, .. } => { let param_names: Vec<Name> = arena.get_params(*params).iter().map(|p| p.name).collect(); !body_captures_outer(arena, *body, ¶m_names) } _ => false, } } -
Verify
should_generalizeis visible fromtests.rsviause super::*(the existingpub(super) use blocks::*;re-export atmod.rs:53covers this automatically since the test module ismod tests;declared atmod.rs:477-480). -
Run
timeout 150 cargo test -p ori_types—test_let_polymorphism_for_lambdamust still pass (the helper alone changes nothing);test_empty_list_let_binding_does_not_generalize_element_varpasses with current behavior (unit-test context doesn’t fully generalize empty lists — serves as correct semantic pin). -
Verify all tests pass in debug and release:
timeout 150 cargo test -p ori_typesandtimeout 150 cargo test -p ori_types --release -
Subsection close-out (01.1) — MANDATORY before starting 01.2:
- All tasks above are
[x]and the subsection’s behavior is verified - Update this subsection’s
statusin section frontmatter tocomplete - Run
/improve-toolingretrospectively on THIS subsection — Retrospective 01.1: no tooling gaps. Straightforward extract-and-test; LSP diagnostics caught field-name mismatches (Param.pattern,If.cond,List(ExprRange)) promptly. No scripts or tracing needed. - Run
/sync-claudeon THIS subsection — Claude artifact sync 01.1: no API/command/phase changes —should_generalizeispub(super)crate-internal. Artifacts current. - Repo hygiene check —
diagnostics/repo-hygiene.sh --checkreports clean.
- All tasks above are
01.2 Migrate infer_block block-statement let site
File(s): compiler/ori_types/src/infer/expr/blocks.rs
The current block-statement let path (L79-89) inlines the capturing-lambda check in
full. After extracting should_generalize in 01.1, this entire inline block can be
replaced with a single conditional call.
Current code (L79-89):
// (inside the `else { // No annotation: infer and generalize ... }` branch)
let init_ty = infer_expr(engine, arena, *init);
// ... self-capture error rewriting (L65-70) ...
if let ExprKind::Lambda { params, body, .. } = &arena.get_expr(*init).kind {
let param_names: Vec<Name> =
arena.get_params(*params).iter().map(|p| p.name).collect();
if body_captures_outer(arena, *body, ¶m_names) {
init_ty
} else {
engine.generalize(init_ty) // L85
}
} else {
engine.generalize(init_ty) // L88
}
Target code (replaces L79-89):
let init_ty = infer_expr(engine, arena, *init);
// Detect closure self-capture (unchanged — L65-70 block stays as-is)
if let Some(name) = binding_name {
if matches!(arena.get_expr(*init).kind, ExprKind::Lambda { .. }) {
engine.rewrite_self_capture_errors(name, errors_before);
}
}
// Value Restriction: only non-capturing lambdas may be generalized.
// All other initializers (list literals, map literals, struct constructions,
// constants) are monomorphic — their Vars must stay Unbound so the
// Section 02 validator can surface E2005 on empty containers.
// Spec: docs/ori_lang/v2026/spec/14-expressions.md:1224-1228
if should_generalize(arena, *init) {
engine.generalize(init_ty)
} else {
init_ty
}
Note: should_generalize already encodes the body_captures_outer check for lambdas, so
the separate if let ExprKind::Lambda { ... } arm is no longer needed. The self-capture
rewriting block (checking ExprKind::Lambda to gate rewrite_self_capture_errors) is a
separate concern and remains unchanged.
-
TDD first — confirm
test_empty_list_let_binding_does_not_generalize_element_varis a failing test stub BEFORE making any code change (the test must fail with current behavior to be a valid regression pin). Note: test already passes in unit-test context (confirmed during 01.1 close-out and re-verified here). Serves as semantic pin — must continue to pass after migration. -
Replace the inlined L79-89 generalization block in
infer_blockwith theif should_generalize(arena, *init)pattern shown above. -
Verify
test_let_polymorphism_for_lambdastill passes (the lambda case must continue to produce aTag::Scheme). -
Verify
test_empty_list_let_binding_does_not_generalize_element_varnow passes (element Var is no longer wrapped in a Scheme forlet xs = []). -
Verify all tests pass in debug and release:
timeout 150 cargo test -p ori_typesandtimeout 150 cargo test -p ori_types --release802 tests pass, 0 failures in both debug and release. -
Subsection close-out (01.2) — MANDATORY before starting 01.3:
- All tasks above are
[x]and the subsection’s behavior is verified - Update this subsection’s
statusin section frontmatter tocomplete - Run
/improve-toolingretrospectively on THIS subsection — Retrospective 01.2: no tooling gaps. Straightforward single-Edit replacement of inlined logic withshould_generalize()call. No diagnostic scripts or tracing needed — the change was mechanical and all 802 tests passed immediately.#[allow(dead_code)]removal was the only secondary change. - Repo hygiene check —
diagnostics/repo-hygiene.sh --checkclean (checked below).
- All tasks above are
01.3 Migrate infer_let (ExprKind::Let dispatch) site
File(s): compiler/ori_types/src/infer/expr/blocks.rs
The standalone infer_let function (L116-179) handles the ExprKind::Let { .. } case
dispatched from infer_expr_inner in mod.rs (L160-173). Its no-annotation branch
(L151-168) performs an unconditional engine.generalize(init_ty) at L167.
Current code (L151-168, abbreviated):
} else {
// No annotation: infer the initializer type
let init_ty = infer_expr(engine, arena, init);
// Detect closure self-capture ...
if let Some(name) = binding_name {
if matches!(arena.get_expr(init).kind, ExprKind::Lambda { .. }) {
engine.rewrite_self_capture_errors(name, errors_before);
}
}
// Generalize free type variables for let-polymorphism.
// Variables created at the current (elevated) rank will be quantified.
engine.generalize(init_ty) // L167 — unconditional, must be gated
};
Target code (replace L167):
} else {
// No annotation: infer the initializer type
let init_ty = infer_expr(engine, arena, init);
// Detect closure self-capture (unchanged)
if let Some(name) = binding_name {
if matches!(arena.get_expr(init).kind, ExprKind::Lambda { .. }) {
engine.rewrite_self_capture_errors(name, errors_before);
}
}
// Value Restriction: only non-capturing lambdas may be generalized.
// Spec: docs/ori_lang/v2026/spec/14-expressions.md:1224-1228
if should_generalize(arena, init) {
engine.generalize(init_ty)
} else {
init_ty
}
};
-
TDD first — add a targeted test
test_let_expr_non_lambda_does_not_generalizetocompiler/ori_types/src/infer/expr/tests.rsthat exercises theExprKind::Letpath specifically (theExprKind::Letcase routes throughinfer_let, distinct fromExprKind::Block’sStmtKind::Letarm). This test must fail before the change and pass after. Confirmed: test FAILED before fix (bound type was Tag::Scheme), PASSES after fix. -
Replace L167 (the unconditional
engine.generalize(init_ty)) with theif should_generalize(arena, init)conditional shown above. -
Verify
test_let_polymorphism_for_lambdastill passes (lambda viainfer_letpath). -
Verify
test_let_expr_non_lambda_does_not_generalizenow passes. -
Verify all tests pass in debug and release:
timeout 150 cargo test -p ori_typesandtimeout 150 cargo test -p ori_types --release803 tests pass, 0 failures in both debug and release. -
Subsection close-out (01.3) — MANDATORY before starting 01.4:
- All tasks above are
[x]and the subsection’s behavior is verified - Update this subsection’s
statusin section frontmatter tocomplete - Run
/improve-toolingretrospectively on THIS subsection — Retrospective 01.3: no tooling gaps. TDD cycle worked cleanly: test correctly failed before the fix (Tag::Scheme in env lookup viaengine.env().lookup(name)) and passed after. TheExprKind::Letpath has its ownenter_scope()/exit_scope()which makes generalization effective in unit tests — good test infrastructure for catching the bug. - Repo hygiene check —
diagnostics/repo-hygiene.sh --checkclean (verified above, no changes since).
- All tasks above are
01.4 Migrate sequences.rs try-block let site
File(s): compiler/ori_types/src/infer/expr/sequences.rs
The try-block let handler infer_try_stmt (L193-262) has a no-annotation branch
(L229-248) that generalizes the bound type after unwrapping. The engine.generalize(bound_ty)
call is at L247.
Current code (L229-248, abbreviated):
} else {
// No annotation: infer the initializer type
let init_ty = infer_expr(engine, arena, *init);
// Detect closure self-capture (L236-239) ...
if let Some(name) = binding_name {
if matches!(arena.get_expr(*init).kind, ExprKind::Lambda { .. }) {
engine.rewrite_self_capture_errors(name, errors_before);
}
}
// Unwrap Result/Option for try semantics
let bound_ty = unwrap_result_or_option(engine, init_ty);
// Generalize free type variables for let-polymorphism
engine.generalize(bound_ty) // L247 — unconditional, must be gated
};
Target code (replace L247):
} else {
// No annotation: infer the initializer type
let init_ty = infer_expr(engine, arena, *init);
// Detect closure self-capture (unchanged)
if let Some(name) = binding_name {
if matches!(arena.get_expr(*init).kind, ExprKind::Lambda { .. }) {
engine.rewrite_self_capture_errors(name, errors_before);
}
}
// Unwrap Result/Option for try semantics
let bound_ty = unwrap_result_or_option(engine, init_ty);
// Value Restriction: only non-capturing lambdas may be generalized.
// Spec: docs/ori_lang/v2026/spec/14-expressions.md:1224-1228
if should_generalize(arena, *init) {
engine.generalize(bound_ty)
} else {
bound_ty
}
};
Note: should_generalize tests the original init expression (before unwrapping),
not bound_ty. The unwrapping step changes the type, not the expression kind — a lambda
inside a try block would be let f = Ok(x -> x)? which parses as init = Ok(...), not
init = Lambda, so should_generalize correctly returns false for it. Plain lambdas
in a try block (let id = x -> x in a try { ... }) have init = Lambda { .. } and
unwrap_result_or_option is a no-op for non-Result/Option types, so the non-capturing
lambda case still reaches engine.generalize correctly.
Import should_generalize at the top of sequences.rs. The function is pub(super) in
blocks.rs and blocks::* is re-exported via pub(super) use blocks::* in mod.rs
(line 53), so should_generalize is already in scope in sequences.rs via
use super::* (or directly via the infer_expr imports it already uses).
Verify the import compiles cleanly.
-
TDD first — add
test_try_block_let_non_lambda_does_not_generalizetocompiler/ori_types/src/infer/expr/tests.rs(the shared test file for theexprmodule;sequences.rsis a flat file, not a directory, so there is nosequences/tests.rs). Test callsinfer_try_stmtdirectly to exercise the try-block let path without scope exit hiding the binding. -
Replace L247 (unconditional
engine.generalize(bound_ty)) with the conditional shown above, noting that the argument toshould_generalizeis*init, notbound_ty. -
Verify the import of
should_generalizecompiles (pub(super) use blocks::*inmod.rsalready exposes it tosequences.rswhen accessed viasuper::). Added explicit import in sequences.rs import list. -
Verify
test_let_polymorphism_for_lambdastill passes (no regression in the primary lambda polymorphism guarantee). -
Verify
test_try_block_let_non_lambda_does_not_generalizenow passes. -
Verify the grep criterion:
grep -n 'engine.generalize' compiler/ori_types/src/infer/expr/blocks.rs compiler/ori_types/src/infer/expr/sequences.rsreturns exactly 3 hits, each immediately following anif should_generalize(line. -
Verify all tests pass in debug and release: 804 tests pass, 0 failures in both debug and release.
-
Verify the full suite:
timeout 150 ./test-all.sh— 15325 pass, 0 failures. -
Subsection close-out (01.4) — MANDATORY before starting 01.R:
- All tasks above are
[x]and the subsection’s behavior is verified - Update this subsection’s
statusin section frontmatter tocomplete - Run
/improve-toolingretrospectively on THIS subsection — Retrospective 01.4: no tooling gaps. The try-block test needed restructuring to callinfer_try_stmtdirectly (try-block scope exit hides bindings), but this was a test-design issue, not a tooling gap. Thegrepcriterion for verifying all 3 sites are migrated was effective. - Run
/sync-claudeon THIS subsection — 01.4 is the final migration subsection. Updated: -typeck.md §GN-3— rewrote from “(target-only) all let-bindings generalizable” to shipped AST-based Value Restriction withshould_generalizeas SSOT. -typeck.md §EX-8step 4 — added conditional generalization viaGN-3. -typeck.md §BD-1let x = erow — added “conditionally generalize per GN-3”. Verified clean (no update needed): -CLAUDE.md— no generalization claims found. -canon.md §4.2— typed IR invariants unaffected (Value Restriction doesn’t introduce Vars; it prevents premature generalization). -docs/compiler/design/05-type-system/type-inference.md— describes the lambda case specifically, which is still correct. - Repo hygiene check —
diagnostics/repo-hygiene.sh --checkclean.
- All tasks above are
01.R Third Party Review Findings
-
[TPR-01-001-codex][high]section-02-validator-module.md:265— GAP: Recurse into applied nominal types in the validator. Resolved: Valid finding, filed in Section 02’s scope. Section 02 must walkTag::Appliedarguments. -
[TPR-01-002-codex][high]section-01-value-restriction.md:120— GAP:body_captures_outernote said “over-approximation” / “safe direction” but the code is an under-approximation (unsafe direction). Resolved: Fixed on 2026-04-14. Corrected the note to accurately describe the under-approximation and the codegen safety net. -
[TPR-01-003-codex][medium]section-05-test-matrix.md:121— GAP: Missing negative pin for direct capturing lambda. Resolved: Fixed on 2026-04-14. Addedtest_capturing_lambda_does_not_generalizeto 01.1 test stubs and 01.N completion checklist. -
[TPR-01-004-codex][medium]section-03-bodies-pass-integration.md:255— DRIFT: Placeholder span lookup in Section 03. Resolved: Valid finding, filed in Section 03’s scope. Section 03 must useExprId::raw() as usize. -
[TPR-01-005-codex][low]00-overview.md:27— DRIFT: Overview/index stale test paths. Resolved: Fixed on 2026-04-14. Updated overview L27 and L179 fromblocks/tests.rstotests.rs. -
[TPR-01-001-gemini][medium]section-05-test-matrix.md:120— Add negative pin tests to Section 05.1.1. Resolved: Valid finding, filed in Section 05’s scope. Section 05 must include the negative pin tests. -
[TPR-01-002-gemini][medium]section-01-value-restriction.md:320— Self-capture error rewriting DRY violation (3-site duplication adjacent to generalization). Resolved: Valid observation. The self-capture rewriting block is duplicated at 3 sites but is unchanged by this plan. Tracked as a follow-up: this is an existingimpl-hygiene.md §Algorithmic DRYviolation that should be addressed when Section 01’sshould_generalizeextraction proves the pattern works. -
[TPR-01-006-codex][medium]compiler/ori_types/src/infer/expr/tests.rs:3532— GAP: Missing positive lambda pins for infer_let and infer_try_stmt. Resolved: Fixed on 2026-04-14. Addedtest_let_expr_lambda_generalizesandtest_try_block_let_lambda_generalizesto pin generalization IS preserved for non-capturing lambdas through both migrated paths. -
[TPR-01-003-gemini][medium]compiler/ori_types/src/infer/expr/tests.rs:3098— GAP: Missing positive semantic pins for lambda generalization in infer_let and infer_try_stmt. Resolved: Fixed on 2026-04-14. Same fix as [TPR-01-006-codex] (independent agreement — both reviewers flagged the same gap). -
[TPR-01-004-gemini][low]compiler/ori_types/src/infer/expr/tests.rs:3017— NAMING: should_generalize test names use wrong shape. Resolved: Fixed on 2026-04-14. Renamed 3 tests to<subject>_<scenario>_<expected>shape. -
[TPR-01-005-gemini][low].claude/rules/typeck.md:418— DRIFT: EX-8 steps 3-4 ordering swapped vs implementation. Resolved: Fixed on 2026-04-14. Swapped steps 3 and 4 to match code: generalize at elevated rank, then exit scope.
01.N Completion Checklist
- Single
pub(super) fn should_generalizeexists inblocks.rs— verified: exactly 1 hit - All 3
engine.generalizecalls gated byif should_generalize(...)— verified: exactly 3 hits - No inlined Lambda-detection logic duplicating
should_generalize’s behavior remains — verified:ExprKind::Lambdain blocks.rs appears only at self-capture detection,should_generalize’s body, andbody_captures_outer -
test_let_polymorphism_for_lambdapasses incompiler/ori_types/src/infer/expr/tests.rs -
test_empty_list_let_binding_does_not_generalize_element_varpasses -
test_let_expr_non_lambda_does_not_generalizepasses -
test_try_block_let_non_lambda_does_not_generalizepasses - Negative pin tests for intentionally monomorphic patterns pass (all in
tests.rs):test_block_wrapped_lambda_does_not_generalizetest_variable_alias_does_not_generalizetest_conditional_lambda_does_not_generalizetest_capturing_lambda_does_not_generalize
- All tests live in
compiler/ori_types/src/infer/expr/tests.rs— noblocks/tests.rsorsequences/tests.rscreated - Plan annotation cleanup: 0 stale-resolved annotations. 8 active-scaffolding are from active plans (expected).
# Plandoc comment inshould_generalizeis intentional scaffolding for Section 07 close-out. - All intermediate subsection close-out tasks complete (01.1–01.4)
- Plan sync — plan metadata updated (details below after overview/index updates)
-
timeout 150 ./test-all.shgreen (debug build) — 15325 pass, 0 fail -
timeout 150 cargo test --release -p ori_typesgreen (release build) — 804 pass -
timeout 150 ./clippy-all.shclean -
/tpr-reviewpassed (final, full-section) — Round 1: 4 findings (codex/gemini independently flagged same matrix gap + 2 minor naming/doc drift). Round 2: clean (0 actionable). Both reviewers verified the fixes against actual code. -
/impl-hygiene-reviewpassed — 4 NAMING findings on pre-existing weak test names resolved in-pass (renamed). 4 BLOAT findings on pre-existing nesting/length filed as BUG-02-005 (architectural refactor, scope exceeds Value Restriction migration). SSOT verified: should_generalize is the single canonical home for the generalization decision. -
/improve-toolingsection-close sweep — all 4 subsections have documented retrospectives (01.1: no gaps; 01.2: no gaps; 01.3: no gaps; 01.4: no gaps). No cross-subsection patterns required new tooling — all subsections were mechanical single-function migrations with identical workflows. Section-close sweep: per-subsection retrospectives covered everything; no cross-subsection patterns required new tooling. -
/sync-claudesection-close doc sync — completed during 01.4 close-out:typeck.md §GN-3— updated from “(target-only) all generalizable” to shipped Value Restrictiontypeck.md §EX-8step 4 — added conditional generalization via GN-3typeck.md §BD-1— added conditional generalization noteCLAUDE.md— no generalization claims found (clean)canon.md §4.2— typed IR invariants unaffected (clean)docs/compiler/design/05-type-system/— lambda example still correct (clean)
- Repo hygiene check —
diagnostics/repo-hygiene.sh --checkclean
Exit Criteria: All 4 subsections complete. Single should_generalize SSOT in blocks.rs. Three engine.generalize calls each gated by if should_generalize(...). Eight new tests pass: 4 positive/semantic pins (lambda polymorphism, empty list, let-expr, try-block) + 4 negative pins (block-wrapped, aliased, conditional, capturing lambda). test_let_polymorphism_for_lambda passes unchanged (semantic pin). timeout 150 ./test-all.sh green in debug and release. /tpr-review and /impl-hygiene-review clean. typeck.md §GN-3 updated to reflect the Value Restriction policy. Section 03 can now assume the 3 generalization sites are correctly gated and that empty-list element Vars flow as Unbound Tag::Var into the validator.
Retrospective: Findings surfaced by §03.N /impl-hygiene-review (2026-04-18)
Why these are retrospectives, not new sections. Running /impl-hygiene-review at §03.N close-out with scope=active-work-arc scanned code authored by §01/§02 and surfaced 5 Critical + 2 Major + 3 Minor findings that §01’s own close-out hygiene review missed. Per CLAUDE.md §Ownership & Deferral “Plan-blocker bugs belong IN the plan — NEVER sibling fix files”, these are absorbed into §01 rather than filed as sibling /add-bug entries. §01’s status flipped from complete back to in-progress until these subsections land.
Auto-fixed under user pre-approval (memory feedback_auto_fix_cleanup.md): F9-F12 (stale plan annotations referencing BUG-02-008, BUG-04-074, BUG-04-084, TPR Round 1 Codex-F2, Plan §02.4 T1) were cleaned by plan-annotations.py --fix --apply during the review’s Phase 0 static-analysis pass. 8 automatic fixes + 5 manual edits (removed bug IDs from assertion string literals and doc comments in empty_container.rs, check/bodies/tests.rs, check/validators/tests.rs, infer/mod.rs). Verified via re-scan: 0 stale matches remaining in scoped files.
01.R-HYGIENE Retrospective: body_captures_outer soundness (F1+F7)
Severity: Critical (F1) + Major (F7)
Source: /impl-hygiene-review §03.N close-out sweep (2026-04-18)
File: compiler/ori_types/src/infer/expr/blocks.rs:243-302
F1 finding summary: The body_captures_outer(arena, expr, param_names, outer_vars) function’s docstring claims “Over-approximates captures (any non-param, non-literal name counts) — conservative: we may skip generalization for some non-capturing lambdas, but never incorrectly generalize a capturing one.” The inline comment admits the opposite: the _ => false match-arm wildcard returns false for every unrecognized ExprKind, which means “this ExprKind has no captures”, which means a capturing lambda in an unrecognized variant is permitted to generalize. This is a soundness inversion: the conservative direction should be _ => true (unknown = assume capture = skip generalization), but the code does the opposite.
F7 finding summary: Beyond the conservative-default inversion, the specific matched arms have blind spots:
ExprKind::Call { func, args, .. }: onlyfuncis recursed;argsslice is in_ => falseExprKind::MethodCall { receiver, args, .. }: onlyreceiver;argsuncheckedExprKind::Block(block_idx): block body not descendedExprKind::List(elements)/ExprKind::Map(entries): elements not scannedExprKind::Struct { fields, .. }: field initializers not scannedExprKind::Match { arms, .. }: arm bodies not scanned
A lambda () -> map.get("key") passed to list.map(...) has map captured via the arg slot, which body_captures_outer does not descend into — the capturing lambda escapes detection and is unsoundly generalized.
Invariant Anchoring (per CLAUDE.md §Invariant-Anchoring Before Test-Chasing)
- System invariant:
typeck.md §GN-3Value Restriction — only direct non-capturing lambda initializers generalize; capturing lambdas stay monomorphic. Soundness depends onbody_captures_outerbeing conservative in the direction of preventing generalization (over-approximate captures), not the direction of allowing it. - Downstream consumer: The type checker’s
should_generalizedecision feeds the inference engine’s generalization step (GN-1). Generalizing a capturing lambda produces a polymorphic scheme whose bound variables include captured variables from an outer scope — when instantiated at a use site, those variables are fresh and unrelated to the outer captures, silently producing incorrect types. - If tests fail after the fix: fix the code-under-test, NOT
body_captures_outer. The current_ => falsearm is the bug; flipping it to_ => truewill correctly reject capturing lambdas that previously passed through, and the test suite must then be updated to pin the correct behavior.
Fix Plan (TDD Matrix Required — MANDATORY)
- TDD matrix first — write failing tests before any code change:
-
test_capturing_lambda_via_call_arg_does_not_generalize— capture via Call args -
test_capturing_lambda_via_method_arg_does_not_generalize— capture via MethodCall args -
test_capturing_lambda_via_block_does_not_generalize— capture via Block body -
test_capturing_lambda_via_list_literal_does_not_generalize— capture via List -
test_capturing_lambda_via_map_literal_does_not_generalize— capture via Map -
test_capturing_lambda_via_struct_literal_does_not_generalize— capture via Struct -
test_capturing_lambda_via_match_arm_does_not_generalize— capture via Match arm body - Verified all 7 tests FAILED pre-fix (same panic at
assert_lambda_with_body_does_not_generalize), confirming the_ => falsewildcard bug.
-
- Architectural fix — flipped the conservative default and enumerated leaf arms in
body_captures_outer:- Changed
_ => falseto_ => true— unknown shapes assume capture. - Explicit
false-returning arms for verified leafExprKinds (literals,Unit,None,HashLength,Const,FunctionRef,TemplateFull,Error). - Complete recursion for every compound
ExprKindwith child expressions:Callwalks func + args (previously only walked func);MethodCallwalks receiver + args; plusBlock,List,Tuple,Map,Struct,Match,If,Range,For,Loop,Assign,WithCapability,Index,Break,Continue,Let,Await,Try,Unsafe,Cast,Ok,Err,Some,Field,Unary,Binary,Lambda(descends with inner params). - Canonical-IR sugar variants also walked (
CallNamed,MethodCallNamed,ListWithSpread,MapWithSpread,StructWithSpread,TemplateLiteral) — the sugar forms appear at typeck time (beforeori_canoneliminates them), so they must be decoded here for the Value Restriction check to be complete. -
SelfRef => true—selfinside a lambda body captures the enclosing method’s receiver.
- Changed
-
Identarm now honorsouter_vars— the architectural key insight. A bare identifier is a capture iff!param_names.contains(n) && outer_vars.contains(n).outer_varsis the set of LEXICALLY-bound names (function params, locallets, loop/match pattern binders), NOT module-level references (prelude, imports, same-module signatures). This is critical: without it, a lambda likexs -> len(collection: xs)would falsely flaglenas a capture and block generalization. - Threaded
outer_varsthrough the capture-analysis signature —should_generalize(arena, init, outer_vars)+body_captures_outer(arena, id, param_names, outer_vars). All 3 let-binding call sites (infer_block,infer_let,infer_try_stmt) now callcollect_outer_vars(engine)beforeshould_generalize. -
InferEngine.module_scope_snapshot: Option<FxHashSet<Name>>— new field populated at engine creation time viaset_module_scope_snapshot, called fromModuleChecker::create_engine_with_env. Captures the module-scope name set (prelude imports viaimport_env, same-module function signatures, test names) before any function-body lexical binder has run.engine.collect_lexical_outer()subtracts this snapshot from the current env’snames()to yield exactly the lexical-outer subset. - Update docstring —
body_captures_outer’s docstring now correctly describes the conservative-direction invariant (“false negative is a type-soundness violation; false positive costs one missed polymorphic generalization”); the contradictory inline “codegen will catch it” comment was removed. - Verify all 7 new tests PASS — all 7
test_capturing_lambda_via_*pass;test_should_generalize_capturing_lambda_returns_false+test_should_generalize_non_capturing_lambda_returns_truealso pass with the new signature (updated to pass appropriateouter_varssets via theouter(&[...])helper). - Verify all pre-existing tests STILL pass —
test_let_polymorphism_for_lambda_produces_functionpasses unchanged; fullori_typessuite: 839 passed / 0 failed. Fulltest-all.sh: 16381 passed / 844 failed / 160 skipped — exactly +7 passes over cache baseline (the 7 new tests) with 0 new failures. - Regression-check on
tests/spec/inference/generalized_var_resolution.ori— a 6-test file that hit the lambda-body-CallNamed path (xs -> len(collection: xs)). Pre-fix: 6 pass. First iteration of the fix: 6 fail (false positive —lenwas being flagged as a capture because it was reachable via env.names() throughimport_env’s parent chain). Final fix withmodule_scope_snapshotsubtraction: 6 pass. - Dual-execution verification — DEFERRED to Retrospective Close-Out
- [ ]“Re-run/tpr-reviewon the combined diff” + “Re-run/impl-hygiene-reviewwith scope=active-work-arc” (anchors in §Retrospective Close-Out at end of this section). The fix is purely a typeck-phaseTag::Schemeassignment change; it does not alter eval or LLVM backend semantics on any program that already type-checked, and the +7 new tests are#[test]Rust units that already exercise the in-process path. Dual-exec parity coverage will be re-validated once all 4 retrospectives land and the combined reviewer re-run executes. - Update
typeck.md §GN-3— amended to reflect the 3-argshould_generalizesignature and theouter_varslexical-outer set. -
/tpr-reviewclean — DEFERRED to Retrospective Close-Out- [ ]“Re-run/tpr-reviewon the combined diff — expect clean”. Per-subsection review would be wasted reviewer cycles because the Close-Out batches review across 01.R-HYGIENE + 01.R-DRY + 01.R-SIDE-LOGIC + 01.R-TEST-HYGIENE in a single pass. -
/impl-hygiene-reviewclean — DEFERRED to Retrospective Close-Out- [ ]“Re-run/impl-hygiene-reviewwith scope=active-work-arc — expect zero Critical/Major findings”. Same batching rationale.
01.R-DRY Retrospective: algorithmic duplication (F4+F5)
Severity: Critical (both)
Source: /impl-hygiene-review §03.N close-out sweep (2026-04-18)
F4: InferEngine constructor duplication
File: compiler/ori_types/src/infer/mod.rs:~50-110
Summary: InferEngine::new(pool) and InferEngine::with_env(pool, env) contain an identical ~27-line struct literal differing only in the env field (TypeEnv::new() vs caller-supplied env). Adding any new field to InferEngine requires editing both constructors; missing one silently produces a divergent state.
Fix plan:
- Extract
fn build(pool: &'pool mut Pool, env: TypeEnv) -> Selfaspub(super)(orfnif crate-local access suffices) with the single struct literal — landed as privatefn buildincompiler/ori_types/src/infer/mod.rs(§Visibility:new/with_envare same-impl callers; private minimizes pub surface per impl-hygiene.md §Visibility). -
new(pool)callsbuild(pool, TypeEnv::new()) -
with_env(pool, env)callsbuild(pool, env) - Verify no behavioral change — both callers produce identical state to pre-fix (verified: full
ori_typessuite 840/0 debug + release; fulltest-all.sh16382/844/160 —+1pass over pre-F4 baseline, zero new failures). - Add a regression test: construct via both entry points and assert all-field equality — landed as
infer_engine_new_and_with_env_produce_identical_default_stateincompiler/ori_types/src/infer/tests.rsusing anEngineSnapshotrecord that compares every publicly-observable default-state accessor. Field-by-fieldPartialEqonInferEngineis impractical (borrowedPool, internalUnifyEngine) — behavioral parity via snapshot is equivalent for the SSOT invariant and catches the “new field added to one constructor only” failure mode identically. - Follow-up: if new fields are added, only
build()requires editing (confirmed structurally —newandwith_envare 1-line delegations).
F5: should_generalize + generalize 3x duplication
Files: compiler/ori_types/src/infer/expr/blocks.rs:77-81, 158-163; compiler/ori_types/src/infer/expr/sequences.rs:249-253
Summary: At all 3 let-binding generalization sites, the same 3-line pattern appears:
if should_generalize(arena, *init) {
engine.generalize(ty)
} else {
ty
}
The only variation is whether ty is the raw inferred type or bound_ty (after unwrap_result_or_option). The duplication means: a future change to generalization policy must be edited at 3 sites; a typo silently diverges one site’s behavior.
Fix plan:
- Extract
pub(super) fn maybe_generalize(engine: &mut InferEngine<'_>, arena: &ExprArena, init: ExprId, ty: Idx) -> Idxinblocks.rs(co-located withshould_generalize) — landed atcompiler/ori_types/src/infer/expr/blocks.rs:303. - Body:
if should_generalize(arena, init, &outer_vars) { engine.generalize(ty) } else { ty }— includescollect_outer_vars(engine)internally so callers don’t repeat it. (Signature extends the plan with theouter_varsparameter threaded through by §01.R-HYGIENE;maybe_generalizesubsumes that plumbing.) - Replace all 3 call sites to use
maybe_generalize(engine, arena, *init, ty):infer_blockblock-statement let atblocks.rs:96infer_letExprKind::Let dispatch atblocks.rs:174infer_try_stmttry-block let atsequences.rs:250
- At
sequences.rs:250, preserve the semantically significant comment (“should_generalize tests the original init expression”) — landed both on the call site as a 3-line block and inmaybe_generalize’s docstring (“Important:initis the original initializer expression, NOT a derived type…”). - Verify no behavioral change — all 8 existing §01 tests (positive + negative pins) and all 7 §01.R-HYGIENE tests continue to pass unchanged. Debug + release full
ori_types: 840/0. Fulltest-all.sh: 16382 pass / 844 fail / 160 skip —+1pass over pre-F4 baseline (the new F4 pin only; no new failures). - Decide: demote
should_generalizevisibility (sincemaybe_generalizeis the new SSOT) OR keep bothpub(super)for granular access. Decision: keep bothpub(super). Rationale: they have distinct SSOT domains —should_generalizeowns the decision predicate (pure, testable in isolation, consumed by the threetest_should_generalize_*tests attests.rs:3026/3053/3077);maybe_generalizeowns the apply-the-decision pattern (imperative, engine-mutating, 3 call sites). Demotingshould_generalizewould break the existing test suite without a behavioral win. Documented in both docstrings (“This is the SSOT for applying the decision, alongsideshould_generalizewhich is the SSOT for making it”).
Exit criteria for 01.R-DRY: F4 and F5 fixes landed. timeout 150 ./test-all.sh green (0 new failures over cache baseline). All pre-existing §01 tests still pass. /tpr-review DEFERRED to Retrospective Close-Out per the - [ ] anchor at end of this section (batched across 01.R-HYGIENE + 01.R-DRY + 01.R-SIDE-LOGIC + 01.R-TEST-HYGIENE — same rationale as §01.R-HYGIENE’s deferral).
Retrospective on 01.R-DRY itself
/improve-toolingretrospective (01.R-DRY): no tooling gaps surfaced. Both extractions were mechanical (one struct-literal dedup, one 3-line pattern dedup) and the existing toolchain caught every issue promptly — LSP rejected theTypeEnv::is_empty()accessor I first reached for (no such method); thegrep 'engine.generalize'/grep 'maybe_generalize'post-checks confirmed the exact 3-site-migration shape the section file documented as success criteria. The intelligence graph’scallersqueries for the extracted symbols returned empty (same-crate-private callers aren’t indexed as CALLS edges), so the authoritative call-site list came from the section file itself — as designed. No new diagnostic scripts, flags, or tests needed.- Repo hygiene: verified via
diagnostics/repo-hygiene.sh --check(see close-out task; expected clean modulo the pre-existing/tmp/ori-tpr-*/scratch-dir noise noted by the scanner at session start).
01.R-SIDE-LOGIC Retrospective: dispatch-module side logic (F2+F3)
Severity: Critical (both, downgraded to Major per Phase 4 cross-check — reviewer noted check_collect_to_set is clearly critical, format functions are borderline)
Source: /impl-hygiene-review §03.N close-out sweep (2026-04-18)
File: compiler/ori_types/src/infer/expr/mod.rs
F2 summary: check_interpolation_printable (~40 lines, ~L380-415) and validate_format_spec (~52 lines, ~L424-475) are substantive validation functions (format spec parsing, alignment/sign/type checking, Printable trait resolution) living in the general dispatch module rather than a dedicated submodule. Per impl-hygiene.md §Side-Logic Rule, non-trivial implementation logic must live in a named submodule.
F3 summary: check_collect_to_set (~28 lines, ~L345-373) handles the Collect::from_iter → Set<T> bidirectional check — Set-specific builtin logic embedded in the general dispatch module instead of in the collections submodule.
Fix plan
- Create
compiler/ori_types/src/infer/expr/format.rs(new submodule) - Move
check_interpolation_printableandvalidate_format_specintoformat.rs - Re-export from
infer/expr/mod.rsvia explicit named re-export:pub(crate) use format::{check_interpolation_printable, validate_format_spec};— notepub(crate)rather thanpub(super)because the re-export pulls the item up to theinfer::level (the submodule functions must be visible there for the re-export to compile;pub(super)at the submodule level means onlyexpr::, narrower than the re-export target). The existing sibling glob re-exports (pub(super) use blocks::*etc.) will be purged in 01.R-TEST-HYGIENE F14. - Move
check_collect_to_setintocompiler/ori_types/src/infer/expr/collections.rs(existing submodule) - Re-export from
infer/expr/mod.rs:pub(super) use collections::check_collect_to_set;(function declaredpub(crate)incollections.rsfor the visibility reason above) - Update the dispatch call sites in
infer_expr_innerto reference the new paths — call sites unchanged (resolve via re-exports); only thefnbodies inmod.rswere deleted. Single-path dispatch preserved. - Verify no behavioral change —
timeout 150 cargo test -p ori_types840 passed / 0 failed;timeout 150 ./test-all.sh16382 passed / 844 failed / 160 skipped — exactly +8 passes over cache baseline (from 01.R-HYGIENE + 01.R-DRY commits post-cache), 0 new failures. Pure relocation is observationally a no-op. -
/tpr-reviewclean — DEFERRED to Retrospective Close-Out- [ ]“Re-run/tpr-reviewon the combined diff”. Same batching rationale as §01.R-HYGIENE / §01.R-DRY — per-subsection review would be wasted reviewer cycles when the Close-Out covers all four retrospectives in a single pass.
Exit criteria for 01.R-SIDE-LOGIC: 3 functions relocated to appropriate submodules. Dispatch module’s mod.rs contains routing only, no implementation. timeout 150 ./test-all.sh green (zero new failures vs cache baseline).
Retrospective on 01.R-SIDE-LOGIC itself
/improve-toolingretrospective (01.R-SIDE-LOGIC): no tooling gaps surfaced. The relocation was mechanical — three functions moved across submodule boundaries with identical signatures. LSP’s dead-code diagnostics flagged each transient state (function moved but not yet re-exported; function re-exported but call site still resolves locally) at exactly the right moment, confirming the name-resolution path before tests ran. One visibility gotcha caught byrustcdirectly:pub(super)at the submodule level is insufficient for apub(super) use submodule::fnre-export atmod.rslevel — the submodule item needspub(crate)(orpub(in crate::infer)) for the re-export’s target visibility (infer::) to be legal. ErrorE0364was clear and actionable; no tooling gap.- Repo hygiene: verified via
diagnostics/repo-hygiene.sh --check(see close-out task).
01.R-TEST-HYGIENE Retrospective: test/import/name drift (F6+F8+F13+F14)
Severity: Major (F6) + Minor (F8, F13, F14)
Source: /impl-hygiene-review §03.N close-out sweep (2026-04-18)
F6: parse_and_check test helper duplicate
File: compiler/ori_types/src/check/bodies/tests.rs:~1-40
Summary: parse_and_check in bodies/tests.rs carries an inline comment acknowledging “copied from check/api/tests.rs — deduplication deferred”. The “deferred” comment is itself a hygiene violation per impl-hygiene.md §Algorithmic DRY and CLAUDE.md banned phrases (“tracked for later” is banned without a concrete anchor).
Fix: Move parse_and_check to a shared test utility location. Options:
- Option A (Recommended):
compiler/ori_types/src/check/test_utils.rswith#[cfg(test)] pub(crate)— bothbodies/tests.rsandapi/tests.rsimport from there - Option B: Keep the helper in
api/tests.rsand havebodies/tests.rsimport viause super::super::api::tests::parse_and_check;— less clean but minimal reorganization (rejected in favor of A) - Remove the duplicate + the deferral comment
- Verify all pre-existing tests in both files still pass
F8: enter_scope vs enter_rank_scope inconsistency
File: compiler/ori_types/src/infer/expr/blocks.rs (infer_let vs block-stmt let in infer_block)
Summary: infer_let at L124/L168 uses engine.enter_scope() / engine.exit_scope(); the block-statement let arm in infer_block at L41/L85 uses engine.enter_rank_scope() / engine.exit_rank_scope(). These are separate APIs. If they are semantically equivalent for rank elevation, the naming should be unified. If they differ in unification-engine behavior, that difference needs to be documented and the correct one selected per context.
Fix:
- Investigate
InferEngine::enter_scopevsenter_rank_scopeimplementations — distinct APIs:enter_scopepushes rank + env.child;enter_rank_scopepushes rank only (pertypeck.md §SG-4/ §SG-5) - Document the semantic difference (if any) in both methods’ docstrings — existing docstrings at
infer/mod.rs:452-459, 477-481already describe the distinction - Unify the two let-binding sites to use the correct API for let-polymorphism (both should elevate rank per
typeck.md §GN-1, §SC-2) —infer_letmigrated fromenter_scope/exit_scope→enter_rank_scope/exit_rank_scope, matchinginfer_blockblock-stmt let andinfer_try_stmttry-block let - If the APIs are semantically identical, delete one and have the other forward — NOT identical; both retained
- If they are semantically distinct, rename for clarity (e.g.,
enter_env_scopefor env-only,enter_rank_scopefor rank-only) — current names already express the distinction; callsite comments added instead to explain the choice - Verify no behavioral change in any existing test —
test-all.shdelta vs cache baseline: 0 new failures (844/844 pre-existing known-failing)
F13: Test naming convention
File: compiler/ori_types/src/check/bodies/tests.rs
Summary: check_empty_module_bodies test name violates <subject>_<scenario>_<expected> convention per impl-hygiene.md §Test Function Naming.
Fix:
- Rename to:
check_module_with_no_function_bodies_produces_no_errors(three-partsubject_scenario_expectedshape perimpl-hygiene.md §Test Function Naming) - Update any doc comments to match — no doc comments referenced the old name
- Verify test still passes under new name
F14: Glob re-exports in mod.rs
File: compiler/ori_types/src/infer/expr/mod.rs:53-63
Summary: pub(super) use blocks::*; and sibling glob re-exports across calls, collections, etc. violate impl-hygiene.md §Import Hygiene no-glob rule. The comment (“for tests and sibling access”) is not an exemption — #[cfg(test)] is the test context, not the module itself.
Fix:
- For each
pub(super) use <submodule>::*, replace with explicit named re-exports listing every symbol actually used by the dispatch module or its sibling modules - Use
cargo checkand grep to enumerate the actually-consumed symbols — intelligence-graph queries (callers/symbols/file-symbols) + iterativecargo checkdrove the explicit list - After the replacement, remove the “for tests and sibling access” comment (no longer needed) — replaced with a brief explanatory header
- Verify no compile errors or behavioral changes —
cargo check --testsclean workspace-wide;test-all.shdelta vs baseline: 0 new failures
Exit criteria for 01.R-TEST-HYGIENE: F6 test helper deduplicated, F8 API consistency resolved, F13 test renamed, F14 globs replaced with explicit re-exports. timeout 150 ./test-all.sh green.
Retrospective on 01.R-TEST-HYGIENE itself
/improve-toolingretrospective (01.R-TEST-HYGIENE): no tooling gaps surfaced. F6+F13 were pure Edits against an existing module structure; F14’s 4-roundcargo check --testsiteration was rustc earning its keep (each round surfaced a distinct missing name, with E0364/E0432/E0425 messages that already included the suggested fix). F8’s scope-API unification was driven byscripts/intel-query.sh callers enter_rank_scopefollowed by readinginfer/mod.rs:440-493— the graph answered the “is this actually used from siblings?” question in one call.test-all.shbaseline diff (16374 → 16382 passed, 844/844 failed, 160/160 skipped) was a single mental subtraction; a--vs-baselineflag was considered and rejected (no recurring pain,state.sh showalready surfaces the cached counts). No new scripts created; nodiagnostics/README.mdupdate required.- Repo hygiene: verified via
diagnostics/repo-hygiene.sh --check(see close-out task below).
Retrospective Close-Out (after 01.R-HYGIENE + 01.R-DRY + 01.R-SIDE-LOGIC + 01.R-TEST-HYGIENE land)
- Re-run
/impl-hygiene-reviewwithscope=active-work-arc— expect zero Critical/Major findings. Result: 0 Critical / 0 Major; 2 pre-existing minor/note findings filed as BUG-02-011 (infer/mod.rs864-line BLOAT) and BUG-02-012 (ModuleCheckerdual-constructor gap); 1 arc-introduced DRIFT (stale plan annotations in 3 test files) auto-fixed in-place. - Re-run
/tpr-reviewon the combined diff — expect clean. Result: round 0 — codex 2 verified medium findings (side-logic leaks ininfer/expr/mod.rs: template-literal iteration + Set-collect dispatch not routing-only); extractedinfer_template_literalintoformat.rsandcheck_collect_method_callintocollections.rs; dead re-exports of now-internal helpers pruned. Round 1 — both reviewers clean; dual-source consensus reached. - Update
typeck.md §GN-3ifbody_captures_outersemantics change substantively in 01.R-HYGIENE. Result:body_captures_outersemantics (outer_vars + conservative_ => truedefault) already current. Updated the rule to reflect the 01.R-DRYmaybe_generalizeSSOT (should_generalize= policy decision;maybe_generalize= applying the decision) — call sites now invokemaybe_generalizerather than inliningcollect_outer_vars+if should_generalize { ... }. - Re-flip §01
status: in-progress→status: completein frontmatter. - Update
00-overview.mdQuick Reference row for §01 back toComplete. - Update
00-overview.mdImplementation Sequence Phase 1 marker back toCOMPLETE. - Refresh
diagnostics/state.shcache — handled by/commit-pushStep 8 auto-refresh (--sha-only). - Commit via
/commit-push.