diff --git a/docs/superpowers/plans/2026-05-09-22b.1-typeclass-schema-floor.md b/docs/superpowers/plans/2026-05-09-22b.1-typeclass-schema-floor.md deleted file mode 100644 index 2405130..0000000 --- a/docs/superpowers/plans/2026-05-09-22b.1-typeclass-schema-floor.md +++ /dev/null @@ -1,927 +0,0 @@ -# 22b.1 — Typeclass Schema Floor + Registry Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Land the AILang JSON schema for `ClassDef` and `InstanceDef` and the workspace-load-time instance registry with coherence/uniqueness/completeness checks. After this iter, a workspace containing classes and instances loads cleanly (or errors with the right diagnostic), but no typecheck arm reads them yet. - -**Architecture:** Two new variants on `Def` (`Class`, `Instance`) following the existing additive-schema pattern. A new `Registry` struct built during `load_workspace` after the DFS. Three new `WorkspaceLoadError` variants. Hash-stability is verified by a regression test against on-disk pre-22b fixtures and a same-shape-different-paths test. - -**Tech Stack:** Rust 2021, `serde` + `serde_json` for AST (de)serialisation, `blake3` for canonical hashing (already in tree), `thiserror` for error enums. No new dependencies. - -**Spec reference:** `docs/DESIGN.md` Decision 11 (committed as commit `f6cb900`). The five committed semantic axes plus the Form-A schema in §"Form-A schema" are the authoritative spec for this iter. - -**Iter scope (what this plan does NOT cover, deferred to 22b.2/3/4):** -- `FnDef.type.constraints` field — 22b.2 -- Class-schema validation diagnostics (`KindMismatch`, `InvalidSuperclassParam`, `ConstraintReferencesUnboundTypeVar`) — 22b.2 -- Typecheck arms `MissingConstraint`, `NoInstance` — 22b.2 -- Monomorphisation pass — 22b.3 -- Prelude module + `print` rewiring — 22b.4 -- Form-B prose projection of class/instance — 22b.4 or later -- Surface (Form-B) parser/printer arms for class/instance — 22b.4 or later - ---- - -### Task 1: Add `ClassDef` and `InstanceDef` AST structs - -**Files:** -- Modify: `crates/ailang-core/src/ast.rs` (add structs, extend `Def` enum, extend `Def::name()` and `def_kind()`) - -- [ ] **Step 1.1: Add `ClassDef` struct after `Suppress` (around line 222)** - -```rust -/// Iter 22b.1: a typeclass declaration (Decision 11). -/// -/// Single-parameter, multi-method, optional-default, optional-superclass -/// typeclass. The `param` is a single string (multi-param classes are -/// not supported per Decision 11 axis 1; the schema enforces this by -/// shape — `param` is `String`, not `Vec`). -/// -/// `superclass`, when present, MUST have its `type` field equal to -/// `param`. The check is enforced in 22b.2 by the -/// `InvalidSuperclassParam` diagnostic; the schema does not encode it. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClassDef { - /// Class name (capitalised by convention). - pub name: String, - /// Single type-parameter name. - pub param: String, - /// Optional single-superclass relation. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub superclass: Option, - /// Methods of the class, in declaration order. - pub methods: Vec, - /// Optional source-level documentation string. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub doc: Option, -} - -/// Iter 22b.1: reference to a superclass relation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SuperclassRef { - /// Superclass name. - pub class: String, - /// Type the superclass is applied to. MUST equal the parent - /// `ClassDef.param` (validated in 22b.2 — schema does not enforce). - #[serde(rename = "type")] - pub type_: String, -} - -/// Iter 22b.1: one method declared in a [`ClassDef`]. -/// -/// `default` is `None` when the method is abstract-required (every -/// instance must specify it); `Some(body)` when the method has a -/// default body (instances may inherit or override). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClassMethod { - /// Method name. - pub name: String, - /// Full method signature including mode annotations. - #[serde(rename = "type")] - pub ty: Type, - /// Default body. `None` ⇒ abstract-required; `Some(body)` ⇒ - /// default-with-fallback. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default: Option, -} - -/// Iter 22b.1: an instance declaration (Decision 11). -/// -/// `class` is the name of the class being instantiated. `type_` is the -/// concrete type expression the class is applied to — never the class -/// param. `methods` contains bodies for the required methods plus any -/// overrides of default-bearing methods. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstanceDef { - /// Class being instantiated. - pub class: String, - /// Concrete type the class is applied to. - #[serde(rename = "type")] - pub type_: Type, - /// Method bodies in declaration order. - pub methods: Vec, - /// Optional source-level documentation string. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub doc: Option, -} - -/// Iter 22b.1: one method body in an [`InstanceDef`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstanceMethod { - /// Method name (must match a method in the corresponding class). - pub name: String, - /// Method body. The class's declared method type with the class - /// param substituted to the instance type is the body's expected - /// type — checked in 22b.2. - pub body: Term, -} -``` - -- [ ] **Step 1.2: Extend `Def` enum with new variants (line 65)** - -Replace the existing `Def` enum: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum Def { - /// Function definition; see [`FnDef`]. - Fn(FnDef), - /// Constant definition; see [`ConstDef`]. - Const(ConstDef), - /// Type (ADT) definition; see [`TypeDef`]. - Type(TypeDef), - /// Iter 22b.1: typeclass declaration; see [`ClassDef`]. - Class(ClassDef), - /// Iter 22b.1: instance declaration; see [`InstanceDef`]. - Instance(InstanceDef), -} -``` - -- [ ] **Step 1.3: Extend `Def::name()` and `def_kind()`** - -```rust -impl Def { - pub fn name(&self) -> &str { - match self { - Def::Fn(f) => &f.name, - Def::Const(c) => &c.name, - Def::Type(t) => &t.name, - Def::Class(c) => &c.name, - Def::Instance(i) => &i.class, // an instance is "named" by its class - } - } -} - -pub fn def_kind(def: &Def) -> &'static str { - match def { - Def::Fn(_) => "fn", - Def::Const(_) => "const", - Def::Type(_) => "type", - Def::Class(_) => "class", - Def::Instance(_) => "instance", - } -} -``` - -- [ ] **Step 1.4: Run `cargo check -p ailang-core`** - -Expected: compiles cleanly. Any non-exhaustive-match warning in dependent crates surfaces here as a downstream-crate compile error — those are addressed in Task 2. - ---- - -### Task 2: Update downstream `match Def::*` sites - -The new `Def` variants force all match-on-Def sites to be updated. Find them and add explicit arms (typically returning a "not yet handled" placeholder for 22b.1, with the real handling deferred to later sub-iters). - -- [ ] **Step 2.1: Find all match sites** - -Run: `cargo build 2>&1 | grep -B1 -A2 "non-exhaustive\|missing match arm"` - -Expected: a list of files with non-exhaustive `match` against `Def`. Likely candidates: `crates/ailang-check/src/lib.rs`, `crates/ailang-codegen/src/lib.rs`, `crates/ailang-prose/src/lib.rs`, `crates/ailang-surface/src/print.rs`, `crates/ail/src/main.rs` (workspace, diff, manifest subcommands), `crates/ailang-core/src/canonical.rs`, `crates/ailang-core/src/desugar.rs`, `crates/ailang-core/src/pretty.rs`. - -- [ ] **Step 2.2: For each site, add explicit `Class` and `Instance` arms** - -Default behaviour for 22b.1 (deferred to later sub-iters): -- **Typecheck (`ailang-check`)**: skip (do not check the class/instance arms yet). Add a TODO comment naming the deferred 22b.2 work. -- **Codegen (`ailang-codegen`)**: skip emission. Class/instance produces no IR until 22b.3 monomorphisation. Add a TODO comment. -- **Prose projection (`ailang-prose`)**: emit a placeholder marker `(class …)` / `(instance …)` so the round-trip test survives. The real projection lands in 22b.4 (or its own iter). -- **Surface print (`ailang-surface/src/print.rs`)**: same — placeholder. -- **CLI subcommands**: `ail manifest` lists the class/instance by `def_kind`; `ail diff` treats class/instance as opaque-but-structural (compare canonical hashes only). -- **Pretty (`ailang-core/src/pretty.rs`)**: emit a one-line summary like `class Show a` / `instance Show Int`. -- **Canonical (`ailang-core/src/canonical.rs`)**: serde already gives canonical bytes; no special handling needed. -- **Desugar (`ailang-core/src/desugar.rs`)**: pass through unchanged. - -Concrete example for `ailang-check/src/lib.rs` (find the `for def in module.defs` loop): - -```rust -for def in &module.defs { - match def { - Def::Fn(f) => check_fn(f, ...)?, - Def::Const(c) => check_const(c, ...)?, - Def::Type(t) => check_type(t, ...)?, - Def::Class(_) | Def::Instance(_) => { - // 22b.1: schema-only landing. Class-schema validation - // (KindMismatch, InvalidSuperclassParam, ...) and - // instance-body typechecking land in 22b.2. - } - } -} -``` - -- [ ] **Step 2.3: Run `cargo build`** - -Expected: builds cleanly across the workspace. No warnings. - -- [ ] **Step 2.4: Run `cargo test`** - -Expected: all 288 existing tests pass, 3 ignored. No new test failures (no new tests yet). - -- [ ] **Step 2.5: Commit** - -```bash -git add crates/ailang-core/src/ast.rs crates/ailang-check/src/ crates/ailang-codegen/src/ crates/ailang-prose/src/ crates/ailang-surface/src/ crates/ail/src/main.rs crates/ailang-core/src/canonical.rs crates/ailang-core/src/desugar.rs crates/ailang-core/src/pretty.rs -git commit -m "iter 22b.1.1: ClassDef/InstanceDef AST + downstream match arms (schema-only)" -``` - ---- - -### Task 3: Hash-stability regression tests - -The schema additions must not change canonical-JSON hashes of any pre-22b definition. Two regression tests follow the established pattern from `iter13a_schema_extension_preserves_pre_13a_hashes` and `iter19b_schema_extension_preserves_pre_19b_hashes` in `crates/ailang-core/src/hash.rs`. - -**Files:** -- Modify: `crates/ailang-core/src/hash.rs` (add two tests inside the existing `#[cfg(test)] mod tests` block) - -- [ ] **Step 3.1: Write hash-stability test against on-disk fixtures** - -Append to the test module in `hash.rs`: - -```rust -/// Iter 22b.1 regression: adding `Def::Class` and `Def::Instance` -/// must NOT change canonical-JSON hashes of any pre-22b def. Pre-22b -/// fixtures had no class/instance variants, so all on-disk fns/types -/// must hash bit-identically to the values recorded by 13a/19b. -#[test] -fn iter22b1_schema_extension_preserves_pre_22b_hashes() { - 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")) - .expect("examples/sum.ail.json present"); - 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"); - - let list_src = std::fs::read(examples.join("list.ail.json")) - .expect("examples/list.ail.json present"); - 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"); -} -``` - -- [ ] **Step 3.2: Run the test to verify it passes** - -Run: `cargo test -p ailang-core iter22b1_schema_extension_preserves_pre_22b_hashes` - -Expected: PASS. Pre-22b fixtures hash identically because the new `Def` variants are alternatives, not field additions — pre-22b fixtures simply don't use them. - -- [ ] **Step 3.3: Write a same-shape-different-paths test for the new variants themselves** - -```rust -/// Iter 22b.1: a `ClassDef` with no doc, no superclass, and an empty -/// methods list serialises to a stable canonical form. Two construction -/// paths (default-elided field vs. explicit-None field) hash identically. -#[test] -fn iter22b1_classdef_empty_optionals_hash_stable() { - use crate::ast::{ClassDef, Def}; - - let bare = Def::Class(ClassDef { - name: "Empty".into(), - param: "a".into(), - superclass: None, - methods: vec![], - doc: None, - }); - - // The same value, but constructed from JSON without the optional - // fields present at all. - let json = r#"{"kind":"class","name":"Empty","param":"a","methods":[]}"#; - let parsed: Def = serde_json::from_str(json).unwrap(); - - assert_eq!( - def_hash(&bare), - def_hash(&parsed), - "ClassDef with elided optionals must hash identically to construction with explicit None" - ); -} -``` - -- [ ] **Step 3.4: Run the test** - -Run: `cargo test -p ailang-core iter22b1_classdef_empty_optionals_hash_stable` - -Expected: PASS. If FAIL: a `skip_serializing_if` is missing on one of `superclass`, `doc`, or the parser is producing different bytes from the same value. - -- [ ] **Step 3.5: Commit** - -```bash -git add crates/ailang-core/src/hash.rs -git commit -m "iter 22b.1.2: hash-stability regression tests for ClassDef/InstanceDef" -``` - ---- - -### Task 4: Workspace-load-time instance registry - -The registry maps `(class-name, canonical-hash-of-instance-type)` to an instance plus the module that defined it. It is built at workspace-load time after the DFS completes. - -**Files:** -- Modify: `crates/ailang-core/src/workspace.rs` (add `Registry` struct, `RegistryEntry`, build during `load_workspace`) - -- [ ] **Step 4.1: Write a failing test for an empty registry** - -Append to the existing `#[cfg(test)] mod tests` block in `workspace.rs` (or create one if absent): - -```rust -#[test] -fn iter22b1_workspace_with_no_classes_has_empty_registry() { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let examples = manifest_dir.join("../../examples"); - let entry = examples.join("sum.ail.json"); - - let ws = load_workspace(&entry).expect("sum.ail.json loads"); - assert!( - ws.registry.entries.is_empty(), - "pre-22b fixture has no class/instance defs, registry must be empty" - ); -} -``` - -- [ ] **Step 4.2: Run the test to verify it fails** - -Run: `cargo test -p ailang-core iter22b1_workspace_with_no_classes_has_empty_registry` - -Expected: FAIL with "no field `registry` on Workspace" (or similar). The struct doesn't have the field yet. - -- [ ] **Step 4.3: Add `Registry` struct and field** - -In `workspace.rs`, after the `Workspace` struct: - -```rust -/// Iter 22b.1: workspace-global instance registry. -/// -/// Built at the end of [`load_workspace`] after all modules are loaded. -/// Keyed by `(class-name, canonical-type-hash)`; values are the -/// `InstanceDef` plus the name of the module it was declared in. The -/// hash key uses the same canonical-byte algorithm as [`def_hash`] but -/// applied only to the instance's `type_` field, so the key is stable -/// against unrelated whitespace / field-order differences. -#[derive(Debug, Clone, Default)] -pub struct Registry { - /// Map from `(class-name, type-hash)` to the registry entry. - pub entries: BTreeMap<(String, String), RegistryEntry>, -} - -/// One entry in the [`Registry`]. -#[derive(Debug, Clone)] -pub struct RegistryEntry { - /// The instance declaration itself. - pub instance: ast::InstanceDef, - /// Name of the module the instance was declared in. - pub defining_module: String, -} -``` - -Add `pub registry: Registry,` to the `Workspace` struct. - -In `load_workspace`, before the final `Ok(Workspace { ... })`, build the registry: - -```rust -let registry = build_registry(&modules)?; -Ok(Workspace { - entry: entry_name, - modules, - root_dir, - registry, -}) -``` - -Add a stub `build_registry`: - -```rust -fn build_registry( - modules: &BTreeMap, -) -> Result { - let mut entries: BTreeMap<(String, String), RegistryEntry> = BTreeMap::new(); - for (mod_name, m) in modules { - for def in &m.defs { - if let Def::Instance(inst) = def { - let type_hash = canonical::type_hash(&inst.type_); - let key = (inst.class.clone(), type_hash); - entries.insert( - key, - RegistryEntry { - instance: inst.clone(), - defining_module: mod_name.clone(), - }, - ); - } - } - } - Ok(Registry { entries }) -} -``` - -This uses `canonical::type_hash` — a function we need to add. See Step 4.4. - -- [ ] **Step 4.4: Add `canonical::type_hash`** - -In `crates/ailang-core/src/canonical.rs`: - -```rust -/// Iter 22b.1: hash a [`Type`] in isolation. -/// -/// Used by the workspace registry to key `InstanceDef`s by their target -/// type. The 16-hex-char prefix matches the convention of [`def_hash`] -/// and [`module_hash`]. -pub fn type_hash(t: &crate::ast::Type) -> String { - let bytes = to_bytes(t); - let h = blake3::hash(&bytes); - h.to_hex().as_str()[..16].to_string() -} -``` - -(`to_bytes` is the existing serialiser-to-canonical-bytes function; `Type` already implements `Serialize`.) - -- [ ] **Step 4.5: Run the test to verify it passes** - -Run: `cargo test -p ailang-core iter22b1_workspace_with_no_classes_has_empty_registry` - -Expected: PASS. - -- [ ] **Step 4.6: Commit** - -```bash -git add crates/ailang-core/src/workspace.rs crates/ailang-core/src/canonical.rs -git commit -m "iter 22b.1.3: workspace-load-time instance registry (empty case)" -``` - ---- - -### Task 5: Coherence check (orphan detection) - -The registry-build phase rejects orphan instances: an `instance C T` declared in a module that is neither `C`'s defining module nor `T`'s defining module. - -**Files:** -- Modify: `crates/ailang-core/src/workspace.rs` (extend `WorkspaceLoadError`, extend `build_registry`) -- Create: `examples/test_22b1_orphan_class.ail.json`, `examples/test_22b1_orphan_type.ail.json`, `examples/test_22b1_orphan_third.ail.json` (three test fixtures) - -- [ ] **Step 5.1: Write a failing test** - -```rust -#[test] -fn iter22b1_orphan_instance_fires_diagnostic() { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let examples = manifest_dir.join("../../examples"); - let entry = examples.join("test_22b1_orphan_third.ail.json"); - - let err = load_workspace(&entry).expect_err("must fire orphan"); - match err { - WorkspaceLoadError::OrphanInstance { class, type_repr, defining_module, .. } => { - assert_eq!(class, "Show"); - assert_eq!(type_repr, "Int"); - assert_eq!(defining_module, "test_22b1_orphan_third"); - } - other => panic!("expected OrphanInstance, got {:?}", other), - } -} -``` - -- [ ] **Step 5.2: Run to verify failure** - -Run: `cargo test -p ailang-core iter22b1_orphan_instance_fires_diagnostic` - -Expected: FAIL with "no variant `OrphanInstance` on `WorkspaceLoadError`" or "fixture not found". - -- [ ] **Step 5.3: Add `OrphanInstance` variant** - -In `WorkspaceLoadError`: - -```rust -/// Iter 22b.1: an `InstanceDef` was declared in a module that is -/// neither the class's defining module nor the instance type's -/// defining module. Coherence violation per Decision 11. -#[error( - "orphan instance: `instance {class} {type_repr}` declared in module `{defining_module}`, \ - but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there" -)] -OrphanInstance { - class: String, - type_repr: String, - defining_module: String, - class_module: String, - type_module: String, -}, -``` - -- [ ] **Step 5.4: Extend `build_registry` with the coherence check** - -```rust -fn build_registry( - modules: &BTreeMap, -) -> Result { - // First pass: build a "where is X defined" lookup for both classes and types. - let mut class_def_module: BTreeMap = BTreeMap::new(); - let mut type_def_module: BTreeMap = BTreeMap::new(); - for (mod_name, m) in modules { - for def in &m.defs { - match def { - Def::Class(c) => { class_def_module.insert(c.name.clone(), mod_name.clone()); } - Def::Type(t) => { type_def_module.insert(t.name.clone(), mod_name.clone()); } - _ => {} - } - } - } - - let mut entries: BTreeMap<(String, String), RegistryEntry> = BTreeMap::new(); - for (mod_name, m) in modules { - for def in &m.defs { - if let Def::Instance(inst) = def { - // Coherence: instance must be in class's module or type's module. - let class_mod = class_def_module - .get(&inst.class) - .cloned() - .unwrap_or_else(|| "".into()); - let type_repr = type_head_name(&inst.type_); - let type_mod = type_def_module - .get(&type_repr) - .cloned() - .unwrap_or_else(|| "".into()); - - let coherent = - mod_name == &class_mod || mod_name == &type_mod; - if !coherent { - return Err(WorkspaceLoadError::OrphanInstance { - class: inst.class.clone(), - type_repr, - defining_module: mod_name.clone(), - class_module: class_mod, - type_module: type_mod, - }); - } - - let type_hash = canonical::type_hash(&inst.type_); - let key = (inst.class.clone(), type_hash); - entries.insert( - key, - RegistryEntry { - instance: inst.clone(), - defining_module: mod_name.clone(), - }, - ); - } - } - } - Ok(Registry { entries }) -} - -/// Iter 22b.1: extract the head constructor name of a type for the -/// purposes of "where is this type defined" lookup. For `Type::Con -/// { name, args }` returns `name`; for primitive types returns the -/// primitive name (`"Int"`, `"Float"`, etc.). Type variables and -/// `Type::Forall` are not legal as instance heads — they would have -/// been rejected at parse time (the schema for `InstanceDef.type_` -/// requires a concrete type expression). -fn type_head_name(t: &crate::ast::Type) -> String { - use crate::ast::Type; - match t { - Type::Con { name, .. } => name.clone(), - Type::Int => "Int".into(), - Type::Float => "Float".into(), - Type::Bool => "Bool".into(), - Type::String => "String".into(), - // Other variants (Var, Forall, Fn, ...) are rejected at parse - // time per the InstanceDef schema; if we reach here it's an - // internal error, but we surface a stable string so the - // diagnostic is at least readable. - _ => "".into(), - } -} -``` - -(Note: the exact `Type` enum variants come from `crates/ailang-core/src/ast.rs:434`. Match arms must be exhaustive; if your AILang version has additional variants, add them here. The implementer should grep `Type::` in `ast.rs` to confirm.) - -- [ ] **Step 5.5: Build test fixtures** - -Create `examples/test_22b1_orphan_class.ail.json`: a one-module workspace where `Show` and `instance Show Int` both live in the same module (the class's module). Coherence holds. - -Concrete content (skeleton — actual schema follows existing examples like `examples/sum.ail.json`): - -```json -{ - "name": "test_22b1_orphan_class", - "imports": [], - "defs": [ - { - "kind": "class", - "name": "Show", - "param": "a", - "methods": [ - { "name": "show", "type": { "Fn": { "params": [{ "Var": "a" }], "return": "String" } } } - ] - }, - { - "kind": "instance", - "class": "Show", - "type": "Int", - "methods": [ - { "name": "show", "body": { "t": "lit", "lit": { "Str": "" } } } - ] - } - ] -} -``` - -(The `Type` JSON encoding shown is illustrative — the implementer should consult the existing `examples/list.ail.json` for the actual `Type::Fn` JSON form.) - -Create `examples/test_22b1_orphan_type.ail.json`: instance lives in the type's module (also coherent). - -Create `examples/test_22b1_orphan_third.ail.json`: instance lives in a third module that is neither the class's nor the type's. This MUST fire `OrphanInstance`. - -- [ ] **Step 5.6: Run the test to verify it passes** - -Run: `cargo test -p ailang-core iter22b1_orphan_instance_fires_diagnostic` - -Expected: PASS. - -- [ ] **Step 5.7: Add positive tests (coherent instance loads)** - -```rust -#[test] -fn iter22b1_instance_in_class_module_loads_clean() { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let examples = manifest_dir.join("../../examples"); - let entry = examples.join("test_22b1_orphan_class.ail.json"); - - let ws = load_workspace(&entry).expect("coherent instance loads"); - assert_eq!(ws.registry.entries.len(), 1); - let (key, entry) = ws.registry.entries.iter().next().unwrap(); - assert_eq!(&key.0, "Show"); - assert_eq!(entry.defining_module, "test_22b1_orphan_class"); -} -``` - -- [ ] **Step 5.8: Run the positive test** - -Run: `cargo test -p ailang-core iter22b1_instance_in_class_module_loads_clean` - -Expected: PASS. - -- [ ] **Step 5.9: Commit** - -```bash -git add crates/ailang-core/src/workspace.rs examples/test_22b1_orphan_*.ail.json -git commit -m "iter 22b.1.4: coherence check (orphan-instance detection)" -``` - ---- - -### Task 6: Uniqueness check (duplicate-instance detection) - -Two `InstanceDef`s with the same `(class, type-hash)` key are a coherence violation. - -**Files:** -- Modify: `crates/ailang-core/src/workspace.rs` -- Create: `examples/test_22b1_dup_instance_a.ail.json`, `examples/test_22b1_dup_instance_b.ail.json`, `examples/test_22b1_dup_entry.ail.json` - -- [ ] **Step 6.1: Write failing test** - -```rust -#[test] -fn iter22b1_duplicate_instance_fires_diagnostic() { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let examples = manifest_dir.join("../../examples"); - let entry = examples.join("test_22b1_dup_entry.ail.json"); - - let err = load_workspace(&entry).expect_err("must fire duplicate"); - match err { - WorkspaceLoadError::DuplicateInstance { class, type_repr, first_module, second_module } => { - assert_eq!(class, "Show"); - assert_eq!(type_repr, "Int"); - assert_ne!(first_module, second_module); - } - other => panic!("expected DuplicateInstance, got {:?}", other), - } -} -``` - -- [ ] **Step 6.2: Run to verify failure** - -Run: `cargo test -p ailang-core iter22b1_duplicate_instance_fires_diagnostic` - -Expected: FAIL ("no variant `DuplicateInstance`"). - -- [ ] **Step 6.3: Add `DuplicateInstance` variant** - -```rust -/// Iter 22b.1: two `InstanceDef`s share the same (class, type-hash) -/// key. Coherence requires uniqueness; the registry has no way to -/// disambiguate at resolution time. -#[error( - "duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`" -)] -DuplicateInstance { - class: String, - type_repr: String, - first_module: String, - second_module: String, -}, -``` - -- [ ] **Step 6.4: Extend `build_registry` with the check** - -In the `for def in &m.defs { if let Def::Instance(inst) = def { ... } }` block, replace the `entries.insert(key, ...)` line with: - -```rust -let key = (inst.class.clone(), type_hash); -if let Some(prior) = entries.get(&key) { - return Err(WorkspaceLoadError::DuplicateInstance { - class: inst.class.clone(), - type_repr: type_head_name(&inst.type_), - first_module: prior.defining_module.clone(), - second_module: mod_name.clone(), - }); -} -entries.insert( - key, - RegistryEntry { - instance: inst.clone(), - defining_module: mod_name.clone(), - }, -); -``` - -- [ ] **Step 6.5: Build test fixtures** - -`examples/test_22b1_dup_instance_a.ail.json`: declares `class Show` and `instance Show Int`. -`examples/test_22b1_dup_instance_b.ail.json`: declares another `instance Show Int` (this module owns no `Int`-relevant type, but coherence is satisfied because... wait — actually this is a tricky setup; if Int is primitive, only the class's module can own instances for it. So the duplicate must be in the class's module, but that's syntactically one module). - -Better setup: define a user type `MyInt` in module B; declare `class Show` in module A; declare `instance Show MyInt` in BOTH module A (legal: Show's module) and module B (legal: MyInt's module). Both are coherent individually but their existence together is a duplicate. - -`examples/test_22b1_dup_entry.ail.json`: imports A and B. - -- [ ] **Step 6.6: Run the test** - -Run: `cargo test -p ailang-core iter22b1_duplicate_instance_fires_diagnostic` - -Expected: PASS. - -- [ ] **Step 6.7: Commit** - -```bash -git add crates/ailang-core/src/workspace.rs examples/test_22b1_dup_*.ail.json -git commit -m "iter 22b.1.5: uniqueness check (duplicate-instance detection)" -``` - ---- - -### Task 7: Method-completeness check - -An `InstanceDef` must specify a body for every required (non-default) method of its class. This check needs the class definition; it runs after the registry pass collects classes. - -**Files:** -- Modify: `crates/ailang-core/src/workspace.rs` -- Create: `examples/test_22b1_missing_method.ail.json` - -- [ ] **Step 7.1: Write failing test** - -```rust -#[test] -fn iter22b1_missing_method_fires_diagnostic() { - let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let examples = manifest_dir.join("../../examples"); - let entry = examples.join("test_22b1_missing_method.ail.json"); - - let err = load_workspace(&entry).expect_err("must fire missing-method"); - match err { - WorkspaceLoadError::MissingMethod { class, type_repr, method } => { - assert_eq!(class, "Eq"); - assert_eq!(type_repr, "Int"); - assert_eq!(method, "eq"); - } - other => panic!("expected MissingMethod, got {:?}", other), - } -} -``` - -- [ ] **Step 7.2: Run to verify failure** - -Expected: FAIL ("no variant `MissingMethod`"). - -- [ ] **Step 7.3: Add `MissingMethod` variant** - -```rust -/// Iter 22b.1: an `InstanceDef` does not specify a body for a -/// required (non-default) method of its class. -#[error( - "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)" -)] -MissingMethod { - class: String, - type_repr: String, - method: String, -}, -``` - -- [ ] **Step 7.4: Extend `build_registry`** - -Build a `class_methods: BTreeMap` lookup in the same first pass as `class_def_module`. Then, after registering a non-orphan, non-duplicate instance: - -```rust -// Method-completeness check. -let class_def = match class_methods.get(&inst.class) { - Some(c) => c, - None => { - // Class not declared anywhere — defer to 22b.2 typecheck for - // the "unknown class" diagnostic. For 22b.1 we just skip the - // completeness check. - // (Alternative: fire a NoSuchClass diagnostic here. Keeping - // simple for 22b.1.) - continue; - } -}; -let provided: BTreeSet<&str> = inst.methods.iter().map(|m| m.name.as_str()).collect(); -for class_method in &class_def.methods { - if class_method.default.is_none() && !provided.contains(class_method.name.as_str()) { - return Err(WorkspaceLoadError::MissingMethod { - class: inst.class.clone(), - type_repr: type_head_name(&inst.type_), - method: class_method.name.clone(), - }); - } -} -``` - -- [ ] **Step 7.5: Build the test fixture** - -`examples/test_22b1_missing_method.ail.json`: declares `class Eq a` with two non-default methods (`eq` and `ne`), then `instance Eq Int` that only specifies `ne`. Expected: `MissingMethod { method: "eq" }`. - -- [ ] **Step 7.6: Run the test** - -Expected: PASS. - -- [ ] **Step 7.7: Commit** - -```bash -git add crates/ailang-core/src/workspace.rs examples/test_22b1_missing_method.ail.json -git commit -m "iter 22b.1.6: method-completeness check" -``` - ---- - -### Task 8: Pre-commit verification — full test suite + bench gates - -Per CLAUDE.md, an iter close requires the test suite passes and bench gates are in defensible state. 22b.1 is schema + workspace-load only — no codegen, no runtime, no typecheck arms touched. The bench gates should not move; if they do, that's a real signal. - -- [ ] **Step 8.1: Run full test suite** - -Run: `cargo test` - -Expected: 288 passed (existing) + 7 new (22b.1.{empty registry, hash-stability x2, orphan, orphan-positive, duplicate, missing-method}) = 295. 0 failed. 3 ignored (existing). - -If FAIL: investigate before committing further. Likely cause: a `match Def::*` site in the workspace was missed in Task 2. - -- [ ] **Step 8.2: Run runtime bench gate** - -Run: `bench/check.py` - -Expected: exit 0. 63 metrics, all stable. If any move: investigate — 22b.1 should not touch any hot path. A moved metric likely means the test fixtures triggered something in the build pipeline that shouldn't be there. - -- [ ] **Step 8.3: Run compile-time bench gate** - -Run: `bench/compile_check.py` - -Expected: exit 0. 24 metrics, all stable. The bench corpus should not have grown — fixtures live under `examples/test_22b1_*`, which the compile-bench corpus does not include. - -- [ ] **Step 8.4: Run cross-language bench gate** - -Run: `bench/cross_lang.py` - -Expected: exit 0. 25 metrics, all stable. - -- [ ] **Step 8.5: Final commit (JOURNAL update)** - -Add a JOURNAL entry for 22b.1's close. Pattern follows the 22a entry from commit `f6cb900`: - -```bash -# Edit docs/JOURNAL.md to add a new section dated today, summarising: -# - what 22b.1 shipped (schema + registry + 3 coherence checks) -# - test count change (288 → 295) -# - bench gates green -# - JOURNAL queue updated: 22b.1 closed, 22b.2 next dispatch -git add docs/JOURNAL.md -git commit -m "iter 22b.1: typeclass schema floor + workspace registry — closed" -``` - ---- - -## Self-review notes - -**Spec coverage:** Every requirement of Decision 11 §"Form-A schema" subsection on ClassDef/InstanceDef shape is covered (Task 1). The three coherence checks called out in §"Resolution and monomorphisation" — coherence, uniqueness, completeness — map to Tasks 5, 6, 7. The `OrphanInstance`, `DuplicateInstance`, and `MissingMethod` diagnostic categories from §"Diagnostic categories — Workspace-load (registry-build) diagnostics" are all wired. - -Deferred to 22b.2: `OverridingNonExistentMethod` (instance specifies method not in class), `MethodNameCollision`, and the schema-time diagnostics `KindMismatch`, `InvalidSuperclassParam`, `ConstraintReferencesUnboundTypeVar`. These need the typechecker to be class-aware, which 22b.1 deliberately does not do. - -**Placeholder scan:** No "TBD" / "TODO" remains in the plan; the only "TODO comments in code" are in Task 2's downstream-match-arms, where they are deliberate markers pointing at later sub-iters. They are not plan placeholders. - -**Type consistency:** `ClassDef.param: String` (single, not `Vec`) consistent across Task 1 and the coherence-check `type_head_name` helper in Task 5. `InstanceDef.type_: Type` consistent. `Registry.entries` keyed by `(String, String)` (class-name, type-hash) consistent across Tasks 4–7. `WorkspaceLoadError` variants `OrphanInstance`/`DuplicateInstance`/`MissingMethod` referenced in tests match the variants added in Tasks 5–7. - -**Project-convention notes (deviations from the writing-plans-skill default):** - -1. **Plan location.** The skill defaults to `docs/superpowers/plans/`. AILang's project conventions place design in `docs/DESIGN.md` and decisions in `docs/JOURNAL.md`; per-iter plans are not part of the canonical-doc set. This plan is being saved to the skill's default location for the experiment, but the JOURNAL queue entry under 22a (commit `f6cb900`) already documents the same scope at a coarser level. Whether plan files like this become a standing artifact is an orchestrator decision, not a 22b.1 task. - -2. **Commit cadence.** The skill prescribes one commit per task. AILang's iter convention (CLAUDE.md §"Iter cycle") prefers tightly-scoped commits at iter boundaries — typically one commit per sub-iter, not one per task. This plan follows the skill's per-task-commit cadence (eight commits) for the 22b.1 sub-iter; the orchestrator may squash before merging into `main`. - -3. **TDD shape.** Strict red-green-commit per task is the skill's prescription and matches CLAUDE.md §"Bug fixes — TDD, always" for fixes. Feature iters in AILang have not historically been per-task TDD, but the discipline is compatible and produces a clean regression set. No conflict. - -4. **Implementer agent.** Per CLAUDE.md, implementer iters are delegated to `ailang-implementer`. This plan is shaped for direct human/LLM execution per the skill's expectation of zero-context implementer. If dispatched to `ailang-implementer`, the agent's mandatory reading list (which includes DESIGN.md, the schema patterns, and `examples/`) would supply project context that this plan does not need to repeat.