Files
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

41 KiB

Iter mq.1 — Canonical-form extension for class-ref fields — Implementation Plan

Parent spec: docs/specs/0023-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):

/// 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:

/// 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:

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:

/// 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:

/// 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:

/// 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:

/// 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:

/// 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:

/// 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:

/// 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:

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:

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:

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):

{
  "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:

{
  "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:

/// 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):

/// 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:

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:

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:

let class_mod = class_def_module
    .get(&inst.class)
    .cloned()
    .unwrap_or_else(|| "<unknown-class>".into());

becomes:

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:

if let Some(class_def) = class_by_name.get(&inst.class) {

becomes:

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:

let key = (inst.class.clone(), type_hash);

becomes:

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:

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:

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:

/// (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:

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:

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:

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:

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):

// 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:

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:

// 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:

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:

// 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.