diff --git a/bench/orchestrator-stats/2026-05-13-iter-mq.1.json b/bench/orchestrator-stats/2026-05-13-iter-mq.1.json new file mode 100644 index 0000000..81078a2 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-13-iter-mq.1.json @@ -0,0 +1,21 @@ +{ + "iter_id": "mq.1", + "date": "2026-05-13", + "mode": "standard", + "outcome": "DONE", + "tasks_total": 7, + "tasks_completed": 7, + "reloops_per_task": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0 + }, + "review_loops_spec": 0, + "review_loops_quality": 1, + "blocked_reason": null, + "notes": "Tasks 3-6 ran as one coherent fix (the plan structured them sequentially but the registry rekey + ClassMethodEntry qualification + class_superclasses qualification are tightly coupled — Task 3 alone left several tests RED). Boss-note recon claim ('zero existing-fixture diff') was empirically wrong: 5 test_22b* fixtures plus 6 others had bare cross-module class refs and required migration to the canonical form. Quality re-loop count of 1 is for the hash.rs:293 pin: initial implementer-phase shape used a length-only assertion (lost regression-protection); quality phase flagged it as Minor and pinned the concrete hex value." +} diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 00edec2..4aa9708 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -1280,6 +1280,38 @@ fn workspace_error_to_diagnostic( "name": name, })), ), + // mq.1 (canonical-class-names): bare class-ref that does not + // resolve to a local class of the owning module. Sibling of + // `BareCrossModuleTypeRef` for class-reference fields. + W::BareCrossModuleClassRef { module, name, candidates } => Some( + ailang_check::Diagnostic::error( + "bare-cross-module-class-ref", + format!( + "module `{module}` contains bare class name `{name}` that does not resolve to a local class; \ + candidates from imports: {candidates:?}" + ), + ) + .with_ctx(serde_json::json!({ + "module": module, + "name": name, + "candidates": candidates, + })), + ), + // mq.1: qualified `.` where `` is not a + // known module or `` has no class ``. + W::BadCrossModuleClassRef { module, name } => Some( + ailang_check::Diagnostic::error( + "bad-cross-module-class-ref", + format!( + "module `{module}` references qualified class `{name}` but the owner module is not known \ + or does not declare a class by that name" + ), + ) + .with_ctx(serde_json::json!({ + "module": module, + "name": name, + })), + ), // ct.1: a class-reference field contains a `.` — class names // are not module-qualified in this milestone. W::QualifiedClassName { module, name, field } => Some( diff --git a/crates/ail/tests/ct1_check_cli.rs b/crates/ail/tests/ct1_check_cli.rs index 9ba295c..77537a8 100644 --- a/crates/ail/tests/ct1_check_cli.rs +++ b/crates/ail/tests/ct1_check_cli.rs @@ -86,14 +86,18 @@ fn check_json_emits_bad_cross_module_type_ref() { ); } -/// Property: a qualified class name in an `InstanceDef.class` field -/// (here `prelude.Eq`) is rejected by `ail check --json` with -/// diagnostic code `qualified-class-name` and non-zero exit. ct.1 -/// reserves module qualification for type names; classes stay bare in -/// this milestone. Guards against `workspace_error_to_diagnostic` -/// losing the `QualifiedClassName` arm. +/// Property: mq.1 — a qualified class name in an `InstanceDef.class` +/// field is the canonical form, not a rejection. The +/// `test_ct1_qualified_class_rejected` fixture (declares +/// `instance prelude.Eq Int` outside prelude and outside Int's +/// defining module) is now rejected by the downstream coherence +/// check with `orphan-instance` instead of the pre-mq.1 +/// `qualified-class-name`. Guards against +/// `workspace_error_to_diagnostic` losing the OrphanInstance arm +/// AND against any regression that would reintroduce +/// `qualified-class-name` on a referencing field. #[test] -fn check_json_emits_qualified_class_name() { +fn check_json_emits_orphan_instance_on_xmod_class_without_coherence_post_mq1() { let fixture = examples_dir().join("test_ct1_qualified_class_rejected.ail.json"); let output = Command::new(ail_bin()) .args(["check", "--json", fixture.to_str().unwrap()]) @@ -101,7 +105,7 @@ fn check_json_emits_qualified_class_name() { .expect("ail binary must launch"); assert!( !output.status.success(), - "ail check must fail on qualified class name; stdout={} stderr={}", + "ail check must fail; stdout={} stderr={}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ); @@ -110,8 +114,12 @@ fn check_json_emits_qualified_class_name() { serde_json::from_str(&stdout).expect("--json mode emits a JSON array on stdout"); let arr = diags.as_array().expect("diagnostics is a JSON array"); assert!( - arr.iter().any(|d| d["code"] == "qualified-class-name"), - "expected `qualified-class-name` in diagnostics array; got {stdout}" + arr.iter().any(|d| d["code"] == "orphan-instance"), + "expected `orphan-instance` in diagnostics array; got {stdout}" + ); + assert!( + !arr.iter().any(|d| d["code"] == "qualified-class-name"), + "must NOT fire `qualified-class-name` on a referencing field post-mq.1; got {stdout}" ); } diff --git a/crates/ail/tests/typeclass_22b2.rs b/crates/ail/tests/typeclass_22b2.rs index 01a3bd7..bf0c0e5 100644 --- a/crates/ail/tests/typeclass_22b2.rs +++ b/crates/ail/tests/typeclass_22b2.rs @@ -37,10 +37,12 @@ fn class_method_is_in_module_globals() { mod_globals.has_class_method("show"), "class method `show` must appear in module globals" ); + // mq.1: class_method_class returns the qualified form + // `.`. assert_eq!( mod_globals.class_method_class("show"), - Some("Show"), - "class method `show` must remember its class is `Show`" + Some("test_22b2_class_method_lookup.Show"), + "class method `show` must remember its class is `Show` (qualified post-mq.1)" ); } diff --git a/crates/ail/tests/typeclass_22b3.rs b/crates/ail/tests/typeclass_22b3.rs index fd8f858..7f820b0 100644 --- a/crates/ail/tests/typeclass_22b3.rs +++ b/crates/ail/tests/typeclass_22b3.rs @@ -148,7 +148,8 @@ fn collect_mono_targets_single_concrete_call_site() { let t = &targets[0]; match t { ailang_check::mono::MonoTarget::ClassMethod { class, method, type_, defining_module } => { - assert_eq!(class, "Show"); + // mq.1: MonoTarget.class carries the qualified workspace-key shape. + assert_eq!(class, "test_22b2_instance_present.Show"); assert_eq!(method, "show"); assert!( matches!(type_, ailang_core::ast::Type::Con { name, args } if name == "Int" && args.is_empty()), diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index b6258c7..4764895 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -716,7 +716,9 @@ impl CheckError { // (`ne` / `lt` / `le` / `gt` / `ge` / direct `eq` / `compare`) // are the natural surface where the LLM hits this. if let CheckError::NoInstance { class, at_type, .. } = self.inner() { - if (class == "Eq" || class == "Ord") && at_type == "Float" { + // mq.1: `class` carries the qualified workspace-key shape + // (`prelude.Eq` / `prelude.Ord`), not the bare name. + if (class == "prelude.Eq" || class == "prelude.Ord") && at_type == "Float" { d.message = format!( "{} — Float has no Eq/Ord instance by design (partial \ orderability per IEEE-754); see DESIGN.md §\"Float semantics\".", @@ -1107,6 +1109,11 @@ impl ModuleGlobals { #[derive(Debug, Clone)] pub struct ClassMethodEntry { /// The class that declared the method (e.g. `Show` for `show`). + /// + /// mq.1: carries the qualified form `.`; + /// flows downstream into `ResidualConstraint.class` and + /// `MonoTarget::ClassMethod.class` unchanged. Match the registry + /// key shape so discharge / mono lookups land on the right entry. pub class_name: String, /// The class's single type parameter name (e.g. `a` in /// `class Show a`). The class param appears at this name inside @@ -1141,7 +1148,13 @@ pub fn build_module_globals( let mut globals = ModuleGlobals::default(); for def in &m.defs { let def_name = def.name(); - if def_name.contains('.') { + // mq.1: for `Def::Instance`, `def.name()` returns + // `inst.class` which carries the canonical-form value + // (qualified for cross-module). The dot-in-def-name check + // is meant for `Fn`/`Const`/`Type` whose names are author- + // chosen identifiers — instances have no author-chosen + // name and are excluded. + if !matches!(def, Def::Instance(_)) && def_name.contains('.') { return Err(CheckError::Def( def_name.to_string(), Box::new(CheckError::InvalidDefName { @@ -1198,11 +1211,17 @@ pub fn build_module_globals( // with enough metadata for the typecheck arms in // Tasks 9 / 10 to build a residual constraint at // the call site. + // + // mq.1: store the qualified class name + // (`.`) so the residual + // emitted at the call site matches the workspace + // registry's qualified key. + let qualified_class = format!("{mname}.{}", cd.name); for meth in &cd.methods { globals.class_methods.insert( meth.name.clone(), ClassMethodEntry { - class_name: cd.name.clone(), + class_name: qualified_class.clone(), class_param: cd.param.clone(), method_ty: meth.ty.clone(), defining_module: mname.clone(), @@ -1301,11 +1320,25 @@ pub fn build_check_env(ws: &Workspace) -> Env { } // env.class_superclasses — walk every module's Def::Class. - for m in ws.modules.values() { + // + // mq.1: key + value both carry the qualified class name. The map + // is queried by `expand_declared_constraints` with `c.class` + // (a `Constraint.class` value, which is canonical-form on the + // residual side post-mq.1), so both halves of the map must match + // the same workspace-key shape. A bare `SuperclassRef.class` + // (same-module superclass) is lifted via `qualify_class_ref` — + // mirrored inline below to avoid pulling in the workspace helper. + for (mod_name, m) in &ws.modules { for d in &m.defs { if let Def::Class(cd) = d { if let Some(sc) = &cd.superclass { - env.class_superclasses.insert(cd.name.clone(), sc.class.clone()); + let class_qualified = format!("{mod_name}.{}", cd.name); + let sc_qualified = if sc.class.contains('.') { + sc.class.clone() + } else { + format!("{mod_name}.{}", sc.class) + }; + env.class_superclasses.insert(class_qualified, sc_qualified); } } } @@ -1604,8 +1637,21 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { // Decision 11) and compare against residuals. Var-shaped residuals // not covered by the expanded set fire `MissingConstraint`. Concrete // residuals are deferred to Task 10's `no-instance` arm. + // + // mq.1: declared constraints carry the canonical-form `class` + // value (bare for same-module, qualified for cross-module). Lift + // to the workspace-key shape (always qualified) before comparing + // against residuals — residuals always carry qualified `class` + // because they come from `ClassMethodEntry.class_name` which is + // qualified post-mq.1. let declared_constraints: Vec = match &f.ty { - Type::Forall { constraints, .. } => constraints.clone(), + Type::Forall { constraints, .. } => constraints + .iter() + .map(|c| Constraint { + class: qualify_class_ref_in_check(&c.class, &env.current_module), + type_: c.type_.clone(), + }) + .collect(), _ => Vec::new(), }; let expanded = expand_declared_constraints(&declared_constraints, &env.class_superclasses); @@ -1733,6 +1779,20 @@ pub struct FreeFnCall { pub metas: Vec, } +/// mq.1: lift a canonical-form class-ref value to the qualified +/// workspace-key shape. Bare ⇒ prepend `caller_module` (the bare +/// form names the caller-module-local class under the canonical-form +/// rule); qualified ⇒ as-is. Sibling of `workspace::qualify_class_ref` +/// for the check-side; duplicated to keep `ailang-check` from +/// reaching across crates for one private helper. +pub(crate) fn qualify_class_ref_in_check(class_ref: &str, caller_module: &str) -> String { + if class_ref.contains('.') { + class_ref.to_string() + } else { + format!("{caller_module}.{class_ref}") + } +} + /// Iter 22b.2 (Task 9): expand a list of declared class constraints /// with their one-step superclass closure (Decision 11). For every /// declared `(C, t)`, append `(S, t)` if class `C` has a superclass diff --git a/crates/ailang-check/src/mono.rs b/crates/ailang-check/src/mono.rs index 23e012d..9990ed0 100644 --- a/crates/ailang-check/src/mono.rs +++ b/crates/ailang-check/src/mono.rs @@ -341,12 +341,18 @@ fn poly_free_fn_names_for_module( /// Iter 22b.3: workspace-wide `class-name -> ClassDef` index. /// Used by the fixpoint to look up the matching class definition /// when synthesising a fn. +/// +/// mq.1: keys carry the qualified class name +/// (`.`) to match the post-mq.1 residual / +/// `MonoTarget::ClassMethod.class` field and the registry's +/// qualified entries key. fn build_class_index(ws: &Workspace) -> BTreeMap { let mut idx = BTreeMap::new(); - for m in ws.modules.values() { + for (mod_name, m) in &ws.modules { for d in &m.defs { if let Def::Class(c) = d { - idx.insert(c.name.clone(), c.clone()); + let qualified = format!("{mod_name}.{}", c.name); + idx.insert(qualified, c.clone()); } } } diff --git a/crates/ailang-check/tests/env_construction_pin.rs b/crates/ailang-check/tests/env_construction_pin.rs index b3c1dad..671eca7 100644 --- a/crates/ailang-check/tests/env_construction_pin.rs +++ b/crates/ailang-check/tests/env_construction_pin.rs @@ -97,11 +97,21 @@ fn build_env_inline_pre_refactor(ws: &ailang_core::workspace::Workspace) -> Env } // env.class_superclasses — walk every module's Def::Class. - for m in ws.modules.values() { + // + // mq.1: both key and value carry the qualified workspace-key shape; + // bare `SuperclassRef.class` is lifted using the parent class's + // defining module. + for (mod_name, m) in &ws.modules { for d in &m.defs { if let Def::Class(cd) = d { if let Some(sc) = &cd.superclass { - env.class_superclasses.insert(cd.name.clone(), sc.class.clone()); + let class_qualified = format!("{mod_name}.{}", cd.name); + let sc_qualified = if sc.class.contains('.') { + sc.class.clone() + } else { + format!("{mod_name}.{}", sc.class) + }; + env.class_superclasses.insert(class_qualified, sc_qualified); } } } diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index 84abff3..2299518 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -258,7 +258,13 @@ pub struct Suppress { /// pre-22b hashes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClassDef { - /// Class name (capitalised by convention). + /// 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, /// Single type-parameter name. pub param: String, @@ -275,7 +281,9 @@ pub struct ClassDef { /// Iter 22b.1: reference to a superclass relation in [`ClassDef`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SuperclassRef { - /// Superclass name. + /// 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, /// Type the superclass is applied to. MUST equal the parent /// `ClassDef.param` (validated in 22b.2 — schema does not enforce). @@ -310,7 +318,9 @@ pub struct ClassMethod { /// overrides of default-bearing methods. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstanceDef { - /// Class being instantiated. + /// 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, /// Concrete type the class is applied to. #[serde(rename = "type")] @@ -341,7 +351,9 @@ pub struct InstanceMethod { /// matching registry entry exists. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Constraint { - /// Class name. + /// 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, /// Type the class is applied to. #[serde(rename = "type")] diff --git a/crates/ailang-core/src/hash.rs b/crates/ailang-core/src/hash.rs index 3001255..d8f1e3a 100644 --- a/crates/ailang-core/src/hash.rs +++ b/crates/ailang-core/src/hash.rs @@ -293,20 +293,34 @@ mod tests { let dup_a_src = std::fs::read(examples.join("test_22b1_dup_a.ail.json")) .expect("examples/test_22b1_dup_a.ail.json present"); let dup_a_mod: crate::ast::Module = serde_json::from_slice(&dup_a_src).unwrap(); - // test_22b1_dup_a has two defs: the Show class and an instance - // of Show for test_22b1_dup_b.MyInt. Def::name() returns the - // class name for both, so pin each by index. The instance - // carries the migrated cross-module qualifier in its `type`. - assert_eq!(dup_a_mod.defs.len(), 2, "test_22b1_dup_a expected to have exactly 2 defs (class + instance)"); + // mq.1: test_22b1_dup_a has one def post-migration — the instance + // of `test_22b1_dup_classmod.Show` for `test_22b1_dup_b.MyInt`. + // The `Show` class itself moved to `test_22b1_dup_classmod` to + // break the dup_a ↔ dup_b cycle that the pre-mq.1 bare-class + // shape required for cross-module duplicate-instance coverage. + assert_eq!(dup_a_mod.defs.len(), 1, "test_22b1_dup_a expected to have exactly 1 def (the instance)"); + // Hash captured post-mq.1 migration; the instance carries the + // canonical qualified `class` field. Computed by running this + // test once with a length-only assertion and recording the + // observed value. A future schema-touching change that shifts + // this hash without intent triggers this pin. assert_eq!( def_hash(&dup_a_mod.defs[0]), - "1c2573661ffd3da3", - "test_22b1_dup_a class Show canonical hash must match captured post-migration value" + "bdefb4ec75e046e4", + "test_22b1_dup_a instance hash drifted; expected post-mq.1 captured value" ); + + // Also pin the new test_22b1_dup_classmod fixture's `Show` class + // hash: the class moved here from dup_a verbatim (same method + // signature), so the hash is independent of the migration. + let dup_classmod_src = std::fs::read(examples.join("test_22b1_dup_classmod.ail.json")) + .expect("examples/test_22b1_dup_classmod.ail.json present"); + let dup_classmod_mod: crate::ast::Module = serde_json::from_slice(&dup_classmod_src).unwrap(); + assert_eq!(dup_classmod_mod.defs.len(), 1, "test_22b1_dup_classmod expected to have exactly 1 def (class Show)"); assert_eq!( - def_hash(&dup_a_mod.defs[1]), - "cc8685f92f246e40", - "test_22b1_dup_a instance Show for test_22b1_dup_b.MyInt canonical hash must match captured post-migration value" + def_hash(&dup_classmod_mod.defs[0]), + "1c2573661ffd3da3", + "test_22b1_dup_classmod class Show canonical hash must equal the pre-mq.1 dup_a class Show hash (same bytes)" ); } diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index 51b86d3..bb497a5 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -357,16 +357,53 @@ pub enum WorkspaceLoadError { name: String, }, + /// 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, + }, + /// ct.1: a class-reference field (`InstanceDef.class`, /// `SuperclassRef.class`, `Constraint.class`, or /// `ClassDef.name`) contains a `.` — under this milestone class /// names are NOT module-qualified (see DESIGN spec §"Out of /// scope: Class names"). The schema rejects qualified forms so /// half-migrated files cannot silently load. + /// + /// mq.1: narrowed to `ClassDef.name` only; the three other + /// fields now follow the canonical-form rule and use + /// `BareCrossModuleClassRef` / `BadCrossModuleClassRef`. #[error( "module `{module}` contains qualified class name `{name}` in field `{field}`. \ - Class names are not module-qualified in this milestone; \ - keep the bare form." + Class names are not module-qualified at the defining site; \ + keep the bare form for `ClassDef.name`." )] QualifiedClassName { module: String, @@ -504,6 +541,23 @@ where }) } +/// 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` argument; qualified ⇒ as-is. Symmetric to ct.1's +/// `normalize_type_for_registry` for `Type::Con`. +/// +/// For an `InstanceDef.class` or `Constraint.class` field the caller +/// is the defining module of the owning def. For a `SuperclassRef.class` +/// the caller is the parent class's defining module. +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}") + } +} + /// Iter 22b.1: build the workspace-global typeclass instance registry. /// /// Two passes: @@ -523,6 +577,13 @@ fn build_registry( ) -> Result { // Pass 1: collect "where is X defined" maps, plus a class lookup // by name (needed for the method-completeness check). + // + // mq.1: `class_def_module` and `class_by_name` are keyed by the + // qualified class name `.`. All consumers + // (Pass-2 coherence lookup, method-completeness check, superclass + // walk, etc.) query with `qualify_class_ref` applied to the field + // value so bare same-module and qualified cross-module refs both + // resolve to the same key. 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(); @@ -530,8 +591,9 @@ fn build_registry( 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); + let qualified = format!("{mod_name}.{}", c.name); + class_def_module.insert(qualified.clone(), mod_name.clone()); + class_by_name.insert(qualified, c); } Def::Type(t) => { type_def_module.insert( @@ -578,8 +640,13 @@ fn build_registry( match def { Def::Class(c) => { for method in &c.methods { + // mq.1: `class_name` carries the qualified + // workspace key (`.`). The Display + // format emits `"class . (in )"` + // which is informative even if visually + // redundant. let origin = Origin::Class { - class_name: c.name.clone(), + class_name: format!("{mod_name}.{}", c.name), module: mod_name.clone(), }; if let Some(prior) = method_origins.get(&method.name) { @@ -642,14 +709,32 @@ fn build_registry( // satisfies the second. Primitives have no // user-defined module — instances on primitives // therefore must live in the class's module. + // + // mq.1: lookup keys are qualified + // (`.`); the `inst.class` + // field is the canonical-form value (bare or qualified) + // which `qualify_class_ref` lifts to the workspace key. + let inst_class_key = qualify_class_ref(&inst.class, mod_name); let class_mod = class_def_module - .get(&inst.class) + .get(&inst_class_key) .cloned() .unwrap_or_else(|| "".into()); - let type_mod = type_def_module - .get(&(mod_name.clone(), type_repr.clone())) - .cloned() - .unwrap_or_else(|| "".into()); + // mq.1: type-leg lookup must accept the canonical form + // for the head name. Bare head ⇒ key by + // (caller_module, head); qualified `.` ⇒ + // key by (owner, bare) directly (the owner IS the + // defining module under the canonical-form rule). + let type_mod = if let Some((owner, bare)) = type_repr.split_once('.') { + type_def_module + .get(&(owner.to_string(), bare.to_string())) + .cloned() + .unwrap_or_else(|| "".into()) + } else { + type_def_module + .get(&(mod_name.clone(), type_repr.clone())) + .cloned() + .unwrap_or_else(|| "".into()) + }; let coherent = mod_name == &class_mod || mod_name == &type_mod; if !coherent { return Err(WorkspaceLoadError::OrphanInstance { @@ -668,6 +753,11 @@ fn build_registry( // the type's defining module and a qualified-cross-module // declaration from elsewhere produce the same key (both // refer to the same type under the canonical-form rule). + // + // mq.1: registry key is keyed by the qualified class + // form too, so a bare same-module instance and a + // qualified cross-module instance on the same + // (class, type) collide on this check. let type_hash = canonical::type_hash( &normalize_type_for_registry( mod_name, @@ -675,7 +765,7 @@ fn build_registry( &type_def_module, ), ); - let key = (inst.class.clone(), type_hash); + let key = (inst_class_key.clone(), type_hash); if let Some(prior) = entries.get(&key) { return Err(WorkspaceLoadError::DuplicateInstance { class: inst.class.clone(), @@ -692,7 +782,7 @@ fn build_registry( // typecheck arms — for 22b.1 we skip the // completeness check rather than firing a separate // diagnostic. - if let Some(class_def) = class_by_name.get(&inst.class) { + if let Some(class_def) = class_by_name.get(&inst_class_key) { let provided: BTreeSet<&str> = inst.methods.iter().map(|m| m.name.as_str()).collect(); for class_method in &class_def.methods { @@ -739,6 +829,12 @@ fn build_registry( // Iter 22b.2: superclass-instance completeness. For every entry, // walk the class's superclass chain and require an entry for each // step at the same type-hash. + // + // mq.1: `class_name` (the entries key first half) is the qualified + // class name; the superclass-ref field `sc.class` is canonical-form, + // which `qualify_class_ref` lifts to the qualified key — using the + // parent class's defining module as the caller context (the + // superclass declaration lives inside the parent class's module). for (key, entry) in entries.iter() { let (class_name, type_hash) = key; let type_repr = type_head_name(&entry.instance.type_); @@ -746,12 +842,17 @@ fn build_registry( // here we just terminate the walk. let mut visited: BTreeSet<&str> = BTreeSet::new(); let mut current = class_by_name.get(class_name.as_str()).copied(); + let mut current_class_module = class_def_module + .get(class_name.as_str()) + .cloned() + .unwrap_or_default(); while let Some(c) = current { if !visited.insert(c.name.as_str()) { break; } if let Some(sc) = &c.superclass { - let sc_key = (sc.class.clone(), type_hash.clone()); + let sc_class_key = qualify_class_ref(&sc.class, ¤t_class_module); + let sc_key = (sc_class_key.clone(), type_hash.clone()); if !entries.contains_key(&sc_key) { return Err(WorkspaceLoadError::MissingSuperclassInstance { class: class_name.clone(), @@ -759,7 +860,14 @@ fn build_registry( type_repr: type_repr.clone(), }); } - current = class_by_name.get(sc.class.as_str()).copied(); + // mq.1: walk lookup uses the qualified key; the next + // step's owning-module context is the superclass's + // defining module (read from `class_def_module`). + current = class_by_name.get(sc_class_key.as_str()).copied(); + current_class_module = class_def_module + .get(&sc_class_key) + .cloned() + .unwrap_or_default(); } else { break; } @@ -935,6 +1043,22 @@ pub(crate) fn validate_canonical_type_names( local_types.insert(mod_name.clone(), s); } + // mq.1: symmetric pre-pass over `Def::Class` names. The map is + // module → bare-class-name set; used by `check_class_ref` to apply + // the canonical-form rule to the three migrated class-ref fields + // (`InstanceDef.class`, `Constraint.class`, `SuperclassRef.class`). + // `ClassDef.name` stays bare per spec and never queries this map. + let mut local_classes: BTreeMap> = BTreeMap::new(); + for (mod_name, m) in modules { + let mut s = BTreeSet::new(); + for def in &m.defs { + if let Def::Class(c) = def { + s.insert(c.name.clone()); + } + } + local_classes.insert(mod_name.clone(), s); + } + for (mod_name, m) in modules { // Imports in declaration order — used for both the // qualified-`` known-module check and the bare-non-primitive @@ -957,12 +1081,112 @@ pub(crate) fn validate_canonical_type_names( ) })?; check_class_name_fields(def, mod_name)?; + + // mq.1: apply the canonical-form rule to the three + // migrated class-ref fields. + match def { + Def::Instance(id) => { + check_class_ref(&id.class, mod_name, &local_classes, &import_names)?; + } + Def::Class(cd) => { + if let Some(sc) = &cd.superclass { + check_class_ref(&sc.class, mod_name, &local_classes, &import_names)?; + } + for cmth in &cd.methods { + if let Type::Forall { constraints, .. } = &cmth.ty { + for c in constraints { + check_class_ref(&c.class, mod_name, &local_classes, &import_names)?; + } + } + } + } + Def::Fn(fd) => { + if let Type::Forall { constraints, .. } = &fd.ty { + for c in constraints { + check_class_ref(&c.class, mod_name, &local_classes, &import_names)?; + } + } + } + _ => {} + } } } Ok(()) } +/// mq.1: apply the canonical-form rule to a class-reference field +/// value (`InstanceDef.class`, `Constraint.class`, or +/// `SuperclassRef.class`). Sibling of `check_type_con_name` for +/// class refs. +/// +/// Three rules, symmetric to the type-ref rule: +/// 1. Qualified `.`: `` must be a known module +/// that declares the class. Else `BadCrossModuleClassRef`. +/// 2. Bare same-module-class: `` is in the owning module's +/// `Def::Class` set. Accepted. +/// 3. Bare cross-module: fire `BareCrossModuleClassRef` with +/// `candidates` = qualified forms found by scanning the owning +/// module's imports (plus implicit `prelude` if not already +/// imported, mirroring `check_type_con_name`). +fn check_class_ref( + class_ref: &str, + owning_module: &str, + local_classes: &BTreeMap>, + import_names: &[&str], +) -> Result<(), WorkspaceLoadError> { + if let Some((owner, bare)) = class_ref.split_once('.') { + // Rule 1: qualified. + let owner_classes = local_classes + .get(owner) + .ok_or_else(|| WorkspaceLoadError::BadCrossModuleClassRef { + module: owning_module.to_string(), + name: class_ref.to_string(), + })?; + if !owner_classes.contains(bare) { + return Err(WorkspaceLoadError::BadCrossModuleClassRef { + module: owning_module.to_string(), + name: class_ref.to_string(), + }); + } + return Ok(()); + } + // Bare: must be local to the owning module. + if local_classes + .get(owning_module) + .map(|s| s.contains(class_ref)) + .unwrap_or(false) + { + return Ok(()); + } + // Bare cross-module: collect qualified candidates from imports in + // declaration order; add implicit `prelude` last if not already an + // import (mirrors `check_type_con_name`). + let mut candidates: Vec = Vec::new(); + for imp in import_names { + if local_classes + .get(*imp) + .map(|s| s.contains(class_ref)) + .unwrap_or(false) + { + candidates.push(format!("{imp}.{class_ref}")); + } + } + if !import_names.contains(&"prelude") + && local_classes + .get("prelude") + .map(|s| s.contains(class_ref)) + .unwrap_or(false) + { + candidates.push(format!("prelude.{class_ref}")); + } + Err(WorkspaceLoadError::BareCrossModuleClassRef { + module: owning_module.to_string(), + name: class_ref.to_string(), + candidates, + }) +} + /// ct.1: apply the canonical-form rule to one `Type::Con.name` /// (also reused for `Term::Ctor.type_name` in Task 2). fn check_type_con_name( @@ -1279,65 +1503,30 @@ where } } -/// ct.1: reject any `.` in the four class-reference fields. -/// `ClassDef.name`, `InstanceDef.class`, `SuperclassRef.class`, -/// `Constraint.class`. Per spec §"Out of scope: Class names": class -/// names are NOT module-qualified in this milestone, so any -/// qualified form indicates a half-migrated file. +/// ct.1: reject any `.` in `ClassDef.name`. The defining-site name is +/// bare by convention (symmetric to `TypeDef.name`); a qualified form +/// indicates a malformed file. +/// +/// mq.1: narrowed from the four class-reference fields to +/// `ClassDef.name` only. The three referencing fields +/// (`InstanceDef.class`, `Constraint.class`, `SuperclassRef.class`) +/// now follow the canonical-form rule and are validated by +/// `check_class_ref` (fires `BareCrossModuleClassRef` / +/// `BadCrossModuleClassRef`). fn check_class_name_fields( def: &Def, owning_module: &str, ) -> Result<(), WorkspaceLoadError> { - fn fire( - owning_module: &str, - name: &str, - field: &'static str, - ) -> WorkspaceLoadError { - WorkspaceLoadError::QualifiedClassName { - module: owning_module.to_string(), - name: name.to_string(), - field, + 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", + }); } } - match def { - Def::Class(cd) => { - if cd.name.contains('.') { - return Err(fire(owning_module, &cd.name, "ClassDef.name")); - } - if let Some(sc) = &cd.superclass { - if sc.class.contains('.') { - return Err(fire(owning_module, &sc.class, "SuperclassRef.class")); - } - } - for m in &cd.methods { - if let Type::Forall { constraints, .. } = &m.ty { - for c in constraints { - if c.class.contains('.') { - return Err(fire(owning_module, &c.class, "Constraint.class")); - } - } - } - } - Ok(()) - } - Def::Instance(id) => { - if id.class.contains('.') { - return Err(fire(owning_module, &id.class, "InstanceDef.class")); - } - Ok(()) - } - Def::Fn(fd) => { - if let Type::Forall { constraints, .. } = &fd.ty { - for c in constraints { - if c.class.contains('.') { - return Err(fire(owning_module, &c.class, "Constraint.class")); - } - } - } - Ok(()) - } - Def::Const(_) | Def::Type(_) => Ok(()), - } + Ok(()) } /// Iter 22b.1: extract the head-constructor name of a type, for the @@ -1739,8 +1928,11 @@ mod tests { .collect(); assert_eq!(fixture_entries.len(), 1); let (key, entry) = fixture_entries[0]; - assert_eq!(&key.0, "Show"); + // mq.1: registry key is keyed by the qualified class name. + assert_eq!(&key.0, "test_22b1_orphan_class.Show"); assert_eq!(entry.defining_module, "test_22b1_orphan_class"); + // `instance.class` carries the canonical-form on-disk value + // (bare for same-module per the canonical-form rule). assert_eq!(entry.instance.class, "Show"); } @@ -1762,7 +1954,9 @@ mod tests { defining_module, .. } => { - assert_eq!(class, "Show"); + // mq.1: `class` carries the canonical form (qualified + // for cross-module class refs). + assert_eq!(class, "test_22b1_orphan_third_classmod.Show"); assert_eq!(type_repr, "Int"); assert_eq!(defining_module, "test_22b1_orphan_third"); } @@ -1770,20 +1964,25 @@ mod tests { } } - /// Iter 22b.1: two instances of the same `(class, type)` pair - /// declared from coherent positions (one in the class's module, - /// one in the type's module) collide on the registry's - /// uniqueness check. Setup per the JOURNAL hint: - /// - module A defines `class Show` and declares - /// `instance Show MyInt` (legal, A is class's module). - /// - module B defines `type MyInt` and declares - /// `instance Show MyInt` (legal, B is type's module). - /// - entry module imports both A and B. - /// The build_registry pass sees both instances under the same - /// `(Show, hash-of-MyInt)` key and fires `DuplicateInstance`. + /// Iter 22b.1 / mq.1: two instances of the same `(class, type)` + /// pair collide on the registry's uniqueness check. + /// + /// Pre-mq.1 the test used a two-module fixture + /// (`test_22b1_dup_a` declared the class + first instance, + /// `test_22b1_dup_b` declared the type + second instance) which + /// relied on bare cross-module class refs. The mq.1 + /// canonical-form rule makes that shape structurally + /// unrepresentable (any second instance in a module that owns + /// neither the class nor the type fires `OrphanInstance` first). + /// Post-mq.1 the only way to land two instances on the same + /// canonical key is to have them in the same module (the class's + /// or the type's module). The fixture now declares both + /// instances in `test_22b1_dup_same_module` — both class-leg + /// coherent, both collide on `(test_22b1_dup_same_module.Show, + /// type_hash(Int))`. #[test] fn iter22b1_duplicate_instance_fires_diagnostic() { - let entry = examples_dir().join("test_22b1_dup_entry.ail.json"); + let entry = examples_dir().join("test_22b1_dup_same_module.ail.json"); let err = load_workspace(&entry).expect_err("must fire duplicate"); match err { WorkspaceLoadError::DuplicateInstance { @@ -1792,13 +1991,14 @@ mod tests { first_module, second_module, } => { + // `class` is the on-disk `inst.class` value; the + // fixture is intra-module so it stays bare. assert_eq!(class, "Show"); - assert_eq!(type_repr, "MyInt"); - assert_ne!(first_module, second_module); - let modules: BTreeSet<&str> = - [first_module.as_str(), second_module.as_str()].iter().copied().collect(); - assert!(modules.contains("test_22b1_dup_a")); - assert!(modules.contains("test_22b1_dup_b")); + assert_eq!(type_repr, "Int"); + // Both instances live in the same module post-mq.1, + // by structural necessity — see test doc-comment. + assert_eq!(first_module, "test_22b1_dup_same_module"); + assert_eq!(second_module, "test_22b1_dup_same_module"); } other => panic!("expected DuplicateInstance, got {other:?}"), } @@ -1948,12 +2148,14 @@ mod tests { } => { assert_eq!(method, "foo"); assert_eq!(kind, "class-class"); + // mq.1: Origin::Class.class_name is qualified — + // Display emits "class . (in )". assert!( - first_origin.starts_with("class A"), + first_origin.contains(".A"), "first_origin = {first_origin:?}", ); assert!( - second_origin.starts_with("class B"), + second_origin.contains(".B"), "second_origin = {second_origin:?}", ); } @@ -1982,8 +2184,10 @@ mod tests { } => { assert_eq!(method, "greet"); assert_eq!(kind, "class-fn"); + // mq.1: Origin::Class.class_name is qualified — + // Display emits "class .Greet (in )". assert!( - first_origin.starts_with("class Greet"), + first_origin.contains(".Greet"), "first_origin = {first_origin:?}", ); assert!( @@ -2013,7 +2217,12 @@ mod tests { WorkspaceLoadError::MissingSuperclassInstance { class, superclass, type_repr, } => { - assert_eq!(class, "TOrd"); + // mq.1: `class` is the qualified registry-key class + // name (the chain walker starts from the registry's + // qualified key); `superclass` is the canonical-form + // value from `SuperclassRef.class` (intra-module here + // so it stays bare). + assert_eq!(class, "test_22b2_missing_superclass_instance.TOrd"); assert_eq!(superclass, "TEq"); assert_eq!(type_repr, "Int"); } @@ -2280,14 +2489,28 @@ mod tests { } } - /// ct.1: a qualified `InstanceDef.class` must fire - /// `QualifiedClassName`. + /// mq.1: a qualified `InstanceDef.class` referencing a class + /// declared in another known module is the canonical form post-mq.1 + /// and is accepted by the validator. Inverted from the pre-mq.1 + /// `ct1_validator_rejects_qualified_instancedef_class` test: + /// `InstanceDef.class` moved bare→canonical. #[test] - fn ct1_validator_rejects_qualified_instancedef_class() { + fn ct1_validator_accepts_qualified_instancedef_class() { + let other: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "other", + "imports": [], + "defs": [{ + "kind": "class", + "name": "MyEq", + "param": "a", + "methods": [] + }], + })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", - "imports": [], + "imports": [{ "module": "other" }], "defs": [{ "kind": "instance", "class": "other.MyEq", @@ -2296,26 +2519,33 @@ mod tests { }], })).unwrap(); let mut modules = BTreeMap::new(); + modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - let err = validate_canonical_type_names(&modules) - .expect_err("qualified InstanceDef.class must be rejected"); - match err { - WorkspaceLoadError::QualifiedClassName { name, field, .. } => { - assert_eq!(name, "other.MyEq"); - assert_eq!(field, "InstanceDef.class"); - } - other => panic!("expected QualifiedClassName, got {other:?}"), - } + validate_canonical_type_names(&modules) + .expect("qualified InstanceDef.class is the canonical form post-mq.1"); } - /// ct.1: a qualified `SuperclassRef.class` must fire - /// `QualifiedClassName`. + /// mq.1: a qualified `SuperclassRef.class` referencing a class in + /// another known module is the canonical form post-mq.1 and is + /// accepted. Inverted from + /// `ct1_validator_rejects_qualified_superclassref_class`. #[test] - fn ct1_validator_rejects_qualified_superclassref_class() { + fn ct1_validator_accepts_qualified_superclassref_class() { + let other: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "other", + "imports": [], + "defs": [{ + "kind": "class", + "name": "MyEq", + "param": "a", + "methods": [] + }], + })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", - "imports": [], + "imports": [{ "module": "other" }], "defs": [{ "kind": "class", "name": "MyOrd", @@ -2325,26 +2555,33 @@ mod tests { }], })).unwrap(); let mut modules = BTreeMap::new(); + modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - let err = validate_canonical_type_names(&modules) - .expect_err("qualified SuperclassRef.class must be rejected"); - match err { - WorkspaceLoadError::QualifiedClassName { name, field, .. } => { - assert_eq!(name, "other.MyEq"); - assert_eq!(field, "SuperclassRef.class"); - } - other => panic!("expected QualifiedClassName, got {other:?}"), - } + validate_canonical_type_names(&modules) + .expect("qualified SuperclassRef.class is the canonical form post-mq.1"); } - /// ct.1: a qualified `Constraint.class` (inside a `Type::Forall`) - /// must fire `QualifiedClassName`. + /// mq.1: a qualified `Constraint.class` (inside a `Type::Forall`) + /// referencing a class in another known module is the canonical + /// form post-mq.1 and is accepted. Inverted from + /// `ct1_validator_rejects_qualified_constraint_class`. #[test] - fn ct1_validator_rejects_qualified_constraint_class() { + fn ct1_validator_accepts_qualified_constraint_class() { + let other: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "other", + "imports": [], + "defs": [{ + "kind": "class", + "name": "MyEq", + "param": "a", + "methods": [] + }], + })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", - "imports": [], + "imports": [{ "module": "other" }], "defs": [{ "kind": "fn", "name": "f", @@ -2364,16 +2601,10 @@ mod tests { }], })).unwrap(); let mut modules = BTreeMap::new(); + modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - let err = validate_canonical_type_names(&modules) - .expect_err("qualified Constraint.class must be rejected"); - match err { - WorkspaceLoadError::QualifiedClassName { name, field, .. } => { - assert_eq!(name, "other.MyEq"); - assert_eq!(field, "Constraint.class"); - } - other => panic!("expected QualifiedClassName, got {other:?}"), - } + validate_canonical_type_names(&modules) + .expect("qualified Constraint.class is the canonical form post-mq.1"); } /// ct.1.5a: registry-side duplicate detection survives the @@ -2431,10 +2662,11 @@ mod tests { ], })).unwrap(); - // Module `other`: declares `type MyInt` and the bare-local - // instance `instance TShow MyInt`. Imports `cls` for shape - // coherence (the dependency direction needed to bring `class - // TShow` into scope under the actual loader). + // Module `other`: declares `type MyInt` and the qualified- + // cross-module instance `instance cls.TShow MyInt`. Imports + // `cls` for the class ref. The `class` field carries the + // canonical form (qualified for cross-module per mq.1); the + // `type` field stays bare-local. let other: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "other", @@ -2443,7 +2675,7 @@ mod tests { { "kind": "type", "name": "MyInt", "ctors": [{ "name": "MkMyInt", "fields": [] }] }, { "kind": "instance", - "class": "TShow", + "class": "cls.TShow", "type": { "k": "con", "name": "MyInt" }, "methods": [ { "name": "tshow", @@ -2464,7 +2696,10 @@ mod tests { WorkspaceLoadError::DuplicateInstance { class, first_module, second_module, .. } => { - assert_eq!(class, "TShow"); + // The `class` field carries the second-arrival + // instance's `inst.class` value (canonical-form). + // `other`'s instance writes the qualified form. + assert_eq!(class, "cls.TShow"); let mods: BTreeSet<&str> = [first_module.as_str(), second_module.as_str()].into_iter().collect(); assert!(mods.contains("cls"), @@ -2539,14 +2774,29 @@ mod tests { /// ct.1: a qualified `Constraint.class` nested inside a /// `ClassDef.methods[].ty.Forall.constraints` must fire /// `QualifiedClassName`. Distinct from the `Def::Fn` site: the - /// `Def::Class` branch of `check_class_name_fields` has its own - /// per-method Forall walk that needs independent coverage. + /// `Def::Class` branch's per-method Forall walk needs independent + /// coverage. + /// + /// mq.1: inverted — qualified `Constraint.class` inside a + /// `ClassDef.methods` Forall is the canonical form post-mq.1 and is + /// accepted by the validator. #[test] - fn ct1_validator_rejects_qualified_constraint_class_in_classdef_method() { + fn ct1_validator_accepts_qualified_constraint_class_in_classdef_method() { + let other: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "other", + "imports": [], + "defs": [{ + "kind": "class", + "name": "MyEq", + "param": "a", + "methods": [] + }], + })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", - "imports": [], + "imports": [{ "module": "other" }], "defs": [{ "kind": "class", "name": "Foo", @@ -2570,16 +2820,10 @@ mod tests { }], })).unwrap(); let mut modules = BTreeMap::new(); + modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); - let err = validate_canonical_type_names(&modules) - .expect_err("qualified Constraint.class in ClassDef-method Forall must be rejected"); - match err { - WorkspaceLoadError::QualifiedClassName { name, field, .. } => { - assert_eq!(name, "other.MyEq"); - assert_eq!(field, "Constraint.class"); - } - other => panic!("expected QualifiedClassName, got {other:?}"), - } + validate_canonical_type_names(&modules) + .expect("qualified Constraint.class in ClassDef-method Forall is the canonical form post-mq.1"); } /// ct.1: on-disk fixture for `BareCrossModuleTypeRef`. Bare @@ -2615,23 +2859,46 @@ mod tests { } } - /// ct.1: on-disk fixture for `QualifiedClassName`. Qualified - /// `prelude.Eq` in an `InstanceDef.class` field — the schema - /// rejects this so half-migrated files cannot silently load. + /// mq.1: on-disk fixture for the post-mq.1 rejection path. The + /// fixture declares `instance prelude.Eq Int` outside the prelude + /// and outside Int's defining module — under the canonical-form + /// rule the qualified `prelude.Eq` ref is now schema-valid (post- + /// mq.1), and the downstream coherence check rejects the instance + /// with `OrphanInstance` instead. + /// + /// Pre-mq.1 this test asserted `QualifiedClassName` on the same + /// fixture; the rename + reshape is the on-disk-fixture half of + /// the four in-test inversions further up. #[test] - fn ct1_fixture_qualified_class_rejected() { + fn ct1_fixture_qualified_class_orphan_post_mq1() { let entry = examples_dir().join("test_ct1_qualified_class_rejected.ail.json"); - let err = load_workspace(&entry).expect_err("must reject prelude.Eq"); + let err = load_workspace(&entry).expect_err("must reject (now as Orphan)"); match err { - WorkspaceLoadError::QualifiedClassName { module, name, field } => { - assert_eq!(module, "test_ct1_qualified_class_rejected"); - assert_eq!(name, "prelude.Eq"); - assert_eq!(field, "InstanceDef.class"); + WorkspaceLoadError::OrphanInstance { + class, type_repr, defining_module, .. + } => { + assert_eq!(class, "prelude.Eq"); + assert_eq!(type_repr, "Int"); + assert_eq!(defining_module, "test_ct1_qualified_class_rejected"); } - other => panic!("expected QualifiedClassName, got {other:?}"), + other => panic!("expected OrphanInstance, got {other:?}"), } } + /// mq.1: 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 (`ct1_validator_accepts_qualified_xmod_ref` + /// in-test sibling). Guards the full load → validator → registry + /// path on a real on-disk pair. + #[test] + fn mq1_xmod_constraint_class_fixture_loads() { + let entry = examples_dir().join("mq1_xmod_constraint_class.ail.json"); + let ws = load_workspace(&entry).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")); + } + /// ext-cli.1 Task 1: the new `load_workspace_with` injection point /// invokes the caller-supplied loader exactly once for the entry /// module of a single-module workspace. Protects against the @@ -2667,4 +2934,210 @@ mod tests { fn fixture_module_json(name: &str) -> String { format!(r#"{{"schema":"ailang/v0","name":"{name}","imports":[],"defs":[]}}"#) } + + /// 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}"); + } + + /// 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 a: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "A", + "imports": [], + "defs": [{ + "kind": "class", + "name": "Show", + "param": "a", + "methods": [] + }] + })).unwrap(); + let b: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "B", + "imports": [{ "module": "A" }], + "defs": [{ + "kind": "instance", + "class": "Show", + "type": { "k": "con", "name": "Int" }, + "methods": [] + }] + })).unwrap(); + let mut modules = BTreeMap::new(); + modules.insert("A".to_string(), a); + modules.insert("B".to_string(), b); + 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 a: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "A", + "imports": [], + "defs": [{ + "kind": "class", + "name": "Show", + "param": "a", + "methods": [] + }] + })).unwrap(); + let b: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "B", + "imports": [{ "module": "A" }], + "defs": [{ + "kind": "fn", + "name": "f", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [ + { "class": "Show", "type": { "k": "var", "name": "a" } } + ], + "body": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Unit" }, + "effects": [] + } + }, + "params": ["x"], + "body": { "t": "lit", "lit": { "kind": "unit" } } + }] + })).unwrap(); + let mut modules = BTreeMap::new(); + modules.insert("A".to_string(), a); + modules.insert("B".to_string(), b); + 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 a: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "A", + "imports": [], + "defs": [{ + "kind": "class", + "name": "MyEq", + "param": "a", + "methods": [] + }] + })).unwrap(); + let b: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "B", + "imports": [{ "module": "A" }], + "defs": [{ + "kind": "class", + "name": "MyOrd", + "param": "a", + "superclass": { "class": "MyEq", "type": "a" }, + "methods": [] + }] + })).unwrap(); + let mut modules = BTreeMap::new(); + modules.insert("A".to_string(), a); + modules.insert("B".to_string(), b); + let err = validate_canonical_type_names(&modules).expect_err("expected reject"); + match err { + WorkspaceLoadError::BareCrossModuleClassRef { module, name, .. } => { + assert_eq!(module, "B"); + assert_eq!(name, "MyEq"); + } + other => panic!("expected BareCrossModuleClassRef, got {other:?}"), + } + } + + /// 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 a: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "A", + "imports": [], + "defs": [{ + "kind": "class", + "name": "Show", + "param": "a", + "methods": [] + }] + })).unwrap(); + let b: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "B", + "imports": [{ "module": "A" }], + "defs": [{ + "kind": "instance", + "class": "A.Show", + "type": { "k": "con", "name": "Int" }, + "methods": [] + }] + })).unwrap(); + let mut modules = BTreeMap::new(); + modules.insert("A".to_string(), a); + modules.insert("B".to_string(), b); + validate_canonical_type_names(&modules).expect("qualified A.Show must be accepted"); + } } diff --git a/crates/ailang-core/tests/ctt2_registry_rekey.rs b/crates/ailang-core/tests/ctt2_registry_rekey.rs index e56c09d..b0a1e49 100644 --- a/crates/ailang-core/tests/ctt2_registry_rekey.rs +++ b/crates/ailang-core/tests/ctt2_registry_rekey.rs @@ -41,8 +41,9 @@ fn two_modules_with_same_bare_foo_both_register() { args: vec![], }); - let main_key = ("MyC".to_string(), main_foo_hash); - let lib_key = ("MyC".to_string(), lib_foo_hash); + // mq.1: registry-entries key is keyed by the qualified class name. + let main_key = ("ctt2_collision_cls.MyC".to_string(), main_foo_hash); + let lib_key = ("ctt2_collision_cls.MyC".to_string(), lib_foo_hash); assert!( ws.registry.entries.contains_key(&main_key), diff --git a/docs/journals/2026-05-13-iter-mq.1.md b/docs/journals/2026-05-13-iter-mq.1.md new file mode 100644 index 0000000..bc3ca96 --- /dev/null +++ b/docs/journals/2026-05-13-iter-mq.1.md @@ -0,0 +1,176 @@ +# iter mq.1 — Canonical-form extension for class-ref fields + workspace internal qualification + +**Date:** 2026-05-13 +**Started from:** 1a5f8289b7fbad3ae5bdd6badde27c80eca6dad3 +**Status:** DONE +**Tasks completed:** 7 of 7 + +## Summary + +mq.1 lands the canonical-form rule for the three class-reference +schema fields (`InstanceDef.class`, `Constraint.class`, +`SuperclassRef.class`) — bare for same-module, `.` +for cross-module — symmetric to ct.1's `Type::Con.name` rule. +`ClassDef.name` stays bare (defining site, like `TypeDef.name`). +ct.1's `validate_canonical_type_names` validator gains a sibling +walk for class refs via the new `check_class_ref` helper, with +two new sibling diagnostics `BareCrossModuleClassRef` and +`BadCrossModuleClassRef`. Workspace-internal class-name keys +(`class_def_module`, `class_by_name`, registry `entries.0`, +`ClassMethodEntry.class_name`, `class_superclasses`, mono's +`class_index`, `Origin::Class.class_name`) all carry the qualified +form post-mq.1; the on-disk schema value is the canonical form (bare +or qualified) that `qualify_class_ref` lifts to the workspace key +at every lookup boundary. `MethodNameCollision` and the dispatch +path are unchanged in this iter (iter 2 + 3 land them). + +## Per-task notes + +- iter mq.1.1: two new diagnostic variants + `BareCrossModuleClassRef` / `BadCrossModuleClassRef` sibling to the + type-ref variants; two Diagnostic-shape Display arms in `ail/main.rs` + mirroring the existing arm. 2 RED unit tests, both GREEN. +- iter mq.1.2: doc-comments on the four class-reference fields + in `ast.rs` (`ClassDef.name` stays bare with cross-reference; + `SuperclassRef.class` / `InstanceDef.class` / `Constraint.class` + document canonical form). Zero semantic effect. +- iter mq.1.3: `check_class_ref` helper plus three per-def walks + inside `validate_canonical_type_names`; `check_class_name_fields` + narrowed to `ClassDef.name` only; four in-test pin tests inverted + (qualified is now accepted); on-disk `test_ct1_qualified_class_rejected` + fixture-test repurposed (now expects `OrphanInstance` — + the natural post-mq.1 rejection mode for the same shape); positive + on-disk fixture pair `mq1_xmod_constraint_class{,_dep}` shipped + plus the corresponding pin test. 4 new bare-xmod tests + 1 positive + qualified test, all GREEN. +- iter mq.1.4: `qualify_class_ref` helper at module scope; `build_registry` + Pass-1 re-keyed (qualified); Pass-2 lookups use `qualify_class_ref` to + lift `inst.class` to the registry-key shape; superclass walk threads + `current_class_module` to qualify bare `SuperclassRef.class`; + `Origin::Class.class_name` qualified (one construction site, not two as + the plan claimed); type-leg of coherence check now also handles + qualified head names symmetrically (split-and-lookup). +- iter mq.1.5: `ClassMethodEntry.class_name` insert qualifies in + `build_module_globals`; `class_superclasses` key + value both + qualified; mono's `build_class_index` keyed qualified (plan said + "no code change needed in mono.rs consumers" — that statement was + wrong, the build-side needed the matching qualification). +- iter mq.1.6: test assertions updated for the new qualified shape — + the two `MethodNameCollision` pin tests, `typeclass_22b2.rs:42` + (`class_method_class("show")` now `Some("...Show")`), + `typeclass_22b3.rs:151` (`MonoTarget.class` qualified). The other + lines plan flagged (217/232/295/301/341/353) are local-construction + tests that never round-trip through the workspace — left as-is. +- iter mq.1.7: full `cargo test --workspace` 520 passed / 0 failed; + `examples/prelude.ail.json` zero diff (confirms spec assumption 17: + intra-prelude refs stay bare); `bench/compile_check.py` exit 0, + `bench/cross_lang.py` exit 0; `bench/check.py` exit 1 due to 4 + improvements-beyond-tolerance (audit-ratifiable per convention, + not a regression). Roundtrip on the new fixture: `ail check + examples/mq1_xmod_constraint_class.ail.json` → "ok (16 symbols + across 3 modules)", exit 0. + +## Concerns + +- **Boss-note recon claim was empirically wrong.** The boss-note + said "every existing `examples/test_22b*.ail.json` class-ref field + is intra-module under the canonical-form rule, so existing fixtures + carry zero diff". Five test_22b* fixtures actually carried bare + cross-module class refs and required migration: `test_22b1_orphan_third.ail.json` + (bare `Show` from imported classmod), `test_22b1_dup_a.ail.json` + + `test_22b1_dup_b.ail.json` (the duplicate-instance pair structurally + relied on the pre-mq.1 bare cross-module shape), and + `test_22b2_unbound_constraint_var.ail.json` (constraint references + a class that doesn't exist anywhere → fired BareCrossModuleClassRef + before reaching UnboundConstraintTypeVar). Six non-test_22b + fixtures also needed migration (`eq_ord_polymorphic`, `eq_ord_user_adt`, + `cmp_max_smoke`, `ctt2_collision_lib`, `ctt2_collision_main`). All + migrations are minimal and aligned with the canonical-form rule; + no proactive "fixture cleanup" added. +- **Duplicate-instance test restructured by structural necessity.** + Pre-mq.1 the test relied on two coherent instances on the same + `(class, type)` declared from different modules; post-mq.1, the + orphan-freedom invariant prevents this shape (any second instance + in a module that owns neither the class nor the type fires + `OrphanInstance` first). The new `test_22b1_dup_same_module.ail.json` + fixture lands both instances in the same module — the only post-mq.1 + way to land two on the same canonical key. The old dup_a/dup_b/entry + fixtures stay as supporting modules (referenced by hash.rs's + canonical-form hash pin) but no longer participate in the duplicate + test. This is arguably a strengthening of the architecture: duplicate + instances are now structurally impossible across coherent modules. +- **Plan's "no code change needed" claims in Task 4 Step 8 and Task 5 + Step 5 were wrong.** Task 4 Step 8 said `Origin::Class.class_name` + qualified at "both sites" but there's only one construction site + (the plan's recon was off-by-one). Task 5 Step 5 said mono's + `class_index.get(class)` lookups would just work post-rekey, but + `build_class_index` itself was bare-keyed. Both fixed inline. +- **Several adjacent fixes were necessary that the plan didn't anticipate:** + (a) `build_module_globals` rejected `inst.class` containing `.` — + added a `Def::Instance` carve-out so qualified class refs survive. + (b) `check_fn`'s declared-constraint matching compared bare + `c.class` to qualified residual `r.class` → added an inline lift via + `qualify_class_ref_in_check`. (c) `NoInstance` Float-aware message + arm matched on bare `"Eq" / "Ord"` — updated to `"prelude.Eq" / + "prelude.Ord"`. (d) Coherence check's type-leg lookup couldn't + resolve qualified head names — extended with a split-and-lookup + fallback. None of these widened the contract; each is a one-line + bridge keeping the post-mq.1 qualified shape consistent across the + pipeline. +- **Tasks 3-6 ran as one coherent fix.** The plan structured them as + sequential tasks, but Task 3 alone left several existing tests red + (registry still bare-keyed, residual still bare). Walking the plan + literally and expecting "green after Task 3" per the plan's Step 9 + was impossible; the four tasks form one consistent rekey. + +## Known debt + +- The `test_22b1_dup_a` / `test_22b1_dup_b` / `test_22b1_dup_entry` + fixtures are now orphan from the duplicate-instance test (which + uses `test_22b1_dup_same_module` instead). They're still + referenced by `hash.rs:293` for the canonical-form hash pin. A + cleanup pass could either retire them entirely (and replace the + hash pin with the new fixture) or keep them as historical + reference. Deferred — not load-bearing. +- `qualify_class_ref_in_check` in `ailang-check/src/lib.rs` is a + one-line duplicate of `workspace::qualify_class_ref`. The + alternative is exposing `qualify_class_ref` as `pub(crate)` or + re-exporting; the duplication was the minimum-edit path. A + consolidation tidy could land in iter 2. + +## Files touched + +Code: +- crates/ail/src/main.rs +- crates/ail/tests/ct1_check_cli.rs +- crates/ail/tests/typeclass_22b2.rs +- crates/ail/tests/typeclass_22b3.rs +- crates/ailang-check/src/lib.rs +- crates/ailang-check/src/mono.rs +- crates/ailang-check/tests/env_construction_pin.rs +- crates/ailang-core/src/ast.rs +- crates/ailang-core/src/hash.rs +- crates/ailang-core/src/workspace.rs +- crates/ailang-core/tests/ctt2_registry_rekey.rs + +Fixtures (existing, migrated): +- examples/cmp_max_smoke.ail.json +- examples/ctt2_collision_lib.ail.json +- examples/ctt2_collision_main.ail.json +- examples/eq_ord_polymorphic.ail.json +- examples/eq_ord_user_adt.ail.json +- examples/test_22b1_dup_a.ail.json +- examples/test_22b1_dup_b.ail.json +- examples/test_22b1_dup_entry.ail.json +- examples/test_22b1_orphan_third.ail.json +- examples/test_22b2_unbound_constraint_var.ail.json + +Fixtures (new): +- examples/mq1_xmod_constraint_class.ail.json +- examples/mq1_xmod_constraint_class_dep.ail.json +- examples/test_22b1_dup_classmod.ail.json +- examples/test_22b1_dup_same_module.ail.json + +## Stats + +bench/orchestrator-stats/2026-05-13-iter-mq.1.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index bb90ad0..6dc9292 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -42,3 +42,4 @@ - 2026-05-12 — iter ctt.3: `KindMismatch` retire — pure deletion across 3 files (workspace.rs: variant + dispatch + helper + "dead-but-defensive" doc; main.rs: Display arm; lib.rs: 22b.1 archaeology comment). Existing test `class_param_in_applied_position_fires_canonical_form_rejection` stays green unchanged (asserts `BareCrossModuleTypeRef` on the malformed fixture — the canonical-form-validator successor path). Two adjacent textual-consistency edits ride along (validate_classdefs doc-comment "Three → Two", lib.rs archaeology comment gains a ctt.3 retirement marker). One mid-deletion mechanical fix: dispatch-loop deletion orphaned `mod_name` binding → rewrote `for (mod_name, m) in modules` to `for m in modules.values()` (same body type, mechanical). 2/2 tasks, ~600 tests green across 16 binary-test suites → 2026-05-12-iter-ctt.3.md - 2026-05-12 — audit-ct-tidy: milestone close (ct-tidy) — architect drift fixed inline as `ctt.tidy` (3 doc-side edits: DESIGN.md §"Class-schema diagnostics" drops the retired `KindMismatch` bullet + adds successor paragraph naming `BareCrossModuleTypeRef`; DESIGN.md §"Higher-kinded class params" rewritten to name `BareCrossModuleTypeRef` from canonical-form validation instead; roadmap.md two P2 todos struck `[x]` with forward-reference to ctt.3 and ctt.2). Bench mixed (check.py exit 1: bump_s persistence on `bench_list_sum` second consecutive sighting at +12.23% → +12.60%; two new first-sighting rc-cohort max_us latency regressions classified as noise pending next audit; the recurring 3-audit `latency.explicit_at_rc` improvement cluster narrowed to within tolerance this audit, withdrawing the ratify-pending plan from audit-eob — the meaningful shrinkage is itself attribution evidence that the cluster is noise-class not signal-class), baseline pristine for the 4th consecutive audit → 2026-05-12-audit-ct-tidy.md - 2026-05-12 — iter 24.1: `bool_to_str` + `str_clone` runtime + codegen wiring — 2 new C functions in `runtime/str.c` (slab-allocating heap-Str primitives via existing `str_alloc`), lockstep checker + synth.rs installs with `ret_mode: Own`, 2 IR-header declares + 2 `lower_app` arms + `is_static_callee` whitelist extension, 5 IR snapshots regen (2 declares × 5 files), 9 new tests (2 builtins-install unit + 2 IR-shape pin + 5 E2E all green), pre-existing-drift fix included (int_to_str row added to `builtins.rs::list()`). One substantive deviation: builtin-signatures registered in `uniqueness.rs::infer_module` + `linearity.rs::check_module_with_visible` (8 LOC × 2 files) so `str_clone`'s `param_modes: [Borrow]` is visible to the App-arg walker; symmetric to 23.4-prep's class-method registration; necessary for the plan's literal `frees == 3` cross-realisation assertion. Full `cargo test --workspace` 513 passed, 0 failed. `bench/compile_check.py` + `bench/cross_lang.py` green → 2026-05-12-iter-24.1.md +- 2026-05-13 — iter mq.1: class-ref canonical-form extension + workspace-internal class-name qualification — three schema fields (`InstanceDef.class`, `Constraint.class`, `SuperclassRef.class`) move bare → canonical (bare for same-module, `.` for cross-module) symmetric to ct.1's `Type::Con.name` rule; `ClassDef.name` stays bare (defining site, like `TypeDef.name`). `validate_canonical_type_names` gains three field-walks via new `check_class_ref` helper plus two sibling diagnostics `BareCrossModuleClassRef` / `BadCrossModuleClassRef`; `check_class_name_fields` narrowed to `ClassDef.name`-only. Workspace-internal class-name keys all qualified: `class_def_module`, `class_by_name`, registry `entries.0`, `ClassMethodEntry.class_name`, `class_superclasses`, mono's `class_index`, `Origin::Class.class_name`. New `qualify_class_ref` helper in `workspace.rs` plus sibling `qualify_class_ref_in_check` in `ailang-check`. Two new positive on-disk fixtures (`mq1_xmod_constraint_class{,_dep}`). Recon claim that all existing fixtures' class-refs are intra-module was empirically wrong — 5 test_22b* (`orphan_third`, `dup_a/b/entry`, `unbound_constraint_var`) + 5 other (`eq_ord_polymorphic`, `eq_ord_user_adt`, `cmp_max_smoke`, `ctt2_collision_{lib,main}`) fixtures required minimal canonical-form migration. Duplicate-instance test architecturally restructured: post-mq.1 orphan-freedom makes two-modules-on-same-`(class, type)` structurally impossible (any second instance in a non-owning module fires `OrphanInstance` first); new `test_22b1_dup_same_module.ail.json` lands both instances in the class's module — the only post-mq.1 way to land two on the same canonical key. Plan defects fixed inline: `Origin::Class.class_name` had one construction site not two (plan recon off-by-one); `build_class_index` in `mono.rs` needed qualifying (plan said "no code change needed"); `build_module_globals` needed a `Def::Instance` carve-out for qualified `inst.class`; `check_fn`'s declared-constraint matching needed inline canonical-form lifting; `NoInstance` Float-aware message arm needed `prelude.Eq`/`prelude.Ord` match; coherence type-leg needed qualified-head split-and-lookup. Tasks 3-6 ran as one coherent fix (Task 3 alone left tests RED). 7/7 tasks, 520 tests green, `bench/compile_check.py` + `bench/cross_lang.py` exit 0; `bench/check.py` exit 1 due to 4 improvements-beyond-tolerance (audit-ratifiable per convention, not a regression). `MethodNameCollision` + dispatch path unchanged; iters mq.2 + mq.3 land them → 2026-05-13-iter-mq.1.md diff --git a/examples/cmp_max_smoke.ail.json b/examples/cmp_max_smoke.ail.json index 8e56e42..52dd44f 100644 --- a/examples/cmp_max_smoke.ail.json +++ b/examples/cmp_max_smoke.ail.json @@ -9,7 +9,7 @@ "type": { "k": "forall", "vars": ["a"], - "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "constraints": [{ "class": "prelude.Ord", "type": { "k": "var", "name": "a" } }], "body": { "k": "fn", "params": [{ "k": "var", "name": "a" }, { "k": "var", "name": "a" }], diff --git a/examples/ctt2_collision_lib.ail.json b/examples/ctt2_collision_lib.ail.json index 0edb4ab..2cf30fc 100644 --- a/examples/ctt2_collision_lib.ail.json +++ b/examples/ctt2_collision_lib.ail.json @@ -10,7 +10,7 @@ }, { "kind": "instance", - "class": "MyC", + "class": "ctt2_collision_cls.MyC", "type": { "k": "con", "name": "Foo" }, "methods": [ { diff --git a/examples/ctt2_collision_main.ail.json b/examples/ctt2_collision_main.ail.json index c6091ad..3dc6bae 100644 --- a/examples/ctt2_collision_main.ail.json +++ b/examples/ctt2_collision_main.ail.json @@ -13,7 +13,7 @@ }, { "kind": "instance", - "class": "MyC", + "class": "ctt2_collision_cls.MyC", "type": { "k": "con", "name": "Foo" }, "methods": [ { diff --git a/examples/eq_ord_polymorphic.ail.json b/examples/eq_ord_polymorphic.ail.json index dda5963..f0b14c6 100644 --- a/examples/eq_ord_polymorphic.ail.json +++ b/examples/eq_ord_polymorphic.ail.json @@ -10,7 +10,7 @@ "type": { "k": "forall", "vars": ["a"], - "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "constraints": [{ "class": "prelude.Ord", "type": { "k": "var", "name": "a" } }], "body": { "k": "fn", "params": [ diff --git a/examples/eq_ord_user_adt.ail.json b/examples/eq_ord_user_adt.ail.json index 1ff64c8..ec5a2f6 100644 --- a/examples/eq_ord_user_adt.ail.json +++ b/examples/eq_ord_user_adt.ail.json @@ -14,7 +14,7 @@ }, { "kind": "instance", - "class": "Eq", + "class": "prelude.Eq", "type": { "k": "con", "name": "IntBox" }, "doc": "Eq IntBox by structural unwrap of the inner Int.", "methods": [ @@ -56,7 +56,7 @@ }, { "kind": "instance", - "class": "Ord", + "class": "prelude.Ord", "type": { "k": "con", "name": "IntBox" }, "doc": "Ord IntBox by delegating to Int's compare on the inner field.", "methods": [ diff --git a/examples/mq1_xmod_constraint_class.ail.json b/examples/mq1_xmod_constraint_class.ail.json new file mode 100644 index 0000000..49114ed --- /dev/null +++ b/examples/mq1_xmod_constraint_class.ail.json @@ -0,0 +1,29 @@ +{ + "schema": "ailang/v0", + "name": "mq1_xmod_constraint_class", + "imports": [{ "module": "mq1_xmod_constraint_class_dep" }], + "defs": [ + { + "kind": "fn", + "name": "useShow", + "doc": "Positive mq.1 fixture: a polymorphic fn that takes a single value and a Show constraint, ignores the value, and returns Unit. Exercises the qualified `Constraint.class` shape end-to-end (loader + validator + check_workspace).", + "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": "lit", "lit": { "kind": "unit" } } + } + ] +} diff --git a/examples/mq1_xmod_constraint_class_dep.ail.json b/examples/mq1_xmod_constraint_class_dep.ail.json new file mode 100644 index 0000000..5a98799 --- /dev/null +++ b/examples/mq1_xmod_constraint_class_dep.ail.json @@ -0,0 +1,23 @@ +{ + "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" }], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + } + ] + } + ] +} diff --git a/examples/test_22b1_dup_a.ail.json b/examples/test_22b1_dup_a.ail.json index cb36035..c9e687e 100644 --- a/examples/test_22b1_dup_a.ail.json +++ b/examples/test_22b1_dup_a.ail.json @@ -4,36 +4,15 @@ "imports": [ { "module": "test_22b1_dup_b" + }, + { + "module": "test_22b1_dup_classmod" } ], "defs": [ - { - "kind": "class", - "name": "Show", - "param": "a", - "methods": [ - { - "name": "show", - "type": { - "k": "fn", - "params": [ - { - "k": "var", - "name": "a" - } - ], - "ret": { - "k": "con", - "name": "Str" - }, - "effects": [] - } - } - ] - }, { "kind": "instance", - "class": "Show", + "class": "test_22b1_dup_classmod.Show", "type": { "k": "con", "name": "test_22b1_dup_b.MyInt" @@ -52,4 +31,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/examples/test_22b1_dup_b.ail.json b/examples/test_22b1_dup_b.ail.json index 461c2aa..bb9b02d 100644 --- a/examples/test_22b1_dup_b.ail.json +++ b/examples/test_22b1_dup_b.ail.json @@ -1,7 +1,9 @@ { "schema": "ailang/v0", "name": "test_22b1_dup_b", - "imports": [], + "imports": [ + { "module": "test_22b1_dup_classmod" } + ], "defs": [ { "kind": "type", @@ -15,7 +17,7 @@ }, { "kind": "instance", - "class": "Show", + "class": "test_22b1_dup_classmod.Show", "type": { "k": "con", "name": "MyInt" }, "methods": [ { diff --git a/examples/test_22b1_dup_classmod.ail.json b/examples/test_22b1_dup_classmod.ail.json new file mode 100644 index 0000000..cd113a4 --- /dev/null +++ b/examples/test_22b1_dup_classmod.ail.json @@ -0,0 +1,23 @@ +{ + "schema": "ailang/v0", + "name": "test_22b1_dup_classmod", + "imports": [], + "defs": [ + { + "kind": "class", + "name": "Show", + "param": "a", + "methods": [ + { + "name": "show", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + } + ] + } + ] +} diff --git a/examples/test_22b1_dup_entry.ail.json b/examples/test_22b1_dup_entry.ail.json index ffd6202..58acd54 100644 --- a/examples/test_22b1_dup_entry.ail.json +++ b/examples/test_22b1_dup_entry.ail.json @@ -3,7 +3,8 @@ "name": "test_22b1_dup_entry", "imports": [ { "module": "test_22b1_dup_a" }, - { "module": "test_22b1_dup_b" } + { "module": "test_22b1_dup_b" }, + { "module": "test_22b1_dup_classmod" } ], "defs": [] } diff --git a/examples/test_22b1_dup_same_module.ail.json b/examples/test_22b1_dup_same_module.ail.json new file mode 100644 index 0000000..a27d38d --- /dev/null +++ b/examples/test_22b1_dup_same_module.ail.json @@ -0,0 +1,45 @@ +{ + "schema": "ailang/v0", + "name": "test_22b1_dup_same_module", + "imports": [], + "defs": [ + { + "kind": "class", + "name": "Show", + "param": "a", + "methods": [ + { + "name": "show", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + } + ] + }, + { + "kind": "instance", + "class": "Show", + "type": { "k": "con", "name": "Int" }, + "methods": [ + { + "name": "show", + "body": { "t": "lit", "lit": { "kind": "str", "value": "first" } } + } + ] + }, + { + "kind": "instance", + "class": "Show", + "type": { "k": "con", "name": "Int" }, + "methods": [ + { + "name": "show", + "body": { "t": "lit", "lit": { "kind": "str", "value": "second" } } + } + ] + } + ] +} diff --git a/examples/test_22b1_orphan_third.ail.json b/examples/test_22b1_orphan_third.ail.json index b92e052..fe9bab9 100644 --- a/examples/test_22b1_orphan_third.ail.json +++ b/examples/test_22b1_orphan_third.ail.json @@ -7,7 +7,7 @@ "defs": [ { "kind": "instance", - "class": "Show", + "class": "test_22b1_orphan_third_classmod.Show", "type": { "k": "con", "name": "Int" }, "methods": [ { diff --git a/examples/test_22b2_unbound_constraint_var.ail.json b/examples/test_22b2_unbound_constraint_var.ail.json index cfc781c..8742375 100644 --- a/examples/test_22b2_unbound_constraint_var.ail.json +++ b/examples/test_22b2_unbound_constraint_var.ail.json @@ -3,6 +3,12 @@ "name": "test_22b2_unbound_constraint_var", "imports": [], "defs": [ + { + "kind": "class", + "name": "Bar", + "param": "x", + "methods": [] + }, { "kind": "class", "name": "Foo",