iter ct.1.5a: registry-side normalize-before-hash
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -115,7 +115,15 @@ pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> {
|
||||
// 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 => {
|
||||
|
||||
@@ -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<String, String>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// ct.1.5a: produce the canonical form of `t` for registry-key
|
||||
/// hashing. Bare-non-primitive `Type::Con` names get qualified to
|
||||
/// `<defining_module>.<name>`; 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
|
||||
/// (`<defining_module>.<name>`). 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<String, String>,
|
||||
) -> 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
|
||||
|
||||
Reference in New Issue
Block a user