Files
AILang/docs/plans/0028-ct.4-milestone-close.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

931 lines
35 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.
# Iteration ct.4: Milestone Close — Implementation Plan
> **Parent spec:** `docs/specs/0007-canonical-type-names.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Land the milestone-closing consolidation: amend DESIGN.md
with the canonical-form rule, pin hashes for the two migrated
cross-module fixtures, add qualifier-trim-on-print to the prose
printer with a snapshot test, and recreate
`examples/compare_primitives_smoke.ail.json` as the end-to-end
demonstration that closes the iter-23.3-Task-4 bug.
**Architecture:** Four tasks across DESIGN, hash regression, prose
printer, and end-to-end fixture. Task 1 is design writing. Task 2
adds new hash-pin tests (the existing pins on `sum`/`list` are
intra-module and unaffected; the migrated `ordering_match` /
`test_22b1_dup_a` get new pins to lock the post-migration state).
Task 3 threads `module_name` context through the prose printer so
intra-module qualified Type::Cons get trimmed on print, plus a
snapshot for `ordering_match`. Task 4 authors the
`compare_primitives_smoke` fixture (9 Ord.compare invocations
across Int/Bool/Str, expected stdout `1\n2\n3\n1\n2\n3\n1\n2\n3\n`)
and adds an E2E test plus an IR-shape integration test asserting
the compare__T mono symbols are emitted.
**Tech Stack:** `docs/DESIGN.md`, `crates/ailang-core/src/hash.rs`,
`crates/ailang-prose/src/lib.rs`, `crates/ailang-prose/tests/snapshot.rs`,
`examples/*.ail.json` + `.prose.txt`, `crates/ail/tests/e2e.rs`.
---
## Files this plan creates or modifies
- Modify: `docs/DESIGN.md` — amend Decision 2 (around line 1067)
with the canonical-form rule for `Type::Con` names; review the
surrounding hash-stability promises at lines 1121, 1136-1139 to
make sure they explicitly cite the migrated fixtures as the
named exception.
- Modify: `crates/ailang-core/src/hash.rs` — append three new pin
tests near the existing `iter22b1_schema_extension_preserves_pre_22b_hashes`
block: one pinning the new canonical hashes of the two migrated
fixtures, one asserting `sum.ail.json` and `list.ail.json` (both
intra-module-only) still hash bit-identically.
- Modify: `crates/ailang-prose/src/lib.rs` — thread an
`owning_module: &str` context through `write_module``write_def`
→ ... → `write_type`; in `write_type`'s `Type::Con` arm, trim a
qualifier whose owner matches `owning_module`.
- Modify: `crates/ailang-prose/tests/snapshot.rs` — add one test
`snapshot_ordering_match` consuming the new
`examples/ordering_match.prose.txt` snapshot.
- Create: `examples/ordering_match.prose.txt` — committed prose
snapshot for the migrated cross-module fixture.
- Create: `examples/compare_primitives_smoke.ail.json` — the
iter-23.3 demonstration fixture.
- Modify: `crates/ail/tests/e2e.rs` — append two tests:
`compare_primitives_smoke_prints_1_2_3_thrice` (E2E) and
`compare_primitives_smoke_emits_compare_mono_symbols` (IR shape).
No files are deleted.
---
## Task 1: DESIGN.md Decision 2 amendment
Decision 2 (content-addressed defs) in DESIGN.md is the spec of
the `.ail.json` canonical form. It currently does not pin down
how `Type::Con` names are scoped — that policy lived in
JOURNAL-level prose until ct.1 made it normative. This task
writes the rule into DESIGN.md so future iters check against it
during architect drift review.
**Files:**
- Modify: `docs/DESIGN.md` near line 1067 (Decision 2 / canonical
form) — add a new subsection or paragraph naming the rule.
- Modify: `docs/DESIGN.md` line 1136-1139 — update the
`iter19b_*` regression-pin citation to also name the new
ct.1-era pins for migrated fixtures.
- [ ] **Step 1: Read the Decision 2 surrounding context**
Read `docs/DESIGN.md` lines 1050-1145 (approximately). Identify
the natural insertion point for the canonical-form rule — likely
adjacent to the existing "JSON canonical hash for every existing
fixture stays bit-identical" paragraph at 1067-1070.
- [ ] **Step 2: Insert the canonical-form rule**
Edit `docs/DESIGN.md`. After the paragraph at lines 1067-1070
("JSON canonical hash for every existing fixture stays bit-
identical..."), insert a new paragraph block:
```markdown
**Type::Con name scoping (canonical form, since ct.1).** Within a
`.ail.json`, a `Type::Con.name` is interpreted relative to the
file's top-level `"name"` field (the owning module). Bare names
(no `.`) refer to a TypeDef in the owning module's own `defs`.
Cross-module references MUST be qualified `<owning_module>.<TypeName>`
where `<owning_module>` is a known module in the workspace.
Primitives (`Int`, `Bool`, `Str`, `Unit`, `Float`) are bare and
have no module qualifier. Bare cross-module references are a
schema violation (`WorkspaceLoadError::BareCrossModuleTypeRef`);
qualified references whose owner is unknown are also a violation
(`WorkspaceLoadError::BadCrossModuleTypeRef`). The same rule
applies to `Term::Ctor.type_name`.
Class names (`ClassDef.name`, `InstanceDef.class`,
`SuperclassRef.class`, `Constraint.class`) are NOT module-scoped
under this rule; they remain workspace-flat with
`MethodNameCollision` enforced at load. Class-name scoping is a
future milestone with its own DESIGN amendment.
```
- [ ] **Step 3: Update the regression-pin citation at 1136-1139**
Edit `docs/DESIGN.md`. Find the paragraph at lines 1136-1139 that
ends with "regression-pinned by `iter19b_empty_suppress_preserves_pre_19b_hashes`
and `iter19b_schema_extension_preserves_pre_19b_hashes`."
Append at the end of that paragraph:
```
The canonical-form tightening in ct.1 shifted the hashes of two
cross-module fixtures (`ordering_match.ail.json` and
`test_22b1_dup_a.ail.json`); all intra-module fixtures, including
the regression-pinned `sum.ail.json` and `list.ail.json`, remain
bit-identical. The new pins are
`ct4_migrated_fixtures_have_canonical_form_hashes` (locks the
post-migration hashes) and
`ct4_unmigrated_fixtures_remain_bit_identical` (re-asserts the
existing 13a/19b/22b.1 hashes still hold).
```
- [ ] **Step 4: Verify DESIGN.md still parses cleanly**
Run: `cargo doc --workspace --no-deps 2>&1 | grep -i 'DESIGN\.md\|warning' | head`
Expected: no new errors (DESIGN.md is rustdoc-rendered in places;
the additions are plain markdown with no special syntax).
- [ ] **Step 5: Commit**
```bash
git add docs/DESIGN.md
git commit -m "iter ct.4.1: DESIGN.md Decision 2 — canonical Type::Con name scoping rule"
```
---
## Task 2: Hash pins for migrated fixtures
The existing pin tests in `crates/ailang-core/src/hash.rs` use
`sum.ail.json` and `list.ail.json` — both intra-module, neither
touched by the ct.1 migration. Their pinned hashes remain valid
and don't need re-baselining. This task ADDS new pins that lock
in the canonical-form hashes of the two migrated fixtures, plus
re-asserts the unmigrated pins still hold.
**Files:**
- Modify: `crates/ailang-core/src/hash.rs` — append two new tests
at the end of `mod tests`.
- [ ] **Step 1: Capture the current hashes of the migrated fixtures**
Run the following one-liner to print the canonical hash of each
migrated fixture's relevant def. From a Rust REPL is overkill;
write a single throwaway test in `crates/ailang-core/src/hash.rs`
that asserts known-wrong hashes (so it fails and prints the
actual hash):
Add inside `mod tests`:
```rust
#[test]
#[ignore] // helper: run manually with --ignored
fn print_migrated_fixture_hashes() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let examples = manifest_dir.join("../../examples");
let ord_src = std::fs::read(examples.join("ordering_match.ail.json")).unwrap();
let ord_mod: crate::ast::Module = serde_json::from_slice(&ord_src).unwrap();
let main_def = ord_mod.defs.iter().find(|d| d.name() == "main").unwrap();
eprintln!("ordering_match::main: {}", def_hash(main_def));
let dup_a_src = std::fs::read(examples.join("test_22b1_dup_a.ail.json")).unwrap();
let dup_a_mod: crate::ast::Module = serde_json::from_slice(&dup_a_src).unwrap();
for d in &dup_a_mod.defs {
eprintln!("test_22b1_dup_a::{}: {}", d.name(), def_hash(d));
}
panic!("captured hashes — see stderr");
}
```
Run: `cargo test --workspace -p ailang-core print_migrated_fixture_hashes --ignored -- --nocapture 2>&1 | grep '::'`
Expected: prints hash lines for `ordering_match::main` and each
`test_22b1_dup_a::<def_name>`. Record the hashes for use in Step 2.
- [ ] **Step 2: Replace the helper with the real pin test**
Edit `crates/ailang-core/src/hash.rs` — replace the throwaway
helper test with the real pin:
```rust
/// Iter ct.4 (canonical-type-names milestone close): pin the
/// canonical-form hashes of the two cross-module fixtures
/// migrated by `ail migrate-canonical-types` in ct.1. These
/// hashes are the post-migration state; pre-migration values
/// (when both fixtures carried bare cross-module Type::Con)
/// are no longer reproducible because the ct.1 validator
/// rejects that shape upstream.
#[test]
fn ct4_migrated_fixtures_have_canonical_form_hashes() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let examples = manifest_dir.join("../../examples");
let ord_src = std::fs::read(examples.join("ordering_match.ail.json"))
.expect("examples/ordering_match.ail.json present");
let ord_mod: crate::ast::Module = serde_json::from_slice(&ord_src).unwrap();
let main_def = ord_mod.defs.iter().find(|d| d.name() == "main").unwrap();
assert_eq!(
def_hash(main_def),
// Replace this string with the actual hash printed in Step 1.
"REPLACE_WITH_CAPTURED_HASH_FROM_STEP_1",
"ordering_match::main canonical hash must match captured post-migration value"
);
let dup_a_src = std::fs::read(examples.join("test_22b1_dup_a.ail.json"))
.expect("examples/test_22b1_dup_a.ail.json present");
let dup_a_mod: crate::ast::Module = serde_json::from_slice(&dup_a_src).unwrap();
// Pin the first def's hash. If test_22b1_dup_a has multiple defs,
// pin only the type def that carries the migrated qualifier.
let first_def = dup_a_mod.defs.first().expect("at least one def");
assert_eq!(
def_hash(first_def),
"REPLACE_WITH_SECOND_CAPTURED_HASH_FROM_STEP_1",
"test_22b1_dup_a::{} canonical hash must match captured post-migration value",
first_def.name(),
);
}
```
You'll need to substitute the actual hash strings from Step 1's
captured output and remove the placeholder strings. If
`test_22b1_dup_a` has multiple defs of relevance, pin each (one
`assert_eq!` per def).
- [ ] **Step 3: Add a re-assertion pin for unmigrated fixtures**
Edit `crates/ailang-core/src/hash.rs` — append a second new test:
```rust
/// Iter ct.4: re-assert that the canonical-form tightening
/// did NOT change hashes of intra-module-only fixtures. The
/// existing iter-13a / iter-19b / iter-22b.1 pin tests already
/// assert these; this one names the canonical-type-names
/// milestone explicitly so future archaeology finds the
/// connection.
#[test]
fn ct4_unmigrated_fixtures_remain_bit_identical() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let examples = manifest_dir.join("../../examples");
let sum_src = std::fs::read(examples.join("sum.ail.json")).unwrap();
let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "db33f57cb329935e",
"sum.sum hash drifted across canonical-form tightening — unexpected");
let list_src = std::fs::read(examples.join("list.ail.json")).unwrap();
let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap();
let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap();
assert_eq!(def_hash(int_list_def), "b082192bd0c99202",
"list.IntList hash drifted across canonical-form tightening — unexpected");
}
```
- [ ] **Step 4: Run the pin tests**
Run: `cargo test --workspace -p ailang-core ct4_migrated_fixtures_have_canonical_form_hashes ct4_unmigrated_fixtures_remain_bit_identical`
Expected: BOTH PASS.
- [ ] **Step 5: Run the full test suite**
Run: `cargo test --workspace`
Expected: all pass (test count grows by 2).
- [ ] **Step 6: Commit**
```bash
git add crates/ailang-core/src/hash.rs
git commit -m "iter ct.4.2: pin canonical-form hashes for migrated fixtures + unmigrated sanity"
```
---
## Task 3: Prose qualifier-trim-on-print + snapshot
The prose printer at `crates/ailang-prose/src/lib.rs:462` writes
`Type::Con.name` verbatim. When the canonical `.ail.json` already
has bare names for intra-module Type::Cons (the canonical
convention), this is fine. But the validator does not reject
qualified intra-module refs (it only rejects bare cross-module
and bad-qualified); a hand-authored fixture could legally have
`prelude.Ordering` inside prelude's own file. The prose form
should print this as bare `Ordering` so the prose surface stays
LLM-author-friendly (qualifier omitted because it follows from
the file's module name).
This task threads `owning_module` context through the prose
printer and trims at `write_type`'s `Type::Con` arm. Plus a
snapshot test that pins the print behaviour for the migrated
cross-module fixture (`ordering_match.ail.json`, which uses
`prelude.Ordering` cross-module qualifier — the trim does NOT
fire here because the qualifier's owner differs from the file's
module).
**Files:**
- Modify: `crates/ailang-prose/src/lib.rs` — thread `owning_module`
through every `write_*` fn signature from `write_module` down to
`write_type`; trim in `write_type`'s `Type::Con` arm.
- Create: `examples/ordering_match.prose.txt` — committed
snapshot.
- Modify: `crates/ailang-prose/tests/snapshot.rs` — register one
new snapshot test.
- [ ] **Step 1: Write the RED test for trim behaviour**
Append to `crates/ailang-prose/src/lib.rs` `mod tests` (or wherever
the existing unit tests live — search for `mod tests` near the
bottom of the file):
```rust
/// ct.4 Task 3: when a `Type::Con.name` is qualified with the
/// owning module's own name (e.g. `prelude.Ordering` inside
/// `prelude`'s own file), the prose printer must trim the
/// qualifier and emit bare `Ordering`. Cross-module qualified
/// refs (`prelude.Ordering` inside any non-prelude file)
/// round-trip verbatim.
#[test]
fn type_con_qualifier_trimmed_when_owner_matches_module() {
use ailang_core::ast::{Module, Def, FnDef, Type, Term, Literal, ParamMode};
let m = Module {
schema: "ailang/v0".into(),
name: "prelude".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "noop".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::Con {
name: "prelude.Ordering".into(),
args: vec![],
}),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Ctor {
type_name: "prelude.Ordering".into(),
ctor: "EQ".into(),
args: vec![],
},
doc: None,
suppress: vec![],
})],
};
let prose = module_to_prose(&m);
assert!(
prose.contains("-> Ordering"),
"expected bare `Ordering` in fn return type (owner == file's \
module); got prose:\n{}",
prose
);
assert!(
!prose.contains("prelude.Ordering"),
"expected NO `prelude.Ordering` after trim; got prose:\n{}",
prose
);
}
/// ct.4 Task 3: a cross-module Type::Con (owner != current
/// file's module) must round-trip verbatim — no trim.
#[test]
fn type_con_qualifier_preserved_when_owner_differs() {
use ailang_core::ast::{Module, Def, FnDef, Type, Term, Literal, ParamMode, Import};
let m = Module {
schema: "ailang/v0".into(),
name: "user".into(),
imports: vec![Import { module: "prelude".into(), alias: None }],
defs: vec![Def::Fn(FnDef {
name: "lt".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::Con {
name: "prelude.Ordering".into(),
args: vec![],
}),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Ctor {
type_name: "prelude.Ordering".into(),
ctor: "LT".into(),
args: vec![],
},
doc: None,
suppress: vec![],
})],
};
let prose = module_to_prose(&m);
assert!(
prose.contains("prelude.Ordering"),
"expected qualified `prelude.Ordering` preserved (owner != file's \
module); got prose:\n{}",
prose
);
}
```
- [ ] **Step 2: Run the RED tests to verify they fail**
Run: `cargo test --workspace -p ailang-prose type_con_qualifier_trimmed type_con_qualifier_preserved`
Expected:
- `type_con_qualifier_trimmed_when_owner_matches_module`: FAIL (current printer writes `prelude.Ordering` verbatim).
- `type_con_qualifier_preserved_when_owner_differs`: PASS (current printer already writes qualified verbatim).
- [ ] **Step 3: Thread `owning_module` through the print signatures**
Edit `crates/ailang-prose/src/lib.rs`. Modify the following
signatures to add `owning_module: &str` as the last parameter
(after `level: usize` where present, before `out`-mutating return
of `()`):
- `write_def`
- `write_type_def`
- `write_fn_def`
- `write_const_def`
- `write_class_def`
- `write_instance_def`
- `write_type`
- `write_mode_type` (if it exists; check)
- any other `write_*` that consumes a `Type` or `Term`
For each call site within the same file, add the propagated
`m.name.as_str()` argument from the entry point (`module_to_prose`
passes `m.name.as_str()` to `write_module`'s helper, which then
threads it to every `write_def` call).
Refactor the entry point `module_to_prose`:
```rust
pub fn module_to_prose(m: &Module) -> String {
let mut out = String::new();
write_module(&mut out, m);
out
}
```
stays the same; the threading begins inside `write_module`.
`write_module`'s body — the `m.name.as_str()` is captured locally
(let `module_name = m.name.as_str();`) and passed to each
`write_def(&mut out, def, level, module_name)` call.
- [ ] **Step 4: Apply the trim in `write_type`'s `Type::Con` arm**
Edit `crates/ailang-prose/src/lib.rs` around line 465. The `Type::Con`
arm currently reads:
```rust
Type::Con { name, args } => {
out.push_str(name);
// ... args writing
}
```
Replace with:
```rust
Type::Con { name, args } => {
// Trim a qualifier whose owner matches the current
// file's module: `prelude.Ordering` printed from
// inside `prelude` becomes bare `Ordering`. Cross-
// module qualifiers (`prelude.Ordering` printed from
// inside any non-prelude module) survive verbatim.
let display_name = match name.split_once('.') {
Some((owner, suffix)) if owner == owning_module => suffix,
_ => name.as_str(),
};
out.push_str(display_name);
// ... args writing (preserved verbatim, recursing
// into write_type with the same owning_module)
}
```
Where the args recursion calls `write_type(out, a)`, update to
`write_type(out, a, owning_module)`.
- [ ] **Step 5: Apply the same trim in `Term::Ctor`'s type_name (in write_term, wherever it lives)**
The prose printer also writes `Term::Ctor.type_name` somewhere
in its term-lowering. Search for `Term::Ctor` in `crates/ailang-prose/src/lib.rs`
and find the analogous `out.push_str(type_name)` site. Apply the
same trim logic:
```rust
// in the Term::Ctor write arm — find the existing string-push site
let display_type = match type_name.split_once('.') {
Some((owner, suffix)) if owner == owning_module => suffix,
_ => type_name.as_str(),
};
out.push_str(display_type);
```
- [ ] **Step 6: Run the RED tests now expecting GREEN**
Run: `cargo test --workspace -p ailang-prose type_con_qualifier_trimmed type_con_qualifier_preserved`
Expected: BOTH PASS.
- [ ] **Step 7: Regenerate existing prose snapshots**
The trim changes how `prelude.Ordering` (and any other
qualifier-matching pattern) renders. Most existing prose
snapshots are intra-module-only fixtures with no qualified
Type::Cons — they should be byte-identical. Verify:
Run: `cargo test --workspace -p ailang-prose`
Expected: all existing snapshot tests still pass. If any fail
with a `.prose.txt.actual` byte diff, manually inspect the diff:
- If the diff is ONLY the qualifier-trim behaviour we just added
(and the snapshot input file does have qualified intra-module
Type::Cons), regenerate the snapshot:
`cargo run -p ail -- prose examples/<stem>.ail.json > examples/<stem>.prose.txt`.
Confirm the diff post-regenerate is what you expect.
- If the diff is OTHERWISE unexpected, that is a bug — investigate.
For each snapshot whose `.actual` file appeared, decide whether
to regenerate or revert.
- [ ] **Step 8: Create the `ordering_match` snapshot**
Run: `cargo run -p ail -- prose examples/ordering_match.ail.json > examples/ordering_match.prose.txt`
Expected: command succeeds, the output file is non-empty and
contains `prelude.Ordering` (cross-module qualifier preserved
because `prelude``ordering_match`).
- [ ] **Step 9: Register the new snapshot test**
Edit `crates/ailang-prose/tests/snapshot.rs`. Append:
```rust
#[test]
fn snapshot_ordering_match() {
check_snapshot("ordering_match");
}
```
- [ ] **Step 10: Run the prose test suite**
Run: `cargo test --workspace -p ailang-prose`
Expected: all pass, including the new `snapshot_ordering_match`.
- [ ] **Step 11: Run the full test suite**
Run: `cargo test --workspace`
Expected: all pass.
- [ ] **Step 12: Commit**
```bash
git add crates/ailang-prose/src/lib.rs \
crates/ailang-prose/tests/snapshot.rs \
examples/ordering_match.prose.txt
git commit -m "iter ct.4.3: prose qualifier-trim-on-print + ordering_match snapshot"
```
---
## Task 4: Recreate `compare_primitives_smoke.ail.json` + E2E
This task resumes the paused iter 23.3 work (TaskList #15+#16):
recreate the end-to-end fixture that demonstrates the
iter-23.1/23.3 bug closure, run it through the full pipeline,
and lock the IR-shape integration assertion.
The fixture: consumer module `compare_primitives_smoke` imports
prelude, calls `Ord.compare` on 9 pairs (3 each of Int/Bool/Str
arranged as LT-case / EQ-case / GT-case), pattern-matches each
result on `LT | EQ | GT`, prints 1 / 2 / 3 respectively. Expected
stdout: `1\n2\n3\n1\n2\n3\n1\n2\n3\n`.
**Files:**
- Create: `examples/compare_primitives_smoke.ail.json` — the
fixture.
- Modify: `crates/ail/tests/e2e.rs` — append the E2E test and the
IR-shape integration test.
- [ ] **Step 1: Author the canonical-form fixture**
Create `examples/compare_primitives_smoke.ail.json` with the
structure below. The fixture has one fn `main` with a `body`
that is a sequence of 9 `do io/print_int` statements; each
`print_int` wraps a `match` on `(call prelude.compare A B)`
returning a `prelude.Ordering`. Use qualified
`prelude.Ordering` throughout (canonical post-ct.1 since the
type's owner ≠ this file's module).
For brevity, build the fixture incrementally — start with one
case, verify it loads, then add the rest. The shape of a single
case (Int, LT):
```json
{
"t": "do",
"op": "io/print_int",
"args": [
{
"t": "match",
"scrutinee": {
"t": "app",
"fn": { "t": "var", "name": "prelude.compare" },
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 1 } },
{ "t": "lit", "lit": { "kind": "int", "value": 2 } }
]
},
"arms": [
{
"pat": { "p": "ctor", "ctor": "LT", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 1 } }
},
{
"pat": { "p": "ctor", "ctor": "EQ", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 2 } }
},
{
"pat": { "p": "ctor", "ctor": "GT", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 3 } }
}
]
}
]
}
```
Compose 9 such blocks (Int LT/EQ/GT, Bool LT/EQ/GT, Str LT/EQ/GT)
in a `seq` chain. For Bool, use `bool` literals (`{"kind": "bool", "value": false}`);
for Str, use `str` literals (`{"kind": "str", "value": "a"}`).
The fn `main` returns `Unit` with `IO` effect:
```json
{
"schema": "ailang/v0",
"name": "compare_primitives_smoke",
"imports": [{ "module": "prelude" }],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": ["IO"]
},
"params": [],
"body": { "t": "seq", "lhs": <case 1>, "rhs": { "t": "seq", "lhs": <case 2>, "rhs": ... <case 9> ... } }
}
]
}
```
The 9 cases pair-pattern (which value, which compare, expected output):
| # | type | A | B | expected match | print |
|---|------|--------|--------|----------------|-------|
| 1 | Int | 1 | 2 | LT | 1 |
| 2 | Int | 1 | 1 | EQ | 2 |
| 3 | Int | 2 | 1 | GT | 3 |
| 4 | Bool | false | true | LT | 1 |
| 5 | Bool | false | false | EQ | 2 |
| 6 | Bool | true | false | GT | 3 |
| 7 | Str | "a" | "b" | LT | 1 |
| 8 | Str | "a" | "a" | EQ | 2 |
| 9 | Str | "b" | "a" | GT | 3 |
You'll write this manually as one big JSON file. Use 2-space
indentation and standard `serde_json`-canonical key ordering
(the validator + manifest test will fail otherwise; alternatively,
run the file through `cargo run -p ail -- manifest examples/compare_primitives_smoke.ail.json`
to canonicalise once it loads, then commit the canonicalised
form).
- [ ] **Step 2: Verify the fixture loads + typechecks**
Run: `cargo run -p ail -- check examples/compare_primitives_smoke.ail.json`
Expected: exit 0, no diagnostics. If validator complains about
canonical form (e.g. bare `Ordering` somewhere), fix and re-run.
If typechecker complains about an unknown ctor or pattern type
mismatch, that is the iter-23.1 bug — investigate (it should
NOT happen post-ct.2).
- [ ] **Step 3: Verify the fixture runs end-to-end**
Run: `cargo run -p ail -- run examples/compare_primitives_smoke.ail.json`
Expected: stdout exactly `1\n2\n3\n1\n2\n3\n1\n2\n3\n` (nine
lines, no extra newline at the very end depending on the runtime
printer convention — match whatever the existing
`ordering_match_via_prelude_prints_1` test asserts for trailing
newline).
If the run fails, the iter-23.1 bug closure is NOT complete —
escalate to orchestrator.
- [ ] **Step 4: Append the E2E test to e2e.rs**
Edit `crates/ail/tests/e2e.rs`. Append after the
`ordering_match_via_prelude_prints_1` test (around line 920):
```rust
/// Iter 23.3 Task 4 (resumed in ct.4): the end-to-end
/// demonstration that the iter-23.1 cross-module Type::Con
/// mismatch bug is closed. `examples/compare_primitives_smoke.ail.json`
/// imports prelude, calls `prelude.compare` on three pairs each
/// of Int / Bool / Str (arranged LT / EQ / GT per type), and
/// pattern-matches the result. Expected stdout encodes the
/// 9-case decision lattice as `1\n2\n3` × 3.
///
/// Pre-ct.1 / pre-ct.2, the bare `Ordering` returned by
/// `compare` mismatched the qualified `prelude.Ordering`
/// scrutinee at the user-side pattern lookup. Post-canonical-
/// type-names, the scrutinee is qualified throughout, the
/// pattern lookup is type-driven, and the run completes cleanly.
#[test]
fn compare_primitives_smoke_prints_1_2_3_thrice() {
let stdout = build_and_run("compare_primitives_smoke.ail.json");
assert_eq!(stdout, "1\n2\n3\n1\n2\n3\n1\n2\n3\n");
}
```
- [ ] **Step 5: Append the IR-shape integration test**
Append immediately after the E2E test:
```rust
/// Iter 23.3 Task 4 / IR-shape: the mono pass must emit
/// `compare__Int`, `compare__Bool`, and `compare__Str` symbols
/// driven by the three `prelude.compare` call sites in the
/// smoke fixture. Codegen's `try_emit_primitive_instance_body`
/// intercepts each one (iter 23.3.2). This test pins the
/// presence of those symbols in the emitted IR so a future
/// refactor cannot silently drop one.
#[test]
fn compare_primitives_smoke_emits_compare_mono_symbols() {
let ir = emit_ir("compare_primitives_smoke.ail.json");
assert!(
ir.contains("compare__Int") || ir.contains("@compare__Int"),
"compare__Int symbol missing from emitted IR"
);
assert!(
ir.contains("compare__Bool") || ir.contains("@compare__Bool"),
"compare__Bool symbol missing from emitted IR"
);
assert!(
ir.contains("compare__Str") || ir.contains("@compare__Str"),
"compare__Str symbol missing from emitted IR"
);
}
```
If a helper `emit_ir(fixture: &str) -> String` does not already
exist in `e2e.rs`, add it adjacent to `build_and_run` (it should
invoke `cargo run -p ail -- emit-ir <fixture>` and capture
stdout). Use the existing `build_and_run`'s implementation as a
template.
- [ ] **Step 6: Run the new E2E + IR tests**
Run: `cargo test --workspace -p ail compare_primitives_smoke_prints compare_primitives_smoke_emits`
Expected: BOTH PASS.
- [ ] **Step 7: Run the full test suite**
Run: `cargo test --workspace`
Expected: all pass.
- [ ] **Step 8: Commit**
```bash
git add examples/compare_primitives_smoke.ail.json crates/ail/tests/e2e.rs
git commit -m "iter ct.4.4: compare_primitives_smoke E2E fixture + IR-shape test (closes iter 23.3 Task 4)"
```
---
## Closing checks
- [ ] **Step C1: Bench script smoke run**
Run: `bash bench/run.sh` (or whichever entry point the project
uses; consult `bench/README.md` or `skills/audit/SKILL.md`).
Expected: exit 0, or a ratified non-zero per the audit-skill
policy. No new regressions vs. baseline.
- [ ] **Step C2: Confirm all acceptance criteria from the spec**
Verify each item in `docs/specs/0007-canonical-type-names.md`
§ "Acceptance criteria":
1.`load_workspace` validates every `Type::Con` (ct.1).
2. ✅ Every fixture migrated to qualified form (ct.1).
3. ✅ Four obsolete mechanisms deleted (ct.2.2, ct.2.3, ct.3.1, ct.3.2).
4.`qualify_local_types` audited and applied uniformly (ct.2.1).
5.`cargo build --workspace && cargo test --workspace` green (this commit).
6. ✅ Iter 23.3 Task 4+5 resumed and complete (ct.4 Task 4 above).
7. ✅ DESIGN.md amended (ct.4 Task 1).
8. ✅ JOURNAL entry (ct.4 Task C3 below).
9. ⏳ Bench scripts exit 0 (verify via C1).
- [ ] **Step C3: JOURNAL entry**
Append to `docs/JOURNAL.md`:
```markdown
## 2026-05-11 — Iteration ct.4: canonical-type-names milestone close
Four tasks landed, closing the milestone:
- **ct.4.1**: amended DESIGN.md Decision 2 with the canonical
Type::Con name scoping rule (bare = local, qualified =
cross-module, primitives bare). Updated the regression-pin
citation at the surrounding "hashes stay bit-identical"
paragraph to name the two new ct.4 pins.
- **ct.4.2**: added two new pin tests in
`crates/ailang-core/src/hash.rs`
`ct4_migrated_fixtures_have_canonical_form_hashes` (locks the
post-migration hashes of `ordering_match.ail.json` and
`test_22b1_dup_a.ail.json`) and
`ct4_unmigrated_fixtures_remain_bit_identical` (re-asserts the
unchanged `sum`/`list` hashes).
- **ct.4.3**: added qualifier-trim-on-print to the prose
printer. `write_type` (and `Term::Ctor.type_name` writing)
now trims a qualifier whose owner matches the current file's
module. The `owning_module` context is threaded from
`write_module` down through the call chain. Two unit tests
pin the trim and the cross-module preserve behaviours; the
`ordering_match.prose.txt` snapshot is committed.
- **ct.4.4**: recreated `examples/compare_primitives_smoke.ail.json`
(the iter-23.3 Task 4 demonstration fixture). 9 cases across
Int / Bool / Str, expected stdout `1\n2\n3` × 3. Two new
E2E tests in `crates/ail/tests/e2e.rs`
`compare_primitives_smoke_prints_1_2_3_thrice` (output
assertion) and `compare_primitives_smoke_emits_compare_mono_symbols`
(IR-shape assertion that mono emits compare__Int /
compare__Bool / compare__Str symbols).
The canonical-type-names milestone closes:
- The structural invariant: every `Type::Con` in canonical
`.ail.json` is bare (= local) or qualified
(`<owner>.<TypeName>`, with primitives bare).
- The four obsolete imports-fallback / overlay mechanisms named
in the ct.1 catalogue are gone (Pattern::Ctor, Term::Ctor
synth, codegen `lookup_ctor_by_type`, mono
`apply_per_module_ctor_index_overlay`'s ctor_index half).
- The iter-23.1-Task-4 bug is empirically closed: the smoke
fixture compiles, runs, and prints the expected 9-line output
end-to-end without any further compiler changes.
Workspace at milestone close: full `cargo build --workspace` and
`cargo test --workspace` green, 453+ tests passing
(ct.4 adds 4 new tests: 2 pin + 2 prose + 2 E2E, minus or plus
any shifts in other test count).
The skill-system pipeline calls for `audit` next (drift review +
bench regression + rustdoc), then `fieldtest` (real-world `.ailx`
examples authored against DESIGN.md only).
Out of scope, deferred:
- The codegen `lookup_ctor_in_pattern` helper (imports-walk, but
no scrutinee type to type-anchor; a later iter can plumb Type
through pattern lowering if desired).
- `check_in_workspace`'s analogous per-module overlay in
`crates/ailang-check/src/lib.rs:1234` (its env.ctor_index half
serves the duplicate-detection diagnostic at workspace-build
time, not the runtime ctor lookup; not in the canonical-type-
names scope).
- Class names (`ClassDef.name`, `InstanceDef.class`, etc.) are
intentionally bare and workspace-flat post-ct.1; type-driven
method dispatch is its own milestone (already named in
`docs/roadmap.md` as a P2 item).
```
- [ ] **Step C4: Commit the JOURNAL entry**
```bash
git add docs/JOURNAL.md
git commit -m "journal: ct.4 canonical-type-names milestone close"
```
- [ ] **Step C5: Optional roadmap update**
If the user wants the milestone removed from
`docs/roadmap.md` (or marked done with a checkmark), make that
edit now and commit alongside the JOURNAL entry. Otherwise leave
the roadmap as-is — the orchestrator typically prunes finished
entries in a small follow-up commit.