From e580f75adf32a69ae27105ba4764fecb61d77ade Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 10 May 2026 22:16:51 +0200 Subject: [PATCH] test: red for mono pass mis-resolving cross-module ctor patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `monomorphise_workspace` re-runs `synth` on every fn body to recover residual class constraints; the env it uses is built by `mono::build_workspace_env`, which delegates to `build_check_env` and produces a workspace-flat `ctor_index` (every Def::Type ctor across every module, keyed by bare ctor name → bare type name). `check_in_workspace` (lib.rs:1247-1258) explicitly clears that flat index after `build_check_env` and rebuilds it per-module so that `Pattern::Ctor`'s local-first / imports-fallback resolution at lib.rs:2486-2521 keeps the qualified-type-name comparison at lib.rs:2526 intact: imports-fallback yields a qualified `resolved_type_name` (`Mod.Type`), local-hit yields a bare one. The mono pass never does the per-module overlay, so when a body in module B pattern-matches a ctor `C` whose Def::Type lives in imported module A, the workspace-flat index resolves `C` locally and yields the bare type name. The scrutinee, however, was typed against the qualified name, and the comparison fails with `PatternTypeMismatch { ctor: "Cons", ty: "A.Type<...>" }`. Surfaced by iter 23.2 Task 3, which adds `class Eq a` + Eq Int/Bool/Str instances to the prelude. Pre-Task-3 the prelude is class-free, so `workspace_has_typeclasses(ws) == false` and the mono pass early-outs at `mono.rs:73`. Task 3 flips the gate; every workspace now traverses bodies, which brings the latent bug to the surface for the pre-existing `nested_ctor_pattern_first_two_sum` and `std_either_list_demo` E2E tests. Sibling regressions in the same family: `mono_xmod_qualified_ref.rs` (env.imports not seeded), commit 13b36cc (env.globals not seeded for self-recursive fns), commit 5c5180f (env.types / env.ctor_index not seeded for user ADTs — the original "flat ctor_index" decision that this bug now exposes as wrong-by-construction for cross-module ctor pattern resolution). The minimal fixture is two modules with five defs total: test_mono_ctor_listmod (data List a = Nil | Cons a (List a)) and test_mono_ctor_main (class Trivial a + instance Trivial Int + a fn that pattern-matches Cons against the imported List + a main). The test pins the inner cause: matches against the specific `CheckError::PatternTypeMismatch` variant with the qualified scrutinee type and bare ctor name, so it stays RED regardless of the prelude's typeclass content. --- crates/ail/tests/mono_xmod_ctor_pattern.rs | 122 +++++++++++++++++++++ examples/test_mono_ctor_listmod.ail.json | 23 ++++ examples/test_mono_ctor_main.ail.json | 116 ++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 crates/ail/tests/mono_xmod_ctor_pattern.rs create mode 100644 examples/test_mono_ctor_listmod.ail.json create mode 100644 examples/test_mono_ctor_main.ail.json diff --git a/crates/ail/tests/mono_xmod_ctor_pattern.rs b/crates/ail/tests/mono_xmod_ctor_pattern.rs new file mode 100644 index 0000000..53549ce --- /dev/null +++ b/crates/ail/tests/mono_xmod_ctor_pattern.rs @@ -0,0 +1,122 @@ +//! Bug regression: the workspace-monomorphisation pass must not mis-resolve +//! a cross-module constructor pattern. The mono pass re-runs `synth` on +//! every fn body to recover residual class constraints; that env is built +//! by `mono::build_workspace_env`, which delegates to `crate::build_check_env` +//! and produces a **workspace-flat** `ctor_index` (every Def::Type ctor +//! across every module, keyed by bare ctor name → bare type name). +//! +//! In contrast, `check_in_workspace` (lib.rs:1247-1258) explicitly clears +//! that flat index after `build_check_env` and rebuilds it per-module so +//! that `Pattern::Ctor`'s local-first / imports-fallback resolution +//! (lib.rs:2486-2521) can keep the qualified-type-name comparison at +//! lib.rs:2526 intact: the imports-fallback branch produces a qualified +//! `resolved_type_name` (`Mod.Type`), the local-hit branch produces a bare +//! one (`Type`). +//! +//! The mono pass never does the per-module overlay. So when a body in +//! module B pattern-matches a constructor `C` whose Def::Type lives in +//! imported module A, the workspace-flat ctor_index resolves `C` locally +//! (in fact: anywhere in the workspace) and yields the bare type name +//! `"Type"`. The scrutinee, however, was typed by the main pass against +//! the qualified name `"A.Type"` (per the imports-fallback path), so the +//! comparison +//! +//! ```ignore +//! Type::Con { name, args } if name == &resolved_type_name => args.clone(), +//! _ => return Err(CheckError::PatternTypeMismatch { ... }), +//! ``` +//! +//! at lib.rs:2526 fails with `cannot match constructor pattern C against +//! type A.Type<...>`. +//! +//! Sibling regressions in the same family: +//! * `mono_xmod_qualified_ref.rs` — `env.imports` not seeded. +//! * commit 13b36cc — `env.globals` not seeded for self-recursive fns. +//! * commit 5c5180f — `env.types` / `env.ctor_index` not seeded for +//! user ADTs (the original "flat ctor_index" decision that this +//! bug now exposes as wrong-by-construction for cross-module +//! pattern-match resolution). +//! +//! Surfaced by iter 23.2 Task 3, which adds `class Eq a` + Eq Int/Bool/Str +//! instances to `examples/prelude.ail.json`. Before Task 3 the prelude has +//! only `Def::Type Ordering`, so `workspace_has_typeclasses(ws) == false` +//! and the mono pass early-outs at `mono.rs:73`. Task 3 flips the gate; +//! every workspace now traverses bodies, which brings the latent bug to +//! the surface. +//! +//! Without the prelude change, two pre-existing E2E tests already +//! exercise the bug shape (`nested_ctor_pattern_first_two_sum`, +//! `std_either_list_demo`) and silently pass because the mono pass is a +//! no-op on a class-free workspace. This test pins the inner cause +//! against a minimal 2-module fixture so it stays RED regardless of the +//! prelude's typeclass content. + +use std::path::PathBuf; + +fn examples_dir() -> PathBuf { + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.pop(); + d.pop(); + d.join("examples") +} + +/// Property: `monomorphise_workspace` must succeed on a workspace that +/// (a) flips the typeclass gate (any `Def::Class` + `Def::Instance` is +/// enough), and (b) contains a fn whose body pattern-matches a +/// constructor whose `Def::Type` lives in an imported module. +/// +/// The minimal fixture is two modules: +/// +/// * `test_mono_ctor_listmod` — declares `data List a = Nil | Cons a (List a)`. +/// * `test_mono_ctor_main` — imports the listmod, declares +/// `class Trivial a` + `instance Trivial Int` (to flip the +/// `workspace_has_typeclasses` gate), and defines +/// `head_or_zero : test_mono_ctor_listmod.List -> Int` that +/// matches on `Cons h _ | Nil`. +/// +/// Pre-fix repro: `monomorphise_workspace` returns +/// `Err(CheckError::PatternTypeMismatch { ctor: "Cons", ty: +/// "test_mono_ctor_listmod.List" })`. The same workspace +/// typechecks cleanly because `check_in_workspace` clears the flat +/// `ctor_index` before running `Pattern::Ctor` resolution. +/// +/// Post-fix expectation: `monomorphise_workspace` returns `Ok` and the +/// `head_or_zero` fn body's match arm types correctly. +#[test] +fn mono_pass_handles_xmod_ctor_pattern() { + let entry = examples_dir().join("test_mono_ctor_main.ail.json"); + let ws = ailang_core::load_workspace(&entry).expect("load"); + + // Pre-condition: the workspace typechecks cleanly. The bug is + // localised to the mono pass; the typecheck path does not have it. + let diags = ailang_check::check_workspace(&ws); + assert!( + diags.is_empty(), + "fixture must typecheck before mono runs: {:?}", + diags + ); + + // Pin the symptom to the inner cause. Pre-fix this returns Err + // with the qualified scrutinee type and the bare `Cons` ctor. + let result = ailang_check::monomorphise_workspace(&ws); + match result { + Ok(_) => { /* post-fix: pass */ } + Err(ailang_check::CheckError::PatternTypeMismatch { ctor, ty }) => { + panic!( + "mono pass mis-resolves cross-module ctor pattern: \ + ctor=`{}` ty=`{}` (expected mono to succeed; the bare \ + ctor_index in build_workspace_env resolves `Cons` to \ + the local bare `List` instead of qualified \ + `test_mono_ctor_listmod.List`)", + ctor, ty + ); + } + Err(other) => { + panic!( + "mono pass failed with unexpected error variant: {:?} \ + (expected PatternTypeMismatch on cross-module Cons)", + other + ); + } + } +} diff --git a/examples/test_mono_ctor_listmod.ail.json b/examples/test_mono_ctor_listmod.ail.json new file mode 100644 index 0000000..fe82087 --- /dev/null +++ b/examples/test_mono_ctor_listmod.ail.json @@ -0,0 +1,23 @@ +{ + "schema": "ailang/v0", + "name": "test_mono_ctor_listmod", + "imports": [], + "defs": [ + { + "kind": "type", + "name": "List", + "vars": ["a"], + "doc": "Minimal polymorphic list for the mono-pass cross-module ctor-pattern bug fixture.", + "ctors": [ + { "name": "Nil", "fields": [] }, + { + "name": "Cons", + "fields": [ + { "k": "var", "name": "a" }, + { "k": "con", "name": "List", "args": [{ "k": "var", "name": "a" }] } + ] + } + ] + } + ] +} diff --git a/examples/test_mono_ctor_main.ail.json b/examples/test_mono_ctor_main.ail.json new file mode 100644 index 0000000..d5a1f02 --- /dev/null +++ b/examples/test_mono_ctor_main.ail.json @@ -0,0 +1,116 @@ +{ + "schema": "ailang/v0", + "name": "test_mono_ctor_main", + "imports": [{ "module": "test_mono_ctor_listmod" }], + "defs": [ + { + "kind": "class", + "name": "Trivial", + "param": "a", + "methods": [ + { + "name": "trivial", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + } + } + ] + }, + { + "kind": "instance", + "class": "Trivial", + "type": { "k": "con", "name": "Int" }, + "methods": [ + { + "name": "trivial", + "body": { + "t": "lam", + "params": ["x"], + "paramTypes": [{ "k": "con", "name": "Int" }], + "retType": { "k": "con", "name": "Int" }, + "body": { "t": "var", "name": "x" } + } + } + ] + }, + { + "kind": "fn", + "name": "head_or_zero", + "doc": "Pattern-matches Cons from an imported module's List. Bug repro: with a class+instance present in the workspace, the mono pass walks this body and synth's local-flat ctor_index resolves Cons to bare \"List\", which mismatches the qualified scrutinee type \"test_mono_ctor_listmod.List\".", + "type": { + "k": "fn", + "params": [ + { + "k": "con", + "name": "test_mono_ctor_listmod.List", + "args": [{ "k": "con", "name": "Int" }] + } + ], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": ["xs"], + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "xs" }, + "arms": [ + { + "pat": { + "p": "ctor", + "ctor": "Cons", + "fields": [ + { "p": "var", "name": "h" }, + { "p": "wild" } + ] + }, + "body": { "t": "var", "name": "h" } + }, + { + "pat": { "p": "ctor", "ctor": "Nil", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "int", "value": 0 } } + } + ] + } + }, + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", + "op": "io/print_int", + "args": [ + { + "t": "app", + "fn": { "t": "var", "name": "head_or_zero" }, + "args": [ + { + "t": "ctor", + "ctor": "Cons", + "type": "test_mono_ctor_listmod.List", + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 7 } }, + { + "t": "ctor", + "ctor": "Nil", + "type": "test_mono_ctor_listmod.List", + "args": [] + } + ] + } + ] + } + ] + } + } + ] +}