iter 22b.1.2: workspace registry skeleton + coherence checks
Adds the workspace-global typeclass instance registry per Decision 11
"Resolution and monomorphisation". Built at the end of load_workspace
after the DFS over imports completes; keyed by (class-name, type-hash)
where type-hash uses the new canonical::type_hash (16-hex prefix,
parallel to def_hash and module_hash).
Three coherence checks fire from build_registry, each surfacing as a
distinct WorkspaceLoadError variant:
- OrphanInstance: `instance C T` not in C's or T's defining module.
- DuplicateInstance: two entries share the same (class, type-hash) key.
- MissingMethod: instance omits a required (non-default) method.
The CLI's workspace_error_to_diagnostic is extended with the three new
codes (orphan-instance / duplicate-instance / missing-method).
All existing Workspace { ... } construction sites get the new
`registry` field, defaulting to Registry::default() at synthetic /
test-only paths and threading through ws.registry.clone() at codepath
sites that already hold a real workspace.
Includes hash-stability regression tests (iter22b1_schema_extension_*
and iter22b1_classdef_empty_optionals_hash_stable) and the empty-
registry positive test against examples/sum.ail.json. Test count:
288 → 291 (3 new tests, all pass).
This commit is contained in:
@@ -968,6 +968,67 @@ fn workspace_error_to_diagnostic(
|
|||||||
"module": name,
|
"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(),
|
entry: ws.entry.clone(),
|
||||||
modules: lifted_modules,
|
modules: lifted_modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
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 ir = ailang_codegen::lower_workspace_with_alloc(&ws, alloc)?;
|
||||||
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
|
let tmpdir = std::env::temp_dir().join(format!("ailang-{}", std::process::id()));
|
||||||
|
|||||||
@@ -1388,6 +1388,7 @@ fn reuse_as_demo_under_rc_uses_inplace_rewrite() {
|
|||||||
entry: ws.entry.clone(),
|
entry: ws.entry.clone(),
|
||||||
modules: lifted_modules,
|
modules: lifted_modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
root_dir: ws.root_dir.clone(),
|
||||||
|
registry: ws.registry.clone(),
|
||||||
};
|
};
|
||||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||||
&lifted_ws,
|
&lifted_ws,
|
||||||
@@ -1586,6 +1587,7 @@ fn alloc_rc_borrow_only_recursive_list_drop() {
|
|||||||
entry: ws.entry.clone(),
|
entry: ws.entry.clone(),
|
||||||
modules: lifted_modules,
|
modules: lifted_modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
root_dir: ws.root_dir.clone(),
|
||||||
|
registry: ws.registry.clone(),
|
||||||
};
|
};
|
||||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||||
&lifted_ws,
|
&lifted_ws,
|
||||||
@@ -1661,6 +1663,7 @@ fn alloc_rc_partial_drop_skips_moved_keeps_wildcarded() {
|
|||||||
entry: ws.entry.clone(),
|
entry: ws.entry.clone(),
|
||||||
modules: lifted_modules,
|
modules: lifted_modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
root_dir: ws.root_dir.clone(),
|
||||||
|
registry: ws.registry.clone(),
|
||||||
};
|
};
|
||||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||||
&lifted_ws,
|
&lifted_ws,
|
||||||
@@ -1750,6 +1753,7 @@ fn alloc_rc_own_param_dec_at_fn_return() {
|
|||||||
entry: ws.entry.clone(),
|
entry: ws.entry.clone(),
|
||||||
modules: lifted_modules,
|
modules: lifted_modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
root_dir: ws.root_dir.clone(),
|
||||||
|
registry: ws.registry.clone(),
|
||||||
};
|
};
|
||||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||||
&lifted_ws,
|
&lifted_ws,
|
||||||
@@ -1875,6 +1879,7 @@ fn iter18e_drop_iterative_emits_worklist_body_no_self_recursion() {
|
|||||||
entry: ws.entry.clone(),
|
entry: ws.entry.clone(),
|
||||||
modules: lifted_modules,
|
modules: lifted_modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
root_dir: ws.root_dir.clone(),
|
||||||
|
registry: ws.registry.clone(),
|
||||||
};
|
};
|
||||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||||
&lifted_ws,
|
&lifted_ws,
|
||||||
@@ -1958,6 +1963,7 @@ fn iter18e_no_annotation_keeps_recursive_drop_body() {
|
|||||||
entry: ws.entry.clone(),
|
entry: ws.entry.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: ws.root_dir.clone(),
|
root_dir: ws.root_dir.clone(),
|
||||||
|
registry: ws.registry.clone(),
|
||||||
};
|
};
|
||||||
let ir = ailang_codegen::lower_workspace_with_alloc(
|
let ir = ailang_codegen::lower_workspace_with_alloc(
|
||||||
&lifted_ws,
|
&lifted_ws,
|
||||||
|
|||||||
@@ -598,6 +598,13 @@ pub fn check_module(m: &Module) -> Vec<Diagnostic> {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
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)
|
check_workspace(&ws)
|
||||||
}
|
}
|
||||||
@@ -628,6 +635,11 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
|
|||||||
.map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m)))
|
.map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m)))
|
||||||
.collect(),
|
.collect(),
|
||||||
root_dir: ws.root_dir.clone(),
|
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 ws = &ws_owned;
|
||||||
// Pass 1: build per-module top-level symbol table — without checking
|
// Pass 1: build per-module top-level symbol table — without checking
|
||||||
@@ -730,6 +742,11 @@ pub fn check(m: &Module) -> Result<CheckedModule> {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
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)?;
|
let module_globals = build_module_globals(&ws)?;
|
||||||
// `check` keeps single-error semantics for callers (snapshot tests,
|
// `check` keeps single-error semantics for callers (snapshot tests,
|
||||||
@@ -2881,6 +2898,7 @@ mod tests {
|
|||||||
entry: consumer.name,
|
entry: consumer.name,
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3056,6 +3074,7 @@ mod tests {
|
|||||||
entry: "use_both".into(),
|
entry: "use_both".into(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -3162,6 +3181,7 @@ mod tests {
|
|||||||
entry: "uses_lst".into(),
|
entry: "uses_lst".into(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fn invalid_def_name_with_dot_is_reported() {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
assert_eq!(diags.len(), 1, "got: {:?}", diags);
|
assert_eq!(diags.len(), 1, "got: {:?}", diags);
|
||||||
@@ -184,6 +185,7 @@ fn body_errors_accumulate_across_defs() {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
|
|
||||||
@@ -288,6 +290,7 @@ fn use_after_consume_on_own_param_is_reported() {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
|
|
||||||
@@ -389,6 +392,7 @@ fn consume_while_borrowed_in_sibling_arg_is_reported() {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
|
|
||||||
@@ -527,6 +531,7 @@ fn reuse_as_happy_path_in_map_inc_is_linearity_clean() {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -638,6 +643,7 @@ fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let diags = check_workspace(&ws);
|
let diags = check_workspace(&ws);
|
||||||
let shape_diags: Vec<&ailang_check::Diagnostic> = diags
|
let shape_diags: Vec<&ailang_check::Diagnostic> = diags
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ pub fn emit_ir(m: &Module) -> Result<String> {
|
|||||||
entry: m.name.clone(),
|
entry: m.name.clone(),
|
||||||
modules,
|
modules,
|
||||||
root_dir: std::path::PathBuf::from("."),
|
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)
|
lower_workspace(&ws)
|
||||||
}
|
}
|
||||||
@@ -251,6 +257,11 @@ fn lower_workspace_inner(ws: &Workspace, alloc: AllocStrategy) -> Result<String>
|
|||||||
.map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m)))
|
.map(|(k, m)| (k.clone(), ailang_core::desugar::desugar_module(m)))
|
||||||
.collect(),
|
.collect(),
|
||||||
root_dir: ws.root_dir.clone(),
|
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 ws = &ws_owned;
|
||||||
let mut header = String::new();
|
let mut header = String::new();
|
||||||
@@ -2837,6 +2848,7 @@ mod tests {
|
|||||||
x
|
x
|
||||||
},
|
},
|
||||||
root_dir: std::path::PathBuf::from("."),
|
root_dir: std::path::PathBuf::from("."),
|
||||||
|
registry: ailang_core::workspace::Registry::default(),
|
||||||
};
|
};
|
||||||
let ir_rc = lower_workspace_with_alloc(&ws, AllocStrategy::Rc).unwrap();
|
let ir_rc = lower_workspace_with_alloc(&ws, AllocStrategy::Rc).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -46,6 +46,23 @@ pub fn to_bytes<T: serde::Serialize>(value: &T) -> Vec<u8> {
|
|||||||
out
|
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<u8>) -> std::io::Result<()> {
|
fn write_value(v: &serde_json::Value, out: &mut Vec<u8>) -> std::io::Result<()> {
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
match v {
|
match v {
|
||||||
|
|||||||
@@ -181,4 +181,60 @@ mod tests {
|
|||||||
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
|
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
|
||||||
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,10 @@
|
|||||||
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
|
//! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(())
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::ast::Module;
|
use crate::ast::{ClassDef, Def, InstanceDef, Module, Type};
|
||||||
use crate::canonical;
|
use crate::canonical;
|
||||||
use crate::{load_module, Error as CoreError};
|
use crate::{load_module, Error as CoreError};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Fully loaded workspace.
|
/// Fully loaded workspace.
|
||||||
@@ -43,6 +43,11 @@ use std::path::{Path, PathBuf};
|
|||||||
/// transitively reachable modules are contained in `modules` and indexed
|
/// transitively reachable modules are contained in `modules` and indexed
|
||||||
/// by `Module.name`. `root_dir` is the directory the entry file lives
|
/// by `Module.name`. `root_dir` is the directory the entry file lives
|
||||||
/// in; all imports are resolved relative to it.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
/// Name of the entry module (the one passed to [`load_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
|
/// Directory the entry file lives in; all imports are resolved
|
||||||
/// relative to it.
|
/// relative to it.
|
||||||
pub root_dir: PathBuf,
|
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.
|
/// Structured errors of the workspace loader.
|
||||||
@@ -110,6 +151,54 @@ pub enum WorkspaceLoadError {
|
|||||||
"module `{name}` was loaded twice with differing content (hashes differ)"
|
"module `{name}` was loaded twice with differing content (hashes differ)"
|
||||||
)]
|
)]
|
||||||
ModuleHashMismatch { name: String },
|
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.
|
/// Hash over the canonical bytes of a complete module.
|
||||||
@@ -161,13 +250,163 @@ pub fn load_workspace(entry_path: &Path) -> Result<Workspace, WorkspaceLoadError
|
|||||||
&mut visiting_set,
|
&mut visiting_set,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Iter 22b.1: build the workspace-global instance registry. Three
|
||||||
|
// coherence checks fire here (Orphan / Duplicate / Missing-method);
|
||||||
|
// any violation is surfaced as a `WorkspaceLoadError`, not as a
|
||||||
|
// per-call-site diagnostic.
|
||||||
|
let registry = build_registry(&modules)?;
|
||||||
|
|
||||||
Ok(Workspace {
|
Ok(Workspace {
|
||||||
entry: entry_name,
|
entry: entry_name,
|
||||||
modules,
|
modules,
|
||||||
root_dir,
|
root_dir,
|
||||||
|
registry,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Iter 22b.1: build the workspace-global typeclass instance registry.
|
||||||
|
///
|
||||||
|
/// Two passes:
|
||||||
|
///
|
||||||
|
/// 1. Scan every loaded module to build a "where is X defined" lookup
|
||||||
|
/// for class names and type names — needed for the coherence
|
||||||
|
/// (orphan) check below.
|
||||||
|
/// 2. For each [`crate::ast::Def::Instance`] in declaration order,
|
||||||
|
/// apply the three checks (coherence / uniqueness / method
|
||||||
|
/// completeness) and insert the entry on success.
|
||||||
|
///
|
||||||
|
/// Iteration is over the modules `BTreeMap` (alphabetical by name) so
|
||||||
|
/// the order in which collisions are detected is deterministic across
|
||||||
|
/// runs. Within a module, defs are scanned in source order.
|
||||||
|
fn build_registry(
|
||||||
|
modules: &BTreeMap<String, Module>,
|
||||||
|
) -> Result<Registry, WorkspaceLoadError> {
|
||||||
|
// 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<String, String> = BTreeMap::new();
|
||||||
|
let mut type_def_module: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
let mut class_by_name: BTreeMap<String, &ClassDef> = BTreeMap::new();
|
||||||
|
for (mod_name, m) in modules {
|
||||||
|
for def in &m.defs {
|
||||||
|
match def {
|
||||||
|
Def::Class(c) => {
|
||||||
|
class_def_module.insert(c.name.clone(), mod_name.clone());
|
||||||
|
class_by_name.insert(c.name.clone(), c);
|
||||||
|
}
|
||||||
|
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(|| "<unknown-class>".into());
|
||||||
|
let type_mod = type_def_module
|
||||||
|
.get(&type_repr)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "<primitive-or-unknown>".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!("<var:{name}>"),
|
||||||
|
Type::Forall { .. } => "<forall>".into(),
|
||||||
|
Type::Fn { .. } => "<fn>".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn visit(
|
fn visit(
|
||||||
module: Module,
|
module: Module,
|
||||||
root_dir: &Path,
|
root_dir: &Path,
|
||||||
@@ -352,4 +591,22 @@ mod tests {
|
|||||||
other => panic!("expected ModuleNotFound, got {other:?}"),
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user