fix(codegen): resolve the dotted T.new user-ADT callee, symmetric to check

A monomorphic `(new Counter 42)` over a user-defined ADT that declares a
`new` fn passed `ail check` but crashed `ail build --alloc=rc` with
`unknown variable: Counter.new` (RED-pinned in 1eff055).

Root: the `Term::New` desugar lowers a no-type-arg monomorphic
`(new Counter 42)` to `(app Counter.new 42)` — a type-scoped `Var`
callee `Counter.new`. The checker resolves that dotted callee via its
TypeDef-first ladder (so check is clean), but codegen had no equivalent
resolution: the name was absent from the import map and the current
module's def list, so it fell through to `CodegenError::UnknownVar`. The
user `new` fn body IS codegen'd — only the dotted call-site failed to
resolve. The error surfaced first in the arg-type pre-pass
(`synth_with_extras`'s dotted-Var branch), which `lower_term`'s
`Term::Let` arm runs before lowering the call.

Fix (crates/ailang-codegen/src/lib.rs, all `// #53`):
- New `Emitter::type_home_module(type_name)`: returns the module
  declaring the named TypeDef (current module first, then any import
  target) by scanning `module_ctor_index` for a matching
  `CtorRef.type_name`. This mirrors the checker's TypeDef-first ladder.
- A dotted `T.def` fallback (resolve via the type's home module) added
  to the three sites that needed it: `resolve_top_level_fn` (the
  fn-pointer / lower_app resolution path), `is_static_callee` (its
  lockstep partner), and the `synth_with_extras` dotted-Var branch (the
  path that actually fired for this fixture).

Lockstep (CLAUDE.md `lower_app` ↔ `is_static_callee`): `is_static_callee`
now returns true for a dotted `T.def` whose `T` is a user ADT declared
in this or an imported module, exactly matching `resolve_top_level_fn`'s
new fallback — so no name is claimed static without being lowerable.

Distinct root from #51 (the polymorphic `(new T <elem> …)` type-arg
drop, ee4107c) — that fix deliberately left the monomorphic
app-lowering path unchanged; this completes it.

Verification: the #53 pin (crates/ail/tests/user_adt_new_mono_pin.rs)
is green; the original 3-module reproducer
examples/fieldtest/kem_2_counter_new.ail builds, runs, prints 42;
ailang-codegen/check/core suites, the intercepts bijection, and both
hash-pins are green; full workspace green.

closes #53
This commit is contained in:
2026-05-31 03:52:23 +02:00
parent 1eff055a0e
commit 420703d321
+61 -10
View File
@@ -2547,11 +2547,23 @@ impl<'a> Emitter<'a> {
let target_module = match self.import_map.get(prefix).cloned() {
Some(m) => m,
None if self.module_user_fns.contains_key(prefix) => prefix.to_string(),
None => {
return Err(CodegenError::Internal(format!(
"cross-module call `{name}`: prefix `{prefix}` not in import map"
)))
}
// #53: type-scoped `T.def` callee (e.g. `Counter.new`,
// produced by the monomorphic `Term::New` desugar). When
// `prefix` is not an import alias or module name but
// names a TypeDef in scope, the call resolves to the
// `def` fn in that type's home module — symmetric to the
// checker's TypeDef-first ladder. Keeps the documented
// `lower_app` ↔ `is_static_callee` lockstep honest:
// `is_static_callee` already returns true for every
// single-dot name, so this branch MUST resolve it.
None => match self.type_home_module(prefix) {
Some(home) => home,
None => {
return Err(CodegenError::Internal(format!(
"cross-module call `{name}`: prefix `{prefix}` not in import map"
)))
}
},
};
// iter 23.4: codegen-time poly-call dispatch removed. Post-mono
// every poly call site has been rewritten by `rewrite_mono_calls`
@@ -2786,6 +2798,30 @@ impl<'a> Emitter<'a> {
}
/// #53: returns the module that declares the named TypeDef — the
/// current module first, then imported modules. `None` if no module
/// in scope declares a TypeDef of that name. A type's presence in a
/// module is read from `module_ctor_index` (a TypeDef declares at
/// least one ctor, each `CtorRef` carrying its `type_name`). This
/// mirrors the checker's TypeDef-first ladder so codegen resolves a
/// type-scoped dotted callee (`T.def`) exactly where the checker
/// does. Used by `lower_app` and `resolve_top_level_fn`.
fn type_home_module(&self, type_name: &str) -> Option<String> {
if let Some(ctors) = self.module_ctor_index.get(self.module_name) {
if ctors.values().any(|c| c.type_name == type_name) {
return Some(self.module_name.to_string());
}
}
for target in self.import_map.values() {
if let Some(ctors) = self.module_ctor_index.get(target) {
if ctors.values().any(|c| c.type_name == type_name) {
return Some(target.clone());
}
}
}
None
}
/// is `name` a callee that can be resolved at compile time
/// (no fn-pointer needed)? True for builtin operators, the
/// current-module top-level fns (mono or poly), and qualified
@@ -2872,11 +2908,17 @@ impl<'a> Emitter<'a> {
// a valid post-mono construct because both ends were
// independently typechecked under their original module
// contexts before mono ran).
let target = match self.import_map.get(prefix) {
Some(m) => m,
None => prefix,
// #53: a type-scoped `T.def` prefix (e.g. `Counter.new`)
// resolves to the type's home module, symmetric to the
// checker's TypeDef-first ladder and the matching arm in
// `lower_app`'s cross-module branch. Tried after import_map
// and the literal-module-name fallback.
let target: String = match self.import_map.get(prefix) {
Some(m) => m.clone(),
None if self.module_user_fns.contains_key(prefix) => prefix.to_string(),
None => self.type_home_module(prefix).unwrap_or_else(|| prefix.to_string()),
};
let sig = self.module_user_fns.get(target)?.get(suffix)?.clone();
let sig = self.module_user_fns.get(&target)?.get(suffix)?.clone();
return Some((format!("@ail_{target}_{suffix}_clos"), sig));
}
if let Some(sig) = self
@@ -3234,6 +3276,14 @@ impl<'a> Emitter<'a> {
// post-mono synthesised cross-module references
// (see `resolve_top_level_fn` and the cross-module
// call arm in `lower_app` for matching fallbacks).
// #53: a type-scoped `T.def` prefix (e.g. `Counter.new`,
// the monomorphic `Term::New` desugar output) resolves
// to the type's home module, after import_map and the
// literal-module-name fallback. Mirrors the checker's
// TypeDef-first ladder and the matching arms in
// `lower_app` / `resolve_top_level_fn`, so arg-type
// synthesis of the dotted callee doesn't hit `UnknownVar`.
let type_home = self.type_home_module(prefix);
let target_opt: Option<&str> = self
.import_map
.get(prefix)
@@ -3244,7 +3294,8 @@ impl<'a> Emitter<'a> {
} else {
None
}
});
})
.or(type_home.as_deref());
if let Some(target) = target_opt {
if let Some(ty) = self
.module_def_ail_types