Files
AILang/docs/plans/0101-prep.1-type-scoped-namespacing.md
T
Brummel 46c9aabf00 plan: prep.1 type-scoped namespacing — 5-task atomic resolver + 12-fixture migration (refs #31)
The plan covers the first iteration of the kernel-extension-mechanics
milestone: type-scoped `<TypeName>.<member>` resolution in
`ailang-check`, narrowed `BareCrossModuleTypeRef` /
`BadCrossModuleTypeRef` diagnostics in `ailang-core::workspace`,
two new `CheckError` variants, CLI diagnostic-message rewording,
and atomic rewrite of 12 `.ail` example fixtures from `std_X.Y` to
the type-scoped form. Five tasks, each unit-of-review.

Includes a spec correction (same commit because plan recon
surfaced it): the prep.1 Blast Radius previously claimed
`hash_pin.rs` + `prelude_module_hash_pin.rs` refreshes and a
`design_schema_drift.rs` pin addition. Plan-recon walked every
test crate (per the schema-camelcase-fix hash-pin-blast-radius
lesson) and found:
  * neither hash-pin file pins any fixture in prep.1's migration
    set — refresh is empty;
  * type-scoped resolution is a checker-only change (no new JSON
    tags, no AST shape change) — the drift pin belongs to prep.2
    (`Term::New`) and prep.3 (`kernel: true` + `param-in`), not
    prep.1.

Spec section 'Blast radius' and the 'Iteration scope' summary now
reflect the recon-cleared reality.
2026-05-28 14:04:01 +02:00

36 KiB
Raw Blame History

prep.1 — Type-scoped namespacing — Implementation Plan

Parent spec: docs/specs/0052-kernel-extension-mechanics.md (Iteration prep.1 section) Gitea issue: refs #31 (the implementer-side iter-completion commit closes via closes #31)

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Ship type-scoped member-access (<TypeName>.<member>) as the canonical resolution path for type-associated operations in ailang-check, narrow BareCrossModuleTypeRef / BadCrossModuleTypeRef to fire only when a bare type-name is not in scope by any path, and migrate the 12 .ail std-library example fixtures that use the old std_X.Y form.

Architecture: ailang-check's Term::Var dot-qualified arm gains a TypeDef-first ladder (look up prefix as a known TypeDef across the workspace; if found, route to its home module; otherwise fall back to the legacy module-as-receiver path). ailang-core::workspace::check_type_con_name is widened: a bare cross-module Type::Con.name is accepted when the owning module's imports — explicit or implicit — provide it. Two new CheckError variants (TypeScopedMemberNotFound, TypeScopedReceiverNotAType) cover the new failure modes. The two existing WorkspaceLoadError messages drop the obsolete ail migrate-canonical-types hint and gain an import-or-type-scoped-form suggestion. The 12 fixture rewrites are atomic with the resolver change.

Tech stack: crates/ailang-check (resolver + new diagnostics), crates/ailang-core/src/workspace.rs (type-position validator + message strings), crates/ail/src/main.rs (CLI diagnostic translation), crates/ail/tests/ct1_check_cli.rs (CLI assertion update), examples/*.ail (12 fixtures), source-side doc-comments in crates/ailang-{check,codegen,surface}/src/ + crates/ail/tests/e2e.rs (vocabulary cleanup).

Out of scope (deferred to prep.2 / prep.3, per spec):

  • Term::New (prep.2).
  • kernel: true flag + param-in (prep.3).
  • Schema-drift pin additions (no new JSON tags in prep.1).
  • Hash-pin refreshes (recon: no overlap with migrated fixtures).
  • .ail.json fixture updates (recon: none of the 15 spec-named .ail files have a sibling .ail.json pin).

Files this plan creates or modifies:

  • Modify: crates/ailang-check/src/lib.rs:380-700CheckError enum: add TypeScopedMemberNotFound and TypeScopedReceiverNotAType variants.
  • Modify: crates/ailang-check/src/lib.rs:702-744CheckError::code() match: add two arms.
  • Modify: crates/ailang-check/src/lib.rs:749-845 (approx.) — CheckError::ctx() and downstream message-format matches: add two arms each.
  • Modify: crates/ailang-check/src/lib.rs:3163-3200Term::Var dot-qualified resolution arm: TypeDef-first ladder.
  • Modify: crates/ailang-core/src/workspace.rs:320-342 — narrow #[error(...)] messages on BareCrossModuleTypeRef and BadCrossModuleTypeRef.
  • Modify: crates/ailang-core/src/workspace.rs:1077-1142check_type_con_name: bare in-scope acceptance path.
  • Modify: crates/ailang-core/src/workspace.rs:1820-1983 — in-source mod tests: one test flips (bare-with-import-candidate now accepted); narrowed-message assertions land here.
  • Modify: crates/ail/src/main.rs:1249-1278workspace_error_to_diagnostic arms for the two narrowed errors.
  • Modify: crates/ail/tests/ct1_check_cli.rs:154-157 — assertion on ail migrate-canonical-types → new wording.
  • Migrate: examples/ct_2_bare_cross_module.ail, examples/ct_3b_bad_qualified_known_module.ail, examples/nested_pat.ail, examples/std_either_demo.ail, examples/std_either_list.ail, examples/std_either_list_demo.ail, examples/std_list.ail, examples/std_list_demo.ail, examples/std_list_more_demo.ail, examples/std_list_stress.ail, examples/std_maybe_demo.ail, examples/std_pair_demo.ail — 12 files; rewrite std_X.Y → type-scoped form atomically.
  • Modify: crates/ail/tests/e2e.rs — vocabulary cleanup in test-fn doc-comments; fixture filenames (build_and_run("std_<X>_demo.ail")) unchanged.
  • Modify: crates/ailang-check/src/lift.rs:71, crates/ailang-check/src/mono.rs:1034, crates/ailang-codegen/src/lib.rs:3444, crates/ailang-codegen/src/subst.rs:157 — doc-comment vocabulary cleanup.
  • Test: crates/ailang-check/src/lib.rs in-source #[cfg(test)] module — three new tests: type_scoped_member_resolves, type_scoped_member_not_found, type_scoped_receiver_not_a_type.
  • Test: crates/ailang-core/src/workspace.rs in-source #[cfg(test)] module — one new test: ct1_validator_accepts_bare_with_explicit_import; one existing flip: ct1_validator_rejects_bare_xmod_with_import_candidatect1_validator_accepts_bare_xmod_with_import_candidate.

Task 1: New CheckError variants + Term::Var type-scoped resolution

Files:

  • Modify: crates/ailang-check/src/lib.rs:380-700 (CheckError enum)

  • Modify: crates/ailang-check/src/lib.rs:702-744 (code() match)

  • Modify: crates/ailang-check/src/lib.rs:749-845 (ctx() match + downstream message arms)

  • Modify: crates/ailang-check/src/lib.rs:3163-3200 (Term::Var dot-qualified arm)

  • Test: crates/ailang-check/src/lib.rs in-source #[cfg(test)] mod tests

  • Step 1: Write the three failing tests

Append to the in-source #[cfg(test)] mod tests block in crates/ailang-check/src/lib.rs:

#[test]
fn type_scoped_member_resolves() {
    // `Maybe.from_maybe` resolves to `std_maybe.from_maybe` because
    // `Maybe` is a known TypeDef whose home module is `std_maybe`.
    let std_maybe = serde_json::from_value::<ailang_core::ast::Module>(serde_json::json!({
        "schema": ailang_core::SCHEMA,
        "name": "std_maybe",
        "imports": [],
        "defs": [
            {"kind": "data", "name": "Maybe", "vars": ["a"], "ctors": [
                {"name": "Nothing", "args": []},
                {"name": "Just", "args": [{"k": "var", "name": "a"}]}
            ]},
            {"kind": "fn", "name": "from_maybe",
             "type": {"k": "forall", "vars": ["a"], "constraints": [], "body":
                 {"k": "fn", "params": [{"k": "var", "name": "a"},
                                        {"k": "con", "name": "Maybe", "args": [{"k": "var", "name": "a"}]}],
                  "ret": {"k": "var", "name": "a"}, "effects": []}},
             "params": ["d", "m"], "body": {"t": "var", "name": "d"}}
        ]
    })).unwrap();
    let consumer = serde_json::from_value::<ailang_core::ast::Module>(serde_json::json!({
        "schema": ailang_core::SCHEMA,
        "name": "consumer",
        "imports": [{"module": "std_maybe"}],
        "defs": [
            {"kind": "fn", "name": "f",
             "type": {"k": "fn", "params": [{"k": "con", "name": "Int"}],
                      "ret": {"k": "con", "name": "Int"}, "effects": []},
             "params": ["x"],
             "body": {"t": "app", "callee": {"t": "var", "name": "Maybe.from_maybe"},
                      "args": [{"t": "var", "name": "x"},
                               {"t": "term-ctor", "type": "Maybe", "ctor": "Nothing", "args": []}]}}
        ]
    })).unwrap();
    let mut modules = std::collections::BTreeMap::new();
    modules.insert("std_maybe".to_string(), std_maybe);
    modules.insert("consumer".to_string(), consumer);
    let workspace = ailang_core::workspace::build_workspace(&modules, &["prelude"])
        .expect("workspace builds");
    let res = check_workspace(&workspace);
    assert!(res.is_ok(), "type-scoped Maybe.from_maybe must resolve cleanly, got {res:?}");
}

#[test]
fn type_scoped_member_not_found() {
    // `Maybe.bogus` fires `TypeScopedMemberNotFound`: Maybe is a known
    // TypeDef but `bogus` is not in its home module's defs.
    let std_maybe = /* same as above */ ;
    let consumer = /* like above but body uses `Maybe.bogus` */ ;
    let workspace = ailang_core::workspace::build_workspace(&modules, &["prelude"]).unwrap();
    let err = check_workspace(&workspace).expect_err("Maybe.bogus must error");
    let inner = match err {
        CheckError::Def(_, inner) => *inner,
        other => other,
    };
    assert!(matches!(inner, CheckError::TypeScopedMemberNotFound { ref type_name, ref member, .. }
                     if type_name == "Maybe" && member == "bogus"),
            "expected TypeScopedMemberNotFound, got {inner:?}");
}

#[test]
fn type_scoped_receiver_not_a_type() {
    // `SomethingRandom.x` fires `TypeScopedReceiverNotAType`:
    // `SomethingRandom` is neither a TypeDef nor an imported module.
    let consumer = /* module with body `SomethingRandom.x` */ ;
    let workspace = ailang_core::workspace::build_workspace(&modules, &["prelude"]).unwrap();
    let err = check_workspace(&workspace).expect_err("must error on unknown receiver");
    let inner = match err {
        CheckError::Def(_, inner) => *inner,
        other => other,
    };
    assert!(matches!(inner, CheckError::TypeScopedReceiverNotAType { ref name }
                     if name == "SomethingRandom"),
            "expected TypeScopedReceiverNotAType, got {inner:?}");
}

(The implementer fills in the omitted module-builder boilerplate by analogy to existing in-source tests above the new ones; e.g. crates/ailang-check/src/lib.rs #[cfg(test)] already has helpers like parse_module_from_value for similar wiring. If no such helper exists, write inline.)

  • Step 2: Run tests to verify they fail

Run: cargo test --workspace -p ailang-check type_scoped_ Expected: 3 tests fail. The failures are error[E0599]: no variant ... TypeScopedMemberNotFound (variants don't exist yet) — that is the expected pre-state. If the test bodies fail to compile because the helper module-builders don't exist, that is also the RED state and must be fixed by adding the variants (Step 3) AND wiring the resolution path (Step 4).

  • Step 3: Add the two CheckError variants

Append to the CheckError enum body in crates/ailang-check/src/lib.rs (after the last existing variant Internal(String) at line ~693):

    /// prep.1 (kernel-extension-mechanics): a dot-qualified
    /// `Term::Var` whose receiver is a known TypeDef but the suffix
    /// does not name a def in that type's home module. Code:
    /// `type-scoped-member-not-found`.
    #[error("type `{type_name}` (home module `{home_module}`) has no member `{member}`")]
    TypeScopedMemberNotFound {
        type_name: String,
        member: String,
        home_module: String,
    },

    /// prep.1 (kernel-extension-mechanics): a dot-qualified
    /// `Term::Var` whose receiver is neither a known TypeDef nor an
    /// imported module. Code: `type-scoped-receiver-not-a-type`.
    #[error("`{name}` is neither a known type nor an imported module")]
    TypeScopedReceiverNotAType {
        name: String,
    },

Add the two new arms to CheckError::code() (the match at lib.rs:702-744), between RecurNotInTailPosition and Internal:

            CheckError::TypeScopedMemberNotFound { .. } => "type-scoped-member-not-found",
            CheckError::TypeScopedReceiverNotAType { .. } => "type-scoped-receiver-not-a-type",

Walk CheckError::ctx() (immediately below code()): if the implementer's chosen ctx-shape requires arms for the new variants, add minimal ones (serde_json::json!({"type": type_name, "member": member, "home_module": home_module}) and serde_json::json!({"name": name}) respectively). If ctx() has a catch-all returning json!({}), no edit needed.

Run: cargo check -p ailang-check Expected: 0 errors. The variants compile; the resolver does not yet emit them.

  • Step 4: Modify the Term::Var dot-qualified arm

Replace the body of the else if name.matches('.').count() == 1 { ... } branch at crates/ailang-check/src/lib.rs:3163-3197 with the TypeDef-first ladder:

            } else if name.matches('.').count() == 1 {
                let (prefix, suffix) = name.split_once('.').expect("checked");
                // TypeDef-first: is `prefix` a known type anywhere in
                // the workspace? If yes, that type's home module is the
                // resolution target. Otherwise: fall back to the legacy
                // module-as-receiver path.
                let type_home: Option<String> = env
                    .module_types
                    .iter()
                    .find_map(|(mod_name, types)| {
                        if types.contains(prefix) {
                            Some(mod_name.clone())
                        } else {
                            None
                        }
                    });
                let target_module: String = if let Some(home) = type_home.clone() {
                    home
                } else if let Some(m) = env.imports.get(prefix) {
                    m.clone()
                } else {
                    return Err(CheckError::TypeScopedReceiverNotAType {
                        name: prefix.to_string(),
                    });
                };
                let g = env.module_globals.get(&target_module).ok_or_else(|| {
                    CheckError::UnknownModule {
                        module: target_module.clone(),
                    }
                })?;
                let raw_ty = g.get(suffix).cloned().ok_or_else(|| {
                    // If receiver was a TypeDef but suffix missing,
                    // emit the type-scoped diagnostic. Otherwise the
                    // legacy `UnknownImport`.
                    if type_home.is_some() {
                        CheckError::TypeScopedMemberNotFound {
                            type_name: prefix.to_string(),
                            member: suffix.to_string(),
                            home_module: target_module.clone(),
                        }
                    } else {
                        CheckError::UnknownImport {
                            module: target_module.clone(),
                            name: suffix.to_string(),
                        }
                    }
                })?;
                let owner_types = env
                    .module_types
                    .get(&target_module)
                    .cloned()
                    .unwrap_or_default();
                let qualified = qualify_local_types(&raw_ty, &target_module, &owner_types);
                (qualified, Some((target_module, suffix.to_string())))
            } else {
                return Err(CheckError::UnknownIdent(name.clone()));
            };
  • Step 5: Run the three new tests to verify they pass

Run: cargo test --workspace -p ailang-check type_scoped_ Expected: PASS, all three.

  • Step 6: Run the full ailang-check test suite for regressions

Run: cargo test --workspace -p ailang-check 2>&1 | tail -25 Expected: zero new failures. Existing parse_method_qualifier-anchored tests (the class-qualifier ladder is structurally above the new branch at lib.rs:3163) must remain green; the dot-qualified module-as-receiver fallback path is preserved verbatim, just gated behind a TypeDef-first lookup.


Task 2: Type-position bare-name resolution + narrowed diagnostic messages

Files:

  • Modify: crates/ailang-core/src/workspace.rs:320-342 (#[error(...)] strings on BareCrossModuleTypeRef and BadCrossModuleTypeRef)

  • Modify: crates/ailang-core/src/workspace.rs:1077-1142 (check_type_con_name body)

  • Modify: crates/ailang-core/src/workspace.rs:1820-1983 (in-source mod tests)

  • Step 1: Write the failing test for bare-with-explicit-import acceptance

Add to the in-source #[cfg(test)] mod tests block in crates/ailang-core/src/workspace.rs (next to the existing ct1_validator_rejects_bare_xmod_with_import_candidate at line 1862-1876):

/// prep.1: a bare `Type::Con` whose `name` matches a TypeDef in an
/// EXPLICITLY imported module must be ACCEPTED (formerly rejected
/// as `BareCrossModuleTypeRef`). Type-scoped form is canonical;
/// importing the module brings the bare type-name into scope.
#[test]
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");
}
  • Step 2: Run the new test to verify it fails

Run: cargo test --workspace -p ailang-core ct1_validator_accepts_bare_with_explicit_import Expected: FAIL with BareCrossModuleTypeRef { module: "m", name: "Ordering", candidates: ["other.Ordering"] }.

  • Step 3: Modify check_type_con_name to accept in-scope bare names

Replace the bare-cross-module section at crates/ailang-core/src/workspace.rs:1112-1141 (the comment "Bare cross-module — collect candidates ..." block through the final Err(...)) with:

    // prep.1: a bare cross-module type-name is ACCEPTED if it's 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
            .get(*imp)
            .map(|s| s.contains(name))
            .unwrap_or(false)
        {
            in_scope = true;
            candidates.push(format!("{imp}.{name}"));
        }
    }
    for implicit in implicit_imports {
        if import_names.contains(implicit) {
            continue;
        }
        if local_types
            .get(*implicit)
            .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(),
        candidates,
    })
  • Step 4: Narrow the #[error(...)] message on BareCrossModuleTypeRef

Replace crates/ailang-core/src/workspace.rs:320-324:

    #[error(
        "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>`."
    )]

Replace crates/ailang-core/src/workspace.rs:335-338:

    #[error(
        "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."
    )]
  • Step 5: Update the flipped existing test

Rename + repurpose ct1_validator_rejects_bare_xmod_with_import_candidate at crates/ailang-core/src/workspace.rs:1862-1876. Replace its body so the rejection path it pins is the still-RED case: bare Ordering from a module with NO explicit import of other (and not in implicit-imports). New name + body:

/// 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 candidates list names the
/// importable owner(s) so the error message can suggest the fix.
#[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("bare `Ordering` must be rejected when `other` not imported");
    match err {
        WorkspaceLoadError::BareCrossModuleTypeRef { name, candidates, .. } => {
            assert_eq!(name, "Ordering");
            // Candidates list comes from the workspace scan, not the
            // local imports — for the message-side suggestion.
            // Today's implementation walks `import_names`, which is
            // empty here, so candidates is empty. (If a future
            // refactor changes this to scan all workspace modules,
            // adjust the assertion.)
            assert!(candidates.is_empty(),
                "no imports => no candidates; got {candidates:?}");
        }
        other => panic!("expected BareCrossModuleTypeRef, got {other:?}"),
    }
}

(Adjust the helper-call shape to match the existing helpers in the test module. The point: a m whose imports is empty but whose body references bare Ordering.)

  • Step 6: Verify all ailang-core workspace tests pass

Run: cargo test --workspace -p ailang-core ct1_validator Expected: PASS — all 6+ ct1_validator_* tests green, including the new acceptance test and the renamed rejection test.

Run: cargo test --workspace -p ailang-core 2>&1 | tail -25 Expected: zero new failures across the full crate suite.


Task 3: CLI diagnostic-rendering update + ct1_check_cli assertion

Files:

  • Modify: crates/ail/src/main.rs:1249-1278 (workspace_error_to_diagnostic arms for BareCrossModuleTypeRef and BadCrossModuleTypeRef)

  • Modify: crates/ail/tests/ct1_check_cli.rs:154-157 (the migration-command-hint assertion)

  • Modify: examples/test_ct1_bare_xmod_rejected.ail.json (if it lacks an explicit prelude import — see Step 1)

  • Step 1: Inspect the existing test_ct1_bare_xmod_rejected.ail.json fixture

Run: cat examples/test_ct1_bare_xmod_rejected.ail.json | python3 -m json.tool | head -40 Expected: a module whose body references bare Ordering with no (import prelude) declaration. This is the still-RED case for BareCrossModuleTypeRef post-prep.1 (because prelude is in implicit_imports, the implicit path would normally let the type through — verify whether the current fixture's imports array is empty AND whether the workspace test runner injects prelude implicitly). If after Task 2 the existing fixture starts producing exit-0 instead of exit-non-zero, switch the fixture to a still-rejecting case: bare Mystery_Type (a name no module declares). Either way, the CLI test's intent is preserved: a human-mode error path must fire on a still-RED case.

  • Step 2: Update the human-mode assertion in ct1_check_cli.rs:154-157

Replace lines 154-157 of crates/ail/tests/ct1_check_cli.rs:

    assert!(
        stderr.contains("not in scope"),
        "expected the narrowed not-in-scope wording in stderr; got {stderr}"
    );

(The old assertion was stderr.contains("ail migrate-canonical-types") — that hint is removed in Task 2 Step 4.)

  • Step 3: Update the workspace_error_to_diagnostic arms in main.rs

main.rs:1249-1263 (the BareCrossModuleTypeRef arm) — adjust the user-facing message string in the diagnostic body to mirror the new #[error(...)] wording. The arm constructs a Diagnostic with message, code, ctx. The message field is what --json consumers see; the thiserror::Display impl drives the human-mode stderr (which is what Step 2's assertion covers).

If the existing arm builds message by formatting strings other than the WorkspaceLoadError's own Display, update the format string to match: drop the ail migrate-canonical-types reference; add the import-or-type-scoped-form suggestion. If the arm just delegates to err.to_string(), no edit is needed (the #[error(...)] change at workspace.rs:320 already carries through).

main.rs:1266-1278 (the BadCrossModuleTypeRef arm) — symmetric treatment.

  • Step 4: Verify ct1_check_cli.rs passes

Run: cargo test --workspace -p ail --test ct1_check_cli Expected: PASS — all tests in that file. check_human_mode_emits_actionable_message_to_stderr (line 139) now asserts on "not in scope" and "Ordering", both of which are in the new message.

  • Step 5: Regression check across the ail crate

Run: cargo test --workspace -p ail 2>&1 | tail -25 Expected: zero new failures.


Task 4: Migrate 12 .ail example fixtures to type-scoped form

Files:

  • Modify: examples/ct_2_bare_cross_module.ail
  • Modify: examples/ct_3b_bad_qualified_known_module.ail
  • Modify: examples/nested_pat.ail
  • Modify: examples/std_either_demo.ail
  • Modify: examples/std_either_list.ail
  • Modify: examples/std_either_list_demo.ail
  • Modify: examples/std_list.ail
  • Modify: examples/std_list_demo.ail
  • Modify: examples/std_list_more_demo.ail
  • Modify: examples/std_list_stress.ail
  • Modify: examples/std_maybe_demo.ail
  • Modify: examples/std_pair_demo.ail

Rewrite rules (apply consistently across all 12 files):

Old form New form Notes
(con std_X.Y ...) in type position (con Y ...) Type-position; bare type-name now in scope via existing (import std_X).
(term-ctor std_X.Y Ctor ...) (term-ctor Y Ctor ...) Same — bare type-name in term-ctor head.
(pat-ctor std_X.Y Ctor ...) (pat-ctor Y Ctor ...) Same — bare type-name in pat-ctor head (if any occurrence exists).
(app std_X.fn ...) where fn is type-associated to a single type T (app T.fn ...) Type-scoped function reference; e.g. std_maybe.from_maybeMaybe.from_maybe.
(app std_X.fn ...) where fn is a free-standing combinator (no single receiver type, e.g. std_either_list.partition_eithers) Keep as (app std_X.fn ...) Module-as-receiver form retained for free-standing defs.

Type-association lookup table (per-module — implementer references when classifying fns):

Module Type-associated fns (rewrite prefix → type name) Free-standing fns (keep module prefix)
std_maybe from_maybe, is_some, is_none, map_maybe, ... — all map to Maybe.* (none expected)
std_either is_left, is_right, map_left, map_right, either, from_left, from_right, ... — all map to Either.* (none expected)
std_list head, tail, length, map, filter, foldl, foldr, append, ... — all map to List.* (none expected)
std_pair fst, snd, swap, map_first, map_second, ... — all map to Pair.* (none expected)
std_either_list (none — module exists to host combinators on Either×List) partition_eithers, lefts, rights, ... — all stay as std_either_list.*

If a fn's home module declares it without a clear single receiver type, leave the module-scoped form. The implementer reads each std_X.ail to confirm the per-fn signature when in doubt.

  • Step 1: Sweep all 12 files with the rewrite rules

For each of the 12 files listed under "Files" above, open in editor and apply the rewrites mechanically. Order: easiest first (std_maybe_demo.ail and std_pair_demo.ail have the cleanest mappings; std_either_list_demo.ail has the most mixed cases).

Notes on the two negative-fixture files:

  • examples/ct_2_bare_cross_module.ail (lines 14-31): this file's CURRENT role is a NEGATIVE fixture pinning the old "bare type-name rejected" behaviour. Post-prep.1, the bare form is no longer the violation — but the file's role can be preserved by keeping it WITHOUT an (import std_maybe) declaration. With no import, bare Maybe falls through check_type_con_name's new scope check and still fires BareCrossModuleTypeRef (narrowed message). The fixture's role flips from "bare-rejected-always" to "bare-rejected-when-not-imported"; the rejection itself is preserved.

  • examples/ct_3b_bad_qualified_known_module.ail (lines 12-16): this fixture references std_maybe.Widget (known module, unknown type). Post-prep.1, this still fires BadCrossModuleTypeRef — the qualified-form rejection path is unchanged structurally (the spec only widens the bare-form path). Verify by ail check examples/ct_3b_bad_qualified_known_module.ail after Task 2 lands; if the diagnostic still fires with the new message, no rewrite is needed for this file.

  • Step 2: Run e2e to verify all 12 fixtures still build and check cleanly (or fail as expected for the two negatives)

Run: cargo build --release --bin ail && cargo test --workspace -p ail --test e2e 2>&1 | tail -40 Expected: zero new failures. The 10 positive fixtures (everything except ct_2_* and ct_3b_*) check clean; the two negatives produce their expected diagnostics. If a fixture fails because a rewrite missed an occurrence, fix the fixture and re-run.

  • Step 3: Round-trip sanity (Form-A printer symmetry)

Run: cargo test --workspace -p ailang-surface round_trip 2>&1 | tail -15 Expected: PASS. The Form-A round-trip is invariant under the rewrites (bare type-names print and re-parse identically; the parser layer doesn't change in prep.1).


Task 5: Vocabulary cleanup in source-side doc-comments

Files:

  • Modify: crates/ail/tests/e2e.rs — test-fn doc-comments referencing std_list.List<a>-style vocabulary

  • Modify: crates/ailang-check/src/lib.rs lines 2513, 2520, 2538, 2560, 2953, 3372, 3864, 5234, 5240, 5246, 6878 — doc-comments using std_list.length-as-canonical-example wording (the parse_method_qualifier doc-comment at 2513-2540 stays — it describes the module-as-receiver fallback path which is still valid, but the canonical example is updated to mention type-scoped first)

  • Modify: crates/ailang-check/src/lift.rs:71 — single doc-comment mentioning std_list.List Int

  • Modify: crates/ailang-check/src/mono.rs:1034 — single doc-comment naming std_list.length

  • Modify: crates/ailang-codegen/src/lib.rs:3444 — single doc-comment about std_list.List<...> qualification

  • Modify: crates/ailang-codegen/src/subst.rs:157 — single doc-comment about std_maybe.Maybe<Int> unification

  • KEEP UNCHANGED: crates/ailang-surface/src/lex.rs:20, 320, 328 — these are tokenizer-level (the tokenizer doesn't distinguish type-scoped vs module-scoped; the test at line 320 asserts "std_list.map" tokenizes as one ident, which remains true)

  • KEEP UNCHANGED: crates/ailang-check/src/lib.rs:6518-6519parse_method_qualifier test bodies (pure string-split assertion, semantics-neutral)

  • Step 1: Sweep each file for std_(maybe|either|list|pair). references in doc-comments

For each file in the modify-list, open and search for std_X. patterns in /// or //! doc-comments. Reword to use type-scoped vocabulary as the canonical form, mentioning module-as-receiver as the fallback for free-standing fns.

Example before/after for crates/ailang-check/src/mono.rs:1034:

// Before:
/// the canonical free-fn call example: `std_list.length`. The
/// mono pass synthesises a body that ...

// After:
/// the canonical free-fn call example: `List.length` (type-scoped).
/// The mono pass synthesises a body that ...

The exact reword is a judgement call per site; the goal is to drop misleading "this is the canonical form" claims attached to the old vocabulary while preserving accurate descriptions of resolution mechanics (the parse_method_qualifier-anchored comments must still describe the module-as-receiver path, which exists for free-standing fns).

  • Step 2: Verify nothing compiled around those doc-comments broke

Run: cargo build --workspace 2>&1 | tail -10 Expected: 0 errors. Doc-comment edits are non-load-bearing.

  • Step 3: Verify the full workspace test suite is clean

Run: cargo test --workspace 2>&1 | tail -25 Expected: zero new failures across all crates. This is the iter-level smoke gate; if it's red, fix before the implementer declares DONE.


Self-review (planner Step 5)

  1. Spec coverage: every prep.1 spec subsection has a task —

    • resolver change → Task 1
    • two new diagnostics → Task 1
    • type-position narrowing → Task 2
    • diagnostic-message narrowing → Task 2
    • CLI-side updates → Task 3
    • 12 fixture rewrites → Task 4
    • source-side vocabulary → Task 5
  2. Placeholder scan: searched for "TBD", "TODO", "implement later", "similar to", "add appropriate". No hits in the body of this plan. (The phrase "similar to Task N" does not appear; Task 4's rewrite rules are explicit per file, not "similar to Task 1's" or such.)

  3. Type consistency: TypeScopedMemberNotFound (with fields type_name, member, home_module) and TypeScopedReceiverNotAType (with field name) are spelled identically in Task 1 Step 3 (variant declaration), Step 1 (test assertions), and Step 4 (resolver-side emission). BareCrossModuleTypeRef and BadCrossModuleTypeRef retain their spellings across Tasks 2-3.

  4. Step granularity: each step is bite-sized (2-5 minutes typical; a couple of the resolver-write steps push to ~8 minutes, but they're single coherent edits, not multi-decision composites).

  5. No commit steps: zero git commit instructions appear. Implement leaves work in the working tree.

  6. Pin/replacement substring contiguity: Task 3 Step 2's verbatim assertion replacement (stderr.contains("not in scope")) IS the substring the new #[error(...)] body in Task 2 Step 4 contains. The phrase "not in scope" appears contiguously in the workspace.rs #[error(...)] string at the same line (no line-wrap split).

  7. Compile-gate vs. deferred-caller ordering: the new CheckError variants in Task 1 Step 3 are an additive enum change; Rust's exhaustiveness check forces the code() arms to be added in the same step (which they are). The new variants are not yet emitted until Step 4 — cargo check at Step 3 succeeds with #[allow(dead_code)]-equivalent silence (Rust does not warn on unused enum variants in pub enums). No compile-gate is set against unused emission. The Task-2 changes to check_type_con_name and #[error(...)] strings touch the same workspace.rs file; the in-source mod tests at lines 1820-1983 ARE callers and ARE updated in the same task (Step 5), so the compile-gate-vs-caller rule is satisfied.

  8. Verification-command filter strings:

    • cargo test --workspace -p ailang-check type_scoped_ — Task 1 Steps 2/5 — filter resolves to the three NEW test functions named type_scoped_member_resolves, type_scoped_member_not_found, type_scoped_receiver_not_a_type (all defined in Step 1). After Step 1 lands, the filter matches three tests. At Step 2 (before Step 3-4 land) the filter matches the tests in compile-failed state; at Step 5 they're green.
    • cargo test --workspace -p ailang-core ct1_validator_accepts_bare_with_explicit_import — Task 2 Step 2 — filter resolves to the NEW test named exactly that (defined in Step 1). One test matched.
    • cargo test --workspace -p ailang-core ct1_validator — Task 2 Step 6 — filter matches all existing ct1_validator_* tests (verified ≥6 tests in workspace.rs:1820-1983) PLUS the new acceptance test. Result count ≥7.
    • cargo test --workspace -p ail --test ct1_check_cli — Task 3 Step 4 — --test ct1_check_cli resolves to crates/ail/tests/ct1_check_cli.rs (verified file exists).
    • cargo test --workspace -p ail --test e2e — Task 4 Step 2 — --test e2e resolves to crates/ail/tests/e2e.rs (verified file exists, contains build_and_run test runner).
    • cargo test --workspace -p ailang-surface round_trip — Task 4 Step 3 — filter matches the round-trip tests in crates/ailang-surface/tests/round_trip.rs (verified file exists).
    • cargo build --workspace 2>&1 | tail -10 and cargo test --workspace 2>&1 | tail -25 — Task 5 Steps 2-3 — unfiltered, count-based assessment via tail output.

All filter strings resolve to ≥1 named target in the current tree.

Handoff

Plan sits in the working tree at docs/plans/0101-prep.1-type-scoped-namespacing.md. Hand off to skills/implement with: path to this plan; no task-range focus (run all 5 tasks in sequence). Iter-completion commit body closes #31.