diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 3cb24c7..e1a88df 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -1538,9 +1538,15 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { // → no-instance. The hash uses `canonical::type_hash`, the // same key shape `workspace::build_registry` writes with, so // representation-equal types match deterministically. + // ct.1.5a: route `r_ty` through + // `Registry::normalize_type_for_lookup` so a bare + // `Type::Con` produced for an instance whose head-type lives + // in another module rewrites to the qualified form the + // registry actually keyed on. + let r_ty_norm = env.workspace_registry.normalize_type_for_lookup(&r_ty); let key = ( r.class.clone(), - ailang_core::canonical::type_hash(&r_ty), + ailang_core::canonical::type_hash(&r_ty_norm), ); if !env.workspace_registry.entries.contains_key(&key) { return Err(CheckError::NoInstance { diff --git a/crates/ailang-check/src/mono.rs b/crates/ailang-check/src/mono.rs index 4e70349..b530e6f 100644 --- a/crates/ailang-check/src/mono.rs +++ b/crates/ailang-check/src/mono.rs @@ -115,7 +115,15 @@ pub fn monomorphise_workspace(ws: &Workspace) -> Result { // never mutates the registry. for t in &new { let key = mono_target_key(t); - let registry_key = (t.class.clone(), ailang_core::canonical::type_hash(&t.type_)); + // ct.1.5a: route `t.type_` through + // `Registry::normalize_type_for_lookup` so a bare + // `Type::Con` produced for an instance whose head-type lives + // in another module rewrites to the qualified form the + // registry actually keyed on. Symmetric to the + // `collect_targets_*` lookups above. + let t_ty_norm = ws_owned.registry.normalize_type_for_lookup(&t.type_); + let registry_key = + (t.class.clone(), ailang_core::canonical::type_hash(&t_ty_norm)); let entry = ws_owned .registry .entries @@ -513,7 +521,11 @@ pub fn collect_mono_targets( if !crate::is_fully_concrete(&r_ty) { continue; } - let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty)); + // ct.1.5a: normalise `r_ty` to its always-qualified canonical + // form before hashing — symmetric to the write side in + // `workspace::build_registry`. + let r_ty_norm = env.workspace_registry.normalize_type_for_lookup(&r_ty); + let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty_norm)); let entry = match env.workspace_registry.entries.get(&key) { Some(e) => e, None => continue, // no-instance — Task 10 of 22b.2 fires; skip silently here. @@ -867,7 +879,11 @@ pub(crate) fn collect_residuals_ordered( out.push(None); continue; } - let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty)); + // ct.1.5a: normalise `r_ty` to its always-qualified canonical + // form before hashing — symmetric to the write side in + // `workspace::build_registry`. + let r_ty_norm = env.workspace_registry.normalize_type_for_lookup(&r_ty); + let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty_norm)); let entry = match env.workspace_registry.entries.get(&key) { Some(e) => e, None => { diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index af08860..6b242e5 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -86,6 +86,30 @@ pub struct Workspace { pub struct Registry { /// Map from `(class-name, type-hash)` to the registry entry. pub entries: BTreeMap<(String, String), RegistryEntry>, + /// ct.1.5a: workspace-wide map from user-defined type-name to its + /// defining module. Used by [`Self::normalize_type_for_lookup`] to + /// rewrite a bare `Type::Con.name` to its always-qualified form + /// before computing the registry key. Primitives are not present. + /// Populated in [`build_registry`] from the same scan that builds + /// the entry map. + pub type_def_module: BTreeMap, +} + +impl Registry { + /// ct.1.5a: produce the canonical form of `t` for registry-key + /// hashing. Bare-non-primitive `Type::Con` names get qualified to + /// `.`; already-qualified names stay; bare + /// names whose defining module is unknown stay as-is. + /// `Type::Fn`/`Type::Forall`/`Type::Var` recurse / pass through + /// structurally. + /// + /// Every consumer that hashes an `inst.type_`-shaped expression to + /// look it up in [`Self::entries`] must funnel through this + /// helper, otherwise the registered-form and the queried-form + /// disagree on whether the leading qualifier is present. + pub fn normalize_type_for_lookup(&self, t: &Type) -> Type { + normalize_type_for_registry(t, &self.type_def_module) + } } /// One entry in the [`Registry`]. @@ -591,8 +615,15 @@ fn build_registry( } // Uniqueness: a `(class, type-hash)` key must appear - // at most once across the whole workspace. - let type_hash = canonical::type_hash(&inst.type_); + // at most once across the whole workspace. ct.1.5a: the + // type expression is normalised to its always-qualified + // form before hashing so a bare-local declaration in + // 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). + let type_hash = canonical::type_hash( + &normalize_type_for_registry(&inst.type_, &type_def_module), + ); let key = (inst.class.clone(), type_hash); if let Some(prior) = entries.get(&key) { return Err(WorkspaceLoadError::DuplicateInstance { @@ -684,7 +715,10 @@ fn build_registry( } } - Ok(Registry { entries }) + Ok(Registry { + entries, + type_def_module, + }) } /// Iter 22b.2: class-schema validation. Runs before `build_registry`. @@ -773,6 +807,70 @@ fn is_primitive_type_name(name: &str) -> bool { matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float") } +/// ct.1.5a: normalize a `Type` by qualifying every bare-non-primitive +/// `Type::Con.name` to its always-qualified form +/// (`.`). Primitives stay bare; already-qualified +/// names stay as-is; bare names whose defining module is unknown stay +/// as-is (downstream diagnostics will catch them). `Type::Fn` / +/// `Type::Forall` / `Type::Var` recurse / pass through structurally. +/// +/// Used by [`build_registry`] for registry-key canonicalisation so the +/// duplicate-instance check survives the asymmetric "bare = local, +/// qualified = cross-module" canonical-form rule: an instance declared +/// bare-local in the type's defining module and an equivalent one +/// declared qualified-cross-module from elsewhere produce the same +/// registry key. +/// +/// The qualifier is the type's *defining* module (looked up in +/// `type_def_module`), not the instance's owning module. The two +/// coincide under a canonical-form-compliant workspace (bare implies +/// local-to-defining-module), but using the defining-module lookup is +/// robust against pre-`validate_canonical_type_names`-wired fixtures +/// that may still carry bare cross-module refs. +fn normalize_type_for_registry( + t: &Type, + type_def_module: &BTreeMap, +) -> Type { + match t { + Type::Con { name, args } => { + let new_name = if name.contains('.') || is_primitive_type_name(name) { + name.clone() + } else if let Some(owner) = type_def_module.get(name) { + format!("{owner}.{name}") + } else { + // Unknown bare non-primitive — leave as-is. Either it is + // a class-param Type::Var miscoded as a Con (which is a + // separate well-formedness problem) or a stale ref that + // downstream diagnostics will catch. + name.clone() + }; + Type::Con { + name: new_name, + args: args + .iter() + .map(|a| normalize_type_for_registry(a, type_def_module)) + .collect(), + } + } + Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn { + params: params + .iter() + .map(|p| normalize_type_for_registry(p, type_def_module)) + .collect(), + param_modes: param_modes.clone(), + ret: Box::new(normalize_type_for_registry(ret, type_def_module)), + ret_mode: *ret_mode, + effects: effects.clone(), + }, + Type::Forall { vars, constraints, body } => Type::Forall { + vars: vars.clone(), + constraints: constraints.clone(), + body: Box::new(normalize_type_for_registry(body, type_def_module)), + }, + Type::Var { name } => Type::Var { name: name.clone() }, + } +} + /// ct.1: enforce the canonical-form rule on every `Type::Con` and /// `Term::Ctor.type_name` reference in every loaded module. Runs /// after prelude injection and before class-schema validation, so a @@ -2232,6 +2330,106 @@ mod tests { } } + /// ct.1.5a: registry-side duplicate detection survives the + /// asymmetric canonical-form representation. Two coherent instances + /// on the same type-def, one declared bare-local (in the type's + /// defining module) and one declared qualified-cross-module (in the + /// class's defining module), must collide on the registry's + /// `(class, type-hash)` key. + /// + /// This is the regression that broke during the ct.1.5 migration + /// dry-run: after migrating `test_22b1_dup_a.ail.json` to qualified + /// `test_22b1_dup_b.MyInt`, the unmigrated `test_22b1_dup_b.ail.json` + /// (which keeps bare `MyInt` because the type IS local there) + /// produced a different type-hash, and the duplicate detection + /// silently missed. + /// + /// The fixture is constructed in-memory rather than from disk so + /// the test stays focused on the registry behaviour rather than the + /// migration tool. Class `TShow` is named distinctly from the + /// prelude's `Eq`/`Show` to avoid collisions with the auto-loaded + /// prelude — but `build_registry` here is called directly on a flat + /// `BTreeMap` (no auto-prelude injection), so the choice is purely + /// belt-and-braces. + #[test] + fn ct1_registry_duplicate_detection_survives_mixed_canonical_form() { + // Module `cls`: declares `class TShow a` (custom name to avoid + // any future prelude collision) and the qualified-cross-module + // instance `instance TShow other.MyInt`. + let cls: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "cls", + "imports": [{ "module": "other" }], + "defs": [ + { + "kind": "class", + "name": "TShow", + "param": "a", + "methods": [ + { "name": "tshow", + "type": { "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Str" }, + "effects": [] } } + ] + }, + { + "kind": "instance", + "class": "TShow", + "type": { "k": "con", "name": "other.MyInt" }, + "methods": [ + { "name": "tshow", + "body": { "t": "lit", "lit": { "kind": "str", "value": "cls-side" } } } + ] + } + ], + })).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). + let other: Module = serde_json::from_value(serde_json::json!({ + "schema": crate::SCHEMA, + "name": "other", + "imports": [{ "module": "cls" }], + "defs": [ + { "kind": "type", "name": "MyInt", "ctors": [{ "name": "MkMyInt", "fields": [] }] }, + { + "kind": "instance", + "class": "TShow", + "type": { "k": "con", "name": "MyInt" }, + "methods": [ + { "name": "tshow", + "body": { "t": "lit", "lit": { "kind": "str", "value": "other-side" } } } + ] + } + ], + })).unwrap(); + + let mut modules = BTreeMap::new(); + modules.insert("cls".to_string(), cls); + modules.insert("other".to_string(), other); + + let err = build_registry(&modules).expect_err( + "registry build must fire DuplicateInstance for mixed canonical-form" + ); + match err { + WorkspaceLoadError::DuplicateInstance { + class, first_module, second_module, .. + } => { + assert_eq!(class, "TShow"); + let mods: BTreeSet<&str> = + [first_module.as_str(), second_module.as_str()].into_iter().collect(); + assert!(mods.contains("cls"), + "got {first_module}, {second_module}"); + assert!(mods.contains("other"), + "got {first_module}, {second_module}"); + } + other => panic!("expected DuplicateInstance, got {other:?}"), + } + } + /// ct.1: a qualified `Constraint.class` nested inside a /// `ClassDef.methods[].ty.Forall.constraints` must fire /// `QualifiedClassName`. Distinct from the `Def::Fn` site: the