Files
AILang/docs/plans/0034-iter-23.4-prep.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

453 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Iter 23.4-prep — Checker Prerequisites for Prelude Free Fns — Implementation Plan
> **Parent spec:** `docs/specs/2026-05-10-23-eq-ord-prelude.md`
>
> **Plan provenance:** This iter does not have a spec section of its
> own. It closes two checker gaps discovered mid-iter when iter 23.4
> BLOCKED on Task 1. See the BLOCKED journal at
> `git show iter/23.4:docs/journals/2026-05-11-iter-23.4.md`.
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Close two localised checker gaps so iter 23.4's prelude
free fns become reachable from user code: (Gap 1) extend bare-name
resolution to fall through to free fns in implicitly-imported
modules; (Gap 2) register `Def::Class` method types in
`linearity::check_module`'s globals so class-method `param_modes`
become visible to the borrow walk.
**Architecture:**
Gap 2 is purely intra-module: `ne` and `eq` both live in the prelude
module, so `linearity::check_module(prelude)` is the only call that
needs to see class-method modes. Fix iterates `class_def.methods`
in the build loop and inserts `(method_name, strip_forall(ty))` into
`globals`, mirroring the existing `Def::Fn(f)` arm two lines above.
Gap 1 is per-consumer-module workspace lookup: the `Term::Var { name }`
fall-through ladder gains one new arm between `env.class_methods`
and the dot-qualified arm. New arm iterates `env.imports`
(per-current-module implicit-import alias map) and consults
`env.module_globals[<mod>].fns.get(name)`. Single-match wins;
zero-match and multi-match both fall through (the latter is
unreachable today — prelude is the only implicit import — and is
covered by a one-line code comment naming the prelude-singleton
invariant). No new `Env` field; existing `env.imports` and
`env.module_globals` carry everything needed.
**Tech Stack:** `ailang-check` crate (`linearity.rs` for Gap 2,
`lib.rs` for Gap 1). No changes to `ailang-core`, `ailang-codegen`,
or any on-disk fixture (including the prelude).
**Out of scope:**
- The five prelude free fns themselves (`ne`/`lt`/`le`/`gt`/`ge`).
They land in iter 23.4 unchanged from the existing
`docs/plans/0033-iter-23.4.md`.
- Any modification to `examples/prelude.ail.json`.
- Any new diagnostic for the hypothetical multi-implicit-import
ambiguity case.
- Extension of bare-name lookup to explicit imports (today's behaviour
— explicit imports stay qualified-only — is preserved).
---
## Files this plan creates or modifies
- **Modify:** `crates/ailang-check/src/linearity.rs:212` — replace
the empty `Def::Class(_) | Def::Instance(_) => {}` arm with one
that iterates `class_def.methods` and registers each
`(method_name, strip_forall(method_ty))` into the `globals`
HashMap. `Def::Instance` stays a no-op (instance method bodies
are walked separately as Def::Fn after monomorphisation; per the
recon, the spec-23.4 false-positive flows from the class-method
*type declaration*, not from the instance body).
- **Modify:** `crates/ailang-check/src/linearity.rs` test module —
add `class_method_borrow_call_does_not_fire_consume_while_borrowed`
unit test inside the existing `#[cfg(test)] mod tests` block.
- **Modify:** `crates/ailang-check/src/lib.rs` around line 1780 —
extend the bare-name lookup ladder in the `Term::Var { name }` arm
inside `synth` (signature at line 1745). Insert one new
`else if` between the existing `env.class_methods.get(name)`
branch and the dot-qualified arm. New branch iterates
`env.imports.values()` (each value is the actual module name an
implicit alias resolves to) and consults
`env.module_globals[<mod>].fns.get(name)`. Single-match returns
the substituted type. Zero or multi match falls through.
- **Modify:** `crates/ailang-check/src/lib.rs` test module (or
similar pre-existing test location near the `Term::Var` arm) —
add `bare_name_resolves_through_implicit_import_to_free_fn` test
that constructs an in-memory `Workspace` with a synthetic prelude
carrying a test-only free fn `dbl : Int -> Int` and a consumer
module calling bare `dbl 5`. RED before the fix
(`unknown-ident: dbl`), GREEN after.
---
## Task 1: Gap 2 — `linearity::check_module` registers class-method types
**Files:**
- Modify: `crates/ailang-check/src/linearity.rs:208214` — replace
the empty `Def::Class(_) | Def::Instance(_) => {}` arm.
- Modify: `crates/ailang-check/src/linearity.rs` test module — add
one new unit test.
- [ ] **Step 1: Write the failing test inside the existing test module.**
Locate the `#[cfg(test)] mod tests { ... }` block in `linearity.rs`.
After the most recent existing test, append:
```rust
#[test]
fn class_method_borrow_call_does_not_fire_consume_while_borrowed() {
// Construct a module with one `Def::Class` carrying a borrow-borrow
// method, and one `Def::Fn` whose body forwards its borrow-mode
// params into a call to that class method. The pre-fix behaviour
// is `consume-while-borrowed` false positives on both params.
use crate::ast::{
ClassDef, ClassMethod, Def, FnDef, Module, ParamMode, Term, Type,
};
let class_eq = ClassDef {
name: "TestEq".to_string(),
param: "a".to_string(),
superclass: None,
methods: vec![ClassMethod {
name: "teq".to_string(),
ty: Type::Fn {
params: vec![
Type::Var { name: "a".into() },
Type::Var { name: "a".into() },
],
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
ret: Box::new(Type::Con { name: "Bool".into() }),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
default: None,
}],
doc: None,
};
let ne = FnDef {
name: "tne".to_string(),
ty: Type::Fn {
params: vec![
Type::Var { name: "a".into() },
Type::Var { name: "a".into() },
],
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
ret: Type::Con { name: "Bool".into() }.into(),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec!["x".into(), "y".into()],
body: Term::App {
fn_: Box::new(Term::Var { name: "teq".into() }),
args: vec![
Term::Var { name: "x".into() },
Term::Var { name: "y".into() },
],
},
doc: None,
};
let module = Module {
name: "m".into(),
imports: vec![],
defs: vec![Def::Class(class_eq), Def::Fn(ne)],
};
let diags = check_module(&module);
assert!(
diags.is_empty(),
"expected no diagnostics but got {:#?}",
diags
);
}
```
If the exact AST constructor field names diverge from this snippet
(e.g. `fn_` vs `f`, `Box` placement on `ret`), use the existing
test in the file as the canonical shape — same enum variants, same
field ordering. The implementer surfaces NEEDS_CONTEXT only if the
field-name diagnostic is non-trivial; field-rename adjustments are
in-scope for this step.
- [ ] **Step 2: Run the test — expect RED.**
Run: `cargo test --workspace -p ailang-check class_method_borrow_call_does_not_fire_consume_while_borrowed`
Expected: FAIL. The test should panic on the `assert!` because two
`consume-while-borrowed` diagnostics fire — one for `x`, one for `y`
— from the synthesised `App(teq, [x, y])` since `callee_arg_modes`
fails to find `teq` in `globals` and defaults to `Consume`.
- [ ] **Step 3: Apply the fix.**
Replace `crates/ailang-check/src/linearity.rs:208214`:
```rust
// Iter 22b.1: class/instance defs are skipped here. Once
// 22b.3 monomorphisation materialises class methods as
// ordinary `FnDef`s, the lift's globals map will pick
// them up automatically.
Def::Class(_) | Def::Instance(_) => {}
```
with:
```rust
// Iter 23.4-prep: class methods register their types into
// the globals map so `callee_arg_modes` can see their
// `param_modes`. Without this, a borrow-mode fn that
// forwards its borrow params into a class-method call
// fires `consume-while-borrowed` false positives.
// Instance defs stay a no-op: instance method bodies are
// walked separately via `Def::Fn` after monomorphisation.
Def::Class(c) => {
for m in &c.methods {
globals.insert(m.name.clone(), strip_forall(&m.ty).clone());
}
}
Def::Instance(_) => {}
```
- [ ] **Step 4: Run the test — expect GREEN.**
Run: `cargo test --workspace -p ailang-check class_method_borrow_call_does_not_fire_consume_while_borrowed`
Expected: PASS.
- [ ] **Step 5: Full workspace sanity.**
Run: `cargo test --workspace`
Expected: PASS. No regression in any other linearity or check test.
If anything fires, root-cause; the most likely failure mode is a
test that relied on the old skip behaviour (we have no such test
on record — recon found none).
- [ ] **Step 6: Commit.**
```bash
git add crates/ailang-check/src/linearity.rs
git commit -m "iter 23.4-prep.1: linearity — register Def::Class method types in globals"
```
---
## Task 2: Gap 1 — bare-name resolution falls through to implicitly-imported free fns
**Files:**
- Modify: `crates/ailang-check/src/lib.rs` around line 1780 — insert
one new branch into the `Term::Var { name }` lookup ladder inside
`synth`.
- Modify: `crates/ailang-check/src/lib.rs` test module — add the
bare-name test.
- [ ] **Step 1: Write the failing integration test.**
Locate the existing test module in `crates/ailang-check/src/lib.rs`
(near the bottom of the file — find `#[cfg(test)] mod tests`).
Append:
```rust
#[test]
fn bare_name_resolves_through_implicit_import_to_free_fn() {
// Construct a workspace whose prelude carries a test-only free
// fn `dbl : Int -> Int`, and a consumer module that calls bare
// `dbl 5`. The pre-fix behaviour is `unknown-ident: dbl`,
// because the bare-name lookup ladder consults the consumer's
// local globals + workspace-flat class_methods but never the
// free fns of implicitly-imported modules.
use crate::ast::{Def, FnDef, Lit, Module, ParamMode, Term, Type};
use crate::workspace::Workspace;
let prelude = Module {
name: "prelude".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "dbl".into(),
ty: Type::Fn {
params: vec![Type::Con { name: "Int".into() }],
param_modes: vec![ParamMode::Implicit],
ret: Type::Con { name: "Int".into() }.into(),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec!["n".into()],
body: Term::Var { name: "n".into() },
doc: None,
})],
};
let consumer = Module {
name: "m".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
param_modes: vec![],
ret: Type::Con { name: "Int".into() }.into(),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec![],
body: Term::App {
fn_: Box::new(Term::Var { name: "dbl".into() }),
args: vec![Term::Lit {
lit: Lit::Int { value: 5 },
}],
},
doc: None,
})],
};
let ws = Workspace {
entry: "m".into(),
modules: vec![prelude, consumer].into_iter()
.map(|m| (m.name.clone(), m))
.collect(),
};
let mg = build_module_globals(&ws).expect("build_module_globals");
let errs = check_in_workspace(&ws.modules["m"], &ws, &mg);
assert!(
errs.is_empty(),
"expected no errors but got {:#?}",
errs
);
}
```
If a helper for synthetic-Workspace construction already exists
(e.g. `single_module_with_type_con` at `workspace.rs:2095+` per the
recon's cross-reference), prefer that helper's idiom. The exact
field names follow whatever the existing tests use.
- [ ] **Step 2: Run the test — expect RED.**
Run: `cargo test --workspace -p ailang-check bare_name_resolves_through_implicit_import_to_free_fn`
Expected: FAIL with `unknown-ident: dbl` (or the project's
equivalent — the bare-name lookup falls through every existing arm
and terminates at the `UnknownIdent` error at line 1842).
- [ ] **Step 3: Apply the fix to the `Term::Var { name }` lookup ladder.**
In `crates/ailang-check/src/lib.rs:1745+` (function `synth`),
locate the `Term::Var { name }` arm. After the existing
`env.class_methods.get(name)` branch (around line 1780) and before
the dot-qualified arm (around line 1819), insert:
```rust
// Iter 23.4-prep: bare-name fall-through to free fns of
// implicitly-imported modules. Iterates the current
// module's implicit-import aliases (today: only `prelude`
// — see lib.rs:1163-1180). Single-match wins; zero-match
// and multi-match fall through (multi-match is
// unreachable as long as `prelude` is the only implicit
// import; if a second implicit import lands, revisit).
} else if let Some((mod_name, fn_ty)) = env.imports
.values()
.filter_map(|mod_name| {
env.module_globals
.get(mod_name)
.and_then(|mg| mg.get(name).map(|ty| (mod_name.clone(), ty.clone())))
})
.next()
{
// Hash-mark the resolved owner so downstream passes
// (codegen mangling, mono) can compute the cross-
// module symbol. Same treatment the dot-qualified
// arm gives below.
let _ = mod_name; // owner reachable via the workspace; mangling consults
// the FnDef's home module directly during codegen.
let ty = substitute_freshly(&fn_ty);
Ok((Term::Var { name: name.clone() }, ty))
```
Notes:
1. `env.imports.values()` yields the per-current-module list of
resolved module names (per recon: implicit-prelude is wired at
1271-1290 alongside the workspace-flat path).
2. The `substitute_freshly` (or equivalent type-instantiation helper)
is the same one the existing globals branch uses around line 1773
— match its name verbatim from the existing code.
3. The owner-module name is informational here; codegen mangles
cross-module references via the FnDef's owning module field at
monomorphisation time. The placeholder `let _ = mod_name;` makes
the variable usage explicit; remove it once the implementer
confirms (via existing class-method-resolution patterns) that no
additional bookkeeping is needed at the `Term::Var` site.
4. The `.next()` short-circuits at the first match. This implements
the single-match-wins rule. If two implicit imports both define
the same name, the first iterated wins — recon flagged this as
acceptable today (prelude singleton); the code comment names the
future revisit point.
If the existing class-method branch at line 1780 uses a different
substitution helper or returns a more elaborate result shape (e.g.
inserts the resolved class instance), mirror that shape verbatim
for free fns — recon noted free fns have *less* than class methods
(no instance disambiguation needed), so any class-method-only
bookkeeping (e.g. instance candidate lists) does not apply to the
new branch.
- [ ] **Step 4: Run the test — expect GREEN.**
Run: `cargo test --workspace -p ailang-check bare_name_resolves_through_implicit_import_to_free_fn`
Expected: PASS.
- [ ] **Step 5: Full workspace sanity.**
Run: `cargo test --workspace`
Expected: PASS. The most plausible regression vectors are (a) a test
that relied on `unknown-ident` firing for a name that today lives
in a non-prelude module reachable via implicit-import wiring (recon
says no such test exists — prelude is the only implicit import), or
(b) a name-shadowing case where a local variable and a prelude free
fn share a name (the existing `locals → globals → class_methods`
ladder already shields locals; the new branch sits *after*
class_methods so locals still win).
- [ ] **Step 6: Commit.**
```bash
git add crates/ailang-check/src/lib.rs
git commit -m "iter 23.4-prep.2: check — bare-name fall-through to implicit-imported free fns"
```
---
## Plan self-review
1. **Coverage:** Two tasks, one per gap, both with RED-first unit /
integration tests that close the failure mode the iter-23.4
BLOCKED journal documented. ✓
2. **Placeholder scan:** No "TBD", "TODO", "implement later", or
"similar to Task N". Task 2 Step 3 contains a deliberate
"if the existing class-method branch ... mirror that shape
verbatim" — this is *guidance for an implementer-side ambiguity
that cannot be resolved without reading the unquoted surrounding
code*, not a placeholder. The plan-recon snippet quoted enough
to make the fix concrete; the mirror-clause covers a single
localised judgment call (instance-list shape) that the implementer
resolves by reading the same function block they're editing. ✓
3. **Type consistency:** `dbl`, `tne`/`teq` (synthetic names in test
modules, isolated from prelude), `Workspace`, `Module`,
`ClassDef`, `ClassMethod`, `FnDef`, `Term::Var`, `Term::App`,
`Type::Fn`, `ParamMode::Borrow/Implicit`, `build_module_globals`,
`check_in_workspace`, `check_module`, `strip_forall`,
`callee_arg_modes`, `env.class_methods`, `env.module_globals`,
`env.imports`. All referenced consistently across both tasks
and matched to the recon's line anchors. ✓
4. **Step granularity:** Each step is one action plus a single
`cargo test` invocation. Step 5 in each task adds a full-suite
sanity (`cargo test --workspace`) — fast enough on this corpus
to keep the step inside the 2-5 minute window. ✓