diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 7945683..180877f 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -968,6 +968,67 @@ fn workspace_error_to_diagnostic( "module": name, })), ), + // Iter 22b.1: typeclass-coherence diagnostics emitted from + // `workspace::build_registry`. The codes follow the JOURNAL + // queue's wording and Decision 11 §"Diagnostic categories". + W::OrphanInstance { + class, + type_repr, + defining_module, + class_module, + type_module, + } => Some( + ailang_check::Diagnostic::error( + "orphan-instance", + format!( + "orphan instance: `instance {class} {type_repr}` declared in `{defining_module}`, \ + but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there" + ), + ) + .with_ctx(serde_json::json!({ + "class": class, + "type": type_repr, + "defining_module": defining_module, + "class_module": class_module, + "type_module": type_module, + })), + ), + W::DuplicateInstance { + class, + type_repr, + first_module, + second_module, + } => Some( + ailang_check::Diagnostic::error( + "duplicate-instance", + format!( + "duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`" + ), + ) + .with_ctx(serde_json::json!({ + "class": class, + "type": type_repr, + "first_module": first_module, + "second_module": second_module, + })), + ), + W::MissingMethod { + class, + type_repr, + method, + } => Some( + ailang_check::Diagnostic::error( + "missing-method", + format!( + "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)" + ), + ) + .with_ctx(serde_json::json!({ + "class": class, + "type": type_repr, + "method": method, + })), + ), } } @@ -1854,6 +1915,11 @@ fn build_to( entry: ws.entry.clone(), modules: lifted_modules, root_dir: ws.root_dir.clone(), + // Iter 22b.1: pass the registry through the lift pass. Lift + // only rewrites `Term::LetRec` into top-level fns; it does + // not touch class/instance defs, so the registry built at + // load-time remains valid. + registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc(&ws, alloc)?; let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id())); diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index c58c530..34f1751 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -1388,6 +1388,7 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() { entry: ws.entry.clone(), modules: lifted_modules, root_dir: ws.root_dir.clone(), + registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc( &lifted_ws, @@ -1586,6 +1587,7 @@ fn alloc_rc_borrow_only_recursive_list_drop() { entry: ws.entry.clone(), modules: lifted_modules, root_dir: ws.root_dir.clone(), + registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc( &lifted_ws, @@ -1661,6 +1663,7 @@ fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() { entry: ws.entry.clone(), modules: lifted_modules, root_dir: ws.root_dir.clone(), + registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc( &lifted_ws, @@ -1750,6 +1753,7 @@ fn alloc_rc_own_param_dec_at_fn_return() { entry: ws.entry.clone(), modules: lifted_modules, root_dir: ws.root_dir.clone(), + registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc( &lifted_ws, @@ -1875,6 +1879,7 @@ fn iter18e_drop_iterative_emits_worklist_body_no_self_recursion() { entry: ws.entry.clone(), modules: lifted_modules, root_dir: ws.root_dir.clone(), + registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc( &lifted_ws, @@ -1958,6 +1963,7 @@ fn iter18e_no_annotation_keeps_recursive_drop_body() { entry: ws.entry.clone(), modules, root_dir: ws.root_dir.clone(), + registry: ws.registry.clone(), }; let ir = ailang_codegen::lower_workspace_with_alloc( &lifted_ws, diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 2d6eff2..74d2ef4 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -598,6 +598,13 @@ pub fn check_module(m: &Module) -> Vec { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + // Iter 22b.1: single-module check_module entry point does not + // build a registry. The registry is workspace-load-time + // metadata; check_module is invoked from in-memory paths + // (tests, single-file CLI) where no workspace DFS happened. + // Once 22b.2 typecheck arms read the registry, those paths + // will need to populate it from the in-memory module too. + registry: ailang_core::workspace::Registry::default(), }; check_workspace(&ws) } @@ -628,6 +635,11 @@ pub fn check_workspace(ws: &Workspace) -> Vec { .map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m))) .collect(), root_dir: ws.root_dir.clone(), + // Iter 22b.1: pass the registry through unchanged. The desugar + // pass does not touch class/instance defs (see desugar.rs: + // 22b.1 passthrough), so the registry built at load time + // remains valid against the desugared modules. + registry: ws.registry.clone(), }; let ws = &ws_owned; // Pass 1: build per-module top-level symbol table — without checking @@ -730,6 +742,11 @@ pub fn check(m: &Module) -> Result { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + // Iter 22b.1: the legacy single-module `check` entry point + // builds an empty registry. See `check_module` for the same + // pattern; once 22b.2 typecheck arms read the registry, both + // paths must populate it from the in-memory module. + registry: ailang_core::workspace::Registry::default(), }; let module_globals = build_module_globals(&ws)?; // `check` keeps single-error semantics for callers (snapshot tests, @@ -2881,6 +2898,7 @@ mod tests { entry: consumer.name, modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), } } @@ -3056,6 +3074,7 @@ mod tests { entry: "use_both".into(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); assert!( @@ -3162,6 +3181,7 @@ mod tests { entry: "uses_lst".into(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); assert!(diags.is_empty(), "expected green; got {diags:?}"); diff --git a/crates/ailang-check/tests/workspace.rs b/crates/ailang-check/tests/workspace.rs index c54baaa..ff12030 100644 --- a/crates/ailang-check/tests/workspace.rs +++ b/crates/ailang-check/tests/workspace.rs @@ -112,6 +112,7 @@ fn invalid_def_name_with_dot_is_reported() { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); assert_eq!(diags.len(), 1, "got: {:?}", diags); @@ -184,6 +185,7 @@ fn body_errors_accumulate_across_defs() { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); @@ -288,6 +290,7 @@ fn use_after_consume_on_own_param_is_reported() { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); @@ -389,6 +392,7 @@ fn consume_while_borrowed_in_sibling_arg_is_reported() { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); @@ -527,6 +531,7 @@ fn reuse_as_happy_path_in_map_inc_is_linearity_clean() { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); assert!( @@ -638,6 +643,7 @@ fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let diags = check_workspace(&ws); let shape_diags: Vec<&ailang_check::Diagnostic> = diags diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index afe7435..2cf3fae 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -195,6 +195,12 @@ pub fn emit_ir(m: &Module) -> Result { entry: m.name.clone(), modules, root_dir: std::path::PathBuf::from("."), + // Iter 22b.1: emit_ir is the single-module shortcut. The + // empty registry is fine for 22b.1 because codegen does not + // yet read it; once 22b.3 monomorphisation runs, the queue + // is built from class-method-call sites in already-typechecked + // module bodies, not from the registry. + registry: ailang_core::workspace::Registry::default(), }; lower_workspace(&ws) } @@ -251,6 +257,11 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result .map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m))) .collect(), root_dir: ws.root_dir.clone(), + // Iter 22b.1: pass the registry through unchanged. The desugar + // pass does not touch class/instance defs (see desugar.rs: + // 22b.1 passthrough), so the registry built at load time + // remains valid against the desugared modules. + registry: ws.registry.clone(), }; let ws = &ws_owned; let mut header = String::new(); @@ -2837,6 +2848,7 @@ mod tests { x }, root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), }; let ir_rc = lower_workspace_with_alloc(&ws, AllocStrategy::Rc).unwrap(); assert!( diff --git a/crates/ailang-core/src/canonical.rs b/crates/ailang-core/src/canonical.rs index 2887064..067eb5d 100644 --- a/crates/ailang-core/src/canonical.rs +++ b/crates/ailang-core/src/canonical.rs @@ -46,6 +46,23 @@ pub fn to_bytes(value: &T) -> Vec { out } +/// Iter 22b.1: 16-hex-char hash of a [`crate::ast::Type`] in isolation. +/// +/// Used by [`crate::workspace::Registry`] to key `InstanceDef`s by +/// their target type. Parallel in shape to [`crate::def_hash`] and +/// [`crate::workspace::module_hash`]: BLAKE3 over canonical-JSON +/// bytes, truncated to 16 hex chars. +/// +/// The key property is determinism — two `Type` values that are +/// canonically equal MUST hash identically — so the workspace +/// registry's "is `instance C T` already declared?" check is +/// representation-independent. +pub fn type_hash(t: &crate::ast::Type) -> String { + let bytes = to_bytes(t); + let h = blake3::hash(&bytes); + h.to_hex().as_str()[..16].to_string() +} + fn write_value(v: &serde_json::Value, out: &mut Vec) -> std::io::Result<()> { use serde_json::Value; match v { diff --git a/crates/ailang-core/src/hash.rs b/crates/ailang-core/src/hash.rs index 96222d1..1070817 100644 --- a/crates/ailang-core/src/hash.rs +++ b/crates/ailang-core/src/hash.rs @@ -181,4 +181,60 @@ mod tests { let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); assert_eq!(def_hash(sum_def), "db33f57cb329935e"); } + + /// Iter 22b.1 regression: adding `Def::Class` and `Def::Instance` + /// must NOT change canonical-JSON hashes of any pre-22b def. The + /// new variants are alternatives, not field additions — pre-22b + /// fixtures simply do not produce a `Class` / `Instance` `Def`, + /// and the existing `Fn` / `Const` / `Type` arms are unchanged. + /// This re-asserts the same on-disk hashes the 13a / 19b + /// regressions pinned, after the 22b.1 schema bump. + #[test] + fn iter22b1_schema_extension_preserves_pre_22b_hashes() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + + let sum_src = std::fs::read(examples.join("sum.ail.json")) + .expect("examples/sum.ail.json present"); + let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap(); + let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); + assert_eq!(def_hash(sum_def), "db33f57cb329935e"); + + let list_src = std::fs::read(examples.join("list.ail.json")) + .expect("examples/list.ail.json present"); + let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap(); + let int_list_def = list_mod + .defs + .iter() + .find(|d| d.name() == "IntList") + .unwrap(); + assert_eq!(def_hash(int_list_def), "b082192bd0c99202"); + } + + /// Iter 22b.1: a [`crate::ast::ClassDef`] with no doc, no + /// superclass, and an empty methods list serialises to a stable + /// canonical form. Two construction paths (default-elided + /// optionals vs. JSON without those keys at all) hash + /// bit-identically. If FAIL: a `skip_serializing_if` is missing + /// on `superclass` or `doc`, or the parser produces different + /// canonical bytes from the same logical value. + #[test] + fn iter22b1_classdef_empty_optionals_hash_stable() { + let bare = Def::Class(crate::ast::ClassDef { + name: "Empty".into(), + param: "a".into(), + superclass: None, + methods: vec![], + doc: None, + }); + + let json = r#"{"kind":"class","name":"Empty","param":"a","methods":[]}"#; + let parsed: Def = serde_json::from_str(json).unwrap(); + + assert_eq!( + def_hash(&bare), + def_hash(&parsed), + "ClassDef with elided optionals must hash identically to construction with explicit None" + ); + } } diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index 298329e..da85d10 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -31,10 +31,10 @@ //! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(()) //! ``` -use crate::ast::Module; +use crate::ast::{ClassDef, Def, InstanceDef, Module, Type}; use crate::canonical; use crate::{load_module, Error as CoreError}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::{Path, PathBuf}; /// Fully loaded workspace. @@ -43,6 +43,11 @@ use std::path::{Path, PathBuf}; /// transitively reachable modules are contained in `modules` and indexed /// by `Module.name`. `root_dir` is the directory the entry file lives /// in; all imports are resolved relative to it. +/// +/// Iter 22b.1 (Decision 11): `registry` is the workspace-global +/// instance registry, built at the end of [`load_workspace`] after the +/// DFS over imports completes. It is empty for any workspace whose +/// modules contain no [`crate::ast::Def::Instance`] defs. #[derive(Debug, Clone)] pub struct Workspace { /// Name of the entry module (the one passed to [`load_workspace`]). @@ -54,6 +59,42 @@ pub struct Workspace { /// Directory the entry file lives in; all imports are resolved /// relative to it. pub root_dir: PathBuf, + /// Iter 22b.1: workspace-global typeclass instance registry. + pub registry: Registry, +} + +/// Iter 22b.1: workspace-global instance registry (Decision 11). +/// +/// Built at the end of [`load_workspace`] after all modules are +/// loaded. Keyed by `(class-name, canonical-type-hash)`; values are +/// the matching [`crate::ast::InstanceDef`] plus the name of the +/// module it was declared in. The hash key uses +/// [`canonical::type_hash`], so the key is stable against unrelated +/// whitespace / field-order differences in the source JSON. +/// +/// 22b.1 enforces three coherence checks during build: +/// +/// 1. **Coherence (orphan-freedom).** Every `instance C T` lives in +/// the module of `C` or in the module of `T` (per Decision 11 +/// axis 3). Otherwise → [`WorkspaceLoadError::OrphanInstance`]. +/// 2. **Uniqueness.** No two entries share a key. Otherwise → +/// [`WorkspaceLoadError::DuplicateInstance`]. +/// 3. **Method completeness.** Each instance specifies a body for +/// every required (non-default) method of its class. Otherwise → +/// [`WorkspaceLoadError::MissingMethod`]. +#[derive(Debug, Clone, Default)] +pub struct Registry { + /// Map from `(class-name, type-hash)` to the registry entry. + pub entries: BTreeMap<(String, String), RegistryEntry>, +} + +/// One entry in the [`Registry`]. +#[derive(Debug, Clone)] +pub struct RegistryEntry { + /// The instance declaration itself. + pub instance: InstanceDef, + /// Name of the module the instance was declared in. + pub defining_module: String, } /// Structured errors of the workspace loader. @@ -110,6 +151,54 @@ pub enum WorkspaceLoadError { "module `{name}` was loaded twice with differing content (hashes differ)" )] ModuleHashMismatch { name: String }, + + /// Iter 22b.1: an [`crate::ast::Def::Instance`] was declared in a + /// module that is neither the class's defining module nor the + /// instance type's defining module. Coherence violation per + /// Decision 11 axis 3 ("orphan-freedom"). The lookup is hard: + /// AILang does not provide a `--allow-orphans` flag. + #[error( + "orphan instance: `instance {class} {type_repr}` declared in module `{defining_module}`, \ + but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there" + )] + OrphanInstance { + class: String, + type_repr: String, + defining_module: String, + class_module: String, + type_module: String, + }, + + /// Iter 22b.1: two [`crate::ast::Def::Instance`]s share the same + /// `(class, canonical-type-hash)` key. Coherence requires + /// uniqueness; the registry has no way to disambiguate at + /// resolution time. Per Decision 11 there is no + /// `AmbiguousInstance` diagnostic — coherence makes the lookup + /// unambiguous by construction, and a duplicate is a workspace + /// configuration error, not a per-call-site one. + #[error( + "duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`" + )] + DuplicateInstance { + class: String, + type_repr: String, + first_module: String, + second_module: String, + }, + + /// Iter 22b.1: an [`crate::ast::Def::Instance`] does not specify + /// a body for a required (non-default) method of its class. + /// Default-bearing methods may be inherited; non-default + /// (abstract-required) methods must be specified by every + /// instance. Per Decision 11 §"Defaults and superclasses". + #[error( + "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)" + )] + MissingMethod { + class: String, + type_repr: String, + method: String, + }, } /// Hash over the canonical bytes of a complete module. @@ -161,13 +250,163 @@ pub fn load_workspace(entry_path: &Path) -> Result, +) -> Result { + // Pass 1: collect "where is X defined" maps, plus a class lookup + // by name (needed for the method-completeness check). + let mut class_def_module: BTreeMap = BTreeMap::new(); + let mut type_def_module: BTreeMap = BTreeMap::new(); + let mut class_by_name: BTreeMap = BTreeMap::new(); + for (mod_name, m) in modules { + for def in &m.defs { + match def { + Def::Class(c) => { + class_def_module.insert(c.name.clone(), mod_name.clone()); + class_by_name.insert(c.name.clone(), c); + } + Def::Type(t) => { + type_def_module.insert(t.name.clone(), mod_name.clone()); + } + _ => {} + } + } + } + + // Pass 2: register each instance, with coherence / uniqueness / + // method-completeness checks. + let mut entries: BTreeMap<(String, String), RegistryEntry> = BTreeMap::new(); + for (mod_name, m) in modules { + for def in &m.defs { + if let Def::Instance(inst) = def { + let type_repr = type_head_name(&inst.type_); + + // Coherence (orphan-freedom): instance's module must + // equal the class's module or the type's module. A + // class declared inside the same module as the + // instance always satisfies the first leg; a + // user-defined type declared in the instance's module + // satisfies the second. Primitives have no + // user-defined module — instances on primitives + // therefore must live in the class's module. + let class_mod = class_def_module + .get(&inst.class) + .cloned() + .unwrap_or_else(|| "".into()); + let type_mod = type_def_module + .get(&type_repr) + .cloned() + .unwrap_or_else(|| "".into()); + let coherent = mod_name == &class_mod || mod_name == &type_mod; + if !coherent { + return Err(WorkspaceLoadError::OrphanInstance { + class: inst.class.clone(), + type_repr, + defining_module: mod_name.clone(), + class_module: class_mod, + type_module: type_mod, + }); + } + + // Uniqueness: a `(class, type-hash)` key must appear + // at most once across the whole workspace. + let type_hash = canonical::type_hash(&inst.type_); + let key = (inst.class.clone(), type_hash); + if let Some(prior) = entries.get(&key) { + return Err(WorkspaceLoadError::DuplicateInstance { + class: inst.class.clone(), + type_repr, + first_module: prior.defining_module.clone(), + second_module: mod_name.clone(), + }); + } + + // Method completeness: every non-default method of + // the class must have a body in this instance. + // Defaults may be inherited (no body required). + // Missing class declaration is deferred to 22b.2's + // 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) { + let provided: BTreeSet<&str> = + inst.methods.iter().map(|m| m.name.as_str()).collect(); + for class_method in &class_def.methods { + if class_method.default.is_none() + && !provided.contains(class_method.name.as_str()) + { + return Err(WorkspaceLoadError::MissingMethod { + class: inst.class.clone(), + type_repr, + method: class_method.name.clone(), + }); + } + } + } + + entries.insert( + key, + RegistryEntry { + instance: inst.clone(), + defining_module: mod_name.clone(), + }, + ); + } + } + } + + Ok(Registry { entries }) +} + +/// Iter 22b.1: extract the head-constructor name of a type, for the +/// "where is this type defined" lookup and for diagnostic-message +/// rendering. +/// +/// For `Type::Con { name, .. }` (the only legal head shape for a +/// non-orphan instance) returns `name`. Other variants (`Var`, +/// `Forall`, `Fn`) are not legal as instance heads — Decision 11 +/// requires a concrete type expression at the instance head — so we +/// emit a stable fallback string. The fallback prevents diagnostic +/// rendering from panicking on a malformed fixture; the "real" +/// rejection of non-concrete instance heads will arrive as a +/// schema-validation diagnostic in 22b.2. +fn type_head_name(t: &Type) -> String { + match t { + Type::Con { name, .. } => name.clone(), + Type::Var { name } => format!(""), + Type::Forall { .. } => "".into(), + Type::Fn { .. } => "".into(), + } +} + fn visit( module: Module, root_dir: &Path, @@ -352,4 +591,22 @@ mod tests { other => panic!("expected ModuleNotFound, got {other:?}"), } } + + /// Iter 22b.1: a workspace whose modules contain no + /// `Def::Instance` defs has an empty registry. This is the + /// happy-path baseline for the registry-build pass — every + /// pre-22b workspace falls into this case. + #[test] + fn iter22b1_workspace_with_no_classes_has_empty_registry() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace_root = + Path::new(manifest_dir).parent().unwrap().parent().unwrap(); + let entry = workspace_root.join("examples").join("sum.ail.json"); + + let ws = load_workspace(&entry).expect("sum.ail.json loads"); + assert!( + ws.registry.entries.is_empty(), + "pre-22b fixture has no class/instance defs, registry must be empty" + ); + } }