Files
AILang/docs/plans/0004-22b4a-form-a-class-instance.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

48 KiB

22b.4a — Form-A parser+printer arms for ClassDef/InstanceDef + spec amendment

Parent spec: docs/specs/0002-22-typeclasses.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: make parse(print(M)) == M (after canonicalisation) for every examples/*.ail.json fixture, including the test_22b1_*, test_22b2_*, and test_22b3_* typeclass fixtures that are currently skipped. Retire the round-trip skip-list. Amend the parent spec to record (a) the iteration split, (b) the __ separator chosen in 22b.3 for mono-symbol names.

Architecture: the surface crate (crates/ailang-surface) is Form-A, the s-expression projection. (The crate's docstring and the round-trip-skip-list comment loosely call this "Form-B"; that is a documentation defect — crates/ailang-prose is the actual Form-B and has no parser. The 22 parent spec inherits the same conflation.) This iteration adds Form-A parser arms for Def::Class / Def::Instance under parse_module's dispatch, plus matching printer arms in write_def, both written so the existing canonical-equal round-trip gate passes for every typeclass fixture in examples/. The 22b.1 placeholder printer arms (single-token (class Name param) / (instance Class)) are replaced with the real implementations.

Tech Stack: crates/ailang-surface (parse + print), ailang-core AST types (ClassDef, InstanceDef, ClassMethod, InstanceMethod, SuperclassRef — all already present from 22b.1).

Out of scope (deferred to 22b.4b): Prelude module with Show class, instance Show Int, the int_to_str C-runtime primitive, the end-to-end do io/print_str (show 42) gate. Substantive reason: the Prelude requires a new C-runtime function, codegen wiring for int_to_str, and an examples-level fixture; that is a different risk profile from pure-surface plumbing and benefits from its own review loop. Eq/Ord deferred further (require str_eq / str_compare primitives that the runtime does not yet expose). Top-level print deferred until polymorphic-fn monomorphisation is on the table — the current mono pass synthesises only class-method instances, not arbitrary polymorphic top-levels.


Files this plan creates or modifies

  • Modify: crates/ailang-surface/src/parse.rs — adds parse_class, parse_instance, two helper sub-parsers (parse_class_method, parse_instance_method, parse_superclass); extends the parse_module def-head dispatch.
  • Modify: crates/ailang-surface/src/print.rs — replaces the 22b.1 placeholder arms in write_def; adds write_class_def, write_instance_def, plus per-method writers (write_class_method, write_instance_method, write_superclass).
  • Modify: crates/ailang-surface/tests/round_trip.rs — removes the test_22b1_* / test_22b2_* / test_22b3_* filter clauses; lets the gate exercise every fixture.
  • Modify: docs/specs/0002-22-typeclasses.md — appends an "Amendments" section recording the 22b.4a / 22b.4b split, the Form-A vs Form-B terminology fix, the __ separator decision, and the 22c-and-beyond impact.
  • Modify: docs/DESIGN.md (Decision 11 §"Resolution and monomorphisation") — one-paragraph note on __ separator, citing LLVM IR identifier legality.

No new files. No test crate changes (round-trip + the existing examples/test_22b* fixtures are the regression surface).


Cross-task invariants

Three constraints every task in this plan obeys:

  1. Round-trip property is sacred. Any task whose final command is cargo test -p ailang-surface --test round_trip MUST observe green before commit.
  2. No new fixture files. Hand-written inline test inputs only; the existing examples/test_22b* set is the round-trip integration target. New fixtures would expand 22b.4a beyond surface plumbing.
  3. No primitive additions and no codegen changes. This is a pure surface-crate iteration. If a task is tempted to touch ailang-codegen or ailang-check, that is a sign it belongs in 22b.4b (or further).

S-expression form (locked here, implemented in tasks)

The following Form-A surface syntax is committed for ClassDef and InstanceDef. The shape mirrors (fn ...) and (data ...): a keyword head, the def name (where applicable), then (...) attribute-clauses in declaration order. Unknown attribute keywords are rejected with ParseError::Production so future schema extensions fail loudly.

ClassDef:

(class <ClassName>
  (param <param-ident>)
  [(superclass (class <Name>) (type <param-ident>))]
  [(doc "<docstring>")]
  (method <name>
    (type <fn-type-expr>)
    [(default <body-term>)])*
  )
  • (param ...) is required; matches ClassDef.param: String.
  • (superclass ...) is optional; at most one.
  • (doc ...) is optional; at most one. Same shape as the fn-def (doc ...) clause.
  • (method ...) clauses appear in declaration order. Each method requires (type ...); (default ...) is optional and represents the explicit-default-keyword body from Decision 11.

InstanceDef:

(instance
  (class <ClassName>)
  (type <type-expr>)
  [(doc "<docstring>")]
  (method <name>
    (body <term>))*
  )
  • No name follows instance — the (class, type) pair identifies it.
  • (class ...) and (type ...) are required; one of each.
  • (doc ...) is optional.
  • (method ...) clauses each contain exactly one (body ...) clause. Method order matches declaration order.

Both heads use the existing helpers:

  • parse_type_attr for (type ...) (class method types and instance type both go through this).
  • parse_body_attr for (default ...) and instance (body ...) bodies — note parse_body_attr consumes literally (body ...), so the class-default clause is parsed by a new sibling helper parse_default_attr that mirrors parse_body_attr but expects the keyword default instead of body.

Task 1: Form-A parser arm for ClassDef

Files:

  • Modify: crates/ailang-surface/src/parse.rs:281-320 (extend parse_module dispatch); append parse_class, parse_class_method, parse_superclass, parse_default_attr after parse_const (around line 700).

  • Step 1.1: Write the failing test (RED)

Append inside the existing #[cfg(test)] mod tests { ... } block in crates/ailang-surface/src/parse.rs (the module starts at parse.rs:1353). Put the new tests just before the closing } of that module, with the existing use super::*; already in scope.

    #[test]
    fn parses_minimal_class_def() {
        let src = r#"(module M
  (class Foo
    (param a)
    (method m
      (type (fn-type (params (con a)) (ret (con Int)) (effects))))))"#;
        let m = parse(src).expect("parse ok");
        assert_eq!(m.defs.len(), 1, "one def");
        match &m.defs[0] {
            ailang_core::ast::Def::Class(c) => {
                assert_eq!(c.name, "Foo");
                assert_eq!(c.param, "a");
                assert!(c.superclass.is_none(), "no superclass");
                assert!(c.doc.is_none(), "no doc");
                assert_eq!(c.methods.len(), 1, "one method");
                assert_eq!(c.methods[0].name, "m");
                assert!(c.methods[0].default.is_none(),
                    "no default");
            }
            other => panic!("expected Def::Class, got {other:?}"),
        }
    }

    #[test]
    fn parses_class_def_with_superclass_and_default() {
        let src = r#"(module M
  (class Bar
    (param a)
    (superclass (class Foo) (type a))
    (doc "bar extends foo")
    (method m
      (type (fn-type (params (con a)) (ret (con Int)) (effects)))
      (default (lit (int 0))))))"#;
        let m = parse(src).expect("parse ok");
        let c = match &m.defs[0] {
            ailang_core::ast::Def::Class(c) => c,
            _ => panic!("expected Class"),
        };
        let sc = c.superclass.as_ref().expect("has superclass");
        assert_eq!(sc.class, "Foo");
        assert_eq!(sc.type_, "a");
        assert_eq!(c.doc.as_deref(), Some("bar extends foo"));
        assert!(c.methods[0].default.is_some(),
            "method default present");
    }

(parse is the same pub fn parse(...) that lives at the top of this file and is re-exported from ailang_surface::parse. The Form-A literal (lit (int 0)) matches the existing Term::Lit parse arm — verify by grepping parse_term for "int" if uncertain. The (fn-type ...) form is what parse_type_attr → parse_type accepts; the existing parse_fn tests upstream use the same shape.)

  • Step 1.2: Run test, observe RED
cargo test -p ailang-surface parses_minimal_class_def

Expected: FAIL with parse error along the lines of unknown def head "class"; expected "data", "fn", "const", or "import" (the existing parse_module error path at parse.rs:312).

  • Step 1.3: Add Class to the def-head dispatch

In parse_module (parse.rs around line 309-318), extend the inner match to recognise class. Leave instance for Task 2 (so Task 2's RED is a parse error rather than a build error). Replace:

            match head {
                "import" => imports.push(self.parse_import()?),
                "data" => defs.push(Def::Type(self.parse_data()?)),
                "fn" => defs.push(Def::Fn(self.parse_fn()?)),
                "const" => defs.push(Def::Const(self.parse_const()?)),
                other => {
                    let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                    return Err(ParseError::Production {
                        production: "module",
                        message: format!(
                            "unknown def head `{other}`; expected `data`, `fn`, `const`, or `import`"
                        ),
                        pos,
                    });
                }
            }

with:

            match head {
                "import" => imports.push(self.parse_import()?),
                "data" => defs.push(Def::Type(self.parse_data()?)),
                "fn" => defs.push(Def::Fn(self.parse_fn()?)),
                "const" => defs.push(Def::Const(self.parse_const()?)),
                "class" => defs.push(Def::Class(self.parse_class()?)),
                other => {
                    let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                    return Err(ParseError::Production {
                        production: "module",
                        message: format!(
                            "unknown def head `{other}`; expected `data`, `fn`, `const`, `class`, or `import`"
                        ),
                        pos,
                    });
                }
            }

Also add the AST imports at parse.rs:88. Replace:

    Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term,

with:

    Arm, ClassDef, ClassMethod, ConstDef, Ctor, Def, FnDef, Import, Literal, Module,
    ParamMode, Pattern, SuperclassRef, Suppress, Term,

(Add the AST imports for ClassDef, ClassMethod, SuperclassRef from ailang_core::ast. InstanceDef / InstanceMethod are added by Task 2.)

  • Step 1.4: Implement parse_class and helpers

Append to parse.rs after the existing parse_const block (around line 700, between parse_const and parse_type_attr):

    // ---- class def -----------------------------------------------------

    fn parse_class(&mut self) -> Result<ClassDef, ParseError> {
        self.expect_lparen("class-def")?;
        self.expect_keyword("class")?;
        let name = self.expect_ident("class name")?;
        let mut param: Option<String> = None;
        let mut superclass: Option<SuperclassRef> = None;
        let mut doc: Option<String> = None;
        let mut methods: Vec<ClassMethod> = Vec::new();
        loop {
            match self.peek_head_ident() {
                Some("param") => {
                    if param.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "class-def",
                            message: format!(
                                "class `{name}` has duplicate `(param ...)` clause"
                            ),
                            pos,
                        });
                    }
                    self.expect_lparen("class.param")?;
                    self.expect_keyword("param")?;
                    param = Some(self.expect_ident("class param ident")?);
                    self.expect_rparen("class.param")?;
                }
                Some("superclass") => {
                    if superclass.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "class-def",
                            message: format!(
                                "class `{name}` has duplicate `(superclass ...)` clause"
                            ),
                            pos,
                        });
                    }
                    superclass = Some(self.parse_superclass()?);
                }
                Some("doc") => {
                    if doc.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "class-def",
                            message: format!(
                                "class `{name}` has duplicate `(doc ...)` clause"
                            ),
                            pos,
                        });
                    }
                    doc = Some(self.parse_doc()?);
                }
                Some("method") => {
                    methods.push(self.parse_class_method()?);
                }
                Some(other) => {
                    let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                    return Err(ParseError::Production {
                        production: "class-def",
                        message: format!(
                            "unknown class attribute `{other}`; expected `param`, `superclass`, `doc`, or `method`"
                        ),
                        pos,
                    });
                }
                None => break,
            }
        }
        self.expect_rparen("class-def")?;
        let param = param.ok_or_else(|| ParseError::Production {
            production: "class-def",
            message: format!("class `{name}` is missing required `(param ...)` clause"),
            pos: 0,
        })?;
        Ok(ClassDef {
            name,
            param,
            superclass,
            methods,
            doc,
        })
    }

    fn parse_superclass(&mut self) -> Result<SuperclassRef, ParseError> {
        self.expect_lparen("superclass-clause")?;
        self.expect_keyword("superclass")?;
        // (class Name)
        self.expect_lparen("superclass.class")?;
        self.expect_keyword("class")?;
        let class = self.expect_ident("superclass name")?;
        self.expect_rparen("superclass.class")?;
        // (type ident)
        self.expect_lparen("superclass.type")?;
        self.expect_keyword("type")?;
        let type_ = self.expect_ident("superclass param ident")?;
        self.expect_rparen("superclass.type")?;
        self.expect_rparen("superclass-clause")?;
        Ok(SuperclassRef { class, type_ })
    }

    fn parse_class_method(&mut self) -> Result<ClassMethod, ParseError> {
        self.expect_lparen("class.method")?;
        self.expect_keyword("method")?;
        let name = self.expect_ident("class method name")?;
        let mut ty: Option<Type> = None;
        let mut default: Option<Term> = None;
        loop {
            match self.peek_head_ident() {
                Some("type") => {
                    if ty.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "class.method",
                            message: format!(
                                "method `{name}` has duplicate `(type ...)` clause"
                            ),
                            pos,
                        });
                    }
                    ty = Some(self.parse_type_attr()?);
                }
                Some("default") => {
                    if default.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "class.method",
                            message: format!(
                                "method `{name}` has duplicate `(default ...)` clause"
                            ),
                            pos,
                        });
                    }
                    default = Some(self.parse_default_attr()?);
                }
                Some(other) => {
                    let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                    return Err(ParseError::Production {
                        production: "class.method",
                        message: format!(
                            "unknown class.method attribute `{other}`; expected `type` or `default`"
                        ),
                        pos,
                    });
                }
                None => break,
            }
        }
        self.expect_rparen("class.method")?;
        let ty = ty.ok_or_else(|| ParseError::Production {
            production: "class.method",
            message: format!("method `{name}` is missing required `(type ...)` clause"),
            pos: 0,
        })?;
        Ok(ClassMethod { name, ty, default })
    }

    fn parse_default_attr(&mut self) -> Result<Term, ParseError> {
        self.expect_lparen("default-attr")?;
        self.expect_keyword("default")?;
        let t = self.parse_term()?;
        self.expect_rparen("default-attr")?;
        Ok(t)
    }
  • Step 1.5: Run RED tests, observe GREEN
cargo test -p ailang-surface parses_minimal_class_def
cargo test -p ailang-surface parses_class_def_with_superclass_and_default
cargo build --workspace

Expected: both tests PASS. Workspace build green.

  • Step 1.6: Commit
git add crates/ailang-surface/src/parse.rs
git commit -m "iter 22b.4a.1: form-a parser arm for ClassDef"

Task 2: Form-A parser arm for InstanceDef

Files:

  • Modify: crates/ailang-surface/src/parse.rs — append parse_instance and parse_instance_method after parse_class block.

  • Step 2.1: Write the failing test (RED)

Append inside the same #[cfg(test)] mod tests { ... } block as Task 1:

    #[test]
    fn parses_minimal_instance_def() {
        let src = r#"(module M
  (instance
    (class Foo)
    (type (con Int))
    (method m
      (body 5))))"#;
        let m = parse(src).expect("parse ok");
        match &m.defs[0] {
            ailang_core::ast::Def::Instance(i) => {
                assert_eq!(i.class, "Foo");
                match &i.type_ {
                    ailang_core::ast::Type::Con { name, args } => {
                        assert_eq!(name, "Int");
                        assert!(args.is_empty(), "Int has no args");
                    }
                    other => panic!("expected Type::Con, got {other:?}"),
                }
                assert!(i.doc.is_none());
                assert_eq!(i.methods.len(), 1);
                assert_eq!(i.methods[0].name, "m");
            }
            other => panic!("expected Def::Instance, got {other:?}"),
        }
    }

    #[test]
    fn parses_instance_def_with_doc() {
        let src = r#"(module M
  (instance
    (class Foo)
    (type (con Int))
    (doc "instance for ints")
    (method m (body 7))))"#;
        let m = parse(src).expect("parse ok");
        let i = match &m.defs[0] {
            ailang_core::ast::Def::Instance(i) => i,
            _ => panic!("expected Instance"),
        };
        assert_eq!(i.doc.as_deref(), Some("instance for ints"));
    }

(Type::Con carries { name, args }args is Vec<Type>; for Int it is empty.)

  • Step 2.2: Run test, observe RED
cargo test -p ailang-surface parses_minimal_instance_def

Expected: FAIL with parse error unknown def head "instance"; expected … emitted by the parse_module dispatch arm.

  • Step 2.3: Add Instance to the def-head dispatch

Locate the same parse_module match block edited in Task 1.3 and add the instance arm + extend the error message:

            match head {
                "import" => imports.push(self.parse_import()?),
                "data" => defs.push(Def::Type(self.parse_data()?)),
                "fn" => defs.push(Def::Fn(self.parse_fn()?)),
                "const" => defs.push(Def::Const(self.parse_const()?)),
                "class" => defs.push(Def::Class(self.parse_class()?)),
                "instance" => defs.push(Def::Instance(self.parse_instance()?)),
                other => {
                    let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                    return Err(ParseError::Production {
                        production: "module",
                        message: format!(
                            "unknown def head `{other}`; expected `data`, `fn`, `const`, `class`, `instance`, or `import`"
                        ),
                        pos,
                    });
                }
            }

Extend the AST import line at parse.rs:88 to include InstanceDef and InstanceMethod:

    Arm, ClassDef, ClassMethod, ConstDef, Ctor, Def, FnDef, Import, InstanceDef,
    InstanceMethod, Literal, Module, ParamMode, Pattern, SuperclassRef, Suppress, Term,
  • Step 2.4: Implement parse_instance and parse_instance_method

Append to parse.rs after the parse_class_method / parse_default_attr block from Task 1:

    // ---- instance def --------------------------------------------------

    fn parse_instance(&mut self) -> Result<InstanceDef, ParseError> {
        self.expect_lparen("instance-def")?;
        self.expect_keyword("instance")?;
        let mut class: Option<String> = None;
        let mut type_: Option<Type> = None;
        let mut doc: Option<String> = None;
        let mut methods: Vec<InstanceMethod> = Vec::new();
        loop {
            match self.peek_head_ident() {
                Some("class") => {
                    if class.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "instance-def",
                            message: "instance has duplicate `(class ...)` clause".into(),
                            pos,
                        });
                    }
                    self.expect_lparen("instance.class")?;
                    self.expect_keyword("class")?;
                    class = Some(self.expect_ident("instance class name")?);
                    self.expect_rparen("instance.class")?;
                }
                Some("type") => {
                    if type_.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "instance-def",
                            message: "instance has duplicate `(type ...)` clause".into(),
                            pos,
                        });
                    }
                    type_ = Some(self.parse_type_attr()?);
                }
                Some("doc") => {
                    if doc.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "instance-def",
                            message: "instance has duplicate `(doc ...)` clause".into(),
                            pos,
                        });
                    }
                    doc = Some(self.parse_doc()?);
                }
                Some("method") => {
                    methods.push(self.parse_instance_method()?);
                }
                Some(other) => {
                    let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                    return Err(ParseError::Production {
                        production: "instance-def",
                        message: format!(
                            "unknown instance attribute `{other}`; expected `class`, `type`, `doc`, or `method`"
                        ),
                        pos,
                    });
                }
                None => break,
            }
        }
        self.expect_rparen("instance-def")?;
        let class = class.ok_or_else(|| ParseError::Production {
            production: "instance-def",
            message: "instance is missing required `(class ...)` clause".into(),
            pos: 0,
        })?;
        let type_ = type_.ok_or_else(|| ParseError::Production {
            production: "instance-def",
            message: "instance is missing required `(type ...)` clause".into(),
            pos: 0,
        })?;
        Ok(InstanceDef {
            class,
            type_,
            methods,
            doc,
        })
    }

    fn parse_instance_method(&mut self) -> Result<InstanceMethod, ParseError> {
        self.expect_lparen("instance.method")?;
        self.expect_keyword("method")?;
        let name = self.expect_ident("instance method name")?;
        let mut body: Option<Term> = None;
        loop {
            match self.peek_head_ident() {
                Some("body") => {
                    if body.is_some() {
                        let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                        return Err(ParseError::Production {
                            production: "instance.method",
                            message: format!(
                                "instance method `{name}` has duplicate `(body ...)` clause"
                            ),
                            pos,
                        });
                    }
                    body = Some(self.parse_body_attr()?);
                }
                Some(other) => {
                    let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
                    return Err(ParseError::Production {
                        production: "instance.method",
                        message: format!(
                            "unknown instance.method attribute `{other}`; expected `body`"
                        ),
                        pos,
                    });
                }
                None => break,
            }
        }
        self.expect_rparen("instance.method")?;
        let body = body.ok_or_else(|| ParseError::Production {
            production: "instance.method",
            message: format!(
                "instance method `{name}` is missing required `(body ...)` clause"
            ),
            pos: 0,
        })?;
        Ok(InstanceMethod { name, body })
    }
  • Step 2.5: Run RED tests, observe GREEN
cargo test -p ailang-surface parses_minimal_instance_def
cargo test -p ailang-surface parses_instance_def_with_doc
cargo build --workspace

Expected: tests PASS. Build green.

  • Step 2.6: Commit
git add crates/ailang-surface/src/parse.rs
git commit -m "iter 22b.4a.2: form-a parser arm for InstanceDef"

Task 3: Form-A printer arm for ClassDef

Files:

  • Modify: crates/ailang-surface/src/print.rs — replace the Def::Class placeholder arm in write_def (print.rs:99-107); append write_class_def, write_class_method, write_superclass, write_class_default after write_const_def.

  • Step 3.1: Write the failing test (RED)

crates/ailang-surface/src/print.rs does NOT have an existing #[cfg(test)] mod tests block (verified 2026-05-09). Add one at end of file:

#[cfg(test)]
mod tests {
    use super::*;
    use ailang_core::ast::{
        ClassDef, ClassMethod, Def, InstanceDef, InstanceMethod, Literal, Module,
        ParamMode, SuperclassRef, Term, Type,
    };

    fn round_trip(m: Module, label: &str) {
        let printed = print(&m);
        let parsed = crate::parse::parse(&printed)
            .unwrap_or_else(|e| panic!(
                "{label}: re-parse failed: {e:?}\nprinted:\n{printed}"
            ));
        let bytes_orig = ailang_core::canonical::to_bytes(&m);
        let bytes_back = ailang_core::canonical::to_bytes(&parsed);
        if bytes_orig != bytes_back {
            let s_orig = String::from_utf8_lossy(&bytes_orig).into_owned();
            let s_back = String::from_utf8_lossy(&bytes_back).into_owned();
            panic!(
                "{label}: canonical bytes differ.\noriginal: {s_orig}\nround:    {s_back}\nprinted:\n{printed}"
            );
        }
    }

    #[test]
    fn print_then_parse_round_trip_minimal_class() {
        let m = Module {
            schema: ailang_core::SCHEMA.to_string(),
            name: "M".into(),
            imports: vec![],
            defs: vec![Def::Class(ClassDef {
                name: "Foo".into(),
                param: "a".into(),
                superclass: None,
                methods: vec![ClassMethod {
                    name: "m".into(),
                    ty: Type::Fn {
                        params: vec![Type::Var { name: "a".into() }],
                        param_modes: vec![ParamMode::Implicit],
                        ret: Box::new(Type::int()),
                        ret_mode: ParamMode::Implicit,
                        effects: vec![],
                    },
                    default: None,
                }],
                doc: None,
            })],
        };
        round_trip(m, "minimal_class");
    }

    #[test]
    fn print_then_parse_round_trip_class_with_superclass_and_default() {
        let m = Module {
            schema: ailang_core::SCHEMA.to_string(),
            name: "M".into(),
            imports: vec![],
            defs: vec![Def::Class(ClassDef {
                name: "Bar".into(),
                param: "a".into(),
                superclass: Some(SuperclassRef {
                    class: "Foo".into(),
                    type_: "a".into(),
                }),
                methods: vec![ClassMethod {
                    name: "m".into(),
                    ty: Type::Fn {
                        params: vec![Type::Var { name: "a".into() }],
                        param_modes: vec![ParamMode::Implicit],
                        ret: Box::new(Type::int()),
                        ret_mode: ParamMode::Implicit,
                        effects: vec![],
                    },
                    default: Some(Term::Lit { lit: Literal::Int { value: 0 } }),
                }],
                doc: Some("docline".into()),
            })],
        };
        round_trip(m, "class_with_superclass_and_default");
    }
}

(Verified against crates/ailang-core/src/ast.rs:585Type::Fn has fields params, param_modes, ret, ret_mode, effects ONLY; constraints live on Type::Forall, not on Type::Fn. The canonical comparison helper is ailang_core::canonical::to_bytes, returning Vec<u8> — same one used in crates/ailang-surface/tests/round_trip.rs:90.)

  • Step 3.2: Run test, observe RED
cargo test -p ailang-surface print_then_parse_round_trip_minimal_class

Expected: FAIL — the existing Def::Class arm in write_def (print.rs:99-107) emits (class Foo a) only, which loses methods, so re-parse either errors (missing (param ...) clause, missing methods) or canonical JSON diverges.

  • Step 3.3: Replace placeholder + add helpers

Edit print.rs's write_def. Replace the entire Def::Class and Def::Instance placeholder arms (the 22b.1 stub block at print.rs:97-114) with:

        Def::Class(c) => write_class_def(out, c, level),
        Def::Instance(i) => write_instance_def(out, i, level),

Append below write_const_def (around print.rs:240):

fn write_class_def(out: &mut String, c: &ClassDef, level: usize) {
    indent(out, level);
    out.push_str("(class ");
    out.push_str(&c.name);
    out.push('\n');
    indent(out, level + 1);
    out.push_str("(param ");
    out.push_str(&c.param);
    out.push(')');
    if let Some(sc) = &c.superclass {
        out.push('\n');
        write_superclass(out, sc, level + 1);
    }
    if let Some(doc) = &c.doc {
        out.push('\n');
        indent(out, level + 1);
        out.push_str("(doc ");
        write_string_lit(out, doc);
        out.push(')');
    }
    for m in &c.methods {
        out.push('\n');
        write_class_method(out, m, level + 1);
    }
    out.push(')');
}

fn write_superclass(out: &mut String, sc: &SuperclassRef, level: usize) {
    indent(out, level);
    out.push_str("(superclass (class ");
    out.push_str(&sc.class);
    out.push_str(") (type ");
    out.push_str(&sc.type_);
    out.push_str("))");
}

fn write_class_method(out: &mut String, m: &ClassMethod, level: usize) {
    indent(out, level);
    out.push_str("(method ");
    out.push_str(&m.name);
    out.push('\n');
    indent(out, level + 1);
    out.push_str("(type ");
    write_type(out, &m.ty);
    out.push(')');
    if let Some(default) = &m.default {
        out.push('\n');
        indent(out, level + 1);
        out.push_str("(default ");
        write_term(out, default, level + 2);
        out.push(')');
    }
    out.push(')');
}

Add the AST imports at print.rs:11. Replace:

    Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term,
    Type, TypeDef,

with:

    Arm, ClassDef, ClassMethod, ConstDef, Ctor, Def, FnDef, Import, InstanceDef,
    InstanceMethod, Literal, Module, ParamMode, Pattern, SuperclassRef, Suppress, Term,
    Type, TypeDef,

(InstanceDef / InstanceMethod will be unused at end of this task; mirrors Task 1's transient-warning shape — the #[allow] gate or the next task closes it.)

  • Step 3.4: Run RED tests, observe GREEN
cargo test -p ailang-surface print_then_parse_round_trip_minimal_class
cargo test -p ailang-surface print_then_parse_round_trip_class_with_superclass_and_default
cargo build --workspace

Expected: both PASS.

  • Step 3.5: Commit
git add crates/ailang-surface/src/print.rs
git commit -m "iter 22b.4a.3: form-a printer arm for ClassDef"

Task 4: Form-A printer arm for InstanceDef

Files:

  • Modify: crates/ailang-surface/src/print.rs — append write_instance_def and write_instance_method after Task 3's block.

  • Step 4.1: Write the failing test (RED)

Append inside the #[cfg(test)] mod tests { ... } block created in Task 3 (in print.rs):

    #[test]
    fn print_then_parse_round_trip_minimal_instance() {
        let m = Module {
            schema: ailang_core::SCHEMA.to_string(),
            name: "M".into(),
            imports: vec![],
            defs: vec![Def::Instance(InstanceDef {
                class: "Foo".into(),
                type_: Type::int(),
                methods: vec![InstanceMethod {
                    name: "m".into(),
                    body: Term::Lit { lit: Literal::Int { value: 5 } },
                }],
                doc: None,
            })],
        };
        round_trip(m, "minimal_instance");
    }
  • Step 4.2: Run test, observe RED
cargo test -p ailang-surface print_then_parse_round_trip_minimal_instance

Expected: FAIL — the original Def::Instance placeholder ((instance Class)) was already replaced in Task 3 with a call to write_instance_def, which doesn't exist yet. This is a build failure (rather than an assertion failure), which still counts as RED.

  • Step 4.3: Implement the helpers

Append to print.rs after write_class_method:

fn write_instance_def(out: &mut String, i: &InstanceDef, level: usize) {
    indent(out, level);
    out.push_str("(instance");
    out.push('\n');
    indent(out, level + 1);
    out.push_str("(class ");
    out.push_str(&i.class);
    out.push(')');
    out.push('\n');
    indent(out, level + 1);
    out.push_str("(type ");
    write_type(out, &i.type_);
    out.push(')');
    if let Some(doc) = &i.doc {
        out.push('\n');
        indent(out, level + 1);
        out.push_str("(doc ");
        write_string_lit(out, doc);
        out.push(')');
    }
    for m in &i.methods {
        out.push('\n');
        write_instance_method(out, m, level + 1);
    }
    out.push(')');
}

fn write_instance_method(out: &mut String, m: &InstanceMethod, level: usize) {
    indent(out, level);
    out.push_str("(method ");
    out.push_str(&m.name);
    out.push('\n');
    indent(out, level + 1);
    out.push_str("(body ");
    write_term(out, &m.body, level + 2);
    out.push(')');
    out.push(')');
}
  • Step 4.4: Run RED test + full surface tests, observe GREEN
cargo test -p ailang-surface print_then_parse_round_trip_minimal_instance
cargo test -p ailang-surface
cargo build --workspace

Expected: GREEN. Including the existing 22b.1 round-trip placeholder test (if one exists) — the printer no longer emits the lossy stub.

  • Step 4.5: Commit
git add crates/ailang-surface/src/print.rs
git commit -m "iter 22b.4a.4: form-a printer arm for InstanceDef"

Task 5: Retire round-trip skip-list

Files:

  • Modify: crates/ailang-surface/tests/round_trip.rs:36-50 — remove the test_22b1_* / test_22b2_* / test_22b3_* filter clauses.

  • Step 5.1: Run the round-trip test against the unfiltered set first

Before editing, sanity-check that the printer/parser arms cover every 22b fixture. From the workspace root:

for f in examples/test_22b1_*.ail.json examples/test_22b2_*.ail.json examples/test_22b3_*.ail.json; do
  cargo run -q -p ail -- render "$f" > /tmp/rt.txt 2>&1 || echo "RENDER FAIL: $f"
done

Expected: every fixture renders without error. ail render is the existing CLI subcommand wired to ailang_surface::print. If a fixture errors, that is a Task 5 blocker — diagnose under debug skill before retiring the filter.

  • Step 5.2: Remove the filter clauses

Edit crates/ailang-surface/tests/round_trip.rs. Find the list_json_fixtures function (round_trip.rs:25-55) and replace the filter block:

            n.ends_with(".ail.json")
                && !n.starts_with("test_22b1_")
                && !n.starts_with("test_22b2_")
                && !n.starts_with("test_22b3_")

with:

            n.ends_with(".ail.json")

Also remove the now-stale comment block immediately above (the // Iter 22b.1 / 22b.2 / 22b.3 ... comment); replace with a one-line comment:

            // Round-trip every fixture. The 22b.1/22b.2/22b.3 filter
            // was retired in 22b.4a once the Form-A parser+printer
            // arms for ClassDef / InstanceDef landed.
  • Step 5.3: Run the gate
cargo test -p ailang-surface --test round_trip

Expected: PASS, with passed: <N> strictly greater than the previous filtered count. If a fixture fails, do NOT re-add the filter — that is silent regression. Diagnose via debug skill mini-mode and fix before continuing.

  • Step 5.4: Run full bench gates

The mono pass and the surface arms must coexist with the regression suite:

python3 bench/check.py
python3 bench/compile_check.py
python3 bench/cross_lang.py

Expected: all three exit 0/0/0 (matches 22b.3 close).

  • Step 5.5: Commit
git add crates/ailang-surface/tests/round_trip.rs
git commit -m "iter 22b.4a.5: retire round-trip skip-list for class/instance fixtures"

Task 6: Spec amendment

Files:

  • Modify: docs/specs/0002-22-typeclasses.md — append an "Amendments" section.

  • Modify: docs/DESIGN.md — locate Decision 11 §"Resolution and monomorphisation" (DESIGN.md mono-symbol naming subsection) and add a one-paragraph note on the __ separator.

  • Step 6.1: Append the spec amendments section

Open docs/specs/0002-22-typeclasses.md. Append at end of file:


## Amendments

### 2026-05-09 — Iteration split: 22b.4 → 22b.4a + 22b.4b

Original 22b.4 scope ("Prelude module Show/Eq/Ord on Int/Float/Bool/String,
print rewiring, Form-B parser/printer arms, round-trip filter retired")
splits into two iterations:

- **22b.4a (this iter):** Form-A parser+printer arms for ClassDef
  and InstanceDef; round-trip skip-list retired for `test_22b1_*`,
  `test_22b2_*`, `test_22b3_*`. Pure surface plumbing, no codegen
  or runtime change.
- **22b.4b (queued):** Prelude module containing `class Show a where
  show : (a borrow) -> Str`, `instance Show Int`, the new
  `int_to_str` C-runtime primitive, codegen wiring for it, and an
  end-to-end `show 42` fixture that prints `42` through the existing
  mono pass.

Substantive rationale: 22b.4a touches one crate (`ailang-surface`)
with no runtime/codegen risk; 22b.4b touches `runtime/`,
`ailang-codegen`, `ailang-check::builtins`, plus a new fixture, with
a different review surface and a different bench-gate exposure.
Bundling them was a brainstorm-time underestimate of the
runtime-side work; running them as separate iterations preserves
the bite-sized cycle the skill system enforces.

### 2026-05-09 — Form-A vs Form-B terminology fix

The original 22 spec ("Form-B parser/printer arms for ClassDef
/InstanceDef") and the 22b.1 round-trip-skip-list comment both
loosely call the s-expression projection "Form-B". That is a
terminology error: `crates/ailang-surface` is **Form-A** (the
parseable s-expression). `crates/ailang-prose` is **Form-B**
(human-readable, one-way projection — no parser by design). The
22b.4a arms are Form-A. Form-B printer arms for ClassDef /
InstanceDef are NOT required for the round-trip gate (it is
unparser-side); they are queued as an audit-cycle nicety, not as
a milestone-22 acceptance criterion.

The skip-list comment that read "the surface (Form-B) parser/printer
arms" was retired alongside the filter (Task 5).

### 2026-05-09 — Mono-symbol separator: `__` chosen over `#`

22a's brainstorm recommended `<method>#<typesurfacename>` for the
mono-symbol naming format with the implementer free to choose. 22b.3
implemented `<method>__<typesurfacename>` instead (e.g. `show__Int`,
`eq__Bool`). Reason: `#` is an invalid character in LLVM IR global
identifiers (`@ail_..._foo#Int_clos` is rejected by the LLVM
verifier). `__` is verifier-legal, identifier-legal in C-glue
contexts, and parses unambiguously into method + type surface name
(neither method names nor type surface names contain `__` by
convention). The recommendation in §"Open commitments (22a → 22b
implementer)" is updated to read `<method>__<typesurfacename>`.

Hash-suffix form for compound types (`<method>__<8-hex>`) follows
the same separator.

### 2026-05-09 — Acceptance criteria 4 still binding

Acceptance criterion 4 ("Round-trip filter `test_22b1_*` is removed;
all `examples/*.ail.json` pass round-trip") is satisfied by 22b.4a.
The `test_22b2_*` and `test_22b3_*` filters added subsequently are
also retired here. No further round-trip gate work is required for
22 milestone close.
  • Step 6.2: Add the __ separator note to DESIGN.md

Locate the DESIGN.md section that describes mono-symbol naming under Decision 11. Find the subsection that mentions the symbol shape (search DESIGN.md for mono-symbol or monomorphis or show#Int).

grep -n "mono.*symbol\|show#Int\|show@Int\|monomorphi" docs/DESIGN.md

If a paragraph references show@Int or show#Int, replace the example to read show__Int and append one sentence:

The separator is `__` rather than `#` or `@` because `#` and `@`
are invalid in LLVM IR global identifiers (the IR verifier rejects
them inside `@ail_<module>_<def>` mangled names). `__` is legal in
both LLVM IR and the C ABI used by the runtime glue, and parses
unambiguously into `<method>__<type-surface-name>` because neither
component contains `__` by project convention.

If DESIGN.md does not yet have a mono-symbol-naming paragraph (possible — Decision 11 is high-level), then this step is a no-op for DESIGN.md. The 22b.4a JOURNAL entry (Task 7) and the spec amendment above suffice; do not invent a DESIGN.md section for the sole purpose of placing the note. (DESIGN.md is canonical; spec is the right home for naming-convention micro-decisions.)

  • Step 6.3: Commit
git add docs/specs/0002-22-typeclasses.md docs/DESIGN.md
git commit -m "iter 22b.4a.6: spec + DESIGN amendments — 22b.4 split, terminology fix, '__' separator"

Task 7: JOURNAL entry

Files:

  • Modify: docs/JOURNAL.md — append iteration entry.

  • Step 7.1: Append the iteration entry

## 2026-05-09 — Iteration 22b.4a: Form-A parser+printer arms for ClassDef/InstanceDef

Closes the round-trip gap that 22b.1 deliberately deferred: every
`examples/*.ail.json` fixture now round-trips through
`parse(print(M))` with canonical-equal JSON, including the
`test_22b1_*`, `test_22b2_*`, `test_22b3_*` typeclass fixtures that
the skip-list excluded.

**Per-task subjects (mirror commit messages):**

- 22b.4a.1: form-a parser arm for ClassDef
- 22b.4a.2: form-a parser arm for InstanceDef
- 22b.4a.3: form-a printer arm for ClassDef
- 22b.4a.4: form-a printer arm for InstanceDef
- 22b.4a.5: retire round-trip skip-list for class/instance fixtures
- 22b.4a.6: spec + DESIGN amendments — 22b.4 split, terminology fix, '__' separator

**S-expression form locked:**

ClassDef:

(class (param ) [(superclass (class ) (type ))] [(doc "...")] (method (type ) [(default )])*)


InstanceDef:

(instance (class ) (type ) [(doc "...")] (method (body ))*)


**Iteration split rationale:** the brainstorm-original 22b.4 bundled
surface arms with the Prelude module + `int_to_str` C-runtime
primitive + end-to-end `show 42` fixture. Surface arms touch one
crate with no runtime risk; the Prelude work touches `runtime/`,
`ailang-codegen`, `ailang-check::builtins`, plus codegen wiring for
the new primitive. Different review surface, different risk profile,
different bench-gate exposure. The split (22b.4a / 22b.4b) is
recorded in the spec amendments section and is the substantive
follow-up for 22b.4b.

**Terminology fix:** the original spec called the surface arms
"Form-B parser/printer arms". `crates/ailang-surface` is in fact
**Form-A** (parseable s-expression); `crates/ailang-prose` is Form-B
(one-way prose, no parser by design). The 22b.1 round-trip-skip-list
comment carried the same conflation. Both corrected in the spec
amendment.

**Known debt (deferred to 22b.4b):**

- `Form-B` (prose) printer arm for ClassDef / InstanceDef. One-way,
  not gating; queued for the audit cycle or 22b.4b cleanup.
- DESIGN.md mono-symbol-naming paragraph: if Decision 11 acquires
  one in a future revision, the `__` rationale lands there. For now
  the spec amendment is the canonical record.

**Bench gates:** all three (`bench/check.py`, `bench/compile_check.py`,
`bench/cross_lang.py`) exited 0/0/0 (matches 22b.3 close).

**Next iter (queued):** 22b.4b — Prelude module with `class Show
a where show : (a borrow) -> Str`, `instance Show Int`, the
`int_to_str` C-runtime primitive, codegen wiring, and an end-to-end
`show 42` fixture printing `42` through the mono pass.
  • Step 7.2: Commit
git add docs/JOURNAL.md
git commit -m "iter 22b.4a: journal entry"

Acceptance gate (run after Task 7)

cargo test --workspace
python3 bench/check.py
python3 bench/compile_check.py
python3 bench/cross_lang.py

All four green is the iteration close. If any fail, the most likely culprit is round-trip on a fixture that uses an unusual Term shape inside a class default or instance body — diagnose via debug skill, NOT by re-introducing the skip-list.