plan: 22b.1 typeclass schema floor + registry (writing-plans skill experiment)
This commit is contained in:
@@ -0,0 +1,927 @@
|
||||
# 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<String>`).
|
||||
///
|
||||
/// `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<SuperclassRef>,
|
||||
/// Methods of the class, in declaration order.
|
||||
pub methods: Vec<ClassMethod>,
|
||||
/// Optional source-level documentation string.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub doc: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Term>,
|
||||
}
|
||||
|
||||
/// 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<InstanceMethod>,
|
||||
/// Optional source-level documentation string.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub doc: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String, Module>,
|
||||
) -> Result<Registry, WorkspaceLoadError> {
|
||||
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<String, Module>,
|
||||
) -> Result<Registry, WorkspaceLoadError> {
|
||||
// First pass: build a "where is X defined" lookup for both classes and types.
|
||||
let mut class_def_module: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut type_def_module: BTreeMap<String, String> = 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(|| "<unknown>".into());
|
||||
let type_repr = type_head_name(&inst.type_);
|
||||
let type_mod = type_def_module
|
||||
.get(&type_repr)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "<primitive-or-unknown>".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.
|
||||
_ => "<non-concrete>".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": "<int>" } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
(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<String, &ClassDef>` 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<String>`) 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.
|
||||
Reference in New Issue
Block a user