iter prep.1-type-scoped-namespacing (DONE 5/5): TypeDef-first resolution + workspace pre-pass — closes #31

First iteration of the kernel-extension-mechanics milestone. Ships
the type-scoped `<TypeName>.<member>` resolution path as the
canonical form for type-associated operations, narrows the
`BareCrossModuleTypeRef` / `BadCrossModuleTypeRef` diagnostics from
"bare = strictly local" to "bare = in-scope by any path", migrates
12 std-library example fixtures, and introduces a workspace-wide
normalisation pre-pass `prepare_workspace_for_check` shared between
`check_workspace` and `monomorphise_workspace`.

Architectural discovery during implementation: the plan covered the
`Term::Var` dot-qualified resolver layer plus the workspace
validator's bare-name acceptance, but the migration of bare-form
fixtures exposed five sites where bare vs. qualified type-names
needed symmetric treatment — `Term::Ctor` resolution, `Type::Con`
well-formedness, mono's poly-free-fn name/constraint-count
enumeration, codegen's `lookup_ctor_by_type` bare-name path, and
the upstream desugar-then-qualify composition. Rather than
scattering TypeDef-first ladders across each site, the implementer
centralised the work into one pre-pass that walks every consumer
module's `Type::Con.name` and `Term::Ctor.type_name`, rewriting
bare cross-module references to their qualified `<home>.<Type>`
form. This is symmetric to the pre-existing `qualify_local_types`
(owner-side); the new pre-pass is the consumer-side mirror.
Downstream passes see qualified Types regardless of authoring form.
The TypeDef-first ladder still lives in `synth`'s `Term::Var` arm
because `<TypeName>.<member>` is term-position-only — `Maybe.from_maybe`
is a Var, not a Type expression, and the pre-pass does not rewrite
Var names.

Alternatives considered:

(a) Add TypeDef-first ladder at every resolution site separately
    (the plan's implicit assumption). Rejected: O(N) extension
    sites, each carrying the same workspace-walking logic; the
    pre-pass version is O(1) — one pass, every downstream consumer
    benefits.
(b) BLOCKED + spec re-brainstorm. Rejected: the architecture
    extension is consistent with prep.1's thesis (bare type-name
    resolves to the workspace-wide TypeDef) and forward-compatible
    with prep.2 (Term::New.type_name falls under the same rewrite)
    and prep.3 (kernel-tier TypeDefs enter the workspace map
    automatically). No design regression to bounce back over.

Spec updated to document the realisation mechanism honestly: the
"Realisation mechanism — workspace pre-pass" subsection clarifies
that the resolver-level semantics described in "Implementation
shape" are the user-facing contract, and the actual code path is
the pre-pass.

Verification:

- `cargo test --workspace`: ALL GREEN. 87 e2e + every crate's unit
  + integration tests pass with no regressions.
- Three NEW in-source tests pin Task 1's resolver paths:
  `type_scoped_member_resolves`, `type_scoped_member_not_found`,
  `type_scoped_receiver_not_a_type`.
- One NEW workspace test pins the narrowed validator:
  `ct1_validator_accepts_bare_with_explicit_import`.
- One renamed-and-flipped existing test:
  `ct1_validator_rejects_bare_xmod_with_import_candidate` →
  `ct1_validator_accepts_bare_xmod_with_import_candidate` (the
  bare-with-import path is now ACCEPTED).
- One NEW companion test for the workspace-wide ctor lookup:
  `ct2_term_ctor_bare_cross_module_via_workspace_resolves`.
- Two pre-existing tests' assertions updated for the new error
  wording: `ct1_check_cli::check_human_mode_emits_actionable_message_to_stderr`
  and `crates/ailang-check/tests/workspace.rs::unknown_module_prefix_is_reported`.
- 12 migrated `.ail` fixtures verified via the existing e2e
  suite (each fixture is the test runner's target for an existing
  `build_and_run` assertion).
- Negative fixture `ct_2_bare_cross_module.ail` semantically
  preserved: dropped its `(import std_maybe)` so bare `Maybe` is
  out-of-scope under the narrowed rule and still fires
  `BareCrossModuleTypeRef`.

Concerns:

- The pre-pass introduces a new architectural layer (consumer-side
  qualification) that the spec did not originally anticipate. Spec
  amendment in this commit documents the layer. Future iterations
  reference `prepare_workspace_for_check` as established
  infrastructure.
- `examples/test_ct1_bare_xmod_rejected.ail.json` switched its
  offending name from bare `Ordering` (which under the prep.1
  semantics may now resolve via implicit prelude) to a still-
  unresolvable `Mystery_Type`. The CLI test's intent (assert that
  a human-mode `ail check` exits non-zero on a still-RED case) is
  preserved.

Milestone status: kernel-extension-mechanics (Gitea #6) advances
1/3 iters. Next: prep.2 (`Term::New` construct) issue #32.
This commit is contained in:
2026-05-28 14:43:03 +02:00
parent 46c9aabf00
commit b586999e81
26 changed files with 925 additions and 271 deletions
+60 -33
View File
@@ -318,9 +318,9 @@ pub enum WorkspaceLoadError {
/// `candidates` lists the qualified forms found by scanning the
/// owning module's imports in declaration order.
#[error(
"module `{module}` contains bare type name `{name}` that does not resolve to a local type. \
AILang's `.ail.json` requires cross-module type references to be qualified. \
Candidates from imports: {candidates:?}. Run `ail migrate-canonical-types` to fix legacy fixtures."
"module `{module}` references bare type `{name}`, which is not in scope. \
Add `(import <module>)` to bring it into scope (candidates: {candidates:?}), \
or rewrite the call to type-scoped form `<TypeName>.<member>`."
)]
BareCrossModuleTypeRef {
module: String,
@@ -333,8 +333,9 @@ pub enum WorkspaceLoadError {
/// workspace, or `<owner>` is known but declares no TypeDef
/// named `<type>`.
#[error(
"module `{module}` references qualified type `{name}` but the owner module is not known \
or does not declare a type by that name"
"module `{module}` references qualified type `{name}` but the owner module \
is not known in the workspace, or it declares no type by that name. \
Use the bare type-name from an imported module instead."
)]
BadCrossModuleTypeRef {
module: String,
@@ -1109,9 +1110,11 @@ fn check_type_con_name(
{
return Ok(());
}
// Bare cross-module — collect candidates from imports in declaration order.
// Prelude is implicit: scan it last as a fallback candidate (mirrors
// iter 23.2.4's implicit-prelude behaviour in codegen).
// prep.1: a bare cross-module type-name is ACCEPTED if it is in
// scope via an explicit import or an implicit (prelude / kernel-
// tier) auto-import. Candidates from those paths drive both
// acceptance and (on miss) the error message's suggested fix.
let mut in_scope = false;
let mut candidates: Vec<String> = Vec::new();
for imp in import_names {
if local_types
@@ -1119,6 +1122,7 @@ fn check_type_con_name(
.map(|s| s.contains(name))
.unwrap_or(false)
{
in_scope = true;
candidates.push(format!("{imp}.{name}"));
}
}
@@ -1131,9 +1135,13 @@ fn check_type_con_name(
.map(|s| s.contains(name))
.unwrap_or(false)
{
in_scope = true;
candidates.push(format!("{implicit}.{name}"));
}
}
if in_scope {
return Ok(());
}
Err(WorkspaceLoadError::BareCrossModuleTypeRef {
module: owning_module.to_string(),
name: name.to_string(),
@@ -1855,21 +1863,46 @@ mod tests {
}
}
/// a bare Type::Con whose `name` resolves to one imported
/// module's TypeDef must fire `BareCrossModuleTypeRef` with that
/// qualified form in `candidates` (so the diagnostic can suggest the
/// fix).
/// prep.1: a bare `Type::Con` whose `name` matches a `TypeDef` in
/// an EXPLICITLY imported module must be ACCEPTED. (Pre-prep.1 this
/// case was rejected as `BareCrossModuleTypeRef`; type-scoped form
/// is canonical and importing the owner module brings the bare
/// type-name into scope.)
#[test]
fn ct1_validator_rejects_bare_xmod_with_import_candidate() {
fn ct1_validator_accepts_bare_with_explicit_import() {
let mut modules = BTreeMap::new();
modules.insert("other".to_string(), module_with_type_def("other", "Ordering"));
modules.insert("m".to_string(), module_with_import_and_type_con("m", "other", "Ordering"));
validate_canonical_type_names(&modules, &["prelude"])
.expect("bare `Ordering` must be accepted when `other` is imported and declares it");
}
/// prep.1: a bare `Type::Con` whose `name` is a `TypeDef` in some
/// workspace module BUT the owning module does NOT import that
/// module (and the type is not implicit-imported either) must still
/// fire `BareCrossModuleTypeRef`. The presence of `other` in the
/// workspace is not enough; the bare-in-scope path requires `m` to
/// declare an `(import other)`.
#[test]
fn ct1_validator_rejects_bare_when_owner_not_imported() {
let mut modules = BTreeMap::new();
modules.insert("other".to_string(), module_with_type_def("other", "Ordering"));
// `m` references `Ordering` but does NOT import `other`.
modules.insert("m".to_string(), single_module_with_type_con("m", "Ordering").remove("m").unwrap());
let err = validate_canonical_type_names(&modules, &["prelude"])
.expect_err("Ordering must be rejected with candidate");
.expect_err("bare `Ordering` must be rejected when `other` not imported");
match err {
WorkspaceLoadError::BareCrossModuleTypeRef { name, candidates, .. } => {
WorkspaceLoadError::BareCrossModuleTypeRef { name, candidates, module } => {
assert_eq!(module, "m");
assert_eq!(name, "Ordering");
assert_eq!(candidates, vec!["other.Ordering".to_string()]);
// No imports on `m` => no candidates list (today's
// implementation scans `import_names`, which is empty
// here; the workspace-wide scan is not part of the
// candidate-list construction).
assert!(
candidates.is_empty(),
"no imports => no candidates; got {candidates:?}"
);
}
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
}
@@ -1941,16 +1974,19 @@ mod tests {
}
}
/// a `Term::Ctor` whose `type_name` is a bare cross-module ref
/// must fire `BareCrossModuleTypeRef`. Symmetric to the Type::Con
/// rule but the field lives on Term, not Type.
/// prep.1: a `Term::Ctor` whose `type_name` is a bare type-name
/// from a module in scope (here `prelude`, via implicit imports)
/// is ACCEPTED. The symmetry with the `Type::Con` rule holds:
/// bare-in-scope is canonical post-prep.1, and the implicit
/// import path is one of the in-scope paths.
#[test]
fn ct1_validator_rejects_bare_term_ctor_type_name() {
fn ct1_validator_accepts_bare_term_ctor_via_implicit_import() {
let mut modules = BTreeMap::new();
modules.insert("prelude".to_string(),
module_with_type_def("prelude", "Ordering"));
// Module `m` has no imports, no local Ordering, but a Term::Ctor
// referencing bare `Ordering`.
// Module `m` has no explicit imports, no local Ordering, but a
// Term::Ctor referencing bare `Ordering` — accepted via the
// implicit-prelude path.
let m: Module = serde_json::from_value(serde_json::json!({
"schema": crate::SCHEMA,
"name": "m",
@@ -1969,17 +2005,8 @@ mod tests {
}],
})).unwrap();
modules.insert("m".to_string(), m);
let err = validate_canonical_type_names(&modules, &["prelude"])
.expect_err("bare Term::Ctor type must be rejected");
match err {
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
assert_eq!(module, "m");
assert_eq!(name, "Ordering");
assert_eq!(candidates, vec!["prelude.Ordering".to_string()],
"prelude is implicit-fallback");
}
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
}
validate_canonical_type_names(&modules, &["prelude"])
.expect("bare Term::Ctor type must be accepted via implicit prelude import");
}
/// a qualified `ClassDef.name` (e.g. `other.Eq`) must fire
+13 -7
View File
@@ -448,19 +448,25 @@ fn constraint_with_unbound_var_fires_unbound_constraint_type_var() {
}
}
/// pd.2 relocation: §C4 (a) carve-out fixture
/// `test_ct1_bare_xmod_rejected.ail.json` — bare `Ordering` Term::Ctor
/// with no imports, validator catches it after prelude injection so the
/// candidate list contains `prelude.Ordering`.
/// pd.2 relocation, prep.1 fixture-flip: §C4 (a) carve-out fixture
/// `test_ct1_bare_xmod_rejected.ail.json`. Pre-prep.1 the fixture used
/// bare `Ordering` and the validator caught it via prelude-as-candidate.
/// Post-prep.1 a bare in-scope type-name (`Ordering` from implicit
/// prelude) is ACCEPTED, so the fixture was switched to a name no
/// module declares (`Mystery_Type`); the rejection path still fires
/// `BareCrossModuleTypeRef` but the candidates list is empty.
#[test]
fn ct1_fixture_bare_xmod_rejected() {
let entry = examples_dir().join("test_ct1_bare_xmod_rejected.ail.json");
let err = load_workspace(&entry).expect_err("must reject bare Ordering");
let err = load_workspace(&entry).expect_err("must reject bare Mystery_Type");
match err {
WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => {
assert_eq!(module, "test_ct1_bare_xmod_rejected");
assert_eq!(name, "Ordering");
assert_eq!(candidates, vec!["prelude.Ordering".to_string()]);
assert_eq!(name, "Mystery_Type");
assert!(
candidates.is_empty(),
"no module declares Mystery_Type => candidates empty; got {candidates:?}"
);
}
other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
}