plan: eob.1 — Effect-op arguments borrow, 7 tasks
Single iter closes the milestone: language rule at three check-time sites (uniqueness.rs + linearity.rs + linearity.rs doc-comment), four lockstep ret_mode-Own edits (builtins.rs + synth.rs for int_to_str and float_to_str), one Str carve-out in drop_symbol_for_binder's App arm, RED→GREEN verification of the two existing pinning tests, two new positive tests (Int-arg primitive non-leak, repeated Str-arg borrow), DESIGN.md anchor under Decision 10 covering BOTH arg-position rules (Ctor=Consume, Do=Borrow), WhatsNew entry, roadmap close of the heap-Str ABI P1 milestone. Plan-recon DONE: lint-side-effect surface (over-strict-mode on (own T) params used solely via effect-ops) is empty for the current corpus, so no follow-up sweep iter needed. IR-snapshot regen confirmed no-op. Two inline IR-shape tests at codegen/src/lib.rs:4127-4205 stay green (ret_mode flip does not change their emitted-call assertions).
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
# eob.1 — Effect-op arguments borrow — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/2026-05-12-effect-op-borrow.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
||||
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Land the language rule that `Term::Do.args[*]` are walked
|
||||
in `Position::Borrow` by the uniqueness and linearity passes,
|
||||
together with the two heap-Str-specific shape fixes (`ret_mode: Own`
|
||||
for `int_to_str` / `float_to_str`; Str carve-out in the App arm of
|
||||
`drop_symbol_for_binder`) that make the existing RED tests at
|
||||
`crates/ail/tests/e2e.rs` flip to GREEN under `--alloc=rc`. Close
|
||||
the heap-str-abi milestone in the same iter via DESIGN anchor +
|
||||
WhatsNew + roadmap update.
|
||||
|
||||
**Architecture:** Five layers, one iteration.
|
||||
(1) Two analyser walkers + their shared doc-comment flip the
|
||||
`Term::Do` arm from Consume to Borrow.
|
||||
(2) Four signature sites (`builtins.rs` + `synth.rs`, lockstep
|
||||
pairs for `int_to_str` and `float_to_str`) switch `ret_mode` from
|
||||
`Implicit` to `Own`.
|
||||
(3) One drop-fn-symbol picker site adds a `Type::Con { name: "Str", .. }`
|
||||
carve-out in the App arm, symmetric to the existing carve-out in
|
||||
`field_drop_call`.
|
||||
(4) The two existing RED tests at `e2e.rs` (commit `592d87b`) flip
|
||||
to GREEN unchanged; two new positive tests pin (a) a primitive Int
|
||||
effect-op arg (no RC bookkeeping) and (b) a repeated Str borrow
|
||||
through two `io/print_str` calls (linearity allows; RC balances).
|
||||
(5) `DESIGN.md` anchors both arg-position rules (Ctor = Consume,
|
||||
Do = Borrow) under Decision 10; `WhatsNew.md` gets the milestone
|
||||
entry; `roadmap.md` checks off the heap-`Str` ABI P1 entry.
|
||||
|
||||
**Tech Stack:** `ailang-check` (uniqueness + linearity passes),
|
||||
`ailang-codegen` (synth-replay table + drop machinery),
|
||||
`ail` (E2E tests), `runtime/str.c` already in place from hs.3.
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Modify: `crates/ailang-check/src/uniqueness.rs:289-292` — Term::Do arg-walk Consume → Borrow.
|
||||
- Modify: `crates/ailang-check/src/linearity.rs:506-510` — Term::Do arg-walk Consume → Borrow.
|
||||
- Modify: `crates/ailang-check/src/linearity.rs:42` — module doc-comment: split Ctor + Do bullet.
|
||||
- Modify: `crates/ailang-check/src/builtins.rs:193-202` — `float_to_str` `ret_mode: Implicit` → `Own`.
|
||||
- Modify: `crates/ailang-check/src/builtins.rs:203-212` — `int_to_str` `ret_mode: Implicit` → `Own`.
|
||||
- Modify: `crates/ailang-codegen/src/synth.rs:167-172` — `float_to_str` synth replay `ret_mode: Implicit` → `Own`.
|
||||
- Modify: `crates/ailang-codegen/src/synth.rs:174-180` — `int_to_str` synth replay `ret_mode: Implicit` → `Own`.
|
||||
- Modify: `crates/ailang-codegen/src/drop.rs:534-549` — App arm of `drop_symbol_for_binder` gets `Str` carve-out.
|
||||
- Modify: `crates/ail/tests/e2e.rs` — append two new positive tests near `:2624`.
|
||||
- Create: `examples/int_to_print_int_borrow.ail.json` — fixture for primitive-Int effect-op-arg test.
|
||||
- Create: `examples/heap_str_repeated_print_borrow.ail.json` — fixture for repeated-Str-borrow test.
|
||||
- Modify: `docs/DESIGN.md` — anchor `Term::Ctor.args[*] = Consume` + `Term::Do.args[*] = Borrow` under Decision 10.
|
||||
- Modify: `docs/WhatsNew.md` — append milestone entry (newest at bottom).
|
||||
- Modify: `docs/roadmap.md:41-56` — close `[milestone] Heap-Str ABI` (check off, then remove); update the dependent `[milestone] Post-22 Prelude` entry's `depends on:` line accordingly.
|
||||
- Test: existing `crates/ail/tests/e2e.rs::int_to_str_drop_balances_rc_stats` and `::str_field_in_adt_drops_heap_str_correctly` — verify GREEN unchanged after Tasks 1-3 land.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Term::Do arg-walks flip Consume → Borrow
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-check/src/uniqueness.rs:289-292`
|
||||
- Modify: `crates/ailang-check/src/linearity.rs:506-510`
|
||||
- Modify: `crates/ailang-check/src/linearity.rs:42` (doc comment)
|
||||
|
||||
The three sites are atomic — analyser code paths and their
|
||||
documentation must stay in sync. Flip all three in one task.
|
||||
|
||||
- [ ] **Step 1: Confirm RED status of the leak's pinned tests.**
|
||||
|
||||
Run: `cargo test --workspace -p ail --test e2e int_to_str_drop_balances_rc_stats str_field_in_adt_drops_heap_str_correctly`
|
||||
|
||||
Expected: both tests FAIL. `int_to_str_drop_balances_rc_stats` fails on
|
||||
`assert_eq!(allocs, frees)` with `allocs=1, frees=0`.
|
||||
`str_field_in_adt_drops_heap_str_correctly` fails on the same assert
|
||||
with `allocs=2, frees=1`. These two failures are the RED pin from
|
||||
commit `592d87b`.
|
||||
|
||||
- [ ] **Step 2: Flip uniqueness.rs Term::Do arm.**
|
||||
|
||||
In `crates/ailang-check/src/uniqueness.rs`, at the `Term::Do { args, .. }` arm
|
||||
near line 289, change:
|
||||
|
||||
```rust
|
||||
Term::Do { args, .. } => {
|
||||
for a in args {
|
||||
self.walk(a, Position::Consume);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```rust
|
||||
Term::Do { args, .. } => {
|
||||
for a in args {
|
||||
self.walk(a, Position::Borrow);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Flip linearity.rs Term::Do arm.**
|
||||
|
||||
In `crates/ailang-check/src/linearity.rs`, at the `Term::Do { args, .. }` arm
|
||||
near line 506, change `Position::Consume` to `Position::Borrow` in the
|
||||
same shape as Step 2. The Ctor arm immediately above (`:500-505`) stays
|
||||
on `Position::Consume` — the asymmetry is the rule.
|
||||
|
||||
- [ ] **Step 4: Update linearity.rs:42 doc comment.**
|
||||
|
||||
Find the bullet at `crates/ailang-check/src/linearity.rs:42`:
|
||||
|
||||
```text
|
||||
//! - `Term::Ctor.args[*]`, `Term::Do.args[*]` — Consume.
|
||||
```
|
||||
|
||||
Replace with two bullets that match the new code structure:
|
||||
|
||||
```text
|
||||
//! - `Term::Ctor.args[*]` — Consume (the new value owns them).
|
||||
//! - `Term::Do.args[*]` — Borrow (effect-op observes; ownership
|
||||
//! stays with the caller).
|
||||
```
|
||||
|
||||
The Borrow bullet sits alongside `Term::Match.scrutinee` (`:37-38`)
|
||||
and `Term::Clone.value` (`:35-36`) as the other Borrow-position sites.
|
||||
|
||||
- [ ] **Step 5: Build the check crate to confirm no compile errors.**
|
||||
|
||||
Run: `cargo build -p ailang-check`
|
||||
|
||||
Expected: clean build. (At this point, the heap-Str-specific shape
|
||||
fixes from Tasks 2-3 are still missing, so the RED tests stay RED.
|
||||
That's the design — Tasks 1, 2, 3 are mutually dependent for the
|
||||
RED→GREEN flip.)
|
||||
|
||||
- [ ] **Step 6: Workspace-wide regression on the analyser layer.**
|
||||
|
||||
Run: `cargo test --workspace -p ailang-check`
|
||||
|
||||
Expected: all green. If any test fires (likely a `use-after-consume`
|
||||
case that previously triggered on a binder passed twice into an
|
||||
effect-op), inspect: the test's old behaviour was wrong (effect-ops
|
||||
do not consume), and the test should be retired or updated. Do not
|
||||
revert Steps 2-4.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: int_to_str / float_to_str ret_mode Implicit → Own
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-check/src/builtins.rs:193-202` (float_to_str)
|
||||
- Modify: `crates/ailang-check/src/builtins.rs:203-212` (int_to_str)
|
||||
- Modify: `crates/ailang-codegen/src/synth.rs:167-172` (float_to_str synth)
|
||||
- Modify: `crates/ailang-codegen/src/synth.rs:174-180` (int_to_str synth)
|
||||
|
||||
Four lockstep edits. The checker side and the codegen-synth side
|
||||
must agree byte-for-byte on each builtin's `Type::Fn`.
|
||||
|
||||
- [ ] **Step 1: Flip builtins.rs float_to_str ret_mode.**
|
||||
|
||||
In `crates/ailang-check/src/builtins.rs`, at the `float_to_str`
|
||||
`env.globals.insert` near line 193, change:
|
||||
|
||||
```rust
|
||||
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```rust
|
||||
ret_mode: ailang_core::ast::ParamMode::Own,
|
||||
```
|
||||
|
||||
(The literal sits at line 200 inside the surrounding insert block at
|
||||
lines 193-202.)
|
||||
|
||||
- [ ] **Step 2: Flip builtins.rs int_to_str ret_mode.**
|
||||
|
||||
In `crates/ailang-check/src/builtins.rs`, at the `int_to_str`
|
||||
`env.globals.insert` near line 203, change the `ret_mode: Implicit`
|
||||
literal (line 210) to `ret_mode: Own` in the same shape as Step 1.
|
||||
|
||||
- [ ] **Step 3: Flip synth.rs float_to_str ret_mode.**
|
||||
|
||||
In `crates/ailang-codegen/src/synth.rs`, at the `"float_to_str"` arm
|
||||
near line 167, change the `ret_mode: ParamMode::Implicit` literal
|
||||
(line 172) to `ret_mode: ParamMode::Own`.
|
||||
|
||||
- [ ] **Step 4: Flip synth.rs int_to_str ret_mode.**
|
||||
|
||||
In `crates/ailang-codegen/src/synth.rs`, at the `"int_to_str"` arm
|
||||
near line 174, change the `ret_mode: ParamMode::Implicit` literal
|
||||
(line 179) to `ret_mode: ParamMode::Own`.
|
||||
|
||||
- [ ] **Step 5: Workspace build to confirm symmetry.**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
|
||||
Expected: clean build. If a divergence between the two layers exists
|
||||
(e.g. one site flipped but not its partner), the type-replay machinery
|
||||
at the codegen-side fires an `internal: type mismatch` error at any
|
||||
fixture that exercises int_to_str / float_to_str.
|
||||
|
||||
- [ ] **Step 6: Confirm the two RED tests now report a different shape.**
|
||||
|
||||
Run: `cargo test --workspace -p ail --test e2e int_to_str_drop_balances_rc_stats`
|
||||
|
||||
Expected: still FAILS, but now likely on a LINK error or a wrong
|
||||
drop-fn symbol — because the let-binder is now trackable
|
||||
(`is_rc_heap_allocated` succeeds on `ret_mode == Own`), but
|
||||
`drop_symbol_for_binder`'s App arm currently emits `drop_<m>_Str`
|
||||
which is undefined. This is the gap Task 3 closes; observe and
|
||||
move on.
|
||||
|
||||
(If the test now fails for a different reason — e.g. compile error,
|
||||
or unexpected stdout — stop and investigate. The expected failure
|
||||
shape is link-time / runtime "undefined symbol drop_<m>_Str" or a
|
||||
similar drop-fn-not-found.)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: drop_symbol_for_binder App arm gets Str carve-out
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/drop.rs:534-549`
|
||||
|
||||
- [ ] **Step 1: Read the current App arm.**
|
||||
|
||||
`crates/ailang-codegen/src/drop.rs:534-549`:
|
||||
|
||||
```rust
|
||||
Term::App { .. } => {
|
||||
if let Ok(ret_ty) = self.synth_arg_type(value) {
|
||||
if let Type::Con { name, .. } = ret_ty {
|
||||
if name.matches('.').count() == 1 {
|
||||
let (prefix, suffix) =
|
||||
name.split_once('.').expect("checked");
|
||||
if let Some(target) = self.import_map.get(prefix) {
|
||||
return format!("drop_{target}_{suffix}");
|
||||
}
|
||||
return format!("drop_{prefix}_{suffix}");
|
||||
}
|
||||
return format!("drop_{m}_{name}", m = self.module_name);
|
||||
}
|
||||
}
|
||||
"ailang_rc_dec".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
The unqualified-name path at the inner `return format!("drop_{m}_{name}")`
|
||||
generates `drop_<current_module>_Str` for `name == "Str"`, which is
|
||||
undefined.
|
||||
|
||||
- [ ] **Step 2: Insert the Str carve-out before the unqualified-name return.**
|
||||
|
||||
Change the `Type::Con` body to special-case `Str` before the qualified
|
||||
/ unqualified routing. The cleanest shape mirrors `field_drop_call`'s
|
||||
Str arm at `drop.rs:392-394`:
|
||||
|
||||
```rust
|
||||
Term::App { .. } => {
|
||||
if let Ok(ret_ty) = self.synth_arg_type(value) {
|
||||
if let Type::Con { name, .. } = ret_ty {
|
||||
// Symmetric to `field_drop_call`'s Str arm: Str is a
|
||||
// built-in pointer type with no per-type drop fn. Both
|
||||
// heap-Str (rc_header at payload-8) and static-Str
|
||||
// (codegen-elision keeps static pointers out of this
|
||||
// path) consume via `ailang_rc_dec`.
|
||||
if name == "Str" {
|
||||
return "ailang_rc_dec".to_string();
|
||||
}
|
||||
if name.matches('.').count() == 1 {
|
||||
let (prefix, suffix) =
|
||||
name.split_once('.').expect("checked");
|
||||
if let Some(target) = self.import_map.get(prefix) {
|
||||
return format!("drop_{target}_{suffix}");
|
||||
}
|
||||
return format!("drop_{prefix}_{suffix}");
|
||||
}
|
||||
return format!("drop_{m}_{name}", m = self.module_name);
|
||||
}
|
||||
}
|
||||
"ailang_rc_dec".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the codegen crate.**
|
||||
|
||||
Run: `cargo build -p ailang-codegen`
|
||||
|
||||
Expected: clean build.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify existing RED tests flip to GREEN
|
||||
|
||||
**Files:** none modified — this task is observational.
|
||||
|
||||
After Tasks 1-3, the two pre-existing RED tests at
|
||||
`crates/ail/tests/e2e.rs:2601, :2617` should pass with strict
|
||||
`allocs == frees && live == 0` invariants.
|
||||
|
||||
- [ ] **Step 1: Run the two target tests.**
|
||||
|
||||
Run: `cargo test --workspace -p ail --test e2e int_to_str_drop_balances_rc_stats str_field_in_adt_drops_heap_str_correctly`
|
||||
|
||||
Expected: both PASS. Concrete numbers:
|
||||
- `int_to_str_drop_balances_rc_stats`: `allocs == 1, frees == 1, live == 0`, stdout `"42\n"`.
|
||||
- `str_field_in_adt_drops_heap_str_correctly`: `allocs == 2, frees == 2, live == 0`, stdout `"42\n"`.
|
||||
|
||||
If either is still RED, stop and inspect. The most likely failure
|
||||
modes:
|
||||
- One of Tasks 1-3 was not fully applied (re-check files).
|
||||
- The two analyser-walkers diverged (Task 1 only flipped uniqueness,
|
||||
not linearity, or vice versa).
|
||||
- A pattern-binder-drop interaction in `match_lower.rs:740-848`
|
||||
fires a double-free under the new rule. Trace via `ail emit-ir`
|
||||
on the failing fixture.
|
||||
|
||||
- [ ] **Step 2: Full e2e regression.**
|
||||
|
||||
Run: `cargo test --workspace -p ail --test e2e`
|
||||
|
||||
Expected: every test PASS. If any other E2E fires (a fixture whose
|
||||
behaviour relied on the old Consume semantics), inspect — likely a
|
||||
fixture where a let-bound pointer was previously NOT freed because
|
||||
it was double-counted as consumed-by-effect-op, and is now freed
|
||||
correctly. Such tests need re-baselining, not reversion of Tasks 1-3.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Two new positive tests + fixtures
|
||||
|
||||
**Files:**
|
||||
- Create: `examples/int_to_print_int_borrow.ail.json`
|
||||
- Create: `examples/heap_str_repeated_print_borrow.ail.json`
|
||||
- Modify: `crates/ail/tests/e2e.rs` (append two tests near line 2624)
|
||||
|
||||
- [ ] **Step 1: Write the primitive-Int fixture.**
|
||||
|
||||
Create `examples/int_to_print_int_borrow.ail.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "ailang/v0",
|
||||
"name": "int_to_print_int_borrow",
|
||||
"imports": [],
|
||||
"defs": [
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "main",
|
||||
"type": {
|
||||
"k": "fn",
|
||||
"params": [],
|
||||
"ret": { "k": "con", "name": "Unit" },
|
||||
"effects": ["IO"]
|
||||
},
|
||||
"params": [],
|
||||
"doc": "Iter eob.1: primitive Int passed to io/print_int. The rule that Term::Do args are Borrow has no observable RC effect here because Int is unboxed; the test pins that no spurious bookkeeping appears (allocs == 0, frees == 0, live == 0).",
|
||||
"body": {
|
||||
"t": "let",
|
||||
"name": "n",
|
||||
"value": { "t": "lit", "lit": { "kind": "int", "value": 7 } },
|
||||
"body": {
|
||||
"t": "do",
|
||||
"op": "io/print_int",
|
||||
"args": [{ "t": "var", "name": "n" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the repeated-Str-borrow fixture.**
|
||||
|
||||
Create `examples/heap_str_repeated_print_borrow.ail.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "ailang/v0",
|
||||
"name": "heap_str_repeated_print_borrow",
|
||||
"imports": [],
|
||||
"defs": [
|
||||
{
|
||||
"kind": "fn",
|
||||
"name": "main",
|
||||
"type": {
|
||||
"k": "fn",
|
||||
"params": [],
|
||||
"ret": { "k": "con", "name": "Unit" },
|
||||
"effects": ["IO"]
|
||||
},
|
||||
"params": [],
|
||||
"doc": "Iter eob.1: heap-Str passed to io/print_str twice in sequence. Under the new rule (Term::Do args = Borrow), the second call no longer triggers use-after-consume; the linearity check accepts the program. Under --alloc=rc + AILANG_RC_STATS=1 the slab allocates once and is freed once at scope close (allocs == 1, frees == 1, live == 0).",
|
||||
"body": {
|
||||
"t": "let",
|
||||
"name": "s",
|
||||
"value": {
|
||||
"t": "app",
|
||||
"fn": { "t": "var", "name": "int_to_str" },
|
||||
"args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }]
|
||||
},
|
||||
"body": {
|
||||
"t": "seq",
|
||||
"lhs": {
|
||||
"t": "do",
|
||||
"op": "io/print_str",
|
||||
"args": [{ "t": "var", "name": "s" }]
|
||||
},
|
||||
"rhs": {
|
||||
"t": "do",
|
||||
"op": "io/print_str",
|
||||
"args": [{ "t": "var", "name": "s" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Append the two e2e tests.**
|
||||
|
||||
In `crates/ail/tests/e2e.rs`, append after the existing
|
||||
`str_field_in_adt_drops_heap_str_correctly` test (currently the file
|
||||
ends at line 2624):
|
||||
|
||||
```rust
|
||||
/// Iter eob.1: primitive-Int arg passed to an effect-op. Under the
|
||||
/// new "Term::Do args = Borrow" rule, the let-binder `n` is not
|
||||
/// consumed by `io/print_int`; but Int is unboxed, so there is no
|
||||
/// pointer to RC-track. Pin: zero allocs / zero frees / zero live —
|
||||
/// the rule introduces no spurious bookkeeping on primitives.
|
||||
#[test]
|
||||
fn int_arg_to_effect_op_does_not_rc_track() {
|
||||
let (stdout, allocs, frees, live) =
|
||||
build_and_run_with_rc_stats("int_to_print_int_borrow.ail.json");
|
||||
assert_eq!(stdout, "7\n");
|
||||
assert_eq!(allocs, 0, "Int arg should not trigger any RC alloc; got allocs={allocs}");
|
||||
assert_eq!(frees, 0, "Int arg should not trigger any RC free; got frees={frees}");
|
||||
assert_eq!(live, 0, "no leaked RC slabs allowed; live={live}");
|
||||
}
|
||||
|
||||
/// Iter eob.1: heap-Str let-binder consumed by two `io/print_str`
|
||||
/// calls in sequence. Pins the linearity-side consequence of the new
|
||||
/// rule: under the old Position::Consume walk for Term::Do args, the
|
||||
/// second print would have triggered `use-after-consume`. Under the
|
||||
/// new Position::Borrow walk, both calls are borrows and the program
|
||||
/// typechecks. Under --alloc=rc the slab is allocated once by
|
||||
/// int_to_str, lives through both prints, and is freed exactly once
|
||||
/// at scope close — allocs == 1, frees == 1, live == 0.
|
||||
#[test]
|
||||
fn heap_str_repeated_print_balances_rc_stats() {
|
||||
let (stdout, allocs, frees, live) =
|
||||
build_and_run_with_rc_stats("heap_str_repeated_print_borrow.ail.json");
|
||||
assert_eq!(stdout, "42\n42\n");
|
||||
assert_eq!(allocs, 1, "expected exactly one heap-Str slab; got allocs={allocs}");
|
||||
assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
|
||||
assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the two new tests.**
|
||||
|
||||
Run: `cargo test --workspace -p ail --test e2e int_arg_to_effect_op_does_not_rc_track heap_str_repeated_print_balances_rc_stats`
|
||||
|
||||
Expected: both PASS.
|
||||
|
||||
If `heap_str_repeated_print_balances_rc_stats` fails with the wrong
|
||||
allocs/frees numbers, the most likely cause is that one of the two
|
||||
analyser walkers in Task 1 was not flipped — the linearity walker
|
||||
would still reject the second print as `use-after-consume`, and the
|
||||
program would not typecheck.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DESIGN.md anchor for both arg-position rules
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/DESIGN.md` (under Decision 10)
|
||||
|
||||
The spec mandates anchoring `Term::Do.args[*] = Borrow`. The existing
|
||||
symmetric Ctor rule (`Term::Ctor.args[*] = Consume`) is documented
|
||||
only in `crates/ailang-check/src/linearity.rs:42`. The Boss decision
|
||||
recorded at brainstorm-close is: anchor BOTH rules together to avoid
|
||||
codifying asymmetry in DESIGN.md.
|
||||
|
||||
- [ ] **Step 1: Locate Decision 10's body.**
|
||||
|
||||
Run: `grep -n "Decision 10\|RC + uniqueness" /home/brummel/dev/ailang/docs/DESIGN.md | head -5`
|
||||
|
||||
Expected: a line near or after the canonical Decision 10 header (a
|
||||
bullet such as "Decision 10: RC + uniqueness inference" or similar).
|
||||
Read the surrounding section to find the right anchor location —
|
||||
typically near the existing param-mode / borrow-mode discussion.
|
||||
|
||||
- [ ] **Step 2: Append the arg-position-table subsection.**
|
||||
|
||||
Under Decision 10, add a small subsection that names both rules:
|
||||
|
||||
```markdown
|
||||
#### Arg-position policy for compound AST nodes
|
||||
|
||||
The uniqueness and linearity passes walk arguments of compound
|
||||
nodes with a fixed Position policy. For ownership-bearing nodes:
|
||||
|
||||
| Node | Arg position | Reason |
|
||||
|-------------------|--------------|----------------------------------------------------------------------|
|
||||
| `Term::Ctor.args[*]` | Consume | constructor packs values into the cell; the cell owns them afterwards |
|
||||
| `Term::Do.args[*]` | Borrow | effect-op observes its arguments; the caller still owns whatever pointer it passed in |
|
||||
|
||||
The two policies are language rules, not per-op annotations. They
|
||||
do not appear as fields on `EffectOpSig` or `Ctor`; the AST node
|
||||
kind itself carries the default. The walkers that read this policy
|
||||
live at `crates/ailang-check/src/uniqueness.rs` and
|
||||
`crates/ailang-check/src/linearity.rs` (matched arms in both).
|
||||
```
|
||||
|
||||
The exact wording / placement is a Boss judgement call — adapt if
|
||||
Decision 10's existing structure suggests a better fit. The
|
||||
non-negotiable elements: both rules are named together, both as
|
||||
language rules not per-op fields, with code-location references.
|
||||
|
||||
- [ ] **Step 3: Verify DESIGN.md still validates structurally.**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
|
||||
Expected: any DESIGN-md-shape tests (if present) stay green. The
|
||||
contents of DESIGN.md are not parsed by the toolchain; this is just
|
||||
a final regression safety net.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: WhatsNew.md entry + roadmap close
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/WhatsNew.md` (append at bottom)
|
||||
- Modify: `docs/roadmap.md:41-56` (close Heap-Str ABI entry)
|
||||
|
||||
- [ ] **Step 1: Append a WhatsNew entry.**
|
||||
|
||||
In `docs/WhatsNew.md`, append at the end (newest at bottom per
|
||||
chronological convention):
|
||||
|
||||
```markdown
|
||||
## 2026-05-12 — Strings produced at runtime now release cleanly
|
||||
|
||||
`int_to_str`, `float_to_str`, and similar built-in primitives that
|
||||
produce a freshly allocated string at runtime now participate in
|
||||
reference counting like every other heap value. A string passed
|
||||
into a print call is now treated as borrowed, not consumed, so
|
||||
it is freed exactly once when it leaves scope. Earlier programs
|
||||
that converted a number to a string and printed it leaked the
|
||||
string buffer at program exit; that no longer happens.
|
||||
|
||||
A side effect: a function parameter declared `(own T)` whose only
|
||||
in-body use was passing it into a print call will now correctly
|
||||
be flagged as over-strict — that annotation should be relaxed to
|
||||
`(borrow T)` (or removed). No current example programs are
|
||||
affected.
|
||||
```
|
||||
|
||||
(Exact prose may shift slightly; the constraint is: no internal
|
||||
identifiers, no iter codes, lead with the user-visible change.)
|
||||
|
||||
- [ ] **Step 2: Close the Heap-Str ABI roadmap entry.**
|
||||
|
||||
In `docs/roadmap.md` at the P1 milestone bullet (lines 41-56),
|
||||
check off the entry:
|
||||
|
||||
Change `- [ ] **\[milestone\]** Heap-`Str` ABI — ...`
|
||||
to `- [x] **\[milestone\]** Heap-`Str` ABI — ...`
|
||||
|
||||
Per roadmap convention, a finished entry may stay briefly with
|
||||
the `[x]` marker before being removed. For this iter, leave the
|
||||
`[x]` line in place; the Boss may remove it during commit-shape
|
||||
or in a follow-up edit once it stops being load-bearing context.
|
||||
|
||||
- [ ] **Step 3: Update the dependent `Post-22 Prelude` entry's depends-on.**
|
||||
|
||||
Run: `grep -n 'depends on: Heap' /home/brummel/dev/ailang/docs/roadmap.md`
|
||||
|
||||
For each match (recon found at least one at `:69`), update the
|
||||
`depends on:` line to reflect that the dependency has shipped —
|
||||
either:
|
||||
- Remove the `depends on:` line entirely (preferred if no other
|
||||
dependency remains), or
|
||||
- Replace with `depends on: <something>` if another live
|
||||
dependency exists.
|
||||
|
||||
The Post-22 Prelude entry has only the Heap-Str dependency; remove
|
||||
the line.
|
||||
|
||||
- [ ] **Step 4: Final full-workspace regression.**
|
||||
|
||||
Run: `cargo test --workspace && bench/cross_lang.py && bench/compile_check.py && bench/check.py`
|
||||
|
||||
Expected: every gate green. If any bench script fails on a fixture
|
||||
whose RC trace shifted (a previously-leaking program now reaches
|
||||
`live == 0`), update the baseline in the same iter — it is the
|
||||
intended consequence of the milestone.
|
||||
Reference in New Issue
Block a user