From 73965a6cbdc3478c71231e35faff04c70a3e5d71 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 9 May 2026 19:19:44 +0200 Subject: [PATCH] iter 22b.2.e2e: cross-module class resolution + multi-fn aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new E2E tests in typeclass_22b2.rs covering invariants the per-task fixtures (all single-module, single-fn) did not exercise: - cross_module_class_method_resolves_and_fires_no_instance: module B's call to `show` (declared in module A) reaches the no-instance arm, proving Env.class_methods is merged workspace- wide and not restricted to the local module. - cross_module_polymorphic_fn_without_constraint_fires_missing_constraint: same merge guarantees the missing-constraint arm sees the residual produced by a call to A's class method from inside B. - cross_module_instance_satisfies_concrete_call: the workspace registry built at load time over all modules must populate keys visible from B; a concrete call resolves clean cross-module. - two_fns_each_missing_constraint_produce_two_diagnostics: pins check_in_workspace's per-def aggregation — two ill-formed fns produce two diagnostics (one per fn), not one. Six new fixtures (xmod_* trio + two_fns_*). --- crates/ail/tests/typeclass_22b2.rs | 118 ++++++++++++++++++ ...t_22b2_two_fns_missing_constraint.ail.json | 63 ++++++++++ examples/test_22b2_xmod_classmod.ail.json | 34 +++++ .../test_22b2_xmod_classmod_noinst.ail.json | 23 ++++ .../test_22b2_xmod_instance_present.ail.json | 23 ++++ ...test_22b2_xmod_missing_constraint.ail.json | 27 ++++ examples/test_22b2_xmod_no_instance.ail.json | 23 ++++ 7 files changed, 311 insertions(+) create mode 100644 examples/test_22b2_two_fns_missing_constraint.ail.json create mode 100644 examples/test_22b2_xmod_classmod.ail.json create mode 100644 examples/test_22b2_xmod_classmod_noinst.ail.json create mode 100644 examples/test_22b2_xmod_instance_present.ail.json create mode 100644 examples/test_22b2_xmod_missing_constraint.ail.json create mode 100644 examples/test_22b2_xmod_no_instance.ail.json diff --git a/crates/ail/tests/typeclass_22b2.rs b/crates/ail/tests/typeclass_22b2.rs index 94a3459..01a3bd7 100644 --- a/crates/ail/tests/typeclass_22b2.rs +++ b/crates/ail/tests/typeclass_22b2.rs @@ -258,3 +258,121 @@ fn fn_calling_class_method_at_type_with_instance_typechecks_green() { diagnostics ); } + +/// Property protected: class-method symbols declared in module A are +/// reachable from a fn in module B that imports A — i.e. the workspace- +/// wide merge of `module_globals.class_methods` into `Env.class_methods` +/// (per `check_in_workspace`) is what call-site resolution actually +/// consults. Without this merge, a `Term::Var { name: "show" }` in B would +/// fire `unbound-var`. The discriminator: when B's `main` calls `show 42` +/// at a fully-concrete type and A has NO matching instance, the diagnostic +/// must be `no-instance` (proving the class method resolved cross-module), +/// NOT `unbound-var` (which would mean cross-module class-method lookup +/// failed silently). Fixture: A = `test_22b2_xmod_classmod_noinst` declares +/// `class Show a`; B = `test_22b2_xmod_no_instance` calls `show 42`. +#[test] +fn cross_module_class_method_resolves_and_fires_no_instance() { + let entry = examples_dir().join("test_22b2_xmod_no_instance.ail.json"); + let ws = ailang_core::workspace::load_workspace(&entry) + .expect("workspace loads"); + let diagnostics = ailang_check::check_workspace(&ws); + let codes: Vec<&str> = diagnostics.iter().map(|d| d.code.as_str()).collect(); + assert!( + codes.contains(&"no-instance"), + "expected no-instance (proving cross-module class-method resolution); got: {:?}", + codes + ); + assert!( + !codes.contains(&"unbound-var"), + "must NOT fire unbound-var — class method `show` from module A is in scope in module B; got: {:?}", + codes + ); +} + +/// Property protected: a polymorphic fn in module B calling a class method +/// declared in module A (without declaring the matching constraint) MUST +/// fire `missing-constraint`. This pins the cross-module class-superclass +/// expansion path: B's `Forall.constraints` is empty, A's `class Show a` +/// is in `Env.class_methods` via the workspace merge, and the residual +/// `(Show, a)` collected at the `show x` call site must reach `check_fn`'s +/// missing-constraint arm. Without the cross-module merge, the call would +/// produce `unbound-var`; with the merge but a broken superclass-table +/// merge, the residual could be spuriously satisfied. Fixture: A = +/// `test_22b2_xmod_classmod_noinst`; B = `test_22b2_xmod_missing_constraint` +/// declares `forall a. (a) -> Str` and calls `show x`. +#[test] +fn cross_module_polymorphic_fn_without_constraint_fires_missing_constraint() { + let entry = examples_dir().join("test_22b2_xmod_missing_constraint.ail.json"); + let ws = ailang_core::workspace::load_workspace(&entry) + .expect("workspace loads"); + let diagnostics = ailang_check::check_workspace(&ws); + assert!( + diagnostics.iter().any(|d| d.code == "missing-constraint"), + "expected missing-constraint across modules; got: {:?}", + diagnostics.iter().map(|d| &d.code).collect::>() + ); +} + +/// Property protected: cross-module instance lookup for the no-instance +/// arm. Module A declares `class Show a` AND `instance Show Int`; module +/// B imports A and calls `show 42`. The call must typecheck clean — the +/// workspace registry built at load time over all modules must include A's +/// `instance Show Int`, and B's `check_fn` must find it under +/// `(class="Show", type_hash(Int))`. Without cross-module registry +/// population, B would spuriously fire `no-instance` at a type for which +/// an instance demonstrably exists. Fixture: A = +/// `test_22b2_xmod_classmod`; B = `test_22b2_xmod_instance_present`. +#[test] +fn cross_module_instance_satisfies_concrete_call() { + let entry = examples_dir().join("test_22b2_xmod_instance_present.ail.json"); + let ws = ailang_core::workspace::load_workspace(&entry) + .expect("workspace loads"); + let diagnostics = ailang_check::check_workspace(&ws); + assert!( + diagnostics.is_empty(), + "expected clean typecheck — A's instance Show Int must be in the workspace registry visible from B; got: {:?}", + diagnostics + ); +} + +/// Property protected: `check_workspace` aggregates diagnostics across +/// distinct fns rather than stopping at the first failing fn. When two +/// independent polymorphic fns each call `show x` without declaring `Show +/// a`, the resulting diagnostic vec must contain TWO `missing-constraint` +/// entries — one per fn — with the right `def` field on each. Without +/// per-def aggregation in the `for def in &m.defs` loop of +/// `check_in_workspace`, an editor consuming the diagnostics list would +/// see only the first failure, hiding the second from the author until +/// the first is fixed. Fixture has `describe_first` and `describe_second`, +/// both ill-formed in the same way; both must surface. +#[test] +fn two_fns_each_missing_constraint_produce_two_diagnostics() { + let entry = examples_dir().join("test_22b2_two_fns_missing_constraint.ail.json"); + let ws = ailang_core::workspace::load_workspace(&entry) + .expect("workspace loads"); + let diagnostics = ailang_check::check_workspace(&ws); + let mc: Vec<&ailang_check::Diagnostic> = diagnostics + .iter() + .filter(|d| d.code == "missing-constraint") + .collect(); + assert_eq!( + mc.len(), + 2, + "expected two missing-constraint diagnostics (one per fn); got: {:?}", + diagnostics.iter().map(|d| &d.code).collect::>() + ); + let defs: std::collections::BTreeSet<&str> = mc + .iter() + .filter_map(|d| d.def.as_deref()) + .collect(); + assert!( + defs.contains("describe_first"), + "expected diagnostic for `describe_first`; got defs: {:?}", + defs + ); + assert!( + defs.contains("describe_second"), + "expected diagnostic for `describe_second`; got defs: {:?}", + defs + ); +} diff --git a/examples/test_22b2_two_fns_missing_constraint.ail.json b/examples/test_22b2_two_fns_missing_constraint.ail.json new file mode 100644 index 0000000..5e915ae --- /dev/null +++ b/examples/test_22b2_two_fns_missing_constraint.ail.json @@ -0,0 +1,63 @@ +{ + "schema": "ailang/v0", + "name": "test_22b2_two_fns_missing_constraint", + "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": "fn", + "name": "describe_first", + "type": { + "k": "forall", + "vars": ["a"], + "body": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + }, + "params": ["x"], + "body": { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "var", "name": "x" }] + } + }, + { + "kind": "fn", + "name": "describe_second", + "type": { + "k": "forall", + "vars": ["b"], + "body": { + "k": "fn", + "params": [{ "k": "var", "name": "b" }], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + }, + "params": ["y"], + "body": { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "var", "name": "y" }] + } + } + ] +} diff --git a/examples/test_22b2_xmod_classmod.ail.json b/examples/test_22b2_xmod_classmod.ail.json new file mode 100644 index 0000000..7a3d78c --- /dev/null +++ b/examples/test_22b2_xmod_classmod.ail.json @@ -0,0 +1,34 @@ +{ + "schema": "ailang/v0", + "name": "test_22b2_xmod_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": [] + } + } + ] + }, + { + "kind": "instance", + "class": "Show", + "type": { "k": "con", "name": "Int" }, + "methods": [ + { + "name": "show", + "body": { "t": "lit", "lit": { "kind": "str", "value": "n" } } + } + ] + } + ] +} diff --git a/examples/test_22b2_xmod_classmod_noinst.ail.json b/examples/test_22b2_xmod_classmod_noinst.ail.json new file mode 100644 index 0000000..d8590cd --- /dev/null +++ b/examples/test_22b2_xmod_classmod_noinst.ail.json @@ -0,0 +1,23 @@ +{ + "schema": "ailang/v0", + "name": "test_22b2_xmod_classmod_noinst", + "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_22b2_xmod_instance_present.ail.json b/examples/test_22b2_xmod_instance_present.ail.json new file mode 100644 index 0000000..cbbc6c4 --- /dev/null +++ b/examples/test_22b2_xmod_instance_present.ail.json @@ -0,0 +1,23 @@ +{ + "schema": "ailang/v0", + "name": "test_22b2_xmod_instance_present", + "imports": [{ "module": "test_22b2_xmod_classmod" }], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + }, + "params": [], + "body": { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }] + } + } + ] +} diff --git a/examples/test_22b2_xmod_missing_constraint.ail.json b/examples/test_22b2_xmod_missing_constraint.ail.json new file mode 100644 index 0000000..158b419 --- /dev/null +++ b/examples/test_22b2_xmod_missing_constraint.ail.json @@ -0,0 +1,27 @@ +{ + "schema": "ailang/v0", + "name": "test_22b2_xmod_missing_constraint", + "imports": [{ "module": "test_22b2_xmod_classmod_noinst" }], + "defs": [ + { + "kind": "fn", + "name": "describe", + "type": { + "k": "forall", + "vars": ["a"], + "body": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + }, + "params": ["x"], + "body": { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "var", "name": "x" }] + } + } + ] +} diff --git a/examples/test_22b2_xmod_no_instance.ail.json b/examples/test_22b2_xmod_no_instance.ail.json new file mode 100644 index 0000000..458224e --- /dev/null +++ b/examples/test_22b2_xmod_no_instance.ail.json @@ -0,0 +1,23 @@ +{ + "schema": "ailang/v0", + "name": "test_22b2_xmod_no_instance", + "imports": [{ "module": "test_22b2_xmod_classmod_noinst" }], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + }, + "params": [], + "body": { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }] + } + } + ] +}