From 1a5f8289b7fbad3ae5bdd6badde27c80eca6dad3 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 13 May 2026 00:40:30 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20mq.1=20=E2=80=94=20Canonical-form=20ext?= =?UTF-8?q?ension=20for=20class-ref=20fields,=207=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter mq.1 plan: move InstanceDef.class / Constraint.class / SuperclassRef.class from bare-only to canonical form per ct.1's existing rule, extend validate_canonical_type_names with three new field walks, narrow check_class_name_fields to ClassDef.name-only, qualify build_registry's class_def_module + class_by_name + entries + four lookup sites, qualify ClassMethodEntry.class_name + class_superclasses, update affected test-assertion strings. Pre-flight recon flagged that the spec's '14 fixtures migrated' cost claim is over-stated: all existing fixture class refs are intra-module under the canonical-form rule. Plan does not add migration work that does not need doing; Task 7 Step 3 verifies zero existing-fixture diff. --- docs/plans/mq.1.md | 1200 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1200 insertions(+) create mode 100644 docs/plans/mq.1.md diff --git a/docs/plans/mq.1.md b/docs/plans/mq.1.md new file mode 100644 index 0000000..1b8560e --- /dev/null +++ b/docs/plans/mq.1.md @@ -0,0 +1,1200 @@ +# Iter mq.1 — Canonical-form extension for class-ref fields — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-13-module-qualified-class-names.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` to +> run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Move the three class-reference schema fields (`InstanceDef.class`, +`Constraint.class`, `SuperclassRef.class`) from bare-only to canonical +form (bare for same-module, `.` for cross-module), extend +ct.1's `validate_canonical_type_names` validator to enforce the new +rule, qualify all workspace-internal class-name keys (`class_def_module`, +`class_by_name`, registry `entries`, `ClassMethodEntry.class_name`, +`class_superclasses`), and adjust test assertions that string-compare +against bare class names. Dispatch and `MethodNameCollision` are +unchanged in this iter. + +**Architecture:** Symmetric application of ct.1's existing rule to three +class-ref fields. The existing `check_class_name_fields` function (which +rejects `.` in all four fields today) is narrowed to `ClassDef.name` +only; the three migrated fields get new `BareCrossModuleClassRef` +diagnostic walks inside `validate_canonical_type_names`. Class-key +qualification flows through the registry construction and the +`ailang-check` `ClassMethodEntry` source insert site; all downstream +consumers (synth Var-arm, mono targets, discharge) read the qualified +strings transparently because they only compare and look up by exact +string match. + +**Tech Stack:** `ailang-core` (`workspace.rs`, `ast.rs`), +`ailang-check` (`lib.rs`), `ail` CLI (`main.rs` Display arm), +`crates/ail/tests/typeclass_22b{2,3}.rs` (assertion-string updates). + +**Pre-flight verification of recon-flagged spec cost overstatement:** the +spec's §"Known costs"-Iter-1 estimates 14 test fixtures migrated. Recon +confirms every existing class-ref field in `examples/*.ail.json` +references a class declared in the same fixture file (intra-module). +Under the canonical-form rule, intra-module refs stay bare, so existing +fixture content carries zero diff. Plan does NOT add work to "migrate" +fixtures that need no migration; a verification step in Task 7 +confirms zero existing-fixture diff. New cross-module fixtures land +only where Task 3 needs them for RED-first validator pin tests. + +--- + +## Task 1: Add `BareCrossModuleClassRef` + `BadCrossModuleClassRef` diagnostic variants + Display arms + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs:341` (add two sibling variants, immediately after `BareCrossModuleTypeRef` / `BadCrossModuleTypeRef`) +- Modify: `crates/ail/src/main.rs:1285` (two Display arms) +- Test: inline in `crates/ailang-core/src/workspace.rs` `#[cfg(test)] mod tests` + +The two new variants are siblings of the existing +`BareCrossModuleTypeRef` (workspace.rs:341) and +`BadCrossModuleTypeRef` (workspace.rs:355) for class-ref fields: +`BareCrossModuleClassRef` fires when a bare class ref does not +resolve to a local class of the owning module; `BadCrossModuleClassRef` +fires when a qualified class ref's owner is not a known module or +declares no class by that name. + +- [ ] **Step 1: Write the RED test for both new variants' Display output** + +Append inside the existing `mod tests` block in `crates/ailang-core/src/workspace.rs` (after the existing `BareCrossModuleTypeRef`-Display test if any; otherwise at the end of the test module): + +```rust +/// mq.1.1: `BareCrossModuleClassRef` is the sibling diagnostic for +/// bare cross-module class references on `InstanceDef.class`, +/// `Constraint.class`, and `SuperclassRef.class`. Verifies the +/// variant is constructible and its Display surface names the +/// "class" wording (vs. "type" in the sibling variant). +#[test] +fn mq1_bare_cross_module_class_ref_display_names_class() { + let err = WorkspaceLoadError::BareCrossModuleClassRef { + module: "user".to_string(), + name: "Show".to_string(), + candidates: vec!["prelude.Show".to_string()], + }; + let rendered = format!("{}", err); + assert!(rendered.contains("class"), "Display must name 'class' wording, got: {rendered}"); + assert!(rendered.contains("Show"), "Display must echo the offending class name, got: {rendered}"); + assert!(rendered.contains("prelude.Show"), "Display must list candidates, got: {rendered}"); +} + +/// mq.1.1b: `BadCrossModuleClassRef` is the sibling diagnostic for +/// qualified class references whose owner module is unknown or +/// declares no class by that name. Verifies the variant is +/// constructible and its Display surface names the offending +/// qualified form. +#[test] +fn mq1_bad_cross_module_class_ref_display_names_qualified() { + let err = WorkspaceLoadError::BadCrossModuleClassRef { + module: "user".to_string(), + name: "unknownlib.Show".to_string(), + }; + let rendered = format!("{}", err); + assert!(rendered.contains("unknownlib.Show"), "Display must echo the qualified form, got: {rendered}"); + assert!(rendered.contains("class") || rendered.contains("module"), + "Display must mention class or module context, got: {rendered}"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --workspace -p ailang-core mq1_bare_cross_module_class_ref_display_names_class mq1_bad_cross_module_class_ref_display_names_qualified` + +Expected: compile error (the new variants do not yet exist). + +- [ ] **Step 3: Add the two variants to `WorkspaceLoadError`** + +Insert immediately after the existing `BareCrossModuleTypeRef` variant +at `crates/ailang-core/src/workspace.rs:341-345`. Two sibling variants +with the same field shapes as the corresponding type-ref variants: + +```rust +/// mq.1 (canonical-class-names): a class-reference field +/// (`InstanceDef.class`, `Constraint.class`, or +/// `SuperclassRef.class`) carries a bare name that does not resolve +/// to a local class of the owning module. Under the canonical-form +/// rule extended in mq.1, bare = local-class-of-owning-module; a +/// bare cross-module class reference is a schema violation. +/// `candidates` lists the qualified forms found by scanning the +/// owning module's imports for matching class declarations. +#[error( + "module `{module}` contains bare class name `{name}` that does not resolve to a local class. \ + AILang's `.ail.json` requires cross-module class references to be qualified. \ + Candidates from imports: {candidates:?}." +)] +BareCrossModuleClassRef { + module: String, + name: String, + candidates: Vec, +}, + +/// mq.1 (canonical-class-names): a qualified class reference of +/// the form `.` was encountered, but `` is +/// not a known module in the workspace, or `` is known +/// but declares no class by that name. Sibling of +/// `BadCrossModuleTypeRef` for class references. +#[error( + "module `{module}` references qualified class `{name}` but the owner module is not known \ + or does not declare a class by that name" +)] +BadCrossModuleClassRef { + module: String, + name: String, +}, +``` + +- [ ] **Step 4: Add the two Display arms in the CLI** + +Open `crates/ail/src/main.rs`. Find the existing `W::BareCrossModuleTypeRef` arm (the one that emits the structured-diagnostic JSON with `"code"` field). Add a sibling arm immediately after it: + +```rust +W::BareCrossModuleClassRef { module, name, candidates } => { + let mut obj = serde_json::json!({ + "code": "bare-cross-module-class-ref", + "module": module, + "name": name, + "candidates": candidates, + }); + println!("{}", obj); +} +W::BadCrossModuleClassRef { module, name } => { + let mut obj = serde_json::json!({ + "code": "bad-cross-module-class-ref", + "module": module, + "name": name, + }); + println!("{}", obj); +} +``` + +(If the existing arm uses a non-JSON Display path, mirror that path +exactly. The implementer reads the existing arms for +`W::BareCrossModuleTypeRef` and `W::BadCrossModuleTypeRef` at +`crates/ail/src/main.rs:1201`-ish and matches their shapes verbatim.) + +- [ ] **Step 5: Run tests to verify both pass** + +Run: `cargo test --workspace -p ailang-core mq1_bare_cross_module_class_ref_display_names_class mq1_bad_cross_module_class_ref_display_names_qualified` + +Expected: both PASS. + +- [ ] **Step 6: Confirm no other test broke** + +Run: `cargo test --workspace` + +Expected: green (only an additive variant; no semantic change yet). + +--- + +## Task 2: Doc-comments on the three migrated class-ref fields + `ClassDef.name` + +**Files:** +- Modify: `crates/ailang-core/src/ast.rs:260-273` (ClassDef.name doc) +- Modify: `crates/ailang-core/src/ast.rs:277-284` (SuperclassRef.class doc) +- Modify: `crates/ailang-core/src/ast.rs:310-323` (InstanceDef.class doc) +- Modify: `crates/ailang-core/src/ast.rs:343-349` (Constraint.class doc) + +- [ ] **Step 1: Update `ClassDef.name` doc-comment** + +In `crates/ailang-core/src/ast.rs:260-273`, find the existing +`/// Class name (capitalised by convention).` line above +`pub name: String`. Replace with: + +```rust +/// Class name (capitalised by convention). Bare — symmetric to +/// `TypeDef.name`, the field is the defining-site context, not +/// a reference. Cross-module class references live in +/// `InstanceDef.class`, `Constraint.class`, and +/// `SuperclassRef.class`; those fields carry the canonical form +/// (bare for same-module, `.` for cross-module) +/// per mq.1. +pub name: String, +``` + +- [ ] **Step 2: Update `SuperclassRef.class` doc-comment** + +In `crates/ailang-core/src/ast.rs:277-284`, replace the +`/// Superclass name.` line above `pub class: String` with: + +```rust +/// Superclass name in canonical form (mq.1): bare for a +/// same-module class, `.` for a cross-module +/// class. Symmetric to ct.1's `Type::Con.name` rule. +pub class: String, +``` + +- [ ] **Step 3: Update `InstanceDef.class` doc-comment** + +In `crates/ailang-core/src/ast.rs:312-317`, replace the +`/// Class being instantiated.` line above `pub class: String` with: + +```rust +/// Class being instantiated, in canonical form (mq.1): bare for +/// a same-module class, `.` for a cross-module +/// class. Symmetric to ct.1's `Type::Con.name` rule. +pub class: String, +``` + +- [ ] **Step 4: Update `Constraint.class` doc-comment** + +In `crates/ailang-core/src/ast.rs:343-349`, replace the +`/// Class name.` line above `pub class: String` with: + +```rust +/// Class name in canonical form (mq.1): bare for a same-module +/// class, `.` for a cross-module class. +/// Symmetric to ct.1's `Type::Con.name` rule. +pub class: String, +``` + +- [ ] **Step 5: Run `cargo test --workspace`** + +Run: `cargo test --workspace` + +Expected: green. Doc-only changes carry zero semantic effect. + +--- + +## Task 3: Extend `validate_canonical_type_names` for the three class-ref fields + invert existing rejection + +**Files:** +- Create: `examples/mq1_xmod_constraint_class.ail.json` (positive cross-module fixture, two-module pair) +- Create: `examples/mq1_xmod_constraint_class_dep.ail.json` (the dependency module) +- Modify: `crates/ailang-core/src/workspace.rs:921-964` (`validate_canonical_type_names`) +- Modify: `crates/ailang-core/src/workspace.rs:1282-1341` (`check_class_name_fields` narrows to ClassDef.name only) +- Modify: `crates/ailang-core/src/workspace.rs:2284-2377` (4 existing pin tests inverted: instance, constraint-on-fn, constraint-on-class-method, superclass) +- Modify: `crates/ailang-core/src/workspace.rs:2618-2632` (on-disk fixture pin test inverted) +- Test: inline in `crates/ailang-core/src/workspace.rs` `mod tests` (3 RED-first pin tests for `BareCrossModuleClassRef`, 1 positive cross-module pin test) + +- [ ] **Step 1: Write three RED-first pin tests for `BareCrossModuleClassRef`** + +Append at the end of the existing `mod tests` block in +`crates/ailang-core/src/workspace.rs`: + +```rust +/// mq.1.3: `InstanceDef.class` carrying a bare name that does NOT +/// resolve to a local class of the owning module must fire +/// `BareCrossModuleClassRef`. The fixture imports a sibling module +/// that declares the class under the bare name, so the candidate +/// list is non-empty. +#[test] +fn mq1_bare_xmod_instancedef_class_fires() { + let mut modules = BTreeMap::new(); + // Owning module B: instance Show Int (bare class ref, but Show + // is not defined in B). + modules.insert( + "B".to_string(), + Module { + name: "B".to_string(), + imports: vec!["A".to_string()], + defs: vec![Def::Instance(InstanceDef { + class: "Show".to_string(), // <- bare, cross-module + type_: Type::Con { name: "Int".to_string(), args: vec![] }, + methods: vec![], + doc: None, + })], + }, + ); + // Dep module A: declares class Show + modules.insert( + "A".to_string(), + Module { + name: "A".to_string(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "Show".to_string(), + param: "a".to_string(), + superclass: None, + methods: vec![], + doc: None, + })], + }, + ); + let err = validate_canonical_type_names(&modules).expect_err("expected reject"); + match err { + WorkspaceLoadError::BareCrossModuleClassRef { module, name, candidates } => { + assert_eq!(module, "B"); + assert_eq!(name, "Show"); + assert!(candidates.contains(&"A.Show".to_string()), "candidates: {candidates:?}"); + } + other => panic!("expected BareCrossModuleClassRef, got {other:?}"), + } +} + +/// mq.1.3: `Constraint.class` on a `Def::Fn` carrying a bare name +/// that does not resolve to a local class fires +/// `BareCrossModuleClassRef`. +#[test] +fn mq1_bare_xmod_constraint_class_on_fn_fires() { + let mut modules = BTreeMap::new(); + modules.insert( + "B".to_string(), + Module { + name: "B".to_string(), + imports: vec!["A".to_string()], + defs: vec![Def::Fn(FnDef { + name: "f".to_string(), + ty: Type::Forall { + vars: vec!["a".to_string()], + constraints: vec![Constraint { + class: "Show".to_string(), // <- bare, cross-module + type_: Type::Var { name: "a".to_string() }, + }], + body: Box::new(Type::Fn { + params: vec![Type::Var { name: "a".to_string() }], + param_modes: vec![ParamMode::Move], + ret: Box::new(Type::Con { name: "Unit".to_string(), args: vec![] }), + effects: vec![], + }), + }, + params: vec!["x".to_string()], + body: Term::Var { name: "x".to_string() }, + doc: None, + })], + }, + ); + modules.insert( + "A".to_string(), + Module { + name: "A".to_string(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "Show".to_string(), + param: "a".to_string(), + superclass: None, + methods: vec![], + doc: None, + })], + }, + ); + let err = validate_canonical_type_names(&modules).expect_err("expected reject"); + match err { + WorkspaceLoadError::BareCrossModuleClassRef { module, name, .. } => { + assert_eq!(module, "B"); + assert_eq!(name, "Show"); + } + other => panic!("expected BareCrossModuleClassRef, got {other:?}"), + } +} + +/// mq.1.3: `SuperclassRef.class` on a `Def::Class` carrying a bare +/// name that does not resolve to a local class fires +/// `BareCrossModuleClassRef`. +#[test] +fn mq1_bare_xmod_superclassref_class_fires() { + let mut modules = BTreeMap::new(); + modules.insert( + "B".to_string(), + Module { + name: "B".to_string(), + imports: vec!["A".to_string()], + defs: vec![Def::Class(ClassDef { + name: "MyOrd".to_string(), + param: "a".to_string(), + superclass: Some(SuperclassRef { + class: "Eq".to_string(), // <- bare, cross-module + type_: "a".to_string(), + }), + methods: vec![], + doc: None, + })], + }, + ); + modules.insert( + "A".to_string(), + Module { + name: "A".to_string(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "Eq".to_string(), + param: "a".to_string(), + superclass: None, + methods: vec![], + doc: None, + })], + }, + ); + let err = validate_canonical_type_names(&modules).expect_err("expected reject"); + match err { + WorkspaceLoadError::BareCrossModuleClassRef { module, name, .. } => { + assert_eq!(module, "B"); + assert_eq!(name, "Eq"); + } + other => panic!("expected BareCrossModuleClassRef, got {other:?}"), + } +} +``` + +- [ ] **Step 2: Write the positive cross-module pin test** + +Append in the same `mod tests` block: + +```rust +/// mq.1.3: a qualified `InstanceDef.class` referencing a class in +/// an imported module is the canonical form post-mq.1 and is +/// accepted by `validate_canonical_type_names`. Symmetric to ct.1's +/// "qualified Type::Con resolves" positive path. +#[test] +fn mq1_qualified_instancedef_class_accepted() { + let mut modules = BTreeMap::new(); + modules.insert( + "B".to_string(), + Module { + name: "B".to_string(), + imports: vec!["A".to_string()], + defs: vec![Def::Instance(InstanceDef { + class: "A.Show".to_string(), // <- qualified, cross-module + type_: Type::Con { name: "Int".to_string(), args: vec![] }, + methods: vec![], + doc: None, + })], + }, + ); + modules.insert( + "A".to_string(), + Module { + name: "A".to_string(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "Show".to_string(), + param: "a".to_string(), + superclass: None, + methods: vec![], + doc: None, + })], + }, + ); + validate_canonical_type_names(&modules).expect("qualified A.Show must be accepted"); +} +``` + +- [ ] **Step 3: Run the four new tests to confirm RED** + +Run: +``` +cargo test --workspace -p ailang-core mq1_bare_xmod_instancedef_class_fires \ + mq1_bare_xmod_constraint_class_on_fn_fires \ + mq1_bare_xmod_superclassref_class_fires \ + mq1_qualified_instancedef_class_accepted +``` +Expected: the three bare tests panic with "expected BareCrossModuleClassRef, got QualifiedClassName" (because `check_class_name_fields` still rejects bare under no current rule — actually it accepts bare and rejects qualified, so the bare tests will likely panic with "expected reject" because no error fires); the qualified test panics because `check_class_name_fields` rejects qualified forms. + +- [ ] **Step 4: Implement the three new field walks in `validate_canonical_type_names`** + +In `crates/ailang-core/src/workspace.rs`, locate the per-def loop at +~line 945-960 inside `validate_canonical_type_names`. Add three new +walks calling a shared helper (or inlining the three-rule check from +`check_type_con_name` at workspace.rs:968-1028). + +The helper signature: + +```rust +/// mq.1: apply the canonical-form rule to a class-reference field. +/// Same three-rule structure as `check_type_con_name` for type refs: +/// qualified known-module ⇒ accept; bare same-module-class ⇒ accept; +/// bare cross-module ⇒ fire `BareCrossModuleClassRef` with candidates +/// scanned from the owning module's imports. +fn check_class_ref( + owning_module: &str, + class_ref: &str, + class_def_module: &BTreeMap, // bare-class -> defining-module + imports: &[String], + field_label: &'static str, +) -> Result<(), WorkspaceLoadError> { + if let Some((owner, bare)) = class_ref.split_once('.') { + // Qualified form: owner must be a known module that declares the class. + if !class_def_module.iter().any(|(k, m)| m == owner && k == bare) { + return Err(WorkspaceLoadError::BadCrossModuleClassRef { + module: owning_module.to_string(), + name: class_ref.to_string(), + }); + } + Ok(()) + } else { + // Bare form: must resolve to a class in the owning module. + let defining = class_def_module.get(class_ref); + match defining { + Some(m) if m == owning_module => Ok(()), + _ => { + // Cross-module bare: list qualified candidates from imports. + let candidates: Vec = imports + .iter() + .filter_map(|imp| { + if class_def_module.get(class_ref).map(|m| m == imp).unwrap_or(false) { + Some(format!("{imp}.{class_ref}")) + } else { + None + } + }) + .collect(); + Err(WorkspaceLoadError::BareCrossModuleClassRef { + module: owning_module.to_string(), + name: class_ref.to_string(), + candidates, + }) + } + } + } +} +``` + +Note: `BadCrossModuleClassRef` is the sibling of `BadCrossModuleTypeRef` +(`workspace.rs:355`). The implementer adds this variant to the enum in +the same step (insert immediately after `BadCrossModuleTypeRef`), +sibling Display arm in `main.rs` if not auto-Display-derived. + +Build the `class_def_module: BTreeMap` (bare-class → +defining-module) once at the top of `validate_canonical_type_names` by +walking every module's `Def::Class` entries, then pass it into the +helper. + +Add the three call-site walks inside the existing per-def loop: + +```rust +for def in &m.defs { + // ...existing Type::Con walks... + match def { + Def::Instance(id) => { + check_class_ref(mod_name, &id.class, &class_def_module, &m.imports, "InstanceDef.class")?; + } + Def::Class(cd) => { + if let Some(sc) = &cd.superclass { + check_class_ref(mod_name, &sc.class, &class_def_module, &m.imports, "SuperclassRef.class")?; + } + for cmth in &cd.methods { + if let Type::Forall { constraints, .. } = &cmth.ty { + for c in constraints { + check_class_ref(mod_name, &c.class, &class_def_module, &m.imports, "Constraint.class")?; + } + } + } + } + Def::Fn(fd) => { + if let Type::Forall { constraints, .. } = &fd.ty { + for c in constraints { + check_class_ref(mod_name, &c.class, &class_def_module, &m.imports, "Constraint.class")?; + } + } + } + _ => {} + } +} +``` + +- [ ] **Step 5: Narrow `check_class_name_fields` to `ClassDef.name`-only** + +In `crates/ailang-core/src/workspace.rs:1287-1341`, delete the three +rejection arms for `SuperclassRef.class`, `InstanceDef.class`, +`Constraint.class` (lines 1307-1320, 1323-1328, 1329-1338). Keep only +the `ClassDef.name` arm at 1303-1306. The function body becomes: + +```rust +fn check_class_name_fields( + def: &Def, + owning_module: &str, +) -> Result<(), WorkspaceLoadError> { + if let Def::Class(cd) = def { + if cd.name.contains('.') { + return Err(WorkspaceLoadError::QualifiedClassName { + module: owning_module.to_string(), + name: cd.name.clone(), + field: "ClassDef.name", + }); + } + } + Ok(()) +} +``` + +Update the function doc-comment to reflect the post-mq.1 scope. + +- [ ] **Step 6: Invert the four in-test pin tests that rejected qualified class-ref forms** + +In `crates/ailang-core/src/workspace.rs`, locate the existing tests at +2284-2377 (three tests at 2284, 2312, 2341, plus the +classdef-method-constraint test at 2541). For each of the four tests +that today asserts `QualifiedClassName` on a qualified class-ref field +in `InstanceDef.class` / `Constraint.class` / `SuperclassRef.class`, +invert the assertion: the qualified form is now ACCEPTED. Replace the +panic-match block with: + +```rust +validate_canonical_type_names(&modules) + .expect("qualified is the canonical form post-mq.1"); +``` + +Update each test's doc-comment to reflect the inverted intent. The test +at 2255 (`ClassDef.name`-rejects-qualified) stays unchanged. + +- [ ] **Step 7: Invert the on-disk fixture pin test at 2618-2632** + +If the on-disk fixture test at workspace.rs:2618 covers a qualified-form +rejection on one of the three migrated fields, invert it the same way +as Step 6 (expect-accept post-mq.1). If it covers `ClassDef.name` +specifically, it stays unchanged. The implementer checks the test body +to determine which case. + +- [ ] **Step 8: Run the four mq.1.3 tests to confirm GREEN** + +Run: +``` +cargo test --workspace -p ailang-core mq1_bare_xmod_instancedef_class_fires \ + mq1_bare_xmod_constraint_class_on_fn_fires \ + mq1_bare_xmod_superclassref_class_fires \ + mq1_qualified_instancedef_class_accepted +``` +Expected: all four PASS. + +- [ ] **Step 9: Run all workspace tests** + +Run: `cargo test --workspace -p ailang-core workspace` + +Expected: green, including the inverted pin tests. + +- [ ] **Step 10: Create positive on-disk cross-module fixture pair** + +Create `examples/mq1_xmod_constraint_class.ail.json` (consumer module +with a qualified `Constraint.class` referencing the dep module's +class): + +```json +{ + "schema": "ailang/v0", + "name": "mq1_xmod_constraint_class", + "imports": ["mq1_xmod_constraint_class_dep"], + "defs": [ + { "kind": "fn", + "name": "useShow", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [ + { "class": "mq1_xmod_constraint_class_dep.Show", + "type": { "k": "var", "name": "a" } } + ], + "body": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "param_modes": ["borrow"], + "ret": { "k": "con", "name": "Unit" }, + "effects": [] + } + }, + "params": ["x"], + "body": { "t": "var", "name": "x" } + } + ] +} +``` + +Create `examples/mq1_xmod_constraint_class_dep.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "mq1_xmod_constraint_class_dep", + "imports": [], + "defs": [ + { "kind": "class", + "name": "Show", + "param": "a", + "methods": [ + { "name": "show", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "param_modes": ["borrow"], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + } + ] + } + ] +} +``` + +(The exact schema-field names must match what the existing fixtures +under `examples/` use. Implementer copies the structure from +`examples/test_22b2_xmod_classmod.ail.json` and adapts the consumer +side to use the qualified class ref.) + +- [ ] **Step 11: Add the on-disk fixture pin test** + +In `crates/ailang-core/src/workspace.rs` test module, add: + +```rust +/// mq.1.3: on-disk fixture pin — a workspace where the consumer's +/// `Constraint.class` references a class in an imported module via +/// the qualified form loads cleanly. Symmetric to ct.1's positive +/// cross-module type-ref fixture. +#[test] +fn mq1_xmod_constraint_class_fixture_loads() { + let ws = load_workspace_from_paths(&[ + Path::new("examples/mq1_xmod_constraint_class.ail.json"), + Path::new("examples/mq1_xmod_constraint_class_dep.ail.json"), + ]) + .expect("workspace must load with qualified Constraint.class"); + assert!(ws.modules.contains_key("mq1_xmod_constraint_class")); + assert!(ws.modules.contains_key("mq1_xmod_constraint_class_dep")); +} +``` + +(The exact loader entry point may differ; the implementer matches the +pattern used by other on-disk fixture pin tests in the same file.) + +- [ ] **Step 12: Run the new on-disk fixture test** + +Run: `cargo test --workspace -p ailang-core mq1_xmod_constraint_class_fixture_loads` + +Expected: PASS. + +--- + +## Task 4: Re-key `build_registry` to qualified class names (Pass-1 + Pass-2 + lookup sites) + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs:521-545` (Pass-1: `class_def_module` + `class_by_name` re-key) +- Modify: `crates/ailang-core/src/workspace.rs:629-755` (Pass-2: `entries` key + 4 consumer sites) + +- [ ] **Step 1: Introduce the qualifier helper at module scope** + +Insert near the top of `workspace.rs` (before `build_registry`): + +```rust +/// mq.1: take a class-ref field value (`InstanceDef.class`, +/// `SuperclassRef.class`, `Constraint.class`) and a defining context, +/// and produce the qualified workspace key. Bare ⇒ prepend the +/// caller_module; qualified ⇒ as-is. Symmetric to ct.1's +/// `normalize_type_for_registry` for `Type::Con`. +fn qualify_class_ref(class_ref: &str, caller_module: &str) -> String { + if class_ref.contains('.') { + class_ref.to_string() + } else { + format!("{caller_module}.{class_ref}") + } +} +``` + +- [ ] **Step 2: Re-key Pass-1 `class_def_module` and `class_by_name`** + +In `build_registry` at `workspace.rs:521-545`, replace: + +```rust +let mut class_def_module: BTreeMap = BTreeMap::new(); +let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new(); +let mut class_by_name: BTreeMap = BTreeMap::new(); +for (mod_name, m) in modules { + for def in &m.defs { + match def { + Def::Class(c) => { + class_def_module.insert(c.name.clone(), mod_name.clone()); + class_by_name.insert(c.name.clone(), c); + } + // ... + } + } +} +``` + +with: + +```rust +let mut class_def_module: BTreeMap = BTreeMap::new(); +let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new(); +let mut class_by_name: BTreeMap = BTreeMap::new(); +for (mod_name, m) in modules { + for def in &m.defs { + match def { + Def::Class(c) => { + let qualified = format!("{mod_name}.{}", c.name); + class_def_module.insert(qualified.clone(), mod_name.clone()); + class_by_name.insert(qualified, c); + } + // ... + } + } +} +``` + +Keys are now qualified; values unchanged. + +- [ ] **Step 3: Update the Pass-2 coherence lookup** + +At `workspace.rs:645-648`: + +```rust +let class_mod = class_def_module + .get(&inst.class) + .cloned() + .unwrap_or_else(|| "".into()); +``` + +becomes: + +```rust +let inst_class_key = qualify_class_ref(&inst.class, mod_name); +let class_mod = class_def_module + .get(&inst_class_key) + .cloned() + .unwrap_or_else(|| "".into()); +``` + +- [ ] **Step 4: Update the Pass-2 method-completeness lookup** + +At `workspace.rs:695`: + +```rust +if let Some(class_def) = class_by_name.get(&inst.class) { +``` + +becomes: + +```rust +if let Some(class_def) = class_by_name.get(&inst_class_key) { +``` + +(Uses the `inst_class_key` already computed in Step 3.) + +- [ ] **Step 5: Update the Pass-2 `entries` map key** + +At `workspace.rs:678`: + +```rust +let key = (inst.class.clone(), type_hash); +``` + +becomes: + +```rust +let key = (inst_class_key.clone(), type_hash); +``` + +The `DuplicateInstance` diagnostic at lines 680-686 receives the +qualified class name via `inst.class` (still the surface schema value) +or via `inst_class_key`; the diagnostic surface uses `inst.class` (the +on-disk shape) for the human-readable message. Implementer chooses: +the simpler option keeps `inst.class` in the diagnostic and uses +`inst_class_key` only for the registry key. No diagnostic-wording +change required. + +- [ ] **Step 6: Update the superclass-instance check** + +At `workspace.rs:744-754`, find the existing `sc.class`-keyed +superclass-instance lookup. Replace the key construction with the +qualified form: + +```rust +let sc_class_key = qualify_class_ref(&sc.class, class_mod_of_inst); +let sc_key = (sc_class_key, type_hash.clone()); +``` + +(The `class_mod_of_inst` variable name is illustrative; implementer +identifies the correct "owner module" for the superclass ref — +typically the `class_def_module` value for the parent class, since +`SuperclassRef` is inside a `ClassDef` and lives in that class's +module.) + +- [ ] **Step 7: Update `Origin::Class.class_name` to qualified** + +At `workspace.rs:561 and 582` (the two construction sites for +`Origin::Class { class_name, module }` inside the +`MethodNameCollision` pre-pass), wrap `c.name.clone()` in the qualifier: + +```rust +let origin = Origin::Class { + class_name: format!("{mod_name}.{}", c.name), + module: mod_name.clone(), +}; +``` + +Both sites. The `Display::format` impl at `workspace.rs:567` stays +unchanged; output becomes `"class M.Show (in M)"` (informative even if +redundant). + +- [ ] **Step 8: Run the targeted `build_registry` tests** + +Run: `cargo test --workspace -p ailang-core registry` + +Expected: green. Existing registry tests assert outcomes by class-name +match; qualified strings still match symmetrically. + +- [ ] **Step 9: Run all workspace tests** + +Run: `cargo test --workspace -p ailang-core` + +Expected: green. Pin tests that string-match on `Origin::Class.class_name` +will fail here — those are Task 6's scope. + +--- + +## Task 5: Qualify `ClassMethodEntry.class_name` + `class_superclasses` in `ailang-check` + +**Files:** +- Modify: `crates/ailang-check/src/lib.rs:1102-1123` (ClassMethodEntry doc) +- Modify: `crates/ailang-check/src/lib.rs:1195-1212` (build_module_globals Class arm) +- Modify: `crates/ailang-check/src/lib.rs:1303-1312` (class_superclasses build) + +- [ ] **Step 1: Update `ClassMethodEntry.class_name` doc-comment** + +In `crates/ailang-check/src/lib.rs:1102-1123`, find the doc-comment on +the `class_name` field. Append: + +```rust +/// (mq.1: carries the qualified form `.`; +/// flows downstream into `ResidualConstraint.class` and +/// `MonoTarget::ClassMethod.class` unchanged.) +``` + +- [ ] **Step 2: Qualify the insert site at lib.rs:1205** + +In `build_module_globals` at `crates/ailang-check/src/lib.rs:1195-1212`, +locate the `Def::Class` arm that inserts into `class_methods`. Find the +construction site of `ClassMethodEntry { class_name: cd.name.clone(), ... }` +(approximately line 1205). Replace with: + +```rust +let qualified_class = format!("{mname}.{}", cd.name); +class_methods.insert( + method.name.clone(), + ClassMethodEntry { + class_name: qualified_class.clone(), + // ...other fields unchanged + }, +); +``` + +(The variable `mname` is the iteration variable for the per-module +loop; implementer confirms the exact name in scope.) + +- [ ] **Step 3: Qualify `class_superclasses` key and value** + +In `crates/ailang-check/src/lib.rs:1303-1312`, find the +`class_superclasses` build loop. The current shape is approximately: + +```rust +for (_mod_name, m) in &ws.modules { + for def in &m.defs { + if let Def::Class(cd) = def { + if let Some(sc) = &cd.superclass { + class_superclasses.insert(cd.name.clone(), sc.class.clone()); + } + } + } +} +``` + +Replace with: + +```rust +for (mod_name, m) in &ws.modules { + for def in &m.defs { + if let Def::Class(cd) = def { + if let Some(sc) = &cd.superclass { + let class_qualified = format!("{mod_name}.{}", cd.name); + let sc_qualified = if sc.class.contains('.') { + sc.class.clone() + } else { + format!("{mod_name}.{}", sc.class) + }; + class_superclasses.insert(class_qualified, sc_qualified); + } + } + } +} +``` + +(If the loop variable is `_mod_name` today, rename to `mod_name`. If +the map type signature carries explicit `String` keys/values, leave it +as-is; only the value content changes.) + +- [ ] **Step 4: Run targeted ailang-check tests** + +Run: `cargo test --workspace -p ailang-check` + +Expected: green at the lib level; pin tests in `crates/ail/tests/` +asserting against bare class strings are Task 6's scope. + +- [ ] **Step 5: Spot-check synth Var-arm + mono targets compile** + +Run: `cargo build --workspace` + +Expected: green. The Var-arm at `lib.rs:1968` reads `cm.class_name` +(now qualified); residual emission at line 1991 carries the qualified +string. Mono's `class_index.get(class)` lookups (in `mono.rs:137`, +634, 1185) read qualified keys and match qualified `MonoTarget.class` +values. No code change needed in these consumers. + +--- + +## Task 6: Update test-assertion strings affected by qualification migration + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs:1937-1996` (`MethodNameCollision` pin tests) +- Modify: `crates/ail/tests/typeclass_22b2.rs:42` (class_method_class assertion) +- Modify: `crates/ail/tests/typeclass_22b3.rs:151, 217, 232, 295, 301, 341, 353` (class-string assertions) + +- [ ] **Step 1: Update `MethodNameCollision` pin tests at workspace.rs:1937-1996** + +Open `crates/ailang-core/src/workspace.rs` and locate the two +`MethodNameCollision` pin tests (`class_class_method_name_collision_fires`, +`class_fn_method_name_collision_fires` — exact names per recon). + +For each pin test, find the assertion lines that match against the +old bare-class format. The shapes are typically: + +```rust +assert!(first_origin.starts_with("class A"), ...); +``` + +Replace with the post-mq.1 qualified form (the actual module name in +the fixture is what determines the prefix): + +```rust +// mq.1: class_name now qualified — the Display format emits +// "class . (in )" +assert!(first_origin.starts_with("class m.A") || first_origin.contains(".A"), + "got first_origin: {first_origin}"); +``` + +(The exact module name `m` is whatever the test fixture declares. +Implementer reads each test's Module construction to derive the right +prefix.) + +- [ ] **Step 2: Update `typeclass_22b2.rs:42`** + +In `crates/ail/tests/typeclass_22b2.rs:42`, find: + +```rust +assert_eq!( + mod_globals.class_method_class("show"), + Some("Show"), +); +``` + +Determine the module name in the fixture (the test loads a fixture +named `test_22b2_class_method_lookup` per recon), and replace with: + +```rust +// mq.1: class_name now qualified +assert_eq!( + mod_globals.class_method_class("show"), + Some("test_22b2_class_method_lookup.Show"), +); +``` + +- [ ] **Step 3: Update `typeclass_22b3.rs` class-string assertions** + +In `crates/ail/tests/typeclass_22b3.rs`, locate the assertion sites +identified by recon (lines 151, 217, 232, 295, 301, 341, 353). +Typical patterns: + +```rust +assert_eq!(class, "Show", ...); +assert_eq!(class, "Foo", ...); +assert_eq!(class, "Greet", ...); +``` + +Replace each with the qualified form. The module prefix depends on the +test's fixture/Module construction: +- Inline `Module { name: "m", ... }` tests → prefix `"m."`. +- On-disk-fixture tests → the fixture's module name (typically the + filename without `.ail.json`). + +Examples: + +```rust +// mq.1: qualified class names +assert_eq!(class, "test_22b2_instance_present.Show", ...); +assert_eq!(class, "m.Foo", ...); +assert_eq!(class, "m.Greet", ...); +``` + +Implementer reads each test's fixture context to pick the right +prefix. Each assertion update is one line. + +- [ ] **Step 4: Run the workspace assertion-touched tests** + +Run: +``` +cargo test --workspace -p ailang-core workspace::tests::class_class_method_name_collision_fires \ + workspace::tests::class_fn_method_name_collision_fires +``` + +Expected: PASS. + +- [ ] **Step 5: Run the typeclass test files** + +Run: +``` +cargo test --workspace --test typeclass_22b2 --test typeclass_22b3 +``` + +Expected: PASS. + +--- + +## Task 7: Integration verification — full workspace + bench scripts + roundtrip + +**Files:** none modified; verification only. + +- [ ] **Step 1: Full `cargo test --workspace`** + +Run: `cargo test --workspace` + +Expected: green. All 16 binary-test suites pass (~600 tests). + +- [ ] **Step 2: Verify zero diff on `examples/prelude.ail.json`** + +Run: `git diff -- examples/prelude.ail.json` + +Expected: no output (spec assumption 17: intra-prelude class refs all +stay bare, prelude unchanged). + +- [ ] **Step 3: Verify zero diff on the 14 `test_22b*` fixtures** + +Run: `git diff -- examples/test_22b*.ail.json` + +Expected: no output (recon-confirmed: all existing class refs in those +fixtures are intra-module; canonical-form rule keeps them bare). + +- [ ] **Step 4: Bench script `bench/check.py`** + +Run: `python3 bench/check.py` + +Expected: exit 0. (If exit non-zero, audit-ratify per audit-skill +convention — note in the journal whether the regression is in-scope or +spurious.) + +- [ ] **Step 5: Bench script `bench/compile_check.py`** + +Run: `python3 bench/compile_check.py` + +Expected: exit 0 OR audit-ratified. + +- [ ] **Step 6: Bench script `bench/cross_lang.py`** + +Run: `python3 bench/cross_lang.py` + +Expected: exit 0 OR audit-ratified. + +- [ ] **Step 7: Roundtrip the new on-disk fixture** + +Run: +``` +cargo run -p ail -- check examples/mq1_xmod_constraint_class.ail.json +``` + +Expected: exit 0, no diagnostics. + +- [ ] **Step 8: Confirm the per-iter journal placeholder is staged** + +The implementer leaves `docs/journals/2026-05-13-iter-mq.1.md` (or the +date the iter actually finishes) in the working tree with a draft +entry covering: tasks ran, deviations from plan (if any), +spec-cost-claim discrepancy (the "14 fixtures migrated" claim was over- +stated; recon-confirmed zero existing-fixture diff), test counts, bench +outcomes. Append a pointer line to `docs/journals/INDEX.md`. The Boss +commits.