Files
AILang/docs/plans/mq.1.md
T
Brummel 1a5f8289b7 plan: mq.1 — Canonical-form extension for class-ref fields, 7 tasks
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.
2026-05-13 00:40:30 +02:00

1201 lines
41 KiB
Markdown

# 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, `<module>.<Class>` 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<String>,
},
/// mq.1 (canonical-class-names): a qualified class reference of
/// the form `<owner>.<class>` was encountered, but `<owner>` is
/// not a known module in the workspace, or `<owner>` 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, `<module>.<Class>` 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, `<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, `<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, `<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<String, String>, // 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<String> = 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<String, String>` (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 <field> 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<String, String> = BTreeMap::new();
let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new();
let mut class_by_name: BTreeMap<String, &ClassDef> = 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<String, String> = BTreeMap::new();
let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new();
let mut class_by_name: BTreeMap<String, &ClassDef> = 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(|| "<unknown-class>".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(|| "<unknown-class>".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 `<defining_module>.<Class>`;
/// 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 <module>.<Class> (in <module>)"
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.