diff --git a/bench/orchestrator-stats/2026-05-13-iter-mq.3.json b/bench/orchestrator-stats/2026-05-13-iter-mq.3.json new file mode 100644 index 0000000..5b3665f --- /dev/null +++ b/bench/orchestrator-stats/2026-05-13-iter-mq.3.json @@ -0,0 +1,22 @@ +{ + "iter_id": "mq.3", + "date": "2026-05-13", + "mode": "standard", + "outcome": "DONE", + "tasks_total": 9, + "tasks_completed": 9, + "reloops_per_task": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0 + }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null +} diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 4aa9708..04033ee 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -1198,25 +1198,6 @@ fn workspace_error_to_diagnostic( "var": var, })), ), - W::MethodNameCollision { - method, - kind, - first_origin, - second_origin, - } => Some( - ailang_check::Diagnostic::error( - "method-name-collision", - format!( - "method name `{method}` collides ({kind}): defined in `{first_origin}` and `{second_origin}`" - ), - ) - .with_ctx(serde_json::json!({ - "method": method, - "kind": kind, - "first_origin": first_origin, - "second_origin": second_origin, - })), - ), W::MissingSuperclassInstance { class, superclass, diff --git a/crates/ail/tests/mq3_multi_class_e2e.rs b/crates/ail/tests/mq3_multi_class_e2e.rs new file mode 100644 index 0000000..e989a7e --- /dev/null +++ b/crates/ail/tests/mq3_multi_class_e2e.rs @@ -0,0 +1,110 @@ +//! mq.3.6: end-to-end coverage of the post-`MethodNameCollision`- +//! retirement multi-candidate dispatch path. Three positive fixtures +//! exercise the three trajectories from the milestone spec +//! §"Data flow": +//! +//! - Trajectory C — ambiguous bare-method call with both candidate +//! classes shipping `Show Int`. Typecheck rejects with +//! `ambiguous-method-resolution`. +//! - Trajectory E — explicit qualifier `..` +//! resolves cleanly to the named class. +//! - Class-fn shadow — bare-method call where both a class method and +//! a free fn of the same name exist. Fn wins per lookup precedence; +//! `class-method-shadowed-by-fn` warning fires so the LLM-author +//! sees the shadow. +//! +//! Each test invokes `ail check --json ` and asserts on the +//! diagnostic stream's `code` field. Stdout / runtime behaviour is +//! out of scope here — the dispatch path is a check-time concern. + +use std::path::PathBuf; +use std::process::Command; + +fn ail_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ail")) +} + +fn examples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("examples") +} + +fn run_ail_check_json(entry: &str) -> std::process::Output { + Command::new(ail_bin()) + .args(["check", "--json", examples_dir().join(entry).to_str().unwrap()]) + .output() + .expect("ail binary must launch") +} + +/// mq.3.6 (Trajectory C): bare `show 42` in a workspace with two +/// `Show` classes (modA and modB) each shipping `Show Int` is +/// genuinely ambiguous. `ail check` rejects with +/// `ambiguous-method-resolution` and names both candidate classes. +#[test] +fn mq3_two_show_ambiguous_fires_ambiguous_method_resolution() { + let out = run_ail_check_json("mq3_two_show_ambiguous.ail.json"); + assert!( + !out.status.success(), + "expected check to reject ambiguous dispatch; stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + let stdout = String::from_utf8(out.stdout).expect("stdout is utf-8"); + let diags: serde_json::Value = + serde_json::from_str(&stdout).expect("--json mode emits a JSON array on stdout"); + let arr = diags.as_array().expect("diagnostics is a JSON array"); + assert!( + arr.iter().any(|d| d["code"] == "ambiguous-method-resolution"), + "expected `ambiguous-method-resolution` diagnostic; got {stdout}", + ); + // Both candidate classes named in the diagnostic so the author + // knows which qualifiers to choose from. + assert!( + stdout.contains("mq3_two_show_ambiguous_a.Show"), + "expected candidate A in diagnostic; got {stdout}", + ); + assert!( + stdout.contains("mq3_two_show_ambiguous_b.Show"), + "expected candidate B in diagnostic; got {stdout}", + ); +} + +/// mq.3.6 (Trajectory E): explicit qualifier +/// `mq3_two_show_ambiguous_a.Show.show 42` disambiguates the same +/// workspace as the ambiguous fixture. Typecheck succeeds. +#[test] +fn mq3_two_show_qualified_resolves_clean() { + let out = run_ail_check_json("mq3_two_show_qualified.ail.json"); + assert!( + out.status.success(), + "expected check to succeed with explicit qualifier; stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); +} + +/// mq.3.6 (class-fn shadow): bare `myeq 1 2` in a workspace with +/// `class MyEq { myeq }` in one module and `fn myeq` in another +/// resolves to the fn per lookup precedence. Typecheck succeeds AND +/// the structured warning `class-method-shadowed-by-fn` fires so the +/// LLM-author sees the shadow. +#[test] +fn mq3_class_eq_vs_fn_eq_fn_wins_with_warning() { + let out = run_ail_check_json("mq3_class_eq_vs_fn_eq.ail.json"); + assert!( + out.status.success(), + "expected check to succeed (fn wins); stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + let stdout = String::from_utf8(out.stdout).expect("stdout is utf-8"); + let diags: serde_json::Value = + serde_json::from_str(&stdout).expect("--json mode emits a JSON array on stdout"); + let arr = diags.as_array().expect("diagnostics is a JSON array"); + assert!( + arr.iter().any(|d| d["code"] == "class-method-shadowed-by-fn"), + "expected `class-method-shadowed-by-fn` warning; got {stdout}", + ); +} diff --git a/crates/ail/tests/typeclass_22b2.rs b/crates/ail/tests/typeclass_22b2.rs index bf0c0e5..382e3e0 100644 --- a/crates/ail/tests/typeclass_22b2.rs +++ b/crates/ail/tests/typeclass_22b2.rs @@ -33,14 +33,17 @@ fn class_method_is_in_module_globals() { let mod_globals = globals .get("test_22b2_class_method_lookup") .expect("module globals present"); + // mq.3: ModuleGlobals accessors are tuple-keyed by (class, method). + // Pre-mq.3 callers passed the bare method name only; post-mq.3 they + // must disambiguate by class. assert!( - mod_globals.has_class_method("show"), - "class method `show` must appear in module globals" + mod_globals.has_class_method("test_22b2_class_method_lookup.Show", "show"), + "class method `show` (in class `Show`) must appear in module globals" ); // mq.1: class_method_class returns the qualified form // `.`. assert_eq!( - mod_globals.class_method_class("show"), + mod_globals.class_method_class("test_22b2_class_method_lookup.Show", "show"), Some("test_22b2_class_method_lookup.Show"), "class method `show` must remember its class is `Show` (qualified post-mq.1)" ); @@ -65,8 +68,8 @@ fn class_method_entry_carries_full_metadata() { .get("test_22b2_class_method_lookup") .expect("module globals present"); let cm_entry = mod_globals - .class_method("show") - .expect("class method `show` must be registered"); + .class_method("test_22b2_class_method_lookup.Show", "show") + .expect("class method `show` (in class `Show`) must be registered"); assert_eq!( cm_entry.defining_module, "test_22b2_class_method_lookup", diff --git a/crates/ail/tests/typeclass_22b3.rs b/crates/ail/tests/typeclass_22b3.rs index 7f820b0..210a18e 100644 --- a/crates/ail/tests/typeclass_22b3.rs +++ b/crates/ail/tests/typeclass_22b3.rs @@ -483,7 +483,16 @@ fn rewrite_walker_skips_locally_shadowed_class_method() { let entry = examples_dir().join("test_22b3_shadow_class_method.ail.json"); let ws = ailang_core::load_workspace(&entry).expect("load"); let diags = ailang_check::check_workspace(&ws); - assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags); + // mq.3: this fixture intentionally shadows the class method `show` + // with a local `let show = "shadow"`, which fires the new + // `class-method-shadowed-by-fn` warning. The warning is an + // expected correct consequence; filter it out so the assertion + // still pins "no errors / no other warnings". + let unexpected: Vec<_> = diags + .iter() + .filter(|d| d.code != "class-method-shadowed-by-fn") + .collect(); + assert!(unexpected.is_empty(), "fixture must typecheck: {:?}", unexpected); let ws = ailang_check::monomorphise_workspace(&ws).expect("mono"); diff --git a/crates/ailang-check/src/builtins.rs b/crates/ailang-check/src/builtins.rs index 67515cd..67888c2 100644 --- a/crates/ailang-check/src/builtins.rs +++ b/crates/ailang-check/src/builtins.rs @@ -362,6 +362,7 @@ mod tests { let mut counter: u32 = 0; let mut residuals = Vec::new(); let mut free_fn_calls = Vec::new(); + let mut warnings: Vec = Vec::new(); let ty = crate::synth( t, &env, @@ -372,6 +373,7 @@ mod tests { &mut counter, &mut residuals, &mut free_fn_calls, + &mut warnings, ) .expect("synth"); subst.apply(&ty) @@ -559,6 +561,7 @@ mod tests { let mut counter: u32 = 0; let mut residuals = Vec::new(); let mut free_fn_calls = Vec::new(); + let mut warnings: Vec = Vec::new(); let ret = crate::synth( &do_term, &env, @@ -569,6 +572,7 @@ mod tests { &mut counter, &mut residuals, &mut free_fn_calls, + &mut warnings, ) .expect("synth"); let ret = subst.apply(&ret); @@ -606,6 +610,7 @@ mod tests { let mut counter: u32 = 0; let mut residuals = Vec::new(); let mut free_fn_calls = Vec::new(); + let mut warnings: Vec = Vec::new(); let err = crate::synth( &term, &env, @@ -616,6 +621,7 @@ mod tests { &mut counter, &mut residuals, &mut free_fn_calls, + &mut warnings, ) .expect_err("must reject"); assert!( diff --git a/crates/ailang-check/src/diagnostic.rs b/crates/ailang-check/src/diagnostic.rs index 5957342..d1f457b 100644 --- a/crates/ailang-check/src/diagnostic.rs +++ b/crates/ailang-check/src/diagnostic.rs @@ -92,6 +92,16 @@ //! qualified class that is not in the workspace registry of //! candidate classes for the method. `ctx`: //! `{"name": ""}`. +//! - `class-method-shadowed-by-fn` (mq.3) — `severity: warning`. +//! Emitted by `synth`'s `Term::Var` arm when a name resolves to a +//! free fn (locals / caller-module-fn / imported-fn) AND a class +//! method of the same name also exists in the workspace. Fn +//! resolution proceeds per lookup precedence; the warning surfaces +//! the shadow so the LLM-author can disambiguate via explicit +//! class-qualified call (`. ...`) if the +//! shadow was unintentional. `ctx`: `{"name": "", +//! "method": "", "fn_owner_module": "", +//! "candidate_classes": ["", ...]}`. use serde::Serialize; diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 32096b8..ef6c0cf 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -906,7 +906,14 @@ pub fn check_workspace(ws: &Workspace) -> Vec { let mut diagnostics: Vec = Vec::new(); for name in order { let m = &ws.modules[name]; - let typecheck_errors = check_in_workspace(m, ws, &module_globals); + // mq.3: synth-time warnings collected per module then merged + // into the per-module diagnostic stream alongside typecheck + // errors. The synth warnings flow through `check_in_workspace` + // even when `had_typecheck_errors` is true; surface them so a + // shadowed-class-method warning is visible even when the + // module also has unrelated errors. + let mut synth_warnings: Vec = Vec::new(); + let typecheck_errors = check_in_workspace(m, ws, &module_globals, &mut synth_warnings); let had_typecheck_errors = !typecheck_errors.is_empty(); // Per-module diagnostic accumulator. The suppress filter // (Iter 19b) runs at the end of the per-module block, so @@ -916,6 +923,7 @@ pub fn check_workspace(ws: &Workspace) -> Vec { for e in typecheck_errors { module_diags.push(e.to_diagnostic()); } + module_diags.extend(synth_warnings); // Iter 18c.2: linearity check runs only on modules that // typechecked clean. Running it on a body that already has a // type error would wade into a partly-defined IR (e.g. @@ -1018,8 +1026,11 @@ pub fn check(m: &Module) -> Result { let module_globals = build_module_globals(&ws)?; // `check` keeps single-error semantics for callers (snapshot tests, // legacy code). Multi-diagnose is exposed via `check_module` / - // `check_workspace`. - if let Some(first) = check_in_workspace(m, &ws, &module_globals).into_iter().next() { + // `check_workspace`. mq.3: synth warnings are collected here but + // discarded — `check`'s legacy contract returns only the first + // error, and warnings on this path have no surface to flow to. + let mut synth_warnings: Vec = Vec::new(); + if let Some(first) = check_in_workspace(m, &ws, &module_globals, &mut synth_warnings).into_iter().next() { return Err(first); } // Collect symbols for the return value (existing semantics). @@ -1119,37 +1130,49 @@ pub struct ModuleGlobals { /// shape — every call site that only needs HM lookup goes through /// here. pub fns: IndexMap, - /// Class-method names, by method name. Populated in - /// [`build_module_globals`] from `Def::Class` entries; consulted by - /// the 22b.2 `missing-constraint` / `no-instance` arms (Tasks 9 / 10). - /// `IndexMap` matches the sibling [`Self::fns`] channel and preserves - /// definition order — Tasks 9 / 10 may surface diagnostics in source - /// order, and downstream consumers should not depend on a sorted - /// iteration order they did not ask for. - pub class_methods: IndexMap, + /// mq.3: re-keyed by `(qualified-class-name, method-name)` tuple — + /// post-retirement of `MethodNameCollision`, two classes can declare + /// same-named methods within the same workspace. The tuple key + /// disambiguates without losing the per-module ordering guarantee + /// (`IndexMap` preserves insertion order). Lookups by method-name- + /// only consult `method_to_candidate_classes` (Env-level) to get + /// the candidate-class set, then index into `class_methods` with + /// `(resolved_class, method)`. + pub class_methods: IndexMap<(String, String), ClassMethodEntry>, } impl ModuleGlobals { - /// True if `name` is a class method declared in this module. - pub fn has_class_method(&self, name: &str) -> bool { - self.class_methods.contains_key(name) + /// True if class `class` declares a method named `name` in this + /// module. mq.3: tuple-keyed lookup post-`MethodNameCollision` + /// retirement. + pub fn has_class_method(&self, class: &str, name: &str) -> bool { + self.class_methods.contains_key(&(class.to_string(), name.to_string())) } - /// The class that declared the method `name`, or `None` if `name` - /// is not a class method in this module. - pub fn class_method_class(&self, name: &str) -> Option<&str> { + /// The class that declared the method `name` in class `class`, or + /// `None` if no such entry exists. (Pre-mq.3 callers passed only the + /// method name; post-mq.3 they must disambiguate by class.) + pub fn class_method_class(&self, class: &str, name: &str) -> Option<&str> { self.class_methods - .get(name) + .get(&(class.to_string(), name.to_string())) .map(|e| e.class_name.as_str()) } - /// The full [`ClassMethodEntry`] for method `name`, or `None` if - /// `name` is not a class method in this module. Tasks 9 / 10 use - /// this to read `method_ty` and `defining_module` together; the - /// narrower `has_class_method` / `class_method_class` accessors - /// stay for callers that only need a yes/no or the class name. - pub fn class_method(&self, name: &str) -> Option<&ClassMethodEntry> { - self.class_methods.get(name) + /// The full [`ClassMethodEntry`] for method `name` in class `class`, + /// or `None` if no such entry exists. + pub fn class_method(&self, class: &str, name: &str) -> Option<&ClassMethodEntry> { + self.class_methods.get(&(class.to_string(), name.to_string())) + } + + /// mq.3: enumerate all `(class, method) -> entry` pairs whose + /// method name matches `name`. Returns zero-or-more entries — + /// pre-mq.3 always at most one, post-mq.3 a workspace may have + /// multiple classes declaring the same method. + pub fn class_method_candidates(&self, name: &str) -> Vec<(&String, &ClassMethodEntry)> { + self.class_methods + .iter() + .filter_map(|((c, m), e)| if m == name { Some((c, e)) } else { None }) + .collect() } } @@ -1271,7 +1294,7 @@ pub fn build_module_globals( let qualified_class = format!("{mname}.{}", cd.name); for meth in &cd.methods { globals.class_methods.insert( - meth.name.clone(), + (qualified_class.clone(), meth.name.clone()), ClassMethodEntry { class_name: qualified_class.clone(), class_param: cd.param.clone(), @@ -1341,14 +1364,20 @@ pub fn build_check_env(ws: &Workspace) -> Env { .map(|(k, v)| (k.clone(), v.fns.clone())) .collect(); for g in mg.values() { - for (n, e) in &g.class_methods { - env.class_methods.insert(n.clone(), e.clone()); + for ((qualified_class, method_name), e) in &g.class_methods { + // mq.3: env.class_methods is tuple-keyed by + // `(qualified-class, method-name)`. Multiple classes may + // share a method name post-`MethodNameCollision`-retirement. + env.class_methods.insert( + (qualified_class.clone(), method_name.clone()), + e.clone(), + ); // mq.2: build the inverse method → candidate-class set in // the same loop. `e.class_name` already carries the // qualified form (mq.1 invariant), so the set is keyed on // workspace-flat qualified class names directly. env.method_to_candidate_classes - .entry(n.clone()) + .entry(method_name.clone()) .or_default() .insert(e.class_name.clone()); } @@ -1428,6 +1457,11 @@ fn check_in_workspace( m: &Module, ws: &Workspace, module_globals: &BTreeMap, + // mq.3: synth-time warnings collected here so the caller can + // surface them into the workspace-wide diagnostic stream. Today + // only `class-method-shadowed-by-fn` flows through this channel + // (emitted from `synth` via `check_fn`'s warnings accumulator). + out_warnings: &mut Vec, ) -> Vec { let mut env = build_check_env(ws); let mut errors: Vec = Vec::new(); @@ -1505,17 +1539,29 @@ fn check_in_workspace( env.current_module = m.name.clone(); for def in &m.defs { - if let Err(e) = check_def(def, &env) { - errors.push(CheckError::Def(def.name().to_string(), Box::new(e))); + match check_def(def, &env, out_warnings) { + Ok(()) => {} + Err(e) => { + errors.push(CheckError::Def(def.name().to_string(), Box::new(e))); + } } } + // mq.3: stamp the synth warnings with the module's name as `def` + // so the JSON consumer can correlate them with the surrounding + // module. The warning's `def` field carries the call-site def name + // already (set inside `check_fn`); the workspace-wide caller + // extends without further annotation. errors } -fn check_def(def: &Def, env: &Env) -> Result<()> { +fn check_def( + def: &Def, + env: &Env, + out_warnings: &mut Vec, +) -> Result<()> { match def { - Def::Fn(f) => check_fn(f, env), - Def::Const(c) => check_const(c, env), + Def::Fn(f) => check_fn(f, env, out_warnings), + Def::Const(c) => check_const(c, env, out_warnings), Def::Type(td) => check_type_def(td, env), // Iter 22b.1: schema-only landing for class/instance defs. // Class-schema validation (InvalidSuperclassParam, @@ -1619,7 +1665,7 @@ fn check_type_well_formed(t: &Type, env: &Env) -> Result<()> { } } -fn check_fn(f: &FnDef, env: &Env) -> Result<()> { +fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec) -> Result<()> { // Peel an outer Forall (Iter 12a). The vars become rigid in the // inner env so they pass `check_type_well_formed` and unify only // with themselves. An empty `vars` list (vacuously polymorphic) @@ -1665,6 +1711,32 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { } check_type_well_formed(&ret_ty, &env)?; + // mq.3: build the post-superclass-expansion declared-constraint + // set ONCE here and stash on env, so synth's Var-arm class-method + // branch can hand it to `resolve_method_dispatch`'s constraint- + // driven filter. The post-synth missing-constraint check below + // re-reads the same expanded list from env to keep one source of + // truth. + // + // mq.1 follow-on: declared constraints carry the canonical-form + // `class` value (bare for same-module, qualified for cross-module). + // Lift to the workspace-key shape (always qualified) before + // expanding — residuals always carry qualified `class` because + // they come from `ClassMethodEntry.class_name` which is qualified + // post-mq.1. + let declared_constraints: Vec = match &f.ty { + Type::Forall { constraints, .. } => constraints + .iter() + .map(|c| Constraint { + class: qualify_class_ref_in_check(&c.class, &env.current_module), + type_: c.type_.clone(), + }) + .collect(), + _ => Vec::new(), + }; + env.active_declared_constraints = + expand_declared_constraints(&declared_constraints, &env.class_superclasses); + let mut locals = IndexMap::new(); for (n, t) in f.params.iter().zip(param_tys.iter()) { locals.insert(n.clone(), t.clone()); @@ -1677,8 +1749,13 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { // body finishes, we compare against the expanded declared set. let mut residuals: Vec = Vec::new(); let mut free_fn_calls: Vec = Vec::new(); - let body_ty = synth(&f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls)?; + let mut warnings: Vec = Vec::new(); + let body_ty = synth(&f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?; unify(&ret_ty, &body_ty, &mut subst)?; + // mq.3: surface synth-time warnings into the caller-supplied + // accumulator. The warnings already carry `def: Some(f.name)` + // set inside synth's emission site. + out_warnings.extend(warnings); // Iter 14e: tail-position verification (Decision 8). Runs after // the main type-check so that a tail-call marker on a malformed @@ -1692,29 +1769,13 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { } } - // Iter 22b.2 (Task 9): missing-constraint check. Build the expanded + // Iter 22b.2 (Task 9): missing-constraint check. The expanded // declared-constraint set (declared + one-step superclass per - // Decision 11) and compare against residuals. Var-shaped residuals + // Decision 11) was computed pre-synth and stashed on + // `env.active_declared_constraints` (mq.3). Var-shaped residuals // not covered by the expanded set fire `MissingConstraint`. Concrete // residuals are deferred to Task 10's `no-instance` arm. - // - // mq.1: declared constraints carry the canonical-form `class` - // value (bare for same-module, qualified for cross-module). Lift - // to the workspace-key shape (always qualified) before comparing - // against residuals — residuals always carry qualified `class` - // because they come from `ClassMethodEntry.class_name` which is - // qualified post-mq.1. - let declared_constraints: Vec = match &f.ty { - Type::Forall { constraints, .. } => constraints - .iter() - .map(|c| Constraint { - class: qualify_class_ref_in_check(&c.class, &env.current_module), - type_: c.type_.clone(), - }) - .collect(), - _ => Vec::new(), - }; - let expanded = expand_declared_constraints(&declared_constraints, &env.class_superclasses); + let expanded = &env.active_declared_constraints; for r in &residuals { let r_ty = subst.apply(&r.type_); @@ -1742,7 +1803,7 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { .collect(); match refine_multi_candidate_residual( &normalized_residual, - &expanded, + expanded, ®istry_unit, ) { RefineOutcome::Resolved(_) => { @@ -2321,7 +2382,7 @@ pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> { } } -fn check_const(c: &ConstDef, env: &Env) -> Result<()> { +fn check_const(c: &ConstDef, env: &Env, out_warnings: &mut Vec) -> Result<()> { // Const types are never polymorphic — a Forall here is rejected // outright. Any other type passes through to `synth` as before. if matches!(&c.ty, Type::Forall { .. }) { @@ -2337,7 +2398,9 @@ fn check_const(c: &ConstDef, env: &Env) -> Result<()> { // accumulator only to keep `synth`'s signature uniform. let mut residuals: Vec = Vec::new(); let mut free_fn_calls: Vec = Vec::new(); - let v = synth(&c.value, env, &mut locals, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls)?; + let mut warnings: Vec = Vec::new(); + let v = synth(&c.value, env, &mut locals, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?; + out_warnings.extend(warnings); unify(&c.ty, &v, &mut subst)?; if !effects.is_empty() { return Err(CheckError::ConstHasEffects( @@ -2348,6 +2411,7 @@ fn check_const(c: &ConstDef, env: &Env) -> Result<()> { Ok(()) } +#[allow(clippy::too_many_arguments)] pub(crate) fn synth( t: &Term, env: &Env, @@ -2358,6 +2422,11 @@ pub(crate) fn synth( counter: &mut u32, residuals: &mut Vec, free_fn_calls: &mut Vec, + // mq.3: out-parameter for synth-time structured warnings. Symmetric + // to `residuals` / `free_fn_calls`. Currently only the + // `class-method-shadowed-by-fn` warning is emitted here; future + // synth-time warnings (if any) would flow through the same channel. + warnings: &mut Vec, ) -> Result { match t { Term::Lit { lit } => Ok(match lit { @@ -2399,7 +2468,45 @@ pub(crate) fn synth( // observation; the mono pass `subst.apply`s the metas // post-synth to recover concrete type-args at each // polymorphic free-fn call site. + // mq.3: a helper closure that emits the + // `class-method-shadowed-by-fn` warning when a fn-precedence + // branch resolves a name that ALSO has class-method + // candidates in the workspace. The fn resolution proceeds; + // the warning surfaces the shadow so the LLM-author can + // disambiguate via explicit class-qualified call if the + // shadow was unintentional. Locals/same-module-fn/implicit- + // import-fn all share this rule. + let emit_shadow_warning_if_class_method = + |name: &str, owner_module: &str, warnings: &mut Vec| { + let (method_name, _) = parse_method_qualifier(name); + if let Some(cands) = env.method_to_candidate_classes.get(method_name) { + let mut candidate_list: Vec = cands.iter().cloned().collect(); + candidate_list.sort(); + let d = crate::diagnostic::Diagnostic::warning( + "class-method-shadowed-by-fn", + format!( + "free fn `{name}` in module `{owner_module}` shadows class method `{method_name}` \ + declared in classes {candidate_list:?}. \ + To call the class method instead, write `.{method_name} ...`.", + ), + ) + .with_def(in_def.to_string()) + .with_ctx(serde_json::json!({ + "name": name, + "method": method_name, + "fn_owner_module": owner_module, + "candidate_classes": candidate_list, + })); + warnings.push(d); + } + }; + let (raw, free_fn_owner): (Type, Option<(String, String)>) = if let Some(t) = locals.get(name) { + // Locals (lambda / let / fn-params) — fn wins per + // lookup precedence. A class method shadowed by a + // local binding fires the shadow warning so the + // author can disambiguate if unintentional. + emit_shadow_warning_if_class_method(name, env.current_module.as_str(), warnings); (t.clone(), None) } else if let Some(t) = env.globals.get(name) { // Same-module global. Owner is the current module IFF @@ -2411,7 +2518,44 @@ pub(crate) fn synth( .get(&env.current_module) .filter(|m| m.contains_key(name)) .map(|_| (env.current_module.clone(), name.clone())); + emit_shadow_warning_if_class_method(name, env.current_module.as_str(), warnings); (t.clone(), owner) + } else if let Some((owner_module, raw_ty)) = env + .imports + .values() + .find_map(|mod_name| { + env.module_globals + .get(mod_name) + .and_then(|mg| mg.get(name).map(|ty| (mod_name.clone(), ty.clone()))) + }) + { + // mq.3: implicit-import free-fn precedence runs BEFORE + // the class-method branch (fn wins per spec + // §"Class-fn collisions"). When the name has class- + // method candidates too, emit the shadow warning. + // + // Iter 23.4-prep: bare-name fall-through to free fns of + // implicitly-imported modules. Today only the prelude is + // implicitly imported (see `build_check_env` and + // `check_in_workspace`'s prelude-injection), so the + // first match is unambiguous. If a second implicit + // import lands, the single-match-wins rule becomes a + // real ambiguity and this branch needs a + // duplicate-detection diagnostic. + emit_shadow_warning_if_class_method(name, owner_module.as_str(), warnings); + // Qualify any bare type-cons referring to a type defined in + // the owning module — same treatment the dot-qualified arm + // below applies — so signatures cross the module boundary + // with fully-qualified Type::Cons. + let owner_types = env + .module_types + .get(&owner_module) + .cloned() + .unwrap_or_default(); + let qualified = qualify_local_types(&raw_ty, &owner_module, &owner_types); + // Bare name resolves through implicit import; the + // unqualified name in the owner module is the same `name`. + (qualified, Some((owner_module, name.clone()))) } else if { // mq.2 dispatch entry: the Var arm matches a class // method either bare (`"show"`) or qualified @@ -2464,7 +2608,7 @@ pub(crate) fn synth( qualifier_opt.as_deref(), candidates, /*concrete_arg_type*/ None, - /*declared_constraints*/ &[], + /*declared_constraints*/ &env.active_declared_constraints, /*registry*/ ®istry_unit, ); @@ -2488,16 +2632,22 @@ pub(crate) fn synth( } }; - // Look up the `ClassMethodEntry` by method name — - // workspace-flat. The `MethodNameCollision` invariant - // (pre-mq.3) guarantees the lookup is unambiguous. Post- - // mq.3, the class_methods table is keyed by - // `(class, method)` and this `.get(method_name)` will - // need to be reworked alongside the table. - let cm = env.class_methods.get(method_name).expect( - "method_to_candidate_classes invariant: method present \ - implies class_methods entry", - ); + // mq.3: class_methods is tuple-keyed by + // `(qualified-class, method)`. The dispatcher above + // resolved a tentative class (single-survivor or first + // BTreeSet element for the multi-candidate path) — use + // it to disambiguate the lookup. The `expect` is sound + // by construction: `method_to_candidate_classes` and + // `class_methods` are built from the same source loop + // (every entry in one has a matching entry in the + // other; see `build_check_env`). + let cm = env + .class_methods + .get(&(residual_class.clone(), method_name.to_string())) + .expect( + "method_to_candidate_classes invariant: \ + (resolved class, method) present implies class_methods entry", + ); let owner_types = env .module_types .get(&cm.defining_module) @@ -2516,37 +2666,6 @@ pub(crate) fn synth( candidates: residual_candidates, }); return Ok(inst_ty); - } else if let Some((owner_module, raw_ty)) = env - .imports - .values() - .find_map(|mod_name| { - env.module_globals - .get(mod_name) - .and_then(|mg| mg.get(name).map(|ty| (mod_name.clone(), ty.clone()))) - }) - { - // Iter 23.4-prep: bare-name fall-through to free fns of - // implicitly-imported modules. Today only the prelude is - // implicitly imported (see `build_check_env` and - // `check_in_workspace`'s prelude-injection at this file's - // ~1170 / ~1285), so the first match is unambiguous. If a - // second implicit import lands, the single-match-wins rule - // becomes a real ambiguity and this branch needs a - // duplicate-detection diagnostic. - // - // Qualify any bare type-cons referring to a type defined in - // the owning module — same treatment the dot-qualified arm - // below applies — so signatures cross the module boundary - // with fully-qualified Type::Cons. - let owner_types = env - .module_types - .get(&owner_module) - .cloned() - .unwrap_or_default(); - let qualified = qualify_local_types(&raw_ty, &owner_module, &owner_types); - // Bare name resolves through implicit import; the - // unqualified name in the owner module is the same `name`. - (qualified, Some((owner_module, name.clone()))) } else if name.matches('.').count() == 1 { let (prefix, suffix) = name.split_once('.').expect("checked"); let target_module = match env.imports.get(prefix) { @@ -2610,7 +2729,7 @@ pub(crate) fn synth( Ok(maybe_instantiate(raw, counter)) } Term::App { callee, args, .. } => { - let cty = synth(callee, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let cty = synth(callee, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let cty = subst.apply(&cty); let (params, ret, fx) = match &cty { Type::Fn { params, ret, effects: fx, .. } => { @@ -2646,7 +2765,7 @@ pub(crate) fn synth( }); } for (a, exp) in args.iter().zip(params.iter()) { - let actual = synth(a, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let actual = synth(a, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; unify(exp, &actual, subst)?; } for e in fx { @@ -2655,9 +2774,9 @@ pub(crate) fn synth( Ok(subst.apply(&ret)) } Term::Let { name, value, body } => { - let v = synth(value, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let v = synth(value, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; let prev = locals.insert(name.clone(), v); - let r = synth(body, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let r = synth(body, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; match prev { Some(p) => { locals.insert(name.clone(), p); @@ -2669,10 +2788,10 @@ pub(crate) fn synth( Ok(r) } Term::If { cond, then, else_ } => { - let c = synth(cond, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let c = synth(cond, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; unify(&Type::bool_(), &c, subst)?; - let t1 = synth(then, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; - let t2 = synth(else_, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let t1 = synth(then, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; + let t2 = synth(else_, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; unify(&t1, &t2, subst)?; Ok(subst.apply(&t1)) } @@ -2690,7 +2809,7 @@ pub(crate) fn synth( }); } for (a, exp) in args.iter().zip(sig.params.iter()) { - let actual = synth(a, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let actual = synth(a, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; unify(exp, &actual, subst)?; } effects.insert(sig.effect.clone()); @@ -2785,7 +2904,7 @@ pub(crate) fn synth( } for (a, exp) in args.iter().zip(qualified_fields.iter()) { let exp_inst = substitute_rigids(exp, &mapping); - let actual = synth(a, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let actual = synth(a, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; unify(&exp_inst, &actual, subst)?; } Ok(Type::Con { @@ -2794,7 +2913,7 @@ pub(crate) fn synth( }) } Term::Match { scrutinee, arms } => { - let s_ty = synth(scrutinee, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let s_ty = synth(scrutinee, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; if arms.is_empty() { return Err(CheckError::NonExhaustive { ty: ailang_core::pretty::type_to_string(&s_ty), @@ -2812,7 +2931,7 @@ pub(crate) fn synth( let prev = locals.insert(n.clone(), t.clone()); pushed.push((n.clone(), prev)); } - let body_ty = synth(&arm.body, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let body_ty = synth(&arm.body, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; for (n, prev) in pushed.into_iter().rev() { match prev { Some(p) => { @@ -2887,9 +3006,9 @@ pub(crate) fn synth( Ok(subst.apply(&result_ty.expect("checked arms is non-empty"))) } Term::Seq { lhs, rhs } => { - let lty = synth(lhs, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let lty = synth(lhs, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; unify(&Type::unit(), <y, subst)?; - synth(rhs, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls) + synth(rhs, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings) } Term::Lam { params, param_tys, ret_ty, effects: lam_effects, body } => { let mut pushed: Vec<(String, Option)> = Vec::new(); @@ -2898,7 +3017,7 @@ pub(crate) fn synth( pushed.push((n.clone(), prev)); } let mut body_effects: BTreeSet = BTreeSet::new(); - let body_ty = synth(body, env, locals, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls); + let body_ty = synth(body, env, locals, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings); for (n, prev) in pushed.into_iter().rev() { match prev { Some(p) => { @@ -2986,7 +3105,7 @@ pub(crate) fn synth( // subset rule against `declared_effs` — exactly like // `Term::Lam`. let mut body_effects: BTreeSet = BTreeSet::new(); - let body_ty = synth(body, env, locals, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls); + let body_ty = synth(body, env, locals, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings); // Restore body-scope locals (params + name). for (n, prev) in pushed.into_iter().rev() { @@ -3013,7 +3132,7 @@ pub(crate) fn synth( // scope (params are not visible here; only the recursive // binding is). let prev_in = locals.insert(name.clone(), ty.clone()); - let in_ty = synth(in_term, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls); + let in_ty = synth(in_term, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings); match prev_in { Some(p) => { locals.insert(name.clone(), p); @@ -3029,7 +3148,7 @@ pub(crate) fn synth( // No constraint generated, no environment change. The // wrapper records author intent for the future RC inc/dec // emission pass (18c.3); typing is pure passthrough. - synth(value, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls) + synth(value, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings) } Term::ReuseAs { source, body } => { // Iter 18d.1: `(reuse-as SRC NEW-CTOR)` requires `body` to @@ -3043,7 +3162,7 @@ pub(crate) fn synth( // shape-compatibility check (18d.2 will add a // `reuse-as-shape-mismatch` diagnostic when codegen has // the actual size info). - let _ = synth(source, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls)?; + let _ = synth(source, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?; match body.as_ref() { Term::Ctor { .. } | Term::Lam { .. } => {} other => { @@ -3069,7 +3188,7 @@ pub(crate) fn synth( } // Body's type is the result type of the whole reuse-as // expression. - synth(body, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls) + synth(body, env, locals, effects, in_def, subst, counter, residuals, free_fn_calls, warnings) } } } @@ -3356,14 +3475,15 @@ pub struct Env { /// these names are legal as `Type::Var { name }` and unify only /// with themselves. pub rigid_vars: BTreeSet, - /// Iter 22b.2 (Task 9): workspace-wide class-method table. Merged - /// from every module's [`ModuleGlobals::class_methods`] so that a - /// `Term::Var { name: "show" }` in any module resolves to the - /// declaring class's [`ClassMethodEntry`]. Method-name uniqueness - /// is enforced earlier by `workspace::build_registry`'s - /// method-name-collision check (Iter 22b.2.6), so the merge is - /// safe (no overwrites). Read in [`synth`]'s `Term::Var` arm. - pub class_methods: BTreeMap, + /// mq.3: workspace-flat aggregate of every module's + /// [`ModuleGlobals::class_methods`], re-keyed to + /// `(qualified-class-name, method-name)` so post-retirement of + /// `MethodNameCollision` two classes can share a method name. + /// Consumed by [`synth`]'s `Term::Var` arm via the resolved class + /// (from `method_to_candidate_classes` + dispatch) plus the method + /// name. Mono's presence checks consult + /// [`Self::method_to_candidate_classes`] (method-keyed natively). + pub class_methods: BTreeMap<(String, String), ClassMethodEntry>, /// mq.2: workspace-flat inverse of [`Self::class_methods`] — for /// each method name, the set of qualified class names that declare /// it. Pre-mq.3, the `MethodNameCollision` invariant guarantees @@ -3371,6 +3491,15 @@ pub struct Env { /// > 1 becomes legal. Synth's `Term::Var` arm consults this index /// for type-driven dispatch (spec §Architecture's 5-step rule). pub method_to_candidate_classes: BTreeMap>, + /// mq.3: post-superclass-expansion declared constraints of the + /// active fn body being checked. Populated by `check_fn` before + /// invoking `synth` on the body; empty at workspace entry. + /// Consumed by `synth`'s Var-arm class-method branch when calling + /// `resolve_method_dispatch` — the constraint-driven filter + /// disambiguates multi-candidate residuals at the rigid-var + /// fallback (e.g. inside a polymorphic fn body where the arg + /// type is still a rigid type variable). + pub active_declared_constraints: Vec, /// Iter 22b.2 (Task 9): one-step superclass expansion table. /// Maps each class name to its superclass class name. Absence from /// the map means the class has no superclass — populated only for @@ -6016,4 +6145,161 @@ mod tests { assert!(candidates.contains("m.MyShow"), "candidates: {candidates:?}"); assert_eq!(candidates.len(), 1, "MethodNameCollision invariant: singleton"); } + + /// mq.3.1: `Env` carries an `active_declared_constraints` field + /// holding the active fn's POST-superclass-expansion declared + /// constraints. Empty at workspace entry; populated by `check_fn` + /// before invoking `synth` on the fn body. Consumed by `synth`'s + /// Var-arm class-method branch when calling `resolve_method_dispatch` + /// (constraint-driven filter path). + #[test] + fn mq3_env_active_declared_constraints_field_exists() { + let env = Env::default(); + assert_eq!(env.active_declared_constraints.len(), 0, + "active_declared_constraints empty at default-construction"); + } + + /// mq.3.3: when synth's Var-arm resolves a name to a free fn + /// (locals / caller-module-fn / implicit-import fn) AND the same + /// method name has class-method candidates in the workspace, a + /// structured warning `class-method-shadowed-by-fn` is emitted + /// via the warnings out-parameter. The fn resolution proceeds; + /// the warning surfaces the shadow so the LLM-author can + /// disambiguate via explicit class-qualified call if the shadow + /// was unintentional. + #[test] + fn mq3_class_method_shadowed_by_fn_warning_fires() { + use ailang_core::ast::{ClassDef, ClassMethod, Module}; + // Synthesise a workspace with `class Show { show: a -> Str }` + // in module `clsmod` and `fn show : (Int) -> Str` in module + // `fnmod`. Module `m` imports both implicitly; `synth` on + // `Term::Var { name: "show" }` should resolve to the fn AND + // emit the shadow warning. + let clsmod = Module { + schema: SCHEMA.into(), + name: "clsmod".into(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "Show".into(), + param: "a".into(), + superclass: None, + methods: vec![ClassMethod { + name: "show".into(), + ty: Type::Fn { + params: vec![Type::Var { name: "a".into() }], + param_modes: vec![ParamMode::Borrow], + ret: Box::new(Type::str_()), + effects: vec![], + ret_mode: ParamMode::Implicit, + }, + default: None, + }], + doc: None, + })], + }; + let show_fn_ty = Type::Fn { + params: vec![Type::int()], + param_modes: vec![ParamMode::Borrow], + ret: Box::new(Type::str_()), + effects: vec![], + ret_mode: ParamMode::Implicit, + }; + let fnmod = Module { + schema: SCHEMA.into(), + name: "fnmod".into(), + imports: vec![], + defs: vec![Def::Fn(FnDef { + name: "show".into(), + ty: show_fn_ty.clone(), + params: vec!["x".into()], + body: Term::Lit { lit: Literal::Str { value: "_".into() } }, + doc: None, + suppress: vec![], + })], + }; + let mut modules = BTreeMap::new(); + modules.insert("clsmod".into(), clsmod); + modules.insert("fnmod".into(), fnmod); + let ws = Workspace { + entry: "fnmod".into(), + modules, + root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), + }; + let mut env = build_check_env(&ws); + // Treat both modules as implicitly imported by `env.imports` + // (the test-only equivalent of the prelude-injection convention). + env.imports.insert("clsmod".into(), "clsmod".into()); + env.imports.insert("fnmod".into(), "fnmod".into()); + env.current_module = "fnmod".into(); + + let term = Term::Var { name: "show".into() }; + let mut locals: IndexMap = IndexMap::new(); + let mut effects: BTreeSet = BTreeSet::new(); + let mut subst = Subst::default(); + let mut counter: u32 = 0; + let mut residuals: Vec = Vec::new(); + let mut free_fn_calls: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + let _ = synth( + &term, + &env, + &mut locals, + &mut effects, + "test_call_site", + &mut subst, + &mut counter, + &mut residuals, + &mut free_fn_calls, + &mut warnings, + ) + .expect("synth must succeed (fn wins precedence)"); + assert!( + warnings.iter().any(|d| d.code == "class-method-shadowed-by-fn"), + "expected at least one class-method-shadowed-by-fn warning, got: {warnings:?}", + ); + } + + /// mq.3.2: `Env.class_methods` is keyed by `(QualifiedClassName, + /// MethodName)` tuple — post-mq.3 the workspace can carry multiple + /// classes sharing a method name, so the key must disambiguate. + /// O(1) lookup post-dispatch via `(resolved_class, method)`. + #[test] + fn mq3_env_class_methods_tuple_keyed() { + use ailang_core::ast::{ClassDef, ClassMethod, Module}; + let m = Module { + schema: SCHEMA.into(), + name: "m".to_string(), + imports: vec![], + defs: vec![Def::Class(ClassDef { + name: "MyCls".to_string(), + param: "a".to_string(), + superclass: None, + methods: vec![ClassMethod { + name: "mymethod".to_string(), + ty: Type::Fn { + params: vec![Type::Var { name: "a".to_string() }], + param_modes: vec![ParamMode::Borrow], + ret: Box::new(Type::Con { name: "Str".to_string(), args: vec![] }), + effects: vec![], + ret_mode: ParamMode::Implicit, + }, + default: None, + }], + doc: None, + })], + }; + let mut modules = BTreeMap::new(); + modules.insert("m".into(), m); + let ws = Workspace { + entry: "m".into(), + modules, + root_dir: std::path::PathBuf::from("."), + registry: ailang_core::workspace::Registry::default(), + }; + let env = build_check_env(&ws); + let key = ("m.MyCls".to_string(), "mymethod".to_string()); + assert!(env.class_methods.contains_key(&key), + "env.class_methods must contain {key:?}"); + } } diff --git a/crates/ailang-check/src/lift.rs b/crates/ailang-check/src/lift.rs index c935f3a..9b4c648 100644 --- a/crates/ailang-check/src/lift.rs +++ b/crates/ailang-check/src/lift.rs @@ -694,7 +694,11 @@ impl<'a> Lifter<'a> { // Iter 23.4: free-fn-call observations are similarly discarded // here — the mono pass will re-synth bodies post-lift. let mut free_fn_calls: Vec = Vec::new(); - let ty = synth(t, &self.env, locals, &mut effects, in_def, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls)?; + // mq.3: lift's synth re-entry is post-typecheck — warnings + // (e.g. class-method-shadowed-by-fn) have already been + // surfaced upstream by `check_workspace`. Discard here. + let mut warnings_discarded: Vec = Vec::new(); + let ty = synth(t, &self.env, locals, &mut effects, in_def, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings_discarded)?; Ok(subst.apply(&ty)) } } diff --git a/crates/ailang-check/src/mono.rs b/crates/ailang-check/src/mono.rs index a1e730a..d7abe8f 100644 --- a/crates/ailang-check/src/mono.rs +++ b/crates/ailang-check/src/mono.rs @@ -204,7 +204,7 @@ pub fn monomorphise_workspace(ws: &Workspace) -> Result { } rewrite_mono_calls( &mut f.body, - &env.class_methods, + &env.method_to_candidate_classes, &poly_free_fns, mname, &ordered, @@ -215,7 +215,7 @@ pub fn monomorphise_workspace(ws: &Workspace) -> Result { Def::Const(c) => { rewrite_mono_calls( &mut c.value, - &env.class_methods, + &env.method_to_candidate_classes, &poly_free_fns, mname, &ordered, @@ -608,6 +608,10 @@ pub fn collect_mono_targets( let mut counter: u32 = 0; let mut residuals: Vec = Vec::new(); let mut free_fn_calls: Vec = Vec::new(); + // mq.3: mono's residual-collection synth re-entry collects-and- + // discards warnings — typecheck has already run and surfaced any + // shadow warnings; mono is a post-typecheck pass. + let mut warnings_discarded: Vec = Vec::new(); crate::synth( &f.body, &env, @@ -618,6 +622,7 @@ pub fn collect_mono_targets( &mut counter, &mut residuals, &mut free_fn_calls, + &mut warnings_discarded, )?; // Filter residuals to fully-concrete ones; look up @@ -954,9 +959,10 @@ pub fn synthesise_mono_fn_for_free_fn( /// - `None` (slot is None): residual was non-concrete or instance /// not registered; cursor still advances (to keep alignment) but /// the name is left unchanged. +#[allow(clippy::too_many_arguments)] fn rewrite_mono_calls( body: &mut Term, - class_methods: &BTreeMap, + method_to_candidate_classes: &BTreeMap>, poly_free_fns: &BTreeSet, caller_module: &str, ordered_targets: &[Option], @@ -969,7 +975,12 @@ fn rewrite_mono_calls( // whose name synth would resolve to either a class-method // residual OR a poly-free-fn observation. Locally // shadowed names match neither — synth pushes nothing. - let is_class_method = class_methods.contains_key(name); + // + // mq.3: the class-method side is method-keyed natively via + // `method_to_candidate_classes`. Class disambiguation + // (which class declared the resolved method) lives in the + // residual slot the cursor consumes, not here. + let is_class_method = method_to_candidate_classes.contains_key(name); let is_poly_free_fn = !is_class_method && poly_free_fns.contains(name); let should_advance = (is_class_method || is_poly_free_fn) && !locals.contains(name); if should_advance { @@ -997,15 +1008,15 @@ fn rewrite_mono_calls( } } Term::App { callee, args, .. } => { - rewrite_mono_calls(callee, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(callee, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); for a in args { - rewrite_mono_calls(a, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); } } Term::Let { name, value, body } => { - rewrite_mono_calls(value, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(value, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); let inserted = locals.insert(name.clone()); - rewrite_mono_calls(body, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); if inserted { locals.remove(name); } @@ -1018,32 +1029,32 @@ fn rewrite_mono_calls( params_inserted.push(p.clone()); } } - rewrite_mono_calls(body, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); for p in ¶ms_inserted { locals.remove(p); } - rewrite_mono_calls(in_term, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(in_term, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); if name_inserted { locals.remove(name); } } Term::If { cond, then, else_ } => { - rewrite_mono_calls(cond, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); - rewrite_mono_calls(then, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); - rewrite_mono_calls(else_, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(cond, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(then, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(else_, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); } Term::Do { args, .. } => { for a in args { - rewrite_mono_calls(a, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); } } Term::Ctor { args, .. } => { for a in args { - rewrite_mono_calls(a, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(a, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); } } Term::Match { scrutinee, arms } => { - rewrite_mono_calls(scrutinee, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(scrutinee, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); for Arm { pat, body } in arms { let binders = pattern_binders(pat); let mut inserted: Vec = Vec::new(); @@ -1052,7 +1063,7 @@ fn rewrite_mono_calls( inserted.push(b.clone()); } } - rewrite_mono_calls(body, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); for b in &inserted { locals.remove(b); } @@ -1065,21 +1076,21 @@ fn rewrite_mono_calls( inserted.push(p.clone()); } } - rewrite_mono_calls(body, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); for p in &inserted { locals.remove(p); } } Term::Seq { lhs, rhs } => { - rewrite_mono_calls(lhs, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); - rewrite_mono_calls(rhs, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(lhs, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(rhs, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); } Term::Clone { value } => { - rewrite_mono_calls(value, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(value, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); } Term::ReuseAs { source, body } => { - rewrite_mono_calls(source, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); - rewrite_mono_calls(body, class_methods, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(source, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); + rewrite_mono_calls(body, method_to_candidate_classes, poly_free_fns, caller_module, ordered_targets, cursor, locals); } Term::Lit { .. } => {} } @@ -1176,6 +1187,10 @@ pub(crate) fn collect_residuals_ordered( let mut counter: u32 = 0; let mut residuals: Vec = Vec::new(); let mut free_fn_calls: Vec = Vec::new(); + // mq.3: mono's residual-collection synth re-entry collects-and- + // discards warnings — typecheck has already run and surfaced any + // shadow warnings; mono is a post-typecheck pass. + let mut warnings_discarded: Vec = Vec::new(); crate::synth( &f.body, &env, @@ -1186,6 +1201,7 @@ pub(crate) fn collect_residuals_ordered( &mut counter, &mut residuals, &mut free_fn_calls, + &mut warnings_discarded, )?; // Iter 23.4 Task 6: build two per-channel slot lists — one for @@ -1280,7 +1296,7 @@ pub(crate) fn collect_residuals_ordered( let mut walker_locals: BTreeSet = f.params.iter().cloned().collect(); interleave_slots( &f.body, - &env.class_methods, + &env.method_to_candidate_classes, poly_free_fns, &class_slots, &free_fn_slots, @@ -1297,10 +1313,15 @@ pub(crate) fn collect_residuals_ordered( /// poly-free-fn slots in source-AST order. The walker MUST stay /// in lockstep with `rewrite_mono_calls` — same predicates, same /// shadowing handling. +/// +/// mq.3: class-method presence is method-keyed natively via +/// `method_to_candidate_classes` (post-`MethodNameCollision`-retirement, +/// `class_methods` is tuple-keyed by `(class, method)` and not +/// directly probable by method name alone). #[allow(clippy::too_many_arguments)] fn interleave_slots( term: &Term, - class_methods: &BTreeMap, + method_to_candidate_classes: &BTreeMap>, poly_free_fns: &BTreeSet, class_slots: &[Option], free_fn_slots: &[Option], @@ -1311,7 +1332,7 @@ fn interleave_slots( ) { match term { Term::Var { name } => { - let is_class_method = class_methods.contains_key(name); + let is_class_method = method_to_candidate_classes.contains_key(name); let is_poly_free_fn = !is_class_method && poly_free_fns.contains(name); let advances = (is_class_method || is_poly_free_fn) && !locals.contains(name); if advances { @@ -1327,15 +1348,15 @@ fn interleave_slots( } } Term::App { callee, args, .. } => { - interleave_slots(callee, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(callee, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); for a in args { - interleave_slots(a, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(a, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } } Term::Let { name, value, body } => { - interleave_slots(value, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(value, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); let inserted = locals.insert(name.clone()); - interleave_slots(body, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(body, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); if inserted { locals.remove(name); } @@ -1348,32 +1369,32 @@ fn interleave_slots( params_inserted.push(p.clone()); } } - interleave_slots(body, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(body, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); for p in ¶ms_inserted { locals.remove(p); } - interleave_slots(in_term, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(in_term, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); if name_inserted { locals.remove(name); } } Term::If { cond, then, else_ } => { - interleave_slots(cond, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); - interleave_slots(then, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); - interleave_slots(else_, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(cond, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(then, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(else_, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } Term::Do { args, .. } => { for a in args { - interleave_slots(a, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(a, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } } Term::Ctor { args, .. } => { for a in args { - interleave_slots(a, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(a, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } } Term::Match { scrutinee, arms } => { - interleave_slots(scrutinee, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(scrutinee, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); for Arm { pat, body } in arms { let binders = pattern_binders(pat); let mut inserted: Vec = Vec::new(); @@ -1382,7 +1403,7 @@ fn interleave_slots( inserted.push(b.clone()); } } - interleave_slots(body, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(body, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); for b in &inserted { locals.remove(b); } @@ -1395,21 +1416,21 @@ fn interleave_slots( inserted.push(p.clone()); } } - interleave_slots(body, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(body, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); for p in &inserted { locals.remove(p); } } Term::Seq { lhs, rhs } => { - interleave_slots(lhs, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); - interleave_slots(rhs, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(lhs, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(rhs, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } Term::Clone { value } => { - interleave_slots(value, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(value, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } Term::ReuseAs { source, body } => { - interleave_slots(source, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); - interleave_slots(body, class_methods, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(source, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); + interleave_slots(body, method_to_candidate_classes, poly_free_fns, class_slots, free_fn_slots, class_cur, free_cur, locals, out); } Term::Lit { .. } => {} } diff --git a/crates/ailang-check/tests/method_collision_pin.rs b/crates/ailang-check/tests/method_collision_pin.rs new file mode 100644 index 0000000..2902790 --- /dev/null +++ b/crates/ailang-check/tests/method_collision_pin.rs @@ -0,0 +1,75 @@ +//! mq.3.5: repurposed pin tests for the post-`MethodNameCollision`- +//! retirement workspace-load path. The two on-disk fixtures that fired +//! `WorkspaceLoadError::MethodNameCollision` pre-mq.3 now load cleanly; +//! the assertion migrates from "expect collision diagnostic" to "load +//! successful + `Env.method_to_candidate_classes` carries the +//! expected multi-entry set" (class-class case) or "load successful + +//! class-fn coexistence is legal" (class-fn case). +//! +//! The relocation from `crates/ailang-core/src/workspace.rs` to this +//! file is required because the post-retirement assertions read +//! `Env.method_to_candidate_classes`, which is built by `ailang-check` +//! (`build_check_env`) and not visible from `ailang-core`. + +use ailang_check::build_check_env; +use ailang_core::load_workspace; +use std::path::Path; + +fn examples_dir() -> std::path::PathBuf { + let manifest = env!("CARGO_MANIFEST_DIR"); + Path::new(manifest) + .parent().expect("CARGO_MANIFEST_DIR has a parent (crates/ailang-check)") + .parent().expect("CARGO_MANIFEST_DIR has a grandparent (crates/)") + .join("examples") +} + +/// mq.3.5 (class-class repurpose): the fixture +/// `examples/test_22b2_method_name_collision_class_class.ail.json` +/// declares two classes `A` and `B` in the same module, each declaring +/// the method `foo`. Pre-mq.3 this fired +/// `WorkspaceLoadError::MethodNameCollision { kind: "class-class" }`. +/// Post-mq.3 the workspace loads cleanly and +/// `env.method_to_candidate_classes` carries both qualified classes as +/// candidates for the method name `foo`. +#[test] +fn mq3_class_class_collision_loads_clean_and_populates_candidates() { + let entry = examples_dir() + .join("test_22b2_method_name_collision_class_class.ail.json"); + let ws = load_workspace(&entry) + .expect("post-mq.3: workspace loads without collision diagnostic"); + + let env = build_check_env(&ws); + let candidates = env + .method_to_candidate_classes + .get("foo") + .expect("`foo` must be in method_to_candidate_classes"); + assert!( + candidates.contains("test_22b2_method_name_collision_class_class.A"), + "candidates must include class A: {candidates:?}", + ); + assert!( + candidates.contains("test_22b2_method_name_collision_class_class.B"), + "candidates must include class B: {candidates:?}", + ); + assert_eq!( + candidates.len(), + 2, + "exactly two candidate classes for `foo`: {candidates:?}", + ); +} + +/// mq.3.5 (class-fn repurpose): the fixture +/// `examples/test_22b2_method_name_collision_class_fn.ail.json` +/// declares `class Greet { greet }` and `fn greet`. Pre-mq.3 this +/// fired `WorkspaceLoadError::MethodNameCollision { kind: "class-fn" }`. +/// Post-mq.3 the workspace loads cleanly — the method-vs-fn +/// coexistence is now legal at load time; the call-site emits a +/// `class-method-shadowed-by-fn` warning (covered by mq.3.6 E2E, +/// not this load-level pin). +#[test] +fn mq3_class_fn_collision_loads_clean() { + let entry = examples_dir() + .join("test_22b2_method_name_collision_class_fn.ail.json"); + load_workspace(&entry) + .expect("post-mq.3: workspace loads — class-vs-fn name overlap is no longer a load error"); +} diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index bb497a5..1de208b 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -294,19 +294,6 @@ pub enum WorkspaceLoadError { method: String, }, - /// Iter 22b.2: a class-method name collides with another - /// class-method or with a top-level fn. `kind` is - /// `"class-class"` or `"class-fn"`. - #[error( - "method name `{method}` collides ({kind}): defined in `{first_origin}` and `{second_origin}`" - )] - MethodNameCollision { - method: String, - kind: &'static str, - first_origin: String, - second_origin: String, - }, - /// Iter 22b.2: an instance `C T` was declared, but `C`'s /// superclass `S` does not have an instance for the same type /// `T`. Decision 11 single-superclass model requires `instance S @@ -606,92 +593,14 @@ fn build_registry( } } - // Iter 22b.2: method-name-collision pre-pass. Bare-name resolution - // (`foo x` rather than `A.foo x`) requires that a method name - // appears in at most one origin across the whole workspace. We - // walk every `Def::Class` and `Def::Fn` once, building a - // `method_origins` map; the first repeat fires the diagnostic. - // The `kind` field distinguishes class-method ↔ class-method from - // class-method ↔ top-level-fn; fn-fn collisions are a separate - // concern surfaced by `CheckError::DuplicateDef` in `ailang-check`, - // not here. - // - // Origins are kept structural (the `Origin` enum below) so the - // `kind` discriminator is a `match` on variants rather than a - // string-prefix check on a display form. - enum Origin { - Class { class_name: String, module: String }, - Fn { name: String, module: String }, - } - impl Origin { - fn format(&self) -> String { - match self { - Origin::Class { class_name, module } => { - format!("class {class_name} (in {module})") - } - Origin::Fn { name, module } => format!("fn {name} (in {module})"), - } - } - } - - let mut method_origins: BTreeMap = BTreeMap::new(); - for (mod_name, m) in modules { - for def in &m.defs { - match def { - Def::Class(c) => { - for method in &c.methods { - // mq.1: `class_name` carries the qualified - // workspace key (`.`). The Display - // format emits `"class . (in )"` - // which is informative even if visually - // redundant. - let origin = Origin::Class { - class_name: format!("{mod_name}.{}", c.name), - module: mod_name.clone(), - }; - if let Some(prior) = method_origins.get(&method.name) { - let kind = match prior { - Origin::Class { .. } => "class-class", - Origin::Fn { .. } => "class-fn", - }; - return Err(WorkspaceLoadError::MethodNameCollision { - method: method.name.clone(), - kind, - first_origin: prior.format(), - second_origin: origin.format(), - }); - } - method_origins.insert(method.name.clone(), origin); - } - } - Def::Fn(f) => { - let origin = Origin::Fn { - name: f.name.clone(), - module: mod_name.clone(), - }; - if let Some(prior) = method_origins.get(&f.name) { - // Only fire on class-fn collisions here. fn-fn - // collisions (two `Def::Fn` with the same name) - // are `CheckError::DuplicateDef`'s job in - // `ailang-check`; firing here with `kind: - // "class-fn"` would misreport. - if matches!(prior, Origin::Class { .. }) { - return Err(WorkspaceLoadError::MethodNameCollision { - method: f.name.clone(), - kind: "class-fn", - first_origin: prior.format(), - second_origin: origin.format(), - }); - } - // prior is a fn: skip; do not overwrite. - } else { - method_origins.insert(f.name.clone(), origin); - } - } - _ => {} - } - } - } + // mq.3: the `MethodNameCollision` pre-pass (variant + Origin enum + + // per-def loop) was retired here. Bare-method resolution no longer + // requires workspace-wide method-name uniqueness — synth's + // `Term::Var` arm consults `Env.method_to_candidate_classes` + // (built workspace-flat in `ailang-check`) and runs the type-driven + // dispatch rule, with `AmbiguousMethodResolution` / + // `class-method-shadowed-by-fn` as the per-call-site outcomes. + // See `docs/journals/2026-05-13-iter-mq.3.md` for the rationale. // Pass 2: register each instance, with coherence / uniqueness / // method-completeness checks. @@ -2127,77 +2036,13 @@ mod tests { } } - /// Iter 22b.2: two classes that declare a method with the same - /// name must fire `MethodNameCollision` with `kind == "class-class"`. - /// Decision 11 keeps method names workspace-unique so that bare - /// method-name resolution (`foo x` rather than `A.foo x`) is - /// unambiguous; if two classes both export `foo`, the registry has - /// no way to choose between them at a use site. - #[test] - fn class_class_method_name_collision_fires() { - let entry = examples_dir() - .join("test_22b2_method_name_collision_class_class.ail.json"); - let err = load_workspace(&entry) - .expect_err("must fire method-name-collision"); - match err { - WorkspaceLoadError::MethodNameCollision { - method, - kind, - first_origin, - second_origin, - } => { - assert_eq!(method, "foo"); - assert_eq!(kind, "class-class"); - // mq.1: Origin::Class.class_name is qualified — - // Display emits "class . (in )". - assert!( - first_origin.contains(".A"), - "first_origin = {first_origin:?}", - ); - assert!( - second_origin.contains(".B"), - "second_origin = {second_origin:?}", - ); - } - other => panic!("expected MethodNameCollision, got {other:?}"), - } - } - - /// Iter 22b.2: a class-method name that collides with a top-level - /// `fn` of the same name must fire `MethodNameCollision` with - /// `kind == "class-fn"`. Same rationale as the class-class case: - /// bare-name resolution must be unambiguous, and a class method - /// shadowing (or being shadowed by) a free function silently is - /// the worst possible failure mode. - #[test] - fn class_fn_method_name_collision_fires() { - let entry = examples_dir() - .join("test_22b2_method_name_collision_class_fn.ail.json"); - let err = load_workspace(&entry) - .expect_err("must fire method-name-collision"); - match err { - WorkspaceLoadError::MethodNameCollision { - method, - kind, - first_origin, - second_origin, - } => { - assert_eq!(method, "greet"); - assert_eq!(kind, "class-fn"); - // mq.1: Origin::Class.class_name is qualified — - // Display emits "class .Greet (in )". - assert!( - first_origin.contains(".Greet"), - "first_origin = {first_origin:?}", - ); - assert!( - second_origin.starts_with("fn greet"), - "second_origin = {second_origin:?}", - ); - } - other => panic!("expected MethodNameCollision, got {other:?}"), - } - } + // mq.3: the two `..._method_name_collision_fires` pin tests + // (class-class and class-fn variants) were retired here and + // re-located as positive-load tests in + // `crates/ailang-check/tests/method_collision_pin.rs`. Post- + // mq.3 those on-disk fixtures load cleanly and the equivalent + // observations move to `env.method_to_candidate_classes` + // (multi-entry set) plus the call-site warning. /// Iter 22b.2: an instance `C T` whose class `C` declares a /// superclass `S` requires that `instance S T` also exist in the diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9076a95..d23ed59 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1153,11 +1153,23 @@ qualified references whose owner is unknown are also a violation (`WorkspaceLoadError::BadCrossModuleTypeRef`). The same rule applies to `Term::Ctor.type_name`. -Class names (`ClassDef.name`, `InstanceDef.class`, -`SuperclassRef.class`, `Constraint.class`) are NOT module-scoped -under this rule; they remain workspace-flat with -`MethodNameCollision` enforced at load. Class-name scoping is a -future milestone with its own DESIGN amendment. +Class names follow the canonical-form rule (mq.1): bare for +same-module references, `.` for cross-module +references — symmetric to `Type::Con.name`'s rule from ct.1. +Three schema fields carry class references in this form: +`InstanceDef.class`, `Constraint.class`, and `SuperclassRef.class`. +`ClassDef.name` itself stays bare (defining-site context, like +`TypeDef.name`). + +Method dispatch is type-driven post-mq.3 (see §"Method dispatch" +below): synth resolves a `Term::Var { name: "show" }` by consulting +the workspace's method-to-candidate-class index, filtering by +argument type (concrete) or by declared constraint (rigid-var), and +routing the residual through the registry at fn-body-end discharge. +Method-name collisions across classes are now structurally legal — +they resolve at the call site via type-driven dispatch with explicit +qualifier (`..`) as the LLM-author's +disambiguation tool. The legacy `(con T)` form is treated as `(own T)` semantically. @@ -1727,8 +1739,12 @@ wording is fixed; the categories and their triggers are: - `MissingMethod` — instance omits a required method. - `OverridingNonExistentMethod` — instance specifies a method not in the class. -- `MethodNameCollision` — same method name across two in-scope - classes, or between a class method and a top-level function. + +(`MethodNameCollision` was retired at iter mq.3. Cross-class method +sharing is now structurally legal; ambiguity surfaces at the call +site via `AmbiguousMethodResolution` or, for class-fn name overlap, +via the `class-method-shadowed-by-fn` warning. See §"Method +dispatch" below.) **Class-schema diagnostics** (validation of class declarations): @@ -1749,10 +1765,75 @@ at iter ctt.3. - `MissingConstraint` — body's residual constraint is not covered by declared (and superclass-expanded) constraints. - `NoInstance` — fully concrete constraint has no registry entry. + mq.2 adds an optional `candidate_classes` field surfacing the + multi-candidate set when the bare-method dispatch path's filter + collapses to zero registry survivors. +- `AmbiguousMethodResolution` (mq.2) — a monomorphic `Term::Var` + call site survives both type-driven and constraint-driven filters + with more than one candidate class. LLM-author writes the explicit + qualifier form `..` to disambiguate. +- `UnknownClass` (mq.2) — an explicit class qualifier in + `Term::Var.name` names a qualified class that is not in the + workspace's candidate-class index for the method. +- `class-method-shadowed-by-fn` (mq.3, warning) — a `Term::Var` + resolved via fn lookup precedence (locals → caller-module-fn → + imported-fn) while a class method of the same name also exists + in the workspace. Fn resolution proceeds; the warning surfaces + the shadow so the LLM-author can disambiguate via explicit + class-qualified call if the shadow was unintentional. -There is no `AmbiguousInstance` diagnostic. Coherence (W2) makes -every `(class, type)` key globally unique; resolution is therefore -deterministic by construction. +There is no `AmbiguousInstance` diagnostic at the registry level — +coherence (`DuplicateInstance` at registry build, W2) makes +per-`(class, type)` ambiguity structurally impossible. Cross-class +method ambiguity is a separate concern resolved at the call site +via `AmbiguousMethodResolution` (see §"Method dispatch" below). + +### Method dispatch + +Post-mq.3, dispatch is two-mode: + +**Polymorphic call sites** (inside a fn body with `forall` + +constraint set): the constraint names the class via the qualified +`Constraint.class` field (canonical-form per mq.1). Synth's +residual carries the class name directly; constraint-discharge at +fn-body-end matches against the workspace registry by +`(class, type_hash)` key. + +**Monomorphic call sites**: synth consults the workspace-flat +`Env.method_to_candidate_classes: BTreeMap>` index, then runs the 5-step +dispatch rule: + +1. Parse the `Term::Var.name` for an optional class qualifier + (last-dot-segment is the method name; everything before is the + qualified class). +2. If `method_to_candidate_classes` has no entry for the method + name, fall through to the existing Var-arm branches (free fn + lookup, dot-qualified cross-module). +3. Qualifier present: filter candidates to the named class. Empty + result fires `UnknownClass`. Singleton survivor proceeds. +4. Qualifier empty (bare-method form): singleton candidate proceeds + directly; multiple candidates yield a multi-candidate residual + for discharge-time refinement. +5. At discharge, refinement runs: concrete `type_` filters + candidates via the workspace registry; rigid-var `type_` filters + via the active fn's declared constraints + (`env.active_declared_constraints`). Single survivor discharges; + multiple survivors fire `AmbiguousMethodResolution` (concrete) or + `MissingConstraint` (rigid-var); zero survivors fire `NoInstance` + (concrete) or `MissingConstraint` (rigid-var). + +The `method_to_candidate_classes` index is the load-bearing data +structure for this routing — its construction in `build_check_env` +inverts the per-module `class_methods` maps (themselves tuple-keyed +by `(qualified-class, method)` post-mq.3) to a workspace-flat +method-name-to-class-set map. + +Class-fn collisions resolve at the call site, not at workspace load +time: the fn lookup precedence (locals → caller-module-fn → +imported-fn) runs ahead of the class-method branch. When both +sides have a match, the fn wins and a `class-method-shadowed-by-fn` +warning surfaces the shadow. ### What the typeclass design explicitly does NOT support diff --git a/docs/journals/2026-05-13-iter-mq.3.md b/docs/journals/2026-05-13-iter-mq.3.md new file mode 100644 index 0000000..56863ce --- /dev/null +++ b/docs/journals/2026-05-13-iter-mq.3.md @@ -0,0 +1,284 @@ +# iter mq.3 — Retire MethodNameCollision + multi-class E2E + DESIGN.md sync + +**Date:** 2026-05-13 +**Started from:** 90075715d99c9e8c59ea79267c2615f5756520f2 +**Status:** DONE +**Tasks completed:** 9 of 9 + +## Summary + +mq.3 closes the module-qualified-class-names milestone by retiring +the `WorkspaceLoadError::MethodNameCollision` workaround that mq.1 + +mq.2 made redundant. The variant + pre-pass + Origin enum + Display +arm + two pin tests are deleted; the two on-disk fixtures that fired +the collision now load cleanly and the equivalent observations +relocate to `env.method_to_candidate_classes` (multi-entry set) plus +a per-call-site warning. Three positive E2E fixtures (Trajectory C +ambiguous, Trajectory E explicit-qualifier, class-fn shadow) exercise +the multi-candidate dispatch path end-to-end via `ail check --json`. + +Two out-of-band corrections that mq.2 surfaced as known debt land +here: (1) `Env.active_declared_constraints: Vec` is +plumbed pre-synth in `check_fn` so the post-superclass-expansion +constraint set is reachable at synth time by +`resolve_method_dispatch`'s constraint-driven filter; (2) +`ModuleGlobals.class_methods` + `Env.class_methods` re-keyed from +`BTreeMap` to +`BTreeMap<(QualifiedClass, MethodName), ClassMethodEntry>` so the +post-retirement world has O(1) `(class, method)` lookup, with +mono's two presence-check sites switched to consult the natively +method-keyed `method_to_candidate_classes`. A third +correction — `synth(...)` signature extended with a `warnings: &mut +Vec` out-parameter — installs the channel for the new +structured warning `class-method-shadowed-by-fn` that fires when a +free fn shadows a class method of the same name (per spec +§"Class-fn collisions": precedence + warning, the spec's default). + +DESIGN.md sync removes the `MethodNameCollision` bullet from +"Workspace-load (registry-build) diagnostics" with a forward-pointing +note, adds `AmbiguousMethodResolution` / `UnknownClass` / +`class-method-shadowed-by-fn` to the "Typecheck diagnostics" block, +rewords the `AmbiguousInstance` paragraph (per-class coherence stays +via `DuplicateInstance`; cross-class method ambiguity is the new +diagnostic at the call site), rewrites the class-names paragraph in +the canonical-form section to point at the mq.1 rule + the new +dispatch model, and adds a new `### Method dispatch` subsection +anchoring the 5-step rule with `method_to_candidate_classes` as the +load-bearing data structure. Roadmap P2 entry → `[x]` with a summary +of the three iters; milestone-24 (`Post-22 Prelude — Show + print +rewire`) `depends on:` line struck and the entry annotated +"ready for re-brainstorm". + +545 tests green (was 539 at start of mq.3; +6 net = 3 new mq.3.x lib +tests + 2 relocated method_collision_pin + 3 new mq3_multi_class_e2e +- 2 deleted workspace.rs pin tests). `bench/compile_check.py` and +`bench/cross_lang.py` exit 0; `bench/check.py` exit 1 with 2 +regressions both noise-class (bench_list_sum.bump_s persistence — +3rd consecutive sighting since audit-ct-tidy — and a max_us tail +metric, plus 4 improvements). Prelude zero-diff. + +## Per-task notes + +- **mq.3.1** — `Env.active_declared_constraints: Vec` + field plumbed; populated in `check_fn` pre-synth alongside the + rigid-vars install (consolidated with the existing post-synth + `expanded` computation — single source of truth, no clone-and- + duplicate as the plan literally suggested). Synth Var-arm + `resolve_method_dispatch` call site swapped `&[]` for + `&env.active_declared_constraints`. 1 new test mq3_env_active_declared_constraints_field_exists. + +- **mq.3.2** — `ModuleGlobals.class_methods` re-keyed + `IndexMap` → `IndexMap<(String, String), + ClassMethodEntry>`; sibling `Env.class_methods` similarly re-keyed + to `BTreeMap<(String, String), ClassMethodEntry>`. Accessor methods + on `ModuleGlobals` (`has_class_method`, `class_method_class`, + `class_method`) all take `(class, name)`; new + `class_method_candidates(name) -> Vec<(&class, &entry)>` for the + enumerate-all-classes-declaring-this-method use case (returning + `Vec` not `impl Iterator` because the borrow-checker rejected the + `'a`/`'_` capture without explicit `use<'a, '_>` syntax not yet + idiomatic in this crate). `build_module_globals` insert site + + `build_check_env` merge loop both threaded through the tuple key. + Synth Var-arm `class_methods.get(&(residual_class, method))` uses + the dispatcher-resolved tentative class. Mono's two presence-check + sites (`rewrite_mono_calls`, `interleave_slots`) switched to + consult `method_to_candidate_classes` natively — method-keyed, + preserves the existing presence-check signature shape; the four + call sites at module-walk + interleave_slots entry pass + `&env.method_to_candidate_classes` instead of `&env.class_methods`. + Test assertion in `crates/ail/tests/typeclass_22b2.rs:42` updated + to pass the qualified class explicitly. 1 new test + mq3_env_class_methods_tuple_keyed. + +- **mq.3.3** — `synth(...)` signature extended with `warnings: + &mut Vec` (15+ recursive synth callsites threaded). + Five external synth callers (check_fn, check_const, 3 in + builtins.rs, 1 in lift.rs, 2 in mono.rs, 1 in `check`) all + updated; check_fn / check_const pass through to caller via new + `out_warnings: &mut Vec` parameter on the caller + chain (`check_def`, `check_in_workspace`); `check_workspace` + extends per-module synth_warnings into `module_diags`. Plan + suggested `check_fn` return `Result<(CheckedFn, Vec)>` + but `check_fn`'s actual signature is `Result<()>` and the + mut-ref-accumulator pattern matches the existing `Vec` + accumulator at `check_in_workspace`'s caller. New diagnostic + `class-method-shadowed-by-fn` (warning, kebab-case code, + structured `ctx` carrying `name` + `method` + `fn_owner_module` + + `candidate_classes`) emitted by a closure at all three + fn-precedence branches (locals, same-module fn, implicit-import + fn). Implicit-import-fn branch reordered ABOVE the class-method + branch per spec §"Class-fn collisions" so fn-wins precedence is + structural, not just convention. Diagnostic doc updated in + `diagnostic.rs`. One existing test + `crates/ail/tests/typeclass_22b3.rs:rewrite_walker_skips_locally_shadowed_class_method` + was a known trip-wire — the fixture intentionally shadows a class + method with a local binding, which now correctly fires the new + warning; filtered out of the test's `assert!(diags.is_empty())` + with a comment naming why. 1 new test + mq3_class_method_shadowed_by_fn_warning_fires. + +- **mq.3.4** — `WorkspaceLoadError::MethodNameCollision` variant + + the Origin enum + the per-def collision loop in `build_registry` + (workspace.rs 596-681 pre-edit) + the Display arm in + `main.rs:1201` deleted. The two in-workspace.rs pin tests + (`class_class_method_name_collision_fires` + + `class_fn_method_name_collision_fires`) deleted; relocation lands + in mq.3.5. The on-disk fixtures + `test_22b2_method_name_collision_class_{class,fn}.ail.json` stay + on-disk (now exercised as positive-load by the relocated tests). + +- **mq.3.5** — `crates/ailang-check/tests/method_collision_pin.rs` + created with two repurposed pin tests: the class-class fixture + loads cleanly and `env.method_to_candidate_classes["foo"]` has + exactly two qualified-class entries (`...A` + `...B`); the + class-fn fixture loads cleanly (the warning is a call-site + concern, not a load-time concern; covered by mq.3.6 E2E). The + relocation from `ailang-core` to `ailang-check` is required + because `Env.method_to_candidate_classes` is built by + `build_check_env`, which lives in `ailang-check` (not visible + from `ailang-core`'s `mod tests`). + +- **mq.3.6** — Three new on-disk fixtures plus three integration + tests in `crates/ail/tests/mq3_multi_class_e2e.rs`. Fixture (a) + `mq3_two_show_ambiguous` exercises the bare-method ambiguous + case (two `Show` classes, both with `Show Int`, bare `show 42` + → `AmbiguousMethodResolution`). Fixture (b) + `mq3_two_show_qualified` exercises explicit-qualifier resolution + (same workspace, `mq3_two_show_ambiguous_a.Show.show 42` → + clean). Fixture (c) `mq3_class_eq_vs_fn_eq` exercises class-fn + shadow (`class MyEq { myeq }` + `fn myeq` + bare `myeq 1 2` → + fn wins, warning fires). The plan's draft fixtures used a bare + `Term::App` for instance method bodies; the existing-convention + shape (per `test_22b3_mono_synthetic.ail.json`) is `Term::Lam` + with `paramTypes`/`retType` — implementer-phase repair adjusted + inline. Naming `MyEq`/`myeq` (rather than `Eq`/`eq`) avoids an + extra prelude-vs-fn shadow that would have confused the warning + assertion. All three tests GREEN first-shot; spot-checked via + `ail check --json` for completeness. + +- **mq.3.7** — DESIGN.md sync: class-names paragraph (1156-1160) + rewritten to point at mq.1 canonical-form + mq.3 dispatch model; + `MethodNameCollision` bullet struck from "Workspace-load + (registry-build) diagnostics" with forward-pointing note; three + new typecheck diagnostics + `NoInstance.candidate_classes` + addendum added to "Typecheck diagnostics"; `AmbiguousInstance` + paragraph reworded to clarify the registry-level vs call-site + distinction; new `### Method dispatch` subsection anchors the + 5-step rule with `method_to_candidate_classes` as the + load-bearing data structure, the class-fn precedence rule, and + the post-mq.3 tuple-keyed `class_methods` shape. + +- **mq.3.8** — Roadmap P2 milestone "Module-qualified class names + + type-driven method dispatch" → `[x]` with a one-paragraph + summary of the three iters (mq.1 canonical-form, mq.2 mechanism, + mq.3 retirement) and the milestone's user-facing outcome (two + libraries can each declare `class Eq` with their own `eq`). + Milestone-24 (`Post-22 Prelude — Show + print rewire`) `depends + on:` line struck and the entry annotated "ready for + re-brainstorm" so the next time `/boss` walks the roadmap it + picks up the un-blocked spec re-derivation against the + post-retirement architecture. + +- **mq.3.9** — Integration verification. `cargo test --workspace` + 545 passed / 0 failed. `bench/compile_check.py` 0 regressed / 0 + improved / 24 stable. `bench/cross_lang.py` 0 regressed / 0 + improved / 25 stable. `bench/check.py` 2 regressed / 4 improved + / 57 stable: regression #1 `throughput.bench_list_sum.bump_s` + (3rd consecutive sighting since audit-ct-tidy 2026-05-12, audit- + ratified-class noise per the journal lineage); regression #2 + `latency.implicit_at_rc.max_us` (same noisy max-tail metric + that mq.2 journal flagged as runtime-uncoupled-to-typecheck- + iter, audit-ratified-class). Prelude zero-diff. All three new + E2E fixtures observable via `ail check` with the expected + diagnostic / no-diagnostic / warning outcomes. + +## Concerns + +- **Plan's `check_fn` signature suggestion was off.** Plan Step 4 + proposed `check_fn(...) -> Result<(CheckedFn, Vec), + CheckError>`; the actual signature is `Result<()>` (no + CheckedFn). The mut-ref-accumulator pattern adopted instead + matches the existing `check_in_workspace` shape (Vec + accumulator). Same outcome, different mechanism. + +- **`class_method_candidates` returns `Vec` not `impl Iterator`.** + The plan's accessor signature `impl Iterator` requires the `use<'a, '_>` opaque-type + capture syntax — not yet idiomatic in this crate (the rest of + the codebase uses `Vec`-returning accessors at module-graph + boundaries). The semantic contract is the same; performance is + one allocation per call, bounded by the per-method candidate + count (currently ≤ 2 across the corpus). + +- **One existing E2E test trips the new warning.** + `crates/ail/tests/typeclass_22b3.rs:rewrite_walker_skips_locally_shadowed_class_method` + uses a fixture (`test_22b3_shadow_class_method`) that + intentionally shadows the class method `show` with a local + `let show = "shadow"`. Pre-mq.3 this fixture typechecked clean; + post-mq.3 it correctly fires the `class-method-shadowed-by-fn` + warning. The test's `assert!(diags.is_empty())` was relaxed to + filter out the new warning with a comment naming why. This is + the warning behaving correctly, not a test workaround. + +- **Plan's instance-method body shape was wrong.** Plan Step 1 of + Task 6 drafted `body: { "t": "app", ... }` for instance + methods; the existing-convention shape (per `test_22b3_mono_synthetic`) + is `Term::Lam` with `paramTypes`/`retType` keys. Implementer-phase + repair adjusted all three fixtures inline. The plan's caveat + ("implementer adjusts to match the exact shape used by + `examples/prelude.ail.json`'s Eq/Ord instances") covered this + case; just exercising the caveat verbatim. + +## Known debt + +- **`bench_list_sum.bump_s` 3rd-consecutive regression.** Noise-class + per the audit-ct-tidy 2026-05-12 journal entry. No attribution + cause across three audits. The pattern is a candidate for + ratify-without-attribution at the milestone-close audit (audit-mq) + but this iter follows the conservative pristine-baseline + convention. + +- **`latency.implicit_at_rc.max_us` max-tail noise.** Continues from + mq.2's bench profile. Runtime metric, structurally uncoupled to + this typecheck-side iter. Audit-ratifiable. + +- **`bench/check.py` exit 1.** Same noise-class status as the two + regressions above. Conservative interpretation: leave the + baseline pristine; audit-mq decides whether to ratify. + +- **`class_method_candidates` allocation per call.** Bounded by + per-method candidate count (currently ≤ 2). A future tidy that + introduces `use<'a, '_>` syntax across the crate could revisit + the iterator-return shape. + +## Files touched + +Code: +- crates/ail/src/main.rs +- crates/ail/tests/typeclass_22b2.rs +- crates/ail/tests/typeclass_22b3.rs +- crates/ailang-check/src/builtins.rs +- crates/ailang-check/src/diagnostic.rs +- crates/ailang-check/src/lib.rs +- crates/ailang-check/src/lift.rs +- crates/ailang-check/src/mono.rs +- crates/ailang-core/src/workspace.rs + +New tests + fixtures: +- crates/ail/tests/mq3_multi_class_e2e.rs +- crates/ailang-check/tests/method_collision_pin.rs +- examples/mq3_two_show_ambiguous_a.ail.json +- examples/mq3_two_show_ambiguous_b.ail.json +- examples/mq3_two_show_ambiguous.ail.json +- examples/mq3_two_show_qualified.ail.json +- examples/mq3_class_eq_vs_fn_eq_classmod.ail.json +- examples/mq3_class_eq_vs_fn_eq_fnmod.ail.json +- examples/mq3_class_eq_vs_fn_eq.ail.json + +Docs: +- docs/DESIGN.md +- docs/roadmap.md + +## Stats + +bench/orchestrator-stats/2026-05-13-iter-mq.3.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index c9740cb..3fe50d9 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -44,3 +44,4 @@ - 2026-05-12 — iter 24.1: `bool_to_str` + `str_clone` runtime + codegen wiring — 2 new C functions in `runtime/str.c` (slab-allocating heap-Str primitives via existing `str_alloc`), lockstep checker + synth.rs installs with `ret_mode: Own`, 2 IR-header declares + 2 `lower_app` arms + `is_static_callee` whitelist extension, 5 IR snapshots regen (2 declares × 5 files), 9 new tests (2 builtins-install unit + 2 IR-shape pin + 5 E2E all green), pre-existing-drift fix included (int_to_str row added to `builtins.rs::list()`). One substantive deviation: builtin-signatures registered in `uniqueness.rs::infer_module` + `linearity.rs::check_module_with_visible` (8 LOC × 2 files) so `str_clone`'s `param_modes: [Borrow]` is visible to the App-arg walker; symmetric to 23.4-prep's class-method registration; necessary for the plan's literal `frees == 3` cross-realisation assertion. Full `cargo test --workspace` 513 passed, 0 failed. `bench/compile_check.py` + `bench/cross_lang.py` green → 2026-05-12-iter-24.1.md - 2026-05-13 — iter mq.1: class-ref canonical-form extension + workspace-internal class-name qualification — three schema fields (`InstanceDef.class`, `Constraint.class`, `SuperclassRef.class`) move bare → canonical (bare for same-module, `.` for cross-module) symmetric to ct.1's `Type::Con.name` rule; `ClassDef.name` stays bare (defining site, like `TypeDef.name`). `validate_canonical_type_names` gains three field-walks via new `check_class_ref` helper plus two sibling diagnostics `BareCrossModuleClassRef` / `BadCrossModuleClassRef`; `check_class_name_fields` narrowed to `ClassDef.name`-only. Workspace-internal class-name keys all qualified: `class_def_module`, `class_by_name`, registry `entries.0`, `ClassMethodEntry.class_name`, `class_superclasses`, mono's `class_index`, `Origin::Class.class_name`. New `qualify_class_ref` helper in `workspace.rs` plus sibling `qualify_class_ref_in_check` in `ailang-check`. Two new positive on-disk fixtures (`mq1_xmod_constraint_class{,_dep}`). Recon claim that all existing fixtures' class-refs are intra-module was empirically wrong — 5 test_22b* (`orphan_third`, `dup_a/b/entry`, `unbound_constraint_var`) + 5 other (`eq_ord_polymorphic`, `eq_ord_user_adt`, `cmp_max_smoke`, `ctt2_collision_{lib,main}`) fixtures required minimal canonical-form migration. Duplicate-instance test architecturally restructured: post-mq.1 orphan-freedom makes two-modules-on-same-`(class, type)` structurally impossible (any second instance in a non-owning module fires `OrphanInstance` first); new `test_22b1_dup_same_module.ail.json` lands both instances in the class's module — the only post-mq.1 way to land two on the same canonical key. Plan defects fixed inline: `Origin::Class.class_name` had one construction site not two (plan recon off-by-one); `build_class_index` in `mono.rs` needed qualifying (plan said "no code change needed"); `build_module_globals` needed a `Def::Instance` carve-out for qualified `inst.class`; `check_fn`'s declared-constraint matching needed inline canonical-form lifting; `NoInstance` Float-aware message arm needed `prelude.Eq`/`prelude.Ord` match; coherence type-leg needed qualified-head split-and-lookup. Tasks 3-6 ran as one coherent fix (Task 3 alone left tests RED). 7/7 tasks, 520 tests green, `bench/compile_check.py` + `bench/cross_lang.py` exit 0; `bench/check.py` exit 1 due to 4 improvements-beyond-tolerance (audit-ratifiable per convention, not a regression). `MethodNameCollision` + dispatch path unchanged; iters mq.2 + mq.3 land them → 2026-05-13-iter-mq.1.md - 2026-05-13 — iter mq.2: type-driven dispatch mechanism installed (mechanism-before-exercise) — `Env.method_to_candidate_classes` workspace-flat inverse index built alongside `class_methods`; two new `CheckError` variants `AmbiguousMethodResolution` + `UnknownClass` (Display+code+ctx) plus additive `NoInstance.candidate_classes` field; `ResidualConstraint` extended with `candidates: Option>` (visibility bumped `pub(crate)` → `pub` for test-crate access); pure `resolve_method_dispatch` helper implementing the spec's 5-step rule (qualifier → singleton → type-driven filter → constraint-driven filter → `Multi` for discharge-time refinement); synth Var-arm class-method branch rewritten via `parse_method_qualifier` with inner-dot gate (qualifier must be `.` form, single-dot names like `std_list.length` fall through to the existing qualified-fn path); `refine_multi_candidate_residual` wired into `check_fn` discharge using `expanded` (post-superclass-expansion constraints) for the rigid-var path; `resolve_residual_class_for_mono` wired into mono's `collect_residuals_ordered` residual-to-target mapping. With `MethodNameCollision` still gating real workspaces, the new multi-candidate branches are exercised exclusively by 15 unit tests: 6 in `tests/method_dispatch_pin.rs` covering the 5-step rule's six cases, 6 in `lib.rs` `mod tests` covering variants + field shapes + discharge refinement, 3 in `mono.rs` `mod tests` covering the mono helper. Real workspaces continue producing single-class residuals (`candidates: None`); every pre-mq.2 fixture typechecks unchanged. Plan-invented `format_type_for_display` replaced with `ailang_core::pretty::type_to_string` (one less duplicate). Synth-time `declared_constraints: &[]` is a deliberate gap documented as known debt — load-bearing only post-mq.3 for the rigid-var fallback (env-plumbing the active fn's constraints into the Var arm is a ~10-line edit slated for mq.3). 9/9 tasks, 539 tests green (was 520 at start of mq.2; +19 = 15 new + 4 pre-existing); `bench/compile_check.py` + `cross_lang.py` clean; `bench/check.py` 1 regression (latency noise, runtime cannot be touched by typecheck-side iter). `MethodNameCollision` retirement lands in mq.3 → 2026-05-13-iter-mq.2.md +- 2026-05-13 — iter mq.3: `MethodNameCollision` retired + multi-class E2E + DESIGN.md sync — milestone close. Deletes `WorkspaceLoadError::MethodNameCollision` variant + `Origin` enum + per-def collision loop in `build_registry` (workspace.rs 596-681) + Display arm in `main.rs:1201`; the two in-workspace.rs pin tests relocate to `crates/ailang-check/tests/method_collision_pin.rs` with inverted assertions (loads cleanly + `env.method_to_candidate_classes["foo"]` has two qualified-class entries). Resolves both mq.2 known-debt items: (1) `Env.active_declared_constraints: Vec` plumbed pre-synth in `check_fn` so the post-superclass-expansion constraint set reaches the synth Var-arm's `resolve_method_dispatch` constraint-driven filter; (2) `ModuleGlobals.class_methods` + `Env.class_methods` re-keyed from `BTreeMap` to `BTreeMap<(QualifiedClass, MethodName), ClassMethodEntry>` with new `class_method_candidates(name) -> Vec<(&class, &entry)>` accessor; mono's two presence-check sites (`rewrite_mono_calls`, `interleave_slots`) switched to consult `method_to_candidate_classes` natively (method-keyed, preserves the presence-check signature shape). New `synth(...)` warnings channel via `warnings: &mut Vec` out-parameter threaded through 15+ recursive callsites + 5 external callers (check_fn, check_const, 3 in builtins.rs, 1 in lift.rs, 2 in mono.rs, 1 in `check`); new structured warning `class-method-shadowed-by-fn` (kebab-case code, structured ctx carrying `name`/`method`/`fn_owner_module`/`candidate_classes`) fires at all three fn-precedence branches (locals, same-module fn, implicit-import fn). Implicit-import-fn branch reordered ABOVE the class-method branch per spec §"Class-fn collisions" so fn-wins precedence is structural. Three new positive E2E fixtures + integration tests in `crates/ail/tests/mq3_multi_class_e2e.rs`: (a) `mq3_two_show_ambiguous` (two `Show` classes, both with `Show Int`, bare `show 42` → `AmbiguousMethodResolution`); (b) `mq3_two_show_qualified` (same workspace, `mq3_two_show_ambiguous_a.Show.show 42` → clean); (c) `mq3_class_eq_vs_fn_eq` (`class MyEq { myeq }` + `fn myeq` + bare `myeq 1 2` → fn wins, warning fires). DESIGN.md sync: class-names paragraph rewritten to point at mq.1 canonical-form + mq.3 dispatch model; `MethodNameCollision` bullet struck from "Workspace-load (registry-build) diagnostics" with forward-pointing note; `AmbiguousMethodResolution` / `UnknownClass` / `class-method-shadowed-by-fn` + `NoInstance.candidate_classes` added to "Typecheck diagnostics"; `AmbiguousInstance` paragraph reworded to distinguish registry-level (per-class coherence via `DuplicateInstance`) from call-site (cross-class method ambiguity, new diagnostic); new `### Method dispatch` subsection anchors the 5-step rule with `method_to_candidate_classes` as the load-bearing data structure, the class-fn precedence rule, and the post-mq.3 tuple-keyed `class_methods` shape. Roadmap P2 milestone → `[x]` with three-iter summary; milestone-24 `depends on:` line struck and entry annotated "ready for re-brainstorm". Plan defects fixed inline: `check_fn` signature is `Result<()>` not `Result<(CheckedFn, Vec)>` — adopted mut-ref-accumulator pattern matching existing `Vec` shape; `class_method_candidates` returns `Vec<(...)>` not `impl Iterator<...>` (the `use<'a, '_>` opaque-type-capture syntax not yet idiomatic in this crate); instance-method body shape draft was `Term::App` but existing-convention is `Term::Lam` with `paramTypes`/`retType` — three fixtures repaired inline. One existing E2E test (`crates/ail/tests/typeclass_22b3.rs:rewrite_walker_skips_locally_shadowed_class_method`) now correctly fires the new warning (the fixture intentionally shadows a class method); test's `assert!(diags.is_empty())` relaxed to filter the new warning with a naming comment. 9/9 tasks, 545 tests green (was 539; +6 net = 3 new mq.3.x lib + 2 method_collision_pin + 3 mq3_multi_class_e2e − 2 deleted workspace.rs pin tests); `bench/compile_check.py` + `cross_lang.py` exit 0; `bench/check.py` exit 1 with 2 noise-class regressions (3rd-consecutive `bench_list_sum.bump_s` persistence + max_us tail metric, both runtime-uncoupled-to-typecheck-iter); prelude zero-diff. Module-qualified-class-names milestone structurally closed → 2026-05-13-iter-mq.3.md diff --git a/docs/roadmap.md b/docs/roadmap.md index 429fa8d..058bb49 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -80,9 +80,10 @@ context. Pick the next milestone from P1.)_ - context: brainstorm 2026-05-12 (iter 23.5 wrap); deferral 2026-05-13 (user-direction Option C after plan-recon flagged the collision; iter 24.1 retained as standalone runtime infrastructure). - - depends on: P2 milestone "Module-qualified class names + - type-driven method dispatch" (retires the `MethodNameCollision` - workaround that drives the collision). + - ready for re-brainstorm — the `MethodNameCollision` workaround + that blocked the original spec retired in mq.3 (2026-05-13). + Fresh brainstorm re-derives the spec against the post-retirement + architecture (type-driven dispatch, class-ref canonical form). ## P2 — Medium-term @@ -123,24 +124,22 @@ context. Pick the next milestone from P1.)_ classes beyond the prelude four; multi-parameter classes; superclass chains; richer instance bodies. Deferred from milestone 22. - context: JOURNAL 2026-05-09 -- [ ] **\[milestone\]** Module-qualified class names + type-driven - method dispatch — retire the `MethodNameCollision` workaround - (`crates/ailang-core/src/workspace.rs:472`) that today keeps bare - class names viable by enforcing workspace-global method-name - uniqueness. Replaces name-driven method resolution - (`ModuleGlobals::class_methods: IndexMap`, - `crates/ailang-check/src/lib.rs:983`; consumed by - `mono::rewrite_class_method_calls`, `crates/ailang-check/src/mono.rs:657`) - with type-driven dispatch — look up `eq` candidates by argument - type, pick the unique class instance, fail closed on ambiguity. - Once shipped, two libraries can each declare `class Eq` with their - own `eq`. Carries its own DESIGN spec: inference rules, ambiguity - diagnostics, and the canonical-form extension for class-reference - fields (`InstanceDef.class`, `SuperclassRef.class`, `Constraint.class`) - that the canonical-type-names milestone explicitly out-of-scoped. +- [x] **\[milestone\]** Module-qualified class names + type-driven + method dispatch — retired the `MethodNameCollision` workaround; + shipped 2026-05-13 as iters mq.1 (canonical-form extension for + class-ref fields + workspace-internal qualification), mq.2 (type- + driven dispatch mechanism installed: `method_to_candidate_classes` + inverse index, multi-candidate `ResidualConstraint`, + `resolve_method_dispatch` 5-step rule, `AmbiguousMethodResolution` + + `UnknownClass` diagnostics), and mq.3 (retirement + multi-class E2E + + `class-method-shadowed-by-fn` warning + DESIGN.md sync). Two + libraries can now each declare `class Eq` with their own `eq`; + cross-class method ambiguity is resolved at the call site via + type-driven dispatch with `..` as the + disambiguation form. - context: `docs/specs/2026-05-10-canonical-type-names.md` "Out of - scope: Class names" — the workaround is named there so it stays - visible until this milestone retires it. + scope: Class names" — the workaround was named there so it + stayed visible until this milestone retired it. - [ ] **\[todo\]** Boehm full retirement — remove the transitional Boehm GC path now that RC + uniqueness is the canonical memory story. diff --git a/examples/mq3_class_eq_vs_fn_eq.ail.json b/examples/mq3_class_eq_vs_fn_eq.ail.json new file mode 100644 index 0000000..9c684af --- /dev/null +++ b/examples/mq3_class_eq_vs_fn_eq.ail.json @@ -0,0 +1,36 @@ +{ + "schema": "ailang/v0", + "name": "mq3_class_eq_vs_fn_eq", + "imports": [ + { "module": "mq3_class_eq_vs_fn_eq_classmod" }, + { "module": "mq3_class_eq_vs_fn_eq_fnmod" } + ], + "defs": [ + { + "kind": "fn", + "name": "main", + "doc": "mq.3.6 fixture (c): bare `myeq 1 2` in a workspace with `class MyEq` (in classmod) and `fn myeq` (in fnmod) both imported. Fn wins per lookup precedence; the typechecker emits `class-method-shadowed-by-fn` warning so the LLM-author sees the shadow.", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", + "op": "io/print_bool", + "args": [ + { + "t": "app", + "fn": { "t": "var", "name": "myeq" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + { "t": "lit", "lit": { "kind": "int", "value": 2 } } + ] + } + ] + } + } + ] +} diff --git a/examples/mq3_class_eq_vs_fn_eq_classmod.ail.json b/examples/mq3_class_eq_vs_fn_eq_classmod.ail.json new file mode 100644 index 0000000..97b9d0e --- /dev/null +++ b/examples/mq3_class_eq_vs_fn_eq_classmod.ail.json @@ -0,0 +1,47 @@ +{ + "schema": "ailang/v0", + "name": "mq3_class_eq_vs_fn_eq_classmod", + "imports": [], + "defs": [ + { + "kind": "class", + "name": "MyEq", + "param": "a", + "methods": [ + { + "name": "myeq", + "type": { + "k": "fn", + "params": [ + { "k": "var", "name": "a" }, + { "k": "var", "name": "a" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + } + } + ] + }, + { + "kind": "instance", + "class": "MyEq", + "type": { "k": "con", "name": "Int" }, + "methods": [ + { + "name": "myeq", + "body": { + "t": "lam", + "params": ["x", "y"], + "paramTypes": [ + { "k": "con", "name": "Int" }, + { "k": "con", "name": "Int" } + ], + "retType": { "k": "con", "name": "Bool" }, + "body": { "t": "lit", "lit": { "kind": "bool", "value": true } } + } + } + ] + } + ] +} diff --git a/examples/mq3_class_eq_vs_fn_eq_fnmod.ail.json b/examples/mq3_class_eq_vs_fn_eq_fnmod.ail.json new file mode 100644 index 0000000..5f1cc44 --- /dev/null +++ b/examples/mq3_class_eq_vs_fn_eq_fnmod.ail.json @@ -0,0 +1,24 @@ +{ + "schema": "ailang/v0", + "name": "mq3_class_eq_vs_fn_eq_fnmod", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "myeq", + "doc": "mq.3.6 fixture (c) helper: free fn `myeq` that shadows the class method `myeq` declared in `mq3_class_eq_vs_fn_eq_classmod`. Always returns false so the fn-wins precedence is observable at runtime if the e2e test ever runs the binary.", + "type": { + "k": "fn", + "params": [ + { "k": "con", "name": "Int" }, + { "k": "con", "name": "Int" } + ], + "param_modes": ["borrow", "borrow"], + "ret": { "k": "con", "name": "Bool" }, + "effects": [] + }, + "params": ["x", "y"], + "body": { "t": "lit", "lit": { "kind": "bool", "value": false } } + } + ] +} diff --git a/examples/mq3_two_show_ambiguous.ail.json b/examples/mq3_two_show_ambiguous.ail.json new file mode 100644 index 0000000..f2e8a68 --- /dev/null +++ b/examples/mq3_two_show_ambiguous.ail.json @@ -0,0 +1,33 @@ +{ + "schema": "ailang/v0", + "name": "mq3_two_show_ambiguous", + "imports": [ + { "module": "mq3_two_show_ambiguous_a" }, + { "module": "mq3_two_show_ambiguous_b" } + ], + "defs": [ + { + "kind": "fn", + "name": "main", + "doc": "mq.3.6 fixture (a): bare `show 42` in a workspace where two Show classes (in modA and modB) each ship Show Int. Without an explicit qualifier the dispatch is genuinely ambiguous; the typechecker must fire `ambiguous-method-resolution` naming both candidate classes.", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", + "op": "io/print_str", + "args": [ + { + "t": "app", + "fn": { "t": "var", "name": "show" }, + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }] + } + ] + } + } + ] +} diff --git a/examples/mq3_two_show_ambiguous_a.ail.json b/examples/mq3_two_show_ambiguous_a.ail.json new file mode 100644 index 0000000..a90e3e3 --- /dev/null +++ b/examples/mq3_two_show_ambiguous_a.ail.json @@ -0,0 +1,45 @@ +{ + "schema": "ailang/v0", + "name": "mq3_two_show_ambiguous_a", + "imports": [], + "defs": [ + { + "kind": "class", + "name": "Show", + "param": "a", + "methods": [ + { + "name": "show", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "param_modes": ["borrow"], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + } + ] + }, + { + "kind": "instance", + "class": "Show", + "type": { "k": "con", "name": "Int" }, + "methods": [ + { + "name": "show", + "body": { + "t": "lam", + "params": ["x"], + "paramTypes": [{ "k": "con", "name": "Int" }], + "retType": { "k": "con", "name": "Str" }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "int_to_str" }, + "args": [{ "t": "var", "name": "x" }] + } + } + } + ] + } + ] +} diff --git a/examples/mq3_two_show_ambiguous_b.ail.json b/examples/mq3_two_show_ambiguous_b.ail.json new file mode 100644 index 0000000..ef1750c --- /dev/null +++ b/examples/mq3_two_show_ambiguous_b.ail.json @@ -0,0 +1,45 @@ +{ + "schema": "ailang/v0", + "name": "mq3_two_show_ambiguous_b", + "imports": [], + "defs": [ + { + "kind": "class", + "name": "Show", + "param": "a", + "methods": [ + { + "name": "show", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "param_modes": ["borrow"], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + } + ] + }, + { + "kind": "instance", + "class": "Show", + "type": { "k": "con", "name": "Int" }, + "methods": [ + { + "name": "show", + "body": { + "t": "lam", + "params": ["x"], + "paramTypes": [{ "k": "con", "name": "Int" }], + "retType": { "k": "con", "name": "Str" }, + "body": { + "t": "app", + "fn": { "t": "var", "name": "int_to_str" }, + "args": [{ "t": "var", "name": "x" }] + } + } + } + ] + } + ] +} diff --git a/examples/mq3_two_show_qualified.ail.json b/examples/mq3_two_show_qualified.ail.json new file mode 100644 index 0000000..7a7145b --- /dev/null +++ b/examples/mq3_two_show_qualified.ail.json @@ -0,0 +1,33 @@ +{ + "schema": "ailang/v0", + "name": "mq3_two_show_qualified", + "imports": [ + { "module": "mq3_two_show_ambiguous_a" }, + { "module": "mq3_two_show_ambiguous_b" } + ], + "defs": [ + { + "kind": "fn", + "name": "main", + "doc": "mq.3.6 fixture (b): explicit qualifier `mq3_two_show_ambiguous_a.Show.show 42` disambiguates the same workspace as fixture (a). Resolves cleanly to modA's class; typecheck passes.", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "do", + "op": "io/print_str", + "args": [ + { + "t": "app", + "fn": { "t": "var", "name": "mq3_two_show_ambiguous_a.Show.show" }, + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }] + } + ] + } + } + ] +}