feat(cli): resolve canonical type-scoped describe form

`ail describe <Type>.<op>` is the canonical form for a type-associated
operation (model 0007 §1), but resolve_describe_name only ever looked
up the left segment as a workspace module, so `Series.push` errored
`no module \`Series\`` even though `series.push` and bare `push`
resolved fine.

The dotted branch now falls through on a module miss: if no module is
named by the left segment, scan the workspace for the `Def::Type` named
by it and resolve `<op>` through that type's home module — returning the
identical `Def` the module-scoped and bare forms return (verified: same
hash d5278789db5f267a for `Series.push` / `series.push`). A literal
module still wins (precedence preserved); the bare-name and
ambiguous-name paths are untouched. The genuinely-unknown-prefix
diagnostic is tightened to `no module or type \`X\` in workspace`, and a
type-found-but-op-missing case reports `no def \`op\` in module \`home\`
(home of type \`Type\`)`.

Fix is CLI-local (crates/ail/src/main.rs); ailang-check untouched (a
direct ws.modules scan sufficed, no env.module_types needed). Full
workspace suite green (738); the RED pin describe_resolves_type_scoped_op
(8d58c83) is now GREEN.

closes #64
This commit is contained in:
2026-06-02 14:17:34 +02:00
parent 8d58c8310b
commit d565ed87a3
+42 -11
View File
@@ -1886,17 +1886,48 @@ fn resolve_describe_name<'ws>(
if let Some(idx) = name.find('.') {
let mod_name = &name[..idx];
let def_name = &name[idx + 1..];
let m = ws.modules.get(mod_name).with_context(|| {
format!("no module `{mod_name}` in workspace `{}`", ws.entry)
})?;
let def = m
.defs
.iter()
.find(|d| d.name() == def_name)
.with_context(|| {
format!("no def `{def_name}` in module `{mod_name}`")
})?;
return Ok((mod_name.to_string(), def));
// Precedence: a literal module wins. `<module>.<def>` resolves
// strictly through that module.
if let Some(m) = ws.modules.get(mod_name) {
let def = m
.defs
.iter()
.find(|d| d.name() == def_name)
.with_context(|| {
format!("no def `{def_name}` in module `{mod_name}`")
})?;
return Ok((mod_name.to_string(), def));
}
// Canonical type-scoped form `<Type>.<op>`: the left segment is
// not a module but a known TYPE. Resolve `<op>` through the
// type's HOME module (the module that defines the `Def::Type`
// named `mod_name`) — same `Def` the module-scoped and bare
// forms return. First home module in iteration order wins; in
// practice a type name is unique across a workspace.
for (home_mod, m) in &ws.modules {
let defines_type = m
.defs
.iter()
.any(|d| matches!(d, ailang_core::Def::Type(_)) && d.name() == mod_name);
if !defines_type {
continue;
}
let def = m
.defs
.iter()
.find(|d| d.name() == def_name)
.with_context(|| {
format!(
"no def `{def_name}` in module `{home_mod}` \
(home of type `{mod_name}`)"
)
})?;
return Ok((home_mod.clone(), def));
}
return Err(anyhow::anyhow!(
"no module or type `{mod_name}` in workspace `{}`",
ws.entry
));
}
// Bare name: entry module first.