diff --git a/docs/plans/ctt.2.md b/docs/plans/ctt.2.md new file mode 100644 index 0000000..c294ebe --- /dev/null +++ b/docs/plans/ctt.2.md @@ -0,0 +1,759 @@ +# ctt.2 — `Registry.type_def_module` re-key — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-12-ct-tidy.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Re-key `Registry.type_def_module` from +`BTreeMap` (bare-name keyed, silently +collision-prone) to `BTreeMap<(String, String), String>` (keyed by +`(owning_module, bare_name)`); thread `caller_module: &str` through +`normalize_type_for_registry` and its public peer +`Registry::normalize_type_for_lookup`; update every consumer site; +add a regression fixture pair that exercises the bare-name +collision shape. + +**Architecture:** RED-first. Task 1 lands a two-module fixture pair +where both modules declare `type Foo` with distinct ctors plus a +class-method instance each; today this trips +`DuplicateInstance` because both instances canonicalise to +`.Foo` after the bare-name overwrite — test asserts +successful load + both instances retrievable in the registry, so +it goes RED today. Task 2 applies the re-key in one atomic edit +pass touching the field, the doc-comment, both pass-1 collection +sites, both pass-2 lookup sites, both signatures plus body of +`normalize_type_for_registry` / `Registry::normalize_type_for_lookup`, +the four `ailang-check` consumer sites at lib.rs and mono.rs, and +the in-`workspace.rs` test at line 2523. Task 3 confirms the new +test goes GREEN and the full `cargo test --workspace` stays green. + +**Tech Stack:** `ailang-core` (`Registry`, `workspace.rs`), +`ailang-check` (`lib.rs:1640` instance lookup at NoInstance-check; +`mono.rs:121, 624, 1175` instance lookups in monomorphisation). + +**Files this plan creates or modifies:** + +- Create: `examples/ctt2_collision_main.ail.json` — entry module: + imports `ctt2_collision_lib`; declares `type Foo = MkMain`; + declares class `MyC a { op : a -> Int }`; declares + `instance MyC main.Foo { op = lam _. 1 }`. +- Create: `examples/ctt2_collision_lib.ail.json` — library module: + imports the entry-module's class via qualifier + (`main.MyC`); declares `type Foo = MkLib`; + declares `instance main.MyC lib.Foo { op = lam _. 2 }`. +- Create: `crates/ailang-core/tests/ctt2_registry_rekey.rs` — + loads the fixture pair via `load_workspace`, asserts + `ws.registry.entries` contains two `(MyC, type_hash)` entries + with distinct keys (one for `main.Foo`, one for `lib.Foo`), each + pointing to its own defining-module. +- Modify: `crates/ailang-core/src/workspace.rs:97-106` — doc-comment + (`:97-105`) replaced with new invariant; field type at `:106` + changes to `BTreeMap<(String, String), String>`. +- Modify: `crates/ailang-core/src/workspace.rs:121-123` — + `Registry::normalize_type_for_lookup` gains `caller_module: &str`. +- Modify: `crates/ailang-core/src/workspace.rs:537, 546-548` — + pass-1 collection: declaration changes to tuple-keyed map; insert + uses `(mod_name.clone(), t.name.clone())` as the key. +- Modify: `crates/ailang-core/src/workspace.rs:656-659` — pass-2 + coherence-check lookup uses `(mod_name.clone(), type_repr.clone())`. +- Modify: `crates/ailang-core/src/workspace.rs:678-680` — pass-2 + canonical-form normalisation passes `mod_name` as `caller_module`. +- Modify: `crates/ailang-core/src/workspace.rs:879-928` — + `normalize_type_for_registry` signature gains `caller_module: &str`; + the `Type::Con` arm's bare-name lookup uses the tuple key; + recursive calls thread `caller_module` through; doc-comment at + `:879-883` updated. +- Modify: `crates/ailang-core/src/workspace.rs:2523-2566` — the + existing `ct1_5a_normalize_recurses_into_forall_constraints` test + updates its inline map type, insert key, and call to pass a + synthetic caller module. +- Modify: `crates/ailang-check/src/lib.rs:1639-1640` — + `normalize_type_for_lookup(&r_ty)` becomes + `normalize_type_for_lookup(env.current_module.as_str(), &r_ty)`. +- Modify: `crates/ailang-check/src/mono.rs:120-121` — + `ws_owned.registry.normalize_type_for_lookup(type_)` becomes + `ws_owned.registry.normalize_type_for_lookup(defining_module.as_str(), type_)`. +- Modify: `crates/ailang-check/src/mono.rs:623-624` — + `env.workspace_registry.normalize_type_for_lookup(&r_ty)` becomes + `env.workspace_registry.normalize_type_for_lookup(module_name, &r_ty)`. +- Modify: `crates/ailang-check/src/mono.rs:1174-1175` — + `env.workspace_registry.normalize_type_for_lookup(&r_ty)` becomes + `env.workspace_registry.normalize_type_for_lookup(module_name, &r_ty)`. + +**Design notes (settled by Boss pre-plan):** + +- At `workspace.rs:656-659`, `caller_module` is `mod_name` (the + instance's own module). Rationale: "from this instance's view, + where is the type it's on defined?" Under coherence, that is + either the instance's module (when type lives there) or the + class's module (matched via the existing `class_mod == + mod_name` leg). The `` fallback is + preserved. +- At `mono.rs:121`, `caller_module` is `defining_module` (the + instance's defining module, lifted from `MonoTarget::ClassMethod`). + Rationale: post-ct.1 the `type_` field is already canonical-form + (qualified), so the bare-name lookup branch at + `normalize_type_for_registry`'s line 892-equivalent is dead at + this site; the threaded `caller_module` is a structurally-correct + no-op. Extending `MonoTarget` with a new field would be + ctt.2-out-of-scope plumbing. +- At `mono.rs:624` and `:1175`, `caller_module` is `module_name` + (the enclosing function parameter to `collect_mono_targets` / + `collect_residuals_ordered`). Rationale: that is the module the + call was authored in. +- At `lib.rs:1640`, `caller_module` is `env.current_module.as_str()`. + Rationale: `env.current_module` carries the active per-module + overlay during `check_fn`, set at `lib.rs:1342-1357`'s caller. + +--- + +### Task 1: RED — fixture pair + regression test + +**Files:** +- Create: `examples/ctt2_collision_main.ail.json` +- Create: `examples/ctt2_collision_lib.ail.json` +- Create: `crates/ailang-core/tests/ctt2_registry_rekey.rs` + +Goal: introduce a workspace that exercises the bare-name +collision the spec fixes. Today both modules' bare-`Foo` instance +canonicalises to `.Foo` via `normalize_type_for_registry`, +so the second-registered instance trips +`workspace.rs:683`'s `DuplicateInstance` check. The new test +asserts successful load + both `(MyC, type_hash)` entries in the +registry → goes RED today. + +- [ ] **Step 1: Write `examples/ctt2_collision_lib.ail.json`** + +Path: `examples/ctt2_collision_lib.ail.json` + +```json +{ + "schema": "ailang/v0", + "name": "ctt2_collision_lib", + "imports": [{ "module": "ctt2_collision_main" }], + "defs": [ + { + "kind": "type", + "name": "Foo", + "ctors": [{ "name": "MkLib", "fields": [] }] + }, + { + "kind": "instance", + "class": "ctt2_collision_main.MyC", + "type": { "k": "con", "name": "Foo" }, + "methods": [ + { + "name": "op", + "body": { + "t": "lam", + "params": ["_x"], + "paramTypes": [{ "k": "con", "name": "Foo" }], + "retType": { "k": "con", "name": "Int" }, + "body": { "t": "lit", "lit": { "kind": "int", "value": 2 } } + } + } + ] + } + ] +} +``` + +- [ ] **Step 2: Write `examples/ctt2_collision_main.ail.json`** + +Path: `examples/ctt2_collision_main.ail.json` + +```json +{ + "schema": "ailang/v0", + "name": "ctt2_collision_main", + "imports": [{ "module": "ctt2_collision_lib" }], + "defs": [ + { + "kind": "class", + "name": "MyC", + "param": "a", + "methods": [ + { + "name": "op", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + } + } + ] + }, + { + "kind": "type", + "name": "Foo", + "ctors": [{ "name": "MkMain", "fields": [] }] + }, + { + "kind": "instance", + "class": "MyC", + "type": { "k": "con", "name": "Foo" }, + "methods": [ + { + "name": "op", + "body": { + "t": "lam", + "params": ["_x"], + "paramTypes": [{ "k": "con", "name": "Foo" }], + "retType": { "k": "con", "name": "Int" }, + "body": { "t": "lit", "lit": { "kind": "int", "value": 1 } } + } + } + ] + } + ] +} +``` + +Note: the entry-module `ctt2_collision_main` imports +`ctt2_collision_lib` (lib-side type definition reachable), and +`ctt2_collision_lib` imports back the main module to see `MyC`. +Two-way import is supported; workspace loader DFS handles cycles +at module-name granularity. + +- [ ] **Step 3: Write the regression test** + +Path: `crates/ailang-core/tests/ctt2_registry_rekey.rs` + +```rust +//! Regression for the `Registry.type_def_module` re-key (ctt.2). +//! +//! Two modules each declare `type Foo` with distinct ctors and +//! provide an `instance MyC Foo`. Pre-ctt.2, both bare `Foo`s +//! collide on the bare-name `BTreeMap` key — +//! `normalize_type_for_registry` qualifies both to whichever +//! module won the insert race, and the loser's instance trips +//! a false `DuplicateInstance`. Post-ctt.2, the tuple-keyed map +//! distinguishes the two `Foo`s by owning module; both instances +//! register cleanly under distinct canonical keys. +//! +//! This test goes red today (DuplicateInstance) and green after +//! the re-key. + +use ailang_core::canonical; +use ailang_core::ast::Type; +use ailang_core::load_workspace; +use std::path::Path; + +fn examples_dir() -> std::path::PathBuf { + let manifest = env!("CARGO_MANIFEST_DIR"); + Path::new(manifest) + .parent().expect("CARGO_MANIFEST_DIR has a parent (crates/)") + .parent().expect("crates has a parent (workspace root)") + .join("examples") +} + +#[test] +fn two_modules_with_same_bare_foo_both_register() { + let entry = examples_dir().join("ctt2_collision_main.ail.json"); + let ws = load_workspace(&entry) + .expect("expected workspace load to succeed; both `type Foo` declarations live in distinct modules and must register under distinct canonical keys"); + + // Compute the canonical type hashes for the two expected entries. + let main_foo_hash = canonical::type_hash(&Type::Con { + name: "ctt2_collision_main.Foo".into(), + args: vec![], + }); + let lib_foo_hash = canonical::type_hash(&Type::Con { + name: "ctt2_collision_lib.Foo".into(), + args: vec![], + }); + + let main_key = ("MyC".to_string(), main_foo_hash); + let lib_key = ("MyC".to_string(), lib_foo_hash); + + assert!( + ws.registry.entries.contains_key(&main_key), + "registry missing entry for (MyC, ctt2_collision_main.Foo); entries: {:?}", + ws.registry.entries.keys().collect::>() + ); + assert!( + ws.registry.entries.contains_key(&lib_key), + "registry missing entry for (MyC, ctt2_collision_lib.Foo); entries: {:?}", + ws.registry.entries.keys().collect::>() + ); + + // Defining-module sanity: each entry's defining_module matches + // the module that wrote the instance. + let main_entry = &ws.registry.entries[&main_key]; + assert_eq!( + main_entry.defining_module, "ctt2_collision_main", + "main-side entry's defining_module mismatched" + ); + let lib_entry = &ws.registry.entries[&lib_key]; + assert_eq!( + lib_entry.defining_module, "ctt2_collision_lib", + "lib-side entry's defining_module mismatched" + ); +} +``` + +- [ ] **Step 4: Run the test, confirm RED** + +Run: `cargo test -p ailang-core --test ctt2_registry_rekey two_modules_with_same_bare_foo_both_register` +Expected: FAIL. The most likely failure shape today is either: +- (a) `load_workspace` returns `Err(DuplicateInstance { class: "MyC", type_repr: "Foo", first_module: , second_module: })` because both bare-`Foo` instances canonicalise to `.Foo` and collide on the `(MyC, type_hash)` key. The test's `expect(...)` panics with the supplied message. +- (b) Less likely but possible: the load succeeds but one of the two `main.Foo` / `lib.Foo` hashes is missing from `ws.registry.entries` because both registered under the same key (last-write-wins). One of the two `contains_key` asserts panics. + +If the failure shape is neither (a) nor (b), the spec's diagnosis +is wrong and the iter pauses for a fresh read. + +--- + +### Task 2: GREEN — atomic re-key edit pass + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs` (multi-line) +- Modify: `crates/ailang-check/src/lib.rs:1639-1640` +- Modify: `crates/ailang-check/src/mono.rs:120-121, 623-624, 1174-1175` + +One edit pass — between Step 1 and the end of Step 8 the workspace +will not compile. That is fine; the Boss is committing the iter as +a whole, and any intermediate-step partial state is internal to +the implement-phase. + +- [ ] **Step 1: Update doc-comment + field type at workspace.rs:97-106** + +`old_string`: +``` + /// `N.Foo` to whichever module wins the race. Acceptable for the + /// current corpus (all in-tree fixtures use distinct bare type + /// names across modules); revisit if a future workspace breaks the + /// assumption. Proper fix is to re-key as + /// `(owning_module, bare_name) -> defining_module` and thread the + /// calling module through every consumer site — out of ct.1's scope. + pub type_def_module: BTreeMap, +``` + +`new_string`: +``` + /// Keyed by `(owning_module, bare_name)`, value is the + /// defining module. The tuple key disambiguates same-named + /// types declared in different modules — bare `Foo` from + /// module M is `(M, "Foo")`, bare `Foo` from module N is + /// `(N, "Foo")`, and the two carry distinct canonical + /// qualifications under + /// [`normalize_type_for_registry`]. Pre-ctt.2 the key was + /// the bare name alone, and a workspace with two `type Foo` + /// declarations silently overwrote one entry, then tripped + /// `DuplicateInstance` on the loser-side instance after + /// both qualified to `.Foo`. + pub type_def_module: BTreeMap<(String, String), String>, +``` + +- [ ] **Step 2: Update `Registry::normalize_type_for_lookup` signature/body** + +`old_string`: +``` + /// ct.1.5a: produce the canonical form of `t` for registry-key + /// hashing. Bare-non-primitive `Type::Con` names get qualified to + /// `.`; already-qualified names stay; bare + /// names whose defining module is unknown stay as-is. + /// `Type::Fn`/`Type::Forall`/`Type::Var` recurse / pass through + /// structurally. + /// + /// Every consumer that hashes an `inst.type_`-shaped expression to + /// look it up in [`Self::entries`] must funnel through this + /// helper, otherwise the registered-form and the queried-form + /// disagree on whether the leading qualifier is present. + pub fn normalize_type_for_lookup(&self, t: &Type) -> Type { + normalize_type_for_registry(t, &self.type_def_module) + } +``` + +`new_string`: +``` + /// ct.1.5a + ctt.2: produce the canonical form of `t` for + /// registry-key hashing. Bare-non-primitive `Type::Con` names + /// get qualified to `.`; already-qualified + /// names stay; bare names whose defining module is unknown stay + /// as-is. `Type::Fn`/`Type::Forall`/`Type::Var` recurse / pass + /// through structurally. + /// + /// `caller_module` is the module in whose scope `t` was authored. + /// Bare-name lookups are keyed by `(caller_module, name)`, so a + /// bare `Foo` written in module M resolves only to M's `Foo`, + /// never to a same-named type from another module. + /// + /// Every consumer that hashes an `inst.type_`-shaped expression + /// to look it up in [`Self::entries`] must funnel through this + /// helper, otherwise the registered-form and the queried-form + /// disagree on whether the leading qualifier is present. + pub fn normalize_type_for_lookup(&self, caller_module: &str, t: &Type) -> Type { + normalize_type_for_registry(caller_module, t, &self.type_def_module) + } +``` + +- [ ] **Step 3: Update pass-1 collection declaration + insert** + +Apply Edit 1 to `crates/ailang-core/src/workspace.rs`: + +`old_string`: +``` + let mut type_def_module: BTreeMap = BTreeMap::new(); +``` + +`new_string`: +``` + let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new(); +``` + +Apply Edit 2 to `crates/ailang-core/src/workspace.rs`: + +`old_string`: +``` + Def::Type(t) => { + type_def_module.insert(t.name.clone(), mod_name.clone()); + } +``` + +`new_string`: +``` + Def::Type(t) => { + type_def_module.insert( + (mod_name.clone(), t.name.clone()), + mod_name.clone(), + ); + } +``` + +- [ ] **Step 4: Update pass-2 coherence-check lookup + canonical-form normalisation** + +Apply Edit 1 to `crates/ailang-core/src/workspace.rs` (pass-2 +type_mod lookup): + +`old_string`: +``` + let type_mod = type_def_module + .get(&type_repr) + .cloned() + .unwrap_or_else(|| "".into()); +``` + +`new_string`: +``` + let type_mod = type_def_module + .get(&(mod_name.clone(), type_repr.clone())) + .cloned() + .unwrap_or_else(|| "".into()); +``` + +Apply Edit 2 to `crates/ailang-core/src/workspace.rs` (pass-2 +canonical-form hashing): + +`old_string`: +``` + let type_hash = canonical::type_hash( + &normalize_type_for_registry(&inst.type_, &type_def_module), + ); +``` + +`new_string`: +``` + let type_hash = canonical::type_hash( + &normalize_type_for_registry( + mod_name, + &inst.type_, + &type_def_module, + ), + ); +``` + +- [ ] **Step 5: Update `normalize_type_for_registry` signature, doc-comment, body** + +`old_string`: +``` +/// `type_def_module`), not the instance's owning module. The two +/// coincide under a canonical-form-compliant workspace (bare implies +/// local-to-defining-module), but using the defining-module lookup is +/// robust against pre-`validate_canonical_type_names`-wired fixtures +/// that may still carry bare cross-module refs. +fn normalize_type_for_registry( + t: &Type, + type_def_module: &BTreeMap, +) -> Type { + match t { + Type::Con { name, args } => { + let new_name = if name.contains('.') || is_primitive_type_name(name) { + name.clone() + } else if let Some(owner) = type_def_module.get(name) { + format!("{owner}.{name}") + } else { + // Unknown bare non-primitive — leave as-is. Either it is + // a class-param Type::Var miscoded as a Con (which is a + // separate well-formedness problem) or a stale ref that + // downstream diagnostics will catch. + name.clone() + }; + Type::Con { + name: new_name, + args: args + .iter() + .map(|a| normalize_type_for_registry(a, type_def_module)) + .collect(), + } + } + Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn { + params: params + .iter() + .map(|p| normalize_type_for_registry(p, type_def_module)) + .collect(), + param_modes: param_modes.clone(), + ret: Box::new(normalize_type_for_registry(ret, type_def_module)), + ret_mode: *ret_mode, + effects: effects.clone(), + }, + Type::Forall { vars, constraints, body } => Type::Forall { + vars: vars.clone(), + constraints: constraints + .iter() + .map(|c| crate::ast::Constraint { + class: c.class.clone(), + type_: normalize_type_for_registry(&c.type_, type_def_module), + }) + .collect(), + body: Box::new(normalize_type_for_registry(body, type_def_module)), + }, + Type::Var { name } => Type::Var { name: name.clone() }, + } +} +``` + +`new_string`: +``` +/// `type_def_module`), not the instance's owning module. The two +/// coincide under a canonical-form-compliant workspace (bare implies +/// local-to-caller-module), but using the defining-module lookup is +/// robust against pre-`validate_canonical_type_names`-wired fixtures +/// that may still carry bare cross-module refs. +/// +/// ctt.2: bare-name lookups are keyed by `(caller_module, name)`, +/// not by `name` alone. A bare `Foo` written from module M resolves +/// to M's `Foo` only; same-named types in other modules are +/// distinct entries under their own caller-keyed tuple. +fn normalize_type_for_registry( + caller_module: &str, + t: &Type, + type_def_module: &BTreeMap<(String, String), String>, +) -> Type { + match t { + Type::Con { name, args } => { + let new_name = if name.contains('.') || is_primitive_type_name(name) { + name.clone() + } else if let Some(owner) = + type_def_module.get(&(caller_module.to_string(), name.clone())) + { + format!("{owner}.{name}") + } else { + // Unknown bare non-primitive — leave as-is. Either it is + // a class-param Type::Var miscoded as a Con (which is a + // separate well-formedness problem) or a stale ref that + // downstream diagnostics will catch. + name.clone() + }; + Type::Con { + name: new_name, + args: args + .iter() + .map(|a| normalize_type_for_registry(caller_module, a, type_def_module)) + .collect(), + } + } + Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn { + params: params + .iter() + .map(|p| normalize_type_for_registry(caller_module, p, type_def_module)) + .collect(), + param_modes: param_modes.clone(), + ret: Box::new(normalize_type_for_registry(caller_module, ret, type_def_module)), + ret_mode: *ret_mode, + effects: effects.clone(), + }, + Type::Forall { vars, constraints, body } => Type::Forall { + vars: vars.clone(), + constraints: constraints + .iter() + .map(|c| crate::ast::Constraint { + class: c.class.clone(), + type_: normalize_type_for_registry(caller_module, &c.type_, type_def_module), + }) + .collect(), + body: Box::new(normalize_type_for_registry(caller_module, body, type_def_module)), + }, + Type::Var { name } => Type::Var { name: name.clone() }, + } +} +``` + +- [ ] **Step 6: Update the inline test at workspace.rs:2523-2566** + +Apply Edit 1 to `crates/ailang-core/src/workspace.rs` (map type + +insert key): + +`old_string`: +``` + let mut type_def_module: BTreeMap = BTreeMap::new(); + type_def_module.insert("MyInt".to_string(), "other".to_string()); +``` + +`new_string`: +``` + let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new(); + // Caller module is "caller" for this test; the type `MyInt` + // lives in `other`. Under the tuple key the bare-name lookup + // resolves only when the caller is "caller". + type_def_module.insert( + ("caller".to_string(), "MyInt".to_string()), + "other".to_string(), + ); +``` + +Apply Edit 2 to `crates/ailang-core/src/workspace.rs` (call site): + +`old_string`: +``` + let out = normalize_type_for_registry(&input, &type_def_module); +``` + +`new_string`: +``` + let out = normalize_type_for_registry("caller", &input, &type_def_module); +``` + +- [ ] **Step 7: Update ailang-check consumer sites** + +Apply Edit 1 to `crates/ailang-check/src/lib.rs:1639-1640` (the +exact surrounding context to disambiguate; the call site is the +NoInstance check inside `check_fn` where `env: &Env` is in scope): + +`old_string`: +``` + .normalize_type_for_lookup(&r_ty) +``` + +`new_string`: +``` + .normalize_type_for_lookup(env.current_module.as_str(), &r_ty) +``` + +Note: if the literal `.normalize_type_for_lookup(&r_ty)` appears at +multiple sites in `lib.rs`, the implementer disambiguates using a +larger surrounding-context Edit (the spec-recon report identified +this as a single site at line 1639-1640 — adjust if recon's +line number is stale). + +Apply Edit 2 to `crates/ailang-check/src/mono.rs:120-121` (the +`MonoTarget::ClassMethod` site inside `monomorphise_workspace`; +`defining_module` is in scope from the destructured pattern): + +`old_string`: +``` + ws_owned.registry.normalize_type_for_lookup(type_) +``` + +`new_string`: +``` + ws_owned.registry.normalize_type_for_lookup(defining_module.as_str(), type_) +``` + +Apply Edit 3 to `crates/ailang-check/src/mono.rs:623-624` (the +`collect_mono_targets` site; `module_name: &str` is in scope from +the enclosing fn signature): + +`old_string`: +``` + env.workspace_registry.normalize_type_for_lookup(&r_ty) +``` + +`new_string`: +``` + env.workspace_registry.normalize_type_for_lookup(module_name, &r_ty) +``` + +Note: if `env.workspace_registry.normalize_type_for_lookup(&r_ty)` +matches both the 623-624 site and the 1174-1175 site, use a larger +surrounding-context Edit for each. The two enclosing functions +(`collect_mono_targets` and `collect_residuals_ordered`) both have +`module_name: &str` in scope; the new call form is identical. + +Apply Edit 4 to `crates/ailang-check/src/mono.rs:1174-1175` (the +`collect_residuals_ordered` site; same shape as Edit 3 — if Edit 3 +matched both already via `replace_all`, this step is a no-op +verification rather than a separate edit). + +- [ ] **Step 8: Build, confirm no compile errors** + +Run: `cargo build --workspace` +Expected: clean build, no errors. If errors surface, they will +most likely be: +- One of the four `ailang-check` call sites was missed and still + passes the old single-argument form. +- A test under `crates/` other than the ones named in the + Files-listing references `normalize_type_for_lookup` or + `normalize_type_for_registry` and needs the same threading. +- An `ailang-codegen` consumer surfaces (recon did not see one, + but a compile error is the catch-all). + +For any such surface, apply the same `caller_module` threading +pattern and re-run `cargo build --workspace`. + +--- + +### Task 3: Workspace-wide test gate + +**Files:** (verification only — no edits) + +- [ ] **Step 1: Run the ctt.2 regression test, confirm GREEN** + +Run: `cargo test -p ailang-core --test ctt2_registry_rekey two_modules_with_same_bare_foo_both_register` +Expected: PASS. Both `(MyC, type_hash)` entries land in +`ws.registry.entries`; the test's two `assert!` calls succeed. + +- [ ] **Step 2: Run the entire workspace test suite** + +Run: `cargo test --workspace` +Expected: all tests pass, including: +- The updated `ct1_5a_normalize_recurses_into_forall_constraints` + (workspace.rs:2523). +- The new `two_modules_with_same_bare_foo_both_register`. +- Every existing test in `ailang-core`, `ailang-check`, + `ailang-codegen`, `ailang-prose`, `ailang-surface`, `ail`. + +If any existing test fails: the most plausible cause is a fixture +under `examples/` that legitimately exercises a bare cross-module +type reference and depended on the silent-overwrite behaviour. +Under the tuple key such a reference now misses the lookup and +the corresponding instance falls into the `` +coherence path. Inspect the failing fixture; if the failure is the +canonical-form-violation it is structurally diagnosing, the fixture +is the bug and the test is the win — fix the fixture by qualifying +the cross-module type reference. + +- [ ] **Step 3: Bench scripts are deferred to audit** + +The audit skill at milestone close runs `bench/check.py`, +`bench/compile_check.py`, `bench/cross_lang.py`. ctt.2 makes +zero codegen-relevant edits (the registry is workspace-load-time +metadata, not runtime), so no bench movement is plausibly +attributable to it; the audit confirms. + +--- + +## Acceptance criteria recap (from spec) + +- `Registry.type_def_module` key is `(String, String)`, value is + `String`. ✓ Task 2 Step 1. +- Every consumer site provides the calling module at the call + site. ✓ Task 2 Steps 2-7. +- A regression test exercises two-module bare-name collision and + confirms both instances resolve correctly. ✓ Task 1 + Task 3 + Step 1. +- `cargo test --workspace` green. ✓ Task 3 Step 2. +- Doc-comment at workspace.rs:97-105 carries the new shape's + invariant. ✓ Task 2 Step 1.