iter mq.3: retire MethodNameCollision + multi-class E2E + DESIGN.md sync

Closes the module-qualified-class-names milestone. Deletes the
workspace-load workaround; the two-libraries case now resolves via
type-driven dispatch at the call site with explicit qualifier as the
LLM-author's disambiguation tool. Resolves both mq.2 known-debt items
(active_declared_constraints plumbing, (class,method) re-key). New
synth warnings channel emits class-method-shadowed-by-fn at all three
fn-precedence branches per spec section Class-fn collisions. Three new
E2E fixtures + integration tests cover the ambiguous, qualified, and
class-fn-shadow trajectories. DESIGN.md class-names paragraph rewritten,
new section Method dispatch added. Roadmap P2 marked done, milestone-24
unblocked for re-brainstorm.

9/9 tasks. 545 tests green. bench/compile_check.py + cross_lang.py
exit 0; bench/check.py exit 1 (2 noise-class regressions, runtime
uncoupled to typecheck iter).
This commit is contained in:
2026-05-13 02:19:21 +02:00
parent 90075715d9
commit 99d3968656
23 changed files with 1399 additions and 399 deletions
@@ -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
}
-19
View File
@@ -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,
+110
View File
@@ -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 `<module>.<Class>.<method>`
//! 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 <entry>` 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}",
);
}
+8 -5
View File
@@ -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
// `<defining_module>.<Class>`.
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",
+10 -1
View File
@@ -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");
+6
View File
@@ -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<crate::diagnostic::Diagnostic> = 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<crate::diagnostic::Diagnostic> = 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<crate::diagnostic::Diagnostic> = 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!(
+10
View File
@@ -92,6 +92,16 @@
//! qualified class that is not in the workspace registry of
//! candidate classes for the method. `ctx`:
//! `{"name": "<qualified-class>"}`.
//! - `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 (`<ClassQualifier>.<method> ...`) if the
//! shadow was unintentional. `ctx`: `{"name": "<resolved-name>",
//! "method": "<bare-method>", "fn_owner_module": "<m>",
//! "candidate_classes": ["<C1>", ...]}`.
use serde::Serialize;
+414 -128
View File
@@ -906,7 +906,14 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
let mut diagnostics: Vec<Diagnostic> = 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<Diagnostic> = 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<Diagnostic> {
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<CheckedModule> {
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<Diagnostic> = 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<String, Type>,
/// 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<String, ClassMethodEntry>,
/// 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<String, ModuleGlobals>,
// 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<Diagnostic>,
) -> Vec<CheckError> {
let mut env = build_check_env(ws);
let mut errors: Vec<CheckError> = 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<Diagnostic>,
) -> 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<Diagnostic>) -> 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<Constraint> = 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<ResidualConstraint> = Vec::new();
let mut free_fn_calls: Vec<FreeFnCall> = 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<Diagnostic> = 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<Constraint> = 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,
&registry_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<Diagnostic>) -> 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<ResidualConstraint> = Vec::new();
let mut free_fn_calls: Vec<FreeFnCall> = 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<Diagnostic> = 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<ResidualConstraint>,
free_fn_calls: &mut Vec<FreeFnCall>,
// 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<crate::diagnostic::Diagnostic>,
) -> Result<Type> {
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<crate::diagnostic::Diagnostic>| {
let (method_name, _) = parse_method_qualifier(name);
if let Some(cands) = env.method_to_candidate_classes.get(method_name) {
let mut candidate_list: Vec<String> = 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 `<ClassQualifier>.{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*/ &registry_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(), &lty, 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<Type>)> = Vec::new();
@@ -2898,7 +3017,7 @@ pub(crate) fn synth(
pushed.push((n.clone(), prev));
}
let mut body_effects: BTreeSet<String> = 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<String> = 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<String>,
/// 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<String, ClassMethodEntry>,
/// 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<String, BTreeSet<String>>,
/// 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<Constraint>,
/// 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<String, Type> = IndexMap::new();
let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default();
let mut counter: u32 = 0;
let mut residuals: Vec<ResidualConstraint> = Vec::new();
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
let mut warnings: Vec<crate::diagnostic::Diagnostic> = 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:?}");
}
}
+5 -1
View File
@@ -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<crate::FreeFnCall> = 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<crate::diagnostic::Diagnostic> = 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))
}
}
+66 -45
View File
@@ -204,7 +204,7 @@ pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> {
}
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<Workspace> {
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<crate::ResidualConstraint> = Vec::new();
let mut free_fn_calls: Vec<crate::FreeFnCall> = 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<crate::diagnostic::Diagnostic> = 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<String, crate::ClassMethodEntry>,
method_to_candidate_classes: &BTreeMap<String, BTreeSet<String>>,
poly_free_fns: &BTreeSet<String>,
caller_module: &str,
ordered_targets: &[Option<MonoTarget>],
@@ -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 &params_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<String> = 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<crate::ResidualConstraint> = Vec::new();
let mut free_fn_calls: Vec<crate::FreeFnCall> = 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<crate::diagnostic::Diagnostic> = 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<String> = 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<String, crate::ClassMethodEntry>,
method_to_candidate_classes: &BTreeMap<String, BTreeSet<String>>,
poly_free_fns: &BTreeSet<String>,
class_slots: &[Option<MonoTarget>],
free_fn_slots: &[Option<MonoTarget>],
@@ -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 &params_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<String> = 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 { .. } => {}
}
@@ -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");
}
+15 -170
View File
@@ -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<String, Origin> = 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 (`<mod>.<Class>`). The Display
// format emits `"class <mod>.<Class> (in <mod>)"`
// 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 <module>.<Class> (in <module>)".
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 <module>.Greet (in <module>)".
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
+91 -10
View File
@@ -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, `<module>.<Class>` 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 (`<module>.<Class>.<method>`) 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 `<module>.<Class>.<method>` 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<MethodName,
BTreeSet<QualifiedClassName>>` 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
+284
View File
@@ -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<Constraint>` 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<MethodName, ClassMethodEntry>` 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<Diagnostic>` 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<Constraint>`
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<String, ClassMethodEntry>``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<Diagnostic>` (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<Diagnostic>` 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<Diagnostic>)>`
but `check_fn`'s actual signature is `Result<()>` and the
mut-ref-accumulator pattern matches the existing `Vec<CheckError>`
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<Diagnostic>),
CheckError>`; the actual signature is `Result<()>` (no
CheckedFn). The mut-ref-accumulator pattern adopted instead
matches the existing `check_in_workspace` shape (Vec<CheckError>
accumulator). Same outcome, different mechanism.
- **`class_method_candidates` returns `Vec` not `impl Iterator`.**
The plan's accessor signature `impl Iterator<Item = (&'a String,
&'a ClassMethodEntry)>` 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
+1
View File
@@ -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, `<module>.<Class>` 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<BTreeSet<String>>` (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 `<module>.<Class>` 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<Constraint>` 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<MethodName, ClassMethodEntry>` 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<Diagnostic>` 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<Diagnostic>)>` — adopted mut-ref-accumulator pattern matching existing `Vec<CheckError>` 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
+19 -20
View File
@@ -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<String, ClassMethodEntry>`,
`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 `<module>.<Class>.<method>` 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.
+36
View File
@@ -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 } }
]
}
]
}
}
]
}
@@ -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 } }
}
}
]
}
]
}
@@ -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 } }
}
]
}
+33
View File
@@ -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 } }]
}
]
}
}
]
}
@@ -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" }]
}
}
}
]
}
]
}
@@ -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" }]
}
}
}
]
}
]
}
+33
View File
@@ -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 } }]
}
]
}
}
]
}