832375f2ac
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.
453 lines
18 KiB
Markdown
453 lines
18 KiB
Markdown
# 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:208–214` — 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:208–214`:
|
||
|
||
```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. ✓
|