diff --git a/docs/plans/0101-prep.1-type-scoped-namespacing.md b/docs/plans/0101-prep.1-type-scoped-namespacing.md new file mode 100644 index 0000000..a6c70a4 --- /dev/null +++ b/docs/plans/0101-prep.1-type-scoped-namespacing.md @@ -0,0 +1,586 @@ +# 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 (`.`) 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-700` — `CheckError` enum: add `TypeScopedMemberNotFound` and `TypeScopedReceiverNotAType` variants. +- Modify: `crates/ailang-check/src/lib.rs:702-744` — `CheckError::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-3200` — `Term::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-1142` — `check_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-1278` — `workspace_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__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_candidate` → `ct1_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`: + +```rust +#[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::(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::(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): + +```rust + /// 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`: + +```rust + 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: + +```rust + } 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 = 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): + +```rust +/// 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: + +```rust + // 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 = 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`: + +```rust + #[error( + "module `{module}` references bare type `{name}`, which is not in scope. \ + Add `(import )` to bring it into scope (candidates: {candidates:?}), \ + or rewrite the call to type-scoped form `.`." + )] +``` + +Replace `crates/ailang-core/src/workspace.rs:335-338`: + +```rust + #[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: + +```rust +/// 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`: + +```rust + 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_maybe` → `Maybe.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`-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` 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-6519` — `parse_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. diff --git a/docs/specs/0052-kernel-extension-mechanics.md b/docs/specs/0052-kernel-extension-mechanics.md index 7025267..cfc7f7f 100644 --- a/docs/specs/0052-kernel-extension-mechanics.md +++ b/docs/specs/0052-kernel-extension-mechanics.md @@ -139,36 +139,52 @@ collision disambiguator (no current collisions exist). **Blast radius.** Per `grep -rE 'std_(maybe|pair|list|either)\.' examples/ crates/`: -- **`.ail` example fixtures** (~14 files): `ct_2_bare_cross_module.ail`, +- **`.ail` example fixtures** (12 with cross-module refs, plus the + three defining modules with no refs): `ct_2_bare_cross_module.ail`, `ct_3b_bad_qualified_known_module.ail`, `nested_pat.ail`, - `std_either.ail`, `std_either_demo.ail`, `std_either_list.ail`, + `std_either_demo.ail`, `std_either_list.ail`, `std_either_list_demo.ail`, `std_list.ail`, `std_list_demo.ail`, - `std_list_more_demo.ail`, `std_list_stress.ail`, `std_maybe.ail`, - `std_maybe_demo.ail`, `std_pair.ail`, `std_pair_demo.ail`. - Each rewritten from `std_X.Y` to `Y` (type-scoped) where Y is - type-associated. The defining modules (`std_maybe.ail` etc.) - may keep their internal references unchanged (intra-module refs - are bare today already). -- **Corresponding `.ail.json` fixtures** for round-trip and hash-pin tests. -- **Hash-pin files** (2): `crates/ailang-core/tests/hash_pin.rs` and - `crates/ailang-surface/tests/prelude_module_hash_pin.rs`. Both - refreshed with new hashes. Each refresh carries an Honesty-Rule - provenance comment quoting the prep.1 commit and naming the - surface change. + `std_list_more_demo.ail`, `std_list_stress.ail`, + `std_maybe_demo.ail`, `std_pair_demo.ail`. Each rewritten from + `std_X.Y` to `Y` (type-scoped) where Y is type-associated. The + three defining modules `std_either.ail`, `std_maybe.ail`, + `std_pair.ail` contain no cross-module references and need no + body changes (intra-module refs are bare today already). + `ct_2_bare_cross_module.ail`'s role flips semantically: it stays + RED (the unimported bare type case still fires + `BareCrossModuleTypeRef` under the narrowed rule), but the + diagnostic *message* it asserts is the new one. +- **No corresponding `.ail.json` fixtures.** None of the 15 + spec-named `.ail` files have a sibling `.ail.json` round-trip + pin in the tree. - **Source-side reference** in `crates/ailang-{check,codegen,surface}/src/` - and `crates/ail/tests/e2e.rs` (~7 files): inspect during recon - whether they hardcode `std_maybe`/`std_pair`-style identifiers - in diagnostic strings or test fixtures. Diagnostic strings - update to new vocabulary; test fixtures update to type-scoped. - Production type-resolution logic is the new code. -- **Drift test** `crates/ailang-core/tests/design_schema_drift.rs` - gains a new pin that exercises the type-scoped resolution end- - to-end at the schema level. + and `crates/ail/tests/e2e.rs` (7 files): doc-comments and + test-helper code mentioning `std_X.Y` vocabulary update to + type-scoped vocabulary. Production type-resolution logic is the + new code. The specific sites are enumerated in the prep.1 plan. +- **CLI diagnostic renderer + assertion update**: + `crates/ail/src/main.rs:1249-1278` (the `BareCrossModuleTypeRef` + and `BadCrossModuleTypeRef` translation arms) and + `crates/ail/tests/ct1_check_cli.rs:154` (an assertion on the old + "ail migrate-canonical-types" hint string) move with the + diagnostic-message narrowing. -The 14 `.ail` examples are rewritten atomically with the resolver -change in a single iteration commit. Walking *every* test crate -for hash-pin sites is mandatory (per the -`hash-pin blast-radius audit` lesson from `schema-camelcase-fix`). +**No hash-pin or schema-drift work in prep.1.** The hash-pin audit +across every test crate (per the `hash-pin blast-radius audit` +lesson from `schema-camelcase-fix`) is mandatory and was performed +during prep.1's plan recon. Result: `crates/ailang-core/tests/hash_pin.rs` +pins `sum.ail`, `list.ail`, `ordering_match.ail`, +`test_22b1_dup_a.ail`, `test_22b1_dup_classmod.ail` — none of which +contain `std_X.Y` references; `crates/ailang-surface/tests/prelude_module_hash_pin.rs` +pins `prelude.ail` — also no `std_X.Y` references. **No pin refresh +applies to prep.1.** The `prelude_module_hash_pin.rs` refresh +happens in prep.3 when `kernel: true` lands on the prelude module. +Schema-drift pins in `crates/ailang-core/tests/design_schema_drift.rs` +also do not apply: type-scoped resolution is a checker change with +no new JSON tags, no new enum variants, no AST shape changes. The +drift pins for this milestone land in prep.2 (new `t: "new"` tag, +new `NewArg` JSON) and prep.3 (new `kernel`, `param-in` schema +attrs). **Integration with existing mechanisms.** @@ -702,8 +718,10 @@ Milestone closes when: The plan that follows this spec carves the work into three iterations: -- **prep.1** — Type-scoped namespacing. Resolver change + 14 - example rewrites + 2 hash-pin refreshes. +- **prep.1** — Type-scoped namespacing. Resolver change + 12 + example rewrites + diagnostic-message narrowing + ct1_check_cli + assertion update. No hash-pin or drift-test work (audit cleared + during plan recon; pins live in prep.2/3). - **prep.2** — `Term::New`. AST + surface + checker arg-kind enforcement. - **prep.3** — Kernel-tier modules + `param-in`. Schema +