Files
AILang/docs/plans/0102-prep.2-term-new-construct.md
Brummel 70e6fcd5c0 plan: prep.2 Term::New construct — 4-task atomic AST + surface + checker + drift (refs #32)
Second iteration of the kernel-extension-mechanics milestone. The
plan ships the `new` Form-A keyword + Term::New AST variant + NewArg
enum + checker elaboration + two diagnostics, plus the schema-drift
pin and the data-model.md / form_a.md anchor additions.

Decomposition:

- Task 1 (large mechanical): AST variant declaration + workspace-wide
  compile-forced arms across 24 files (44+ exhaustive Term-match
  sites). Six arm patterns inlined; per-site table assigns one
  pattern per site. Includes the prep.1 pre-pass partner — the
  `qualify_workspace_term` arm that rewrites bare Term::New.type_name
  to qualified form, mirroring the existing Term::Ctor arm. Synth
  gets a Pattern-E stub here; Task 3 replaces with real logic.
- Task 2: Form-A lex/parse/print + round-trip fixture. New
  `parse_new` method with Type-vs-Term arg disambiguation by
  syntactic form (head keyword = Type-production heads
  `con`/`fn-type`/`borrow`/`own` → NewArg::Type; else NewArg::Value).
- Task 3: Checker synth real-logic + 2 new CheckError variants
  (NewTypeNotConstructible, NewArgKindMismatch) + 3 RED-first
  in-source tests covering the spec's worked example, the missing-
  `new`-def case, and the kind-mismatch case.
- Task 4: Schema-drift pin (term_new_round_trips +
  term_new_type_arg_round_trips) + spec_drift exemplar + form_a.md
  keyword cheatsheet + design/contracts/0002-data-model.md JSON-tag
  block.

Spec is already correct on prep.2 scope; the recon surfaced one
design decision the plan resolves inline: NewArg uses
`#[serde(tag = "kind", content = "value", rename_all = "lowercase")]`
to land the spec's exact JSON byte shape. Codegen remains explicitly
out of scope per the milestone spec; Task 1's codegen sites get
Pattern-D error-arms ("Term::New requires desugar first; codegen
support lands in the raw-buf milestone").

Forward-reference: Task 1's Pattern-C arm in `qualify_workspace_term`
extends prep.1's workspace-wide normalisation pre-pass — Term::New
joins Term::Ctor and Type::Con as a type_name-bearing site that
gets bare→qualified normalised before the checker sees it.
2026-05-28 15:00:27 +02:00

48 KiB

prep.2 — Term::New construct — Implementation Plan

Parent spec: docs/specs/0052-kernel-extension-mechanics.md (Iteration prep.2 section, lines 232-356) Gitea issue: refs #32 (the implementer-side iter-completion commit closes via closes #32)

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

Goal: Ship the new Form-A keyword + Term::New { type_name, args: Vec<NewArg> } AST variant + NewArg::Type|Value enum end-to-end through schema/surface/checker, with two new diagnostics, a workspace-wide bare→qualified rewrite extension (piggybacking on prep.1's qualify_workspace_term), and drift/spec anchors. Codegen is deliberately out of scope — the construct is verified at ail check cleanness.

Architecture: A new Term variant Term::New holds an unqualified type-name plus a heterogeneous arg vector (NewArg::Type or NewArg::Value). The prep.1 pre-pass qualify_workspace_term gains a Term::New arm that rewrites bare type_name to qualified <home>.<type> form, mirroring the existing Term::Ctor arm — so the synth pass sees qualified Types. The checker elaborates Term::New via type-scoped lookup → home module → find new def → arg-count + arg-kind check → forall binding. The variant addition is compile-forced across 24 files (44+ exhaustive match … { Term::Lit | … } sites); Task 1 walks all of them with a small set of mechanical arm patterns. Tasks 2-4 layer surface, checker, and schema-anchor work on top.

Tech stack: crates/ailang-core (AST + serde + drift tests), crates/ailang-surface (lex/parse/print + round-trip), crates/ailang-check (synth elaboration + diagnostics + pre-pass arm + 21+ analysis-walker arms), crates/ailang-codegen (error arms — codegen is out of scope), crates/ailang-prose, crates/ail/src/main.rs, and the spec/contract anchors design/contracts/0002-data-model.md + crates/ailang-core/specs/form_a.md.

Out of scope (deferred per spec §50-55, §327-330, §687-700):

  • Codegen lowering of Term::New — codegen arms return CodegenError::Internal(...). Real codegen lands in the raw-buf milestone.
  • param-in enforcement on the type-arg of Term::New — prep.3.
  • Kernel-tier module auto-import — prep.3.
  • Hash-pin refreshes — additive variant, no existing fixture emits "t": "new".

Files this plan creates or modifies:

  • Modify: crates/ailang-core/src/ast.rs — add Term::New { type_name, args } to the Term enum (line 405); add pub enum NewArg { Type(Type), Value(Term) } with tagged serde repr (after line 565).
  • Modify: crates/ailang-core/src/desugar.rs — 9 exhaustive Term matches gain Term::New arms (Pattern A or B per site).
  • Modify: crates/ailang-core/src/workspace.rs — 2 walker sites gain Term::New arms (Pattern A).
  • Modify: crates/ailang-check/src/lib.rssubstitute_rigids_in_term (line 181, Pattern B), verify_tail_positions (line 2749, Pattern A), verify_loop_body (line 2862, Pattern A), synth Term-match (line ~3005, Pattern E stub in Task 1 → real elaboration in Task 3), synth's "what was here" descriptor (line ~3886, Pattern F), qualify_workspace_term (line 4155, Pattern C).
  • Modify: crates/ailang-check/src/lib.rs — add CheckError::NewTypeNotConstructible + CheckError::NewArgKindMismatch variants (Task 3, after TypeScopedReceiverNotAType at line ~705); add to code() (line ~759), ctx() (line ~837), and message-format arms.
  • Modify: crates/ailang-check/src/lift.rs — 3 exhaustive Term matches gain Term::New arms (Pattern A).
  • Modify: crates/ailang-check/src/linearity.rs — 5 Term matches (Pattern A or specific per arm).
  • Modify: crates/ailang-check/src/mono.rs — 2 sites (Pattern A walker + Pattern B rewrite).
  • Modify: crates/ailang-check/src/pre_desugar_validation.rs — 1 site (Pattern A).
  • Modify: crates/ailang-check/src/reuse_shape.rs — 2 sites (Pattern A).
  • Modify: crates/ailang-check/src/uniqueness.rs — 2 sites (Pattern A).
  • Modify: crates/ailang-codegen/src/lib.rslower_term + synth_with_extras (Pattern D — error).
  • Modify: crates/ailang-codegen/src/escape.rs — 3 sites (Pattern A).
  • Modify: crates/ailang-codegen/src/lambda.rs — 1 site (Pattern A).
  • Modify: crates/ailang-surface/src/parse.rs — head-keyword dispatcher (~line 1227), is_term_head_kw (~line 1610), new parse_new method (Task 2).
  • Modify: crates/ailang-surface/src/print.rswrite_term (line 420-597) gains Term::New arm (Task 2).
  • Modify: crates/ailang-prose/src/lib.rs — 3 sites (Pattern A — walker / printer per site).
  • Modify: crates/ail/src/main.rswalk_term + rewrite_term inner (Pattern A).
  • Modify: crates/ailang-core/tests/design_schema_drift.rs — exhaustive Term match arm (Task 1, Pattern F) + new term_new_round_trips test (Task 4).
  • Modify: crates/ailang-core/tests/schema_coverage.rsVariantTag::TermNew enum entry; EXPECTED_TAGS array gains the entry; visit_term arm.
  • Modify: crates/ailang-core/tests/spec_drift.rs — exhaustive Term match arm + exemplar row + form_a.md anchor assertion (Task 4 covers the anchor add).
  • Modify: crates/ail/tests/codegen_import_map_fallback_pin.rscontains_xmod_show_var Term match (Pattern A).
  • Modify: crates/ailang-surface/tests/round_trip.rs — Task 2: add (new T <type-arg> <value-arg>) fixture.
  • Modify: design/contracts/0002-data-model.md — Task 4: add { "t": "new", "type": "<id>", "args": [NewArg...] } to the JSON-tag block (lines 107-178 area) + add the NewArg shape definition.
  • Modify: crates/ailang-core/specs/form_a.md — Task 4: add (new TYPE-NAME ARG*) to the surface-keyword cheatsheet (lines 289-298 area).
  • Test (in-source #[cfg(test)] mod tests in crates/ailang-check/src/lib.rs): three new tests — new_resolves_via_type_scope, new_type_not_constructible, new_arg_kind_mismatch_value_where_type (Task 3).

Task 1: AST variant + NewArg + workspace-wide compile-forced arms

Files: see "Files this plan creates or modifies" above — every entry except the surface-parse/print sites (Task 2), the synth real-logic arm (Task 3), and the spec/contract anchors (Task 4). Roughly 20 production files + 4 test files.

Six arm patterns (each new Term::New arm matches one of these):

// Pattern A — walker that recurses into sub-terms. Most analysis
// passes use this. NewArg::Type carries no value and contributes
// nothing.
Term::New { args, .. } => {
    for arg in args {
        match arg {
            NewArg::Value(v) => /* the function's recursion call on v */,
            NewArg::Type(_) => {}
        }
    }
}
// Pattern B — Term-reconstruction (substitution, desugar map).
// NewArg::Type may carry a Type that itself needs rewriting.
Term::New { type_name, args } => Term::New {
    type_name: type_name.clone(),
    args: args.iter().map(|arg| match arg {
        NewArg::Value(v) => NewArg::Value(/* recursive rewrite of v */),
        NewArg::Type(t)  => NewArg::Type(/* recursive rewrite of t — or t.clone() if the fn doesn't touch Types */),
    }).collect(),
}
// Pattern C — bare→qualified rewrite. Used by qualify_workspace_term
// ONLY. Mirrors the existing Term::Ctor arm at lib.rs:4187.
Term::New { type_name, args } => {
    if !type_name.contains('.')
        && !ailang_core::primitives::is_primitive_name(type_name)
        && !own_local_types.contains_key(type_name)
    {
        if let Some(home) = module_types
            .iter()
            .find_map(|(m, types)| if types.contains_key(type_name) { Some(m.clone()) } else { None })
        {
            *type_name = format!("{home}.{type_name}");
        }
    }
    for arg in args {
        match arg {
            NewArg::Value(v) => qualify_workspace_term(v, own_local_types, module_types),
            NewArg::Type(t)  => *t = qualify_workspace_types(t, own_local_types, module_types),
        }
    }
}
// Pattern D — codegen error stub. Real codegen for Term::New lands
// in the raw-buf milestone.
Term::New { .. } => Err(CodegenError::Internal(
    "Term::New requires the type's `new` def to be desugared first; \
     codegen does not yet support Term::New directly — milestone raw-buf".into()
))
// Pattern E — synth elaboration STUB (Task 1 only). Task 3 replaces
// this with the real per-spec elaboration logic.
Term::New { .. } => Err(CheckError::Internal(
    "Term::New elaboration not yet implemented — prep.2 Task 3".into()
))
// Pattern F — small descriptor (e.g. "what was here" in synth's
// inner error, or the exhaustive name-match in design_schema_drift.rs's
// test).
Term::New { .. } => "new"

Per-site assignment table:

File Line / fn name Pattern Notes
crates/ailang-core/src/ast.rs Term enum body (~line 405) ADD variant: New { type_name: String, args: Vec<NewArg> }. Carries #[serde(rename = "type")] on type_name per the spec's "type": "<id>" byte shape; no rename on args (default snake_case matches "args").
crates/ailang-core/src/ast.rs (after Term, before LoopBinder) ADD pub enum NewArg { Type(Type), Value(Term) } with #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", content = "value", rename_all = "lowercase")]. The tag = "kind", content = "value" lands the spec's {"kind": "type", "value": ...} shape exactly.
crates/ailang-core/src/ast.rs in-source round-trip tests (~line 920+, ~line 953+) A + B (test helpers) ADD Term::New round-trip test using the standard pattern (build a Term::New, serialise to JSON, assert deserialise round-trips). The exhaustive matches inside test helpers also need arms.
crates/ailang-core/src/desugar.rs collect_used_in_term (~line 283) A recurse over NewArg::Value with the function's collector
crates/ailang-core/src/desugar.rs Desugarer::desugar_term (~line 467) B reconstruct Term::New; desugar each NewArg::Value
crates/ailang-core/src/desugar.rs free_vars_in_term (~line 1136) A union free vars over NewArg::Value
crates/ailang-core/src/desugar.rs subst_var (~line 1263) B substitute through each NewArg::Value
crates/ailang-core/src/desugar.rs peel walker (~line 1414) A walker semantics — recurse over NewArg::Value
crates/ailang-core/src/desugar.rs subst_call_with_extras (~line 1432) B substitute, reconstruct
crates/ailang-core/src/desugar.rs find_non_callee_use (~line 1557) A binder-use walker — recurse over NewArg::Value
crates/ailang-core/src/desugar.rs any_nested_ctor (~line 1599) A recurse, return false for NewArg::Type
crates/ailang-core/src/desugar.rs any_let_rec (~line 1657) A recurse
crates/ailang-core/src/desugar.rs any_lit_pattern or similar (~line 2783) A recurse
crates/ailang-core/src/workspace.rs walk_term_embedded_types (~line 1205) A walk each NewArg::Type via walk_type; recurse into NewArg::Value
crates/ailang-core/src/workspace.rs walk_term (~line 1337) A recurse over NewArg::Value
crates/ailang-check/src/lib.rs substitute_rigids_in_term (~line 181) B substitute through NewArg::Value; apply substitute_rigids to NewArg::Type
crates/ailang-check/src/lib.rs verify_tail_positions (~line 2749) A recurse over NewArg::Value with non-tail semantics
crates/ailang-check/src/lib.rs verify_loop_body (~line 2862) A recurse with in_loop_tail=false for arg subterms
crates/ailang-check/src/lib.rs synth Term match (~line 3005) E (stub) Task 3 replaces with real logic
crates/ailang-check/src/lib.rs "what was here" descriptor (~line 3886) F Term::New { .. } => "new"
crates/ailang-check/src/lib.rs qualify_workspace_term (line 4155) C THE prep.1 pre-pass partner — full bare→qualified arm
crates/ailang-check/src/lift.rs 3 exhaustive matches A recurse over NewArg::Value
crates/ailang-check/src/linearity.rs walk (~line 427) A walk(NewArg::Value, Position::Consume) for each value arg
crates/ailang-check/src/linearity.rs any_sub_binder_consumed_for (~line 880) A recurse
crates/ailang-check/src/linearity.rs ctor_uses_any_binder (~line 1046) A recurse
crates/ailang-check/src/linearity.rs term_mentions_any_binder (~line 1098) A recurse
crates/ailang-check/src/linearity.rs joined pattern at ~line 1102 A — separate arm If the existing joined pattern (`Term::Ctor
crates/ailang-check/src/mono.rs walker at ~line 1123 A recurse
crates/ailang-check/src/mono.rs rewrite at ~line 1520 B reconstruct
crates/ailang-check/src/pre_desugar_validation.rs exhaustive match (~line 71) A recurse
crates/ailang-check/src/reuse_shape.rs walker (~line 127) A recurse
crates/ailang-check/src/reuse_shape.rs rewrite (~line 466) B reconstruct
crates/ailang-check/src/uniqueness.rs walker (~line 153) A recurse over NewArg::Value with App-like ownership semantics
crates/ailang-check/src/uniqueness.rs walker (~line 233) A recurse
crates/ailang-codegen/src/lib.rs lower_term (~line 1525-2068) D error
crates/ailang-codegen/src/lib.rs synth_with_extras (~line 3415-3546) D error
crates/ailang-codegen/src/escape.rs 3 sites A recurse over NewArg::Value (escape analysis)
crates/ailang-codegen/src/lambda.rs 1 site (~line 460-510) A recurse
crates/ailang-prose/src/lib.rs walker / printer (~line 927) A or Form-B printer walker arms recurse; Form-B printer emits new T(args...)
crates/ailang-prose/src/lib.rs (~line 1127) A recurse
crates/ailang-prose/src/lib.rs (~line 1274) A recurse
crates/ail/src/main.rs walk_term (~line 1430) A recurse
crates/ail/src/main.rs rewrite_term inner (~line 2676) A recurse
crates/ail/tests/codegen_import_map_fallback_pin.rs contains_xmod_show_var (~line 62) A recurse
crates/ailang-core/tests/design_schema_drift.rs exhaustive Term match arm (~line 166) F Term::New { .. } => "new"
crates/ailang-core/tests/schema_coverage.rs VariantTag enum (~line 29) ADD TermNew discriminant
crates/ailang-core/tests/schema_coverage.rs EXPECTED_TAGS array (~line 84-98) ADD VariantTag::TermNew
crates/ailang-core/tests/schema_coverage.rs visit_term exhaustive match A — variant Term::New { args, .. } => { tags.insert(VariantTag::TermNew); for a in args { match a { NewArg::Type(t) => visit_type(t, tags), NewArg::Value(v) => visit_term(v, tags) } } }
crates/ailang-core/tests/spec_drift.rs exemplar list (~line 90-158) ADD exemplar row for Term::New (asserting "(new" anchor in form_a.md)
crates/ailang-core/tests/spec_drift.rs exhaustive Term match (~line 135-151) F Term::New { .. } => /* anchor literal */
  • Step 1: Add the Term::New variant and NewArg enum

In crates/ailang-core/src/ast.rs, add the variant inside the Term enum (insert before the closing } of the enum at the end; or alphabetically near Recur — Boss judgement on ordering, but past-convention is "last-added is at the end"):

    /// Functional construction: `(new T arg+)` calls the `new` def in
    /// `T`'s home module with the supplied args. Each arg is either a
    /// Type or a Value (see [`NewArg`]). Resolution: type-scoped
    /// lookup of `type_name` → home module → find `new` def → check
    /// arg-count and arg-kind. See prep.2 of the
    /// kernel-extension-mechanics milestone.
    New {
        #[serde(rename = "type")]
        type_name: String,
        args: Vec<NewArg>,
    },

After the Term enum closing brace, before the next enum/struct (or near LoopBinder at ~line 586), add:

/// One positional arg to a `(new T args...)` call. Either a `Type`
/// (e.g. `(new Series (con Float) 3)` — first arg is `NewArg::Type`)
/// or a `Value` (e.g. the literal `3` in the same example, a
/// `Term`). Disambiguation at parse-time is by syntactic form: a
/// Type starts with one of the Type-production heads (`con`,
/// `fn-type`, `borrow`, `own`); anything else is a Term.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
pub enum NewArg {
    Type(Type),
    Value(Term),
}
  • Step 2: Run the workspace compile-gate

Run: cargo build --workspace 2>&1 | tail -60 Expected: a cluster of error[E0004]: non-exhaustive patterns: \Term::New { .. }` not covered` errors across the per-site table above. This is the EXPECTED RED state for Task 1.

  • Step 3: Add the Term::New arm to every exhaustive Term match (per-site table)

For each entry in the per-site table above, edit the file and add the listed Pattern's arm. The patterns are inline in this task; the per-site table assigns one to each. Notes:

  • Apply Pattern E (stub) to synth's big Term-match in lib.rs — Task 3 replaces with the real elaboration.

  • Apply Pattern C (qualify-workspace-term) ONLY to qualify_workspace_term. Other sites use Pattern A or B.

  • For test files (schema_coverage.rs, spec_drift.rs), update both the enum/exemplar data AND the exhaustive match arm — they go together.

  • Step 4: Run the workspace compile-gate again

Run: cargo build --workspace 2>&1 | tail -15 Expected: 0 errors. The variant addition + per-site arms compile cleanly.

  • Step 5: Run the workspace test suite for regressions

Run: cargo test --workspace 2>&1 | tail -30 Expected: All tests pass. The variant is additive — no existing fixture emits "t": "new", so byte-shapes are unchanged. schema_coverage.rs passes because EXPECTED_TAGS gained the new entry AND visit_term emits it.

If any test fails, it's because a Pattern was applied incorrectly (e.g. uniqueness.rs arm not properly recursing into NewArg::Value, causing a test that exercises ownership analysis to break). Fix per-site, re-run.


Task 2: Form-A surface — lex/parse + print + round-trip fixture

Files:

  • Modify: crates/ailang-surface/src/parse.rs:1227-1257 — head-keyword dispatcher

  • Modify: crates/ailang-surface/src/parse.rs:1610-1631is_term_head_kw helper

  • Modify: crates/ailang-surface/src/parse.rs — new parse_new method (after parse_recur at line ~1687)

  • Modify: crates/ailang-surface/src/print.rs:420-597write_term Term::New arm

  • Modify: crates/ailang-surface/tests/round_trip.rs — new fixture exercising (new T <type> <value>)

  • Step 1: Write the failing round-trip test

Add to crates/ailang-surface/tests/round_trip.rs (find the existing fixture pattern; one new fn following the convention):

#[test]
fn round_trip_term_new_mixed_args() {
    // (new T (con Float) 3) — mixed type-arg + value-arg.
    let form_a = "(module new_demo\n  (data Counter (ctor MkCounter (con Int)))\n  (fn new (type (fn-type (params (con Int)) (ret (con Counter)))) (params n) (body (term-ctor Counter MkCounter n)))\n  (fn main (type (fn-type (params) (ret (con Counter)))) (params) (body (new Counter 42))))\n";
    let module = ailang_surface::parse_module(form_a).expect("parse OK");
    let printed = ailang_surface::print_module(&module);
    assert_eq!(printed.trim(), form_a.trim(),
        "round-trip Form-A → JSON → Form-A must be bit-identical");
}
  • Step 2: Run the test to verify it fails

Run: cargo test --workspace -p ailang-surface round_trip_term_new Expected: FAIL — parse_module returns a ParseError because new is not a known head keyword.

  • Step 3: Add "new" to the head-keyword dispatcher in parse.rs

At crates/ailang-surface/src/parse.rs:1227-1257 (the match block dispatching on the head keyword of a (<kw> …) form), add an arm:

            "new" => self.parse_new()?,

between the existing "recur" arm and the other => fallthrough. Update the error-message enumeration in the fallthrough at line 1249-1252 to include new in the list of known keywords.

  • Step 4: Add "new" to is_term_head_kw helper

At crates/ailang-surface/src/parse.rs:1610-1631, the is_term_head_kw helper enumerates head keywords that introduce a Term form (used to disambiguate Term vs. Type vs. Pat in contexts like loop binders). Add "new" to the matches set:

fn is_term_head_kw(kw: &str) -> bool {
    matches!(kw,
        "app" | "let" | "letrec" | "if" | "do" | "term-ctor" |
        "match" | "lam" | "seq" | "clone" | "reuse-as" | "loop" |
        "recur" | "new"
    )
}

(Adjust the existing list to match — this template assumes the exact set; verify against the function body before editing.)

  • Step 5: Add the parse_new method

After parse_recur (line ~1687), add:

fn parse_new(&mut self) -> Result<Term, ParseError> {
    // Caller has consumed `(` and `new`. Read the type-name ident,
    // then one-or-more args. Each arg is a Type if its head ident
    // matches the Type-production keyword set (`con`, `fn-type`,
    // `borrow`, `own`); otherwise a Term.
    let type_name = self.expect_ident_for_type_name()?;
    let mut args: Vec<NewArg> = Vec::new();
    loop {
        // Closing paren = end of args.
        if self.peek_close_paren() {
            self.expect_close_paren()?;
            break;
        }
        // Look at the next token: if `(`, peek the head ident to
        // disambiguate Type vs Term. Otherwise (bare token), it's
        // a Term (a literal or var).
        if self.peek_open_paren() {
            // Save state, peek the head ident, restore.
            let head = self.peek_head_ident_after_lparen();
            match head.as_deref() {
                Some("con") | Some("fn-type") | Some("borrow") | Some("own") => {
                    let t = self.parse_type()?;
                    args.push(NewArg::Type(t));
                }
                _ => {
                    let v = self.parse_term()?;
                    args.push(NewArg::Value(v));
                }
            }
        } else {
            // Bare token — must be a Term (literal or var).
            let v = self.parse_term()?;
            args.push(NewArg::Value(v));
        }
    }
    if args.is_empty() {
        return Err(self.error_at_cursor("`(new T ...)` requires at least one arg"));
    }
    Ok(Term::New { type_name, args })
}

(The helper names expect_ident_for_type_name, peek_close_paren, peek_open_paren, peek_head_ident_after_lparen may not exist verbatim; use the existing parser conventions in parse.rs. The implementer adjusts the spelling.)

  • Step 6: Add the write_term arm in print.rs

At crates/ailang-surface/src/print.rs:420-597 (fn write_term), add an arm before the closing }:

        Term::New { type_name, args } => {
            buf.push_str("(new ");
            buf.push_str(type_name);
            for arg in args {
                buf.push(' ');
                match arg {
                    NewArg::Type(t) => write_type(buf, t),
                    NewArg::Value(v) => write_term(buf, v),
                }
            }
            buf.push(')');
        }
  • Step 7: Run the round-trip test to verify it passes

Run: cargo test --workspace -p ailang-surface round_trip_term_new Expected: PASS.

  • Step 8: Run the full surface test suite for regressions

Run: cargo test --workspace -p ailang-surface 2>&1 | tail -15 Expected: 0 failures. The new keyword does not collide with any existing keyword (per spec §351 verification).


Task 3: Checker — synth Term::New + 2 new diagnostics + 3 in-source tests

Files:

  • Modify: crates/ailang-check/src/lib.rs:~705 — add CheckError::NewTypeNotConstructible + NewArgKindMismatch variants (alongside prep.1's two type-scoped variants)

  • Modify: crates/ailang-check/src/lib.rs:~759code() arms

  • Modify: crates/ailang-check/src/lib.rs:~837ctx() arms (note: this match has a _ => catch-all so adding arms is for explicit structured diagnostics, not compile-forced)

  • Modify: crates/ailang-check/src/lib.rs:~3005 — replace Task 1's Pattern-E stub in synth's Term match with the real elaboration

  • Test (in-source): crates/ailang-check/src/lib.rs #[cfg(test)] mod tests — three new tests

  • Step 1: Write the three failing tests

Append to the in-source #[cfg(test)] mod tests block in crates/ailang-check/src/lib.rs:

#[test]
fn new_resolves_via_type_scope() {
    // The prep.2 spec's worked example. (module new_demo
    //   (data Counter (ctor MkCounter (con Int)))
    //   (fn new ... (body (term-ctor Counter MkCounter n)))
    //   (fn main ... (body (let c (new Counter 42) ...))))
    let module = serde_json::from_value::<ailang_core::ast::Module>(serde_json::json!({
        "schema": ailang_core::SCHEMA,
        "name": "new_demo",
        "imports": [],
        "defs": [
            {"kind": "data", "name": "Counter", "vars": [], "ctors": [
                {"name": "MkCounter", "args": [{"k": "con", "name": "Int"}]}
            ]},
            {"kind": "fn", "name": "new",
             "type": {"k": "fn", "params": [{"k": "con", "name": "Int"}],
                      "ret": {"k": "con", "name": "Counter"}, "effects": []},
             "params": ["n"],
             "body": {"t": "term-ctor", "type": "Counter", "ctor": "MkCounter",
                      "args": [{"t": "var", "name": "n"}]}},
            {"kind": "fn", "name": "main",
             "type": {"k": "fn", "params": [],
                      "ret": {"k": "con", "name": "Counter"}, "effects": []},
             "params": [],
             "body": {"t": "new", "type": "Counter",
                      "args": [{"kind": "value", "value": {"t": "lit", "lit": {"kind": "int", "value": 42}}}]}}
        ]
    })).unwrap();
    let mut modules = std::collections::BTreeMap::new();
    modules.insert("new_demo".to_string(), module);
    let workspace = ailang_core::workspace::build_workspace(&modules, &["prelude"])
        .expect("workspace builds");
    let res = check_workspace(&workspace);
    assert!(res.is_empty(), "(new Counter 42) must check cleanly; got {res:?}");
}

#[test]
fn new_type_not_constructible() {
    // (new T 42) where T has NO `new` def in its home module.
    let module = serde_json::from_value::<ailang_core::ast::Module>(serde_json::json!({
        "schema": ailang_core::SCHEMA,
        "name": "no_new",
        "imports": [],
        "defs": [
            {"kind": "data", "name": "T", "vars": [], "ctors": [
                {"name": "MkT", "args": []}
            ]},
            // No `new` def for T.
            {"kind": "fn", "name": "main",
             "type": {"k": "fn", "params": [], "ret": {"k": "con", "name": "T"}, "effects": []},
             "params": [],
             "body": {"t": "new", "type": "T",
                      "args": [{"kind": "value", "value": {"t": "lit", "lit": {"kind": "int", "value": 42}}}]}}
        ]
    })).unwrap();
    let mut modules = std::collections::BTreeMap::new();
    modules.insert("no_new".to_string(), module);
    let workspace = ailang_core::workspace::build_workspace(&modules, &["prelude"])
        .expect("workspace builds");
    let diagnostics = check_workspace(&workspace);
    assert!(diagnostics.iter().any(|d| d.code == "new-type-not-constructible"),
        "expected new-type-not-constructible diagnostic; got {diagnostics:?}");
}

#[test]
fn new_arg_kind_mismatch_value_where_type() {
    // (new T arg) where `new` expects a Type arg in position 0 but
    // a Value was supplied (or vice versa).
    // Construct T with a `new` fn whose first param is a Type (i.e.
    // a forall-bound type variable supplied as a Type at the new-site).
    // Provide a Value where the Type was expected.
    let module = serde_json::from_value::<ailang_core::ast::Module>(serde_json::json!({
        "schema": ailang_core::SCHEMA,
        "name": "kind_mismatch",
        "imports": [],
        "defs": [
            {"kind": "data", "name": "T", "vars": ["a"], "ctors": [
                {"name": "MkT", "args": [{"k": "var", "name": "a"}]}
            ]},
            // `new` declared as `forall a. (a) -> T a`. The new-site
            // supplies the type-arg via a NewArg::Type, then the value-
            // arg via a NewArg::Value. Below the new-site mistakenly
            // supplies NewArg::Value for both positions.
            {"kind": "fn", "name": "new",
             "type": {"k": "forall", "vars": ["a"], "constraints": [], "body":
                 {"k": "fn", "params": [{"k": "var", "name": "a"}],
                  "ret": {"k": "con", "name": "T", "args": [{"k": "var", "name": "a"}]},
                  "effects": []}},
             "params": ["x"],
             "body": {"t": "term-ctor", "type": "T", "ctor": "MkT",
                      "args": [{"t": "var", "name": "x"}]}},
            {"kind": "fn", "name": "main",
             "type": {"k": "fn", "params": [], "ret": {"k": "con", "name": "T",
                      "args": [{"k": "con", "name": "Int"}]}, "effects": []},
             "params": [],
             "body": {"t": "new", "type": "T", "args": [
                 // WRONG: should be `{kind: "type", value: <Int type>}` for position 0
                 {"kind": "value", "value": {"t": "lit", "lit": {"kind": "int", "value": 42}}}
             ]}}
        ]
    })).unwrap();
    let mut modules = std::collections::BTreeMap::new();
    modules.insert("kind_mismatch".to_string(), module);
    let workspace = ailang_core::workspace::build_workspace(&modules, &["prelude"])
        .expect("workspace builds");
    let diagnostics = check_workspace(&workspace);
    assert!(diagnostics.iter().any(|d| d.code == "new-arg-kind-mismatch"),
        "expected new-arg-kind-mismatch diagnostic; got {diagnostics:?}");
}
  • Step 2: Run the three tests to verify they fail

Run: cargo test --workspace -p ailang-check new_resolves_via_type_scope new_type_not_constructible new_arg_kind_mismatch Expected: FAIL — 1st test fails because Task 1's stub returns CheckError::Internal; 2nd + 3rd fail because the variants NewTypeNotConstructible / NewArgKindMismatch don't exist yet.

  • Step 3: Add the two new CheckError variants

In crates/ailang-check/src/lib.rs, after TypeScopedReceiverNotAType { name: String } at ~line 705 (Task 1's prep.1 variants), add:

    /// prep.2 (kernel-extension-mechanics): a `(new T args...)` call
    /// where `T`'s home module has no `new` def. Code:
    /// `new-type-not-constructible`.
    #[error("type `{type_name}` is not constructible: home module `{home_module}` declares no `new` def")]
    NewTypeNotConstructible {
        type_name: String,
        home_module: String,
    },

    /// prep.2 (kernel-extension-mechanics): a `(new T args...)` call
    /// where the arg at `position` has kind `got` (Type / Value) but
    /// the `new` def's signature expects `expected`. Code:
    /// `new-arg-kind-mismatch`.
    #[error("`(new {type_name} …)` arg at position {position}: expected {expected}, got {got}")]
    NewArgKindMismatch {
        type_name: String,
        position: usize,
        expected: &'static str,
        got: &'static str,
    },

Add the two arms to CheckError::code() (the match at ~line 759), right after prep.1's two new arms:

            CheckError::NewTypeNotConstructible { .. } => "new-type-not-constructible",
            CheckError::NewArgKindMismatch { .. } => "new-arg-kind-mismatch",

Add the two arms to CheckError::ctx() (the match at ~line 837, which has a _ => catch-all — add explicit arms for richer structured diagnostics):

            CheckError::NewTypeNotConstructible { type_name, home_module } => {
                serde_json::json!({"type": type_name, "home_module": home_module})
            }
            CheckError::NewArgKindMismatch { type_name, position, expected, got } => {
                serde_json::json!({"type": type_name, "position": position,
                                   "expected": expected, "actual": got})
            }
  • Step 4: Implement the real synth Term::New arm

In crates/ailang-check/src/lib.rs, replace the Pattern-E stub in synth's Term match (at ~line 3005, the arm added in Task 1) with the real elaboration per spec §318-330. Insert before the existing Term::Recur arm:

        Term::New { type_name, args } => {
            // prep.2 §318-330. After prep.1's pre-pass, `type_name` is
            // already qualified to `<home>.<Type>` form (or stays
            // local if `T` is defined in this module). Resolve to
            // home module:
            let (home_module, bare_type) = if let Some((m, t)) = type_name.split_once('.') {
                (m.to_string(), t.to_string())
            } else {
                // Local — find own home.
                let owning = env.module_name.clone();
                (owning, type_name.clone())
            };
            // Step 2: find `new` def in home module's globals.
            let home_globals = env.module_globals.get(&home_module).ok_or_else(|| {
                CheckError::TypeScopedReceiverNotAType { name: type_name.clone() }
            })?;
            let new_ty = home_globals.get("new").cloned().ok_or_else(|| {
                CheckError::NewTypeNotConstructible {
                    type_name: bare_type.clone(),
                    home_module: home_module.clone(),
                }
            })?;
            // Qualify any bare type-cons in `new`'s signature against
            // the home module's owner-types (mirror the existing
            // module-as-receiver path).
            let owner_types = env
                .module_types
                .get(&home_module)
                .cloned()
                .unwrap_or_default();
            let qualified = qualify_local_types(&new_ty, &home_module, &owner_types);
            // Step 3: arg count. If `new`'s sig is Forall, first peel
            // it; the params/ret on the inner Fn type is what we count
            // against, with the type-vars filled by Type-positional args.
            let (vars, param_tys, ret_ty, effects) = match &qualified {
                Type::Forall { vars, body, .. } => match body.as_ref() {
                    Type::Fn { params, ret, effects, .. } => {
                        (vars.clone(), params.clone(), (**ret).clone(), effects.clone())
                    }
                    _ => return Err(CheckError::FnTypeRequired("new".into())),
                },
                Type::Fn { params, ret, effects, .. } => {
                    (Vec::new(), params.clone(), (**ret).clone(), effects.clone())
                }
                _ => return Err(CheckError::FnTypeRequired("new".into())),
            };
            // Count Type-positional args (these instantiate `vars`)
            // and Value-positional args (these are checked against
            // `param_tys`).
            let type_arg_count = args.iter().filter(|a| matches!(a, NewArg::Type(_))).count();
            let value_arg_count = args.iter().filter(|a| matches!(a, NewArg::Value(_))).count();
            if type_arg_count != vars.len() {
                return Err(CheckError::NewArgKindMismatch {
                    type_name: bare_type.clone(),
                    position: 0,
                    expected: if vars.is_empty() { "value-arg" } else { "type-arg" },
                    got: if type_arg_count > vars.len() { "type-arg" } else { "value-arg" },
                });
            }
            if value_arg_count != param_tys.len() {
                return Err(CheckError::ArityMismatch {
                    callee: format!("new {bare_type}"),
                    expected: param_tys.len(),
                    got: value_arg_count,
                });
            }
            // Step 4: build the var-binding map from Type-args (in order).
            let mut var_mapping: BTreeMap<String, Type> = BTreeMap::new();
            let mut value_args_iter: Vec<&Term> = Vec::new();
            let mut type_vars_iter = vars.iter();
            for (i, a) in args.iter().enumerate() {
                match a {
                    NewArg::Type(t) => {
                        let var = type_vars_iter.next().ok_or_else(|| {
                            CheckError::NewArgKindMismatch {
                                type_name: bare_type.clone(),
                                position: i,
                                expected: "value-arg",
                                got: "type-arg",
                            }
                        })?;
                        var_mapping.insert(var.clone(), t.clone());
                    }
                    NewArg::Value(v) => value_args_iter.push(v),
                }
            }
            // Step 5: substitute the type-vars into param_tys + ret_ty,
            // then synth each value-arg against the corresponding
            // (substituted) param-type.
            let mut substituted_params: Vec<Type> = param_tys
                .iter()
                .map(|p| substitute_rigids(p, &var_mapping))
                .collect();
            let substituted_ret = substitute_rigids(&ret_ty, &var_mapping);
            for (i, (arg, expected)) in value_args_iter.iter().zip(substituted_params.iter()).enumerate() {
                let arg_ty = synth(arg, env, counter, residuals)?;
                unify(&arg_ty, expected).map_err(|_| CheckError::TypeMismatch {
                    expected: expected.clone(),
                    got: arg_ty,
                })?;
            }
            // Effects are inherited from `new`'s signature.
            for e in &effects {
                if !env.declared_effects.contains(e) {
                    return Err(CheckError::UndeclaredEffect(e.clone()));
                }
            }
            Ok(substituted_ret)
        }

(The implementer reconciles any helper-name spellings — unify may be unify_types; env.module_name may be env.owning_module; Diagnostic field shapes follow the existing convention. Adjust to compile.)

  • Step 5: Run the three tests to verify they pass

Run: cargo test --workspace -p ailang-check new_resolves_via_type_scope new_type_not_constructible new_arg_kind_mismatch Expected: PASS, all three.

  • Step 6: Run the full ailang-check test suite for regressions

Run: cargo test --workspace -p ailang-check 2>&1 | tail -20 Expected: 0 new failures. No existing fixture uses Term::New, so the synth elaboration cannot break pre-existing paths.

  • Step 7: Run the full workspace test suite

Run: cargo test --workspace 2>&1 | tail -25 Expected: 0 new failures.


Task 4: Schema drift + spec / contract anchors

Files:

  • Modify: crates/ailang-core/tests/design_schema_drift.rs:130-189 — add the term_new_round_trips test pin AND the exemplar row (the match arm was already added in Task 1)

  • Modify: crates/ailang-core/tests/spec_drift.rs — add the exemplar row asserting the (new anchor in form_a.md

  • Modify: crates/ailang-core/specs/form_a.md — add (new TYPE-NAME ARG*) to the surface-keyword cheatsheet

  • Modify: design/contracts/0002-data-model.md — add { "t": "new", "type": "<id>", "args": [NewArg...] } to the JSON-tag block + add the NewArg shape definition

  • Step 1: Add the term_new_round_trips schema-drift test

In crates/ailang-core/tests/design_schema_drift.rs, add a new test fn (place near the other Term-round-trip pins, e.g. after term_recur_round_trips if it exists, or alongside the exemplars list):

#[test]
fn term_new_round_trips() {
    // Pin Term::New's JSON shape: `t: "new"`, `type: "<TypeName>"`,
    // `args: [{kind, value}]`. Each NewArg uses tag = "kind",
    // content = "value", lowercase ("type" / "value").
    let t = Term::New {
        type_name: "Counter".into(),
        args: vec![
            NewArg::Value(Term::Lit { lit: Literal::Int { value: 42 } }),
        ],
    };
    let json = serde_json::to_value(&t).expect("serialise");
    assert_eq!(json["t"], "new");
    assert_eq!(json["type"], "Counter");
    let args = json["args"].as_array().expect("args is array");
    assert_eq!(args.len(), 1);
    assert_eq!(args[0]["kind"], "value");
    assert!(args[0]["value"].is_object(), "value is the inlined Term JSON");
    // Round-trip back through Deserialize.
    let recovered: Term = serde_json::from_value(json).expect("deserialise");
    assert!(matches!(recovered, Term::New { type_name, .. } if type_name == "Counter"));
}

#[test]
fn term_new_type_arg_round_trips() {
    // Pin: NewArg::Type serializes with kind: "type", value: <Type JSON>.
    let t = Term::New {
        type_name: "Series".into(),
        args: vec![
            NewArg::Type(Type::Con { name: "Float".into(), args: vec![] }),
            NewArg::Value(Term::Lit { lit: Literal::Int { value: 3 } }),
        ],
    };
    let json = serde_json::to_value(&t).expect("serialise");
    let args = json["args"].as_array().expect("args is array");
    assert_eq!(args[0]["kind"], "type");
    assert_eq!(args[0]["value"]["k"], "con");
    assert_eq!(args[0]["value"]["name"], "Float");
    assert_eq!(args[1]["kind"], "value");
}
  • Step 2: Add the form_a.md anchor for the new keyword

In crates/ailang-core/specs/form_a.md, locate the surface-keyword cheatsheet (around lines 289-298 per recon; verify by grep -n "term-ctor" specs/form_a.md). Add a new line in the keyword-list block:

- `(new TYPE-NAME ARG*)` — functional construction; calls `TYPE-NAME`'s
  home-module `new` def with the supplied args. Each arg is a Type
  (e.g. `(con Float)`) or a Term. Disambiguation is by syntactic form.
  • Step 3: Add the Term::New row to spec_drift.rs's exemplars list

In crates/ailang-core/tests/spec_drift.rs, locate the exemplars list (around lines 90-158 per recon). Add a row asserting the (new literal appears in form_a.md:

    (
        Term::New {
            type_name: "T".into(),
            args: vec![NewArg::Value(Term::Lit { lit: Literal::Int { value: 0 } })],
        },
        "(new",  // anchor expected in form_a.md per Step 2
    ),

(The exact tuple shape depends on the existing exemplars-list format; mirror the prior rows.)

  • Step 4: Add the JSON-tag block to data-model.md

In design/contracts/0002-data-model.md, locate the JSON-tag block listing existing Term variants (around lines 107-178 per recon; the block contains entries like lit, var, app, let, …). Add a new entry for new plus a NewArg shape:

- `{ "t": "new", "type": "<TypeName>", "args": [NewArg, ...] }` — functional
  construction `(new T args...)`. Resolution: the `type` is looked up
  via type-scoped resolution to its home module; the home module's
  `new` def is invoked with the args. Each arg is a `NewArg`. See
  prep.2 of the kernel-extension-mechanics milestone.

`NewArg` shape:

- `{ "kind": "type", "value": <Type JSON> }` — a Type-positional arg.
- `{ "kind": "value", "value": <Term JSON> }` — a Value-positional arg.
  • Step 5: Run the schema-drift + spec-drift tests

Run: cargo test --workspace -p ailang-core --test design_schema_drift && cargo test --workspace -p ailang-core --test spec_drift Expected: PASS — both new pin functions land green, plus the existing pin tests stay green (no byte-shift on existing fixtures).

  • Step 6: Run the full workspace test suite

Run: cargo test --workspace 2>&1 | tail -25 Expected: 0 new failures. The iter is complete.


Self-review (planner Step 5)

  1. Spec coverage: every prep.2 spec subsection has a task —

    • AST schema (Term::New + NewArg) → Task 1
    • Surface (lex/parse/print + round-trip) → Task 2
    • Checker (synth elaboration + 2 diagnostics) → Task 3
    • Drift / round-trip / spec anchors → Task 4
    • Out-of-scope (codegen): Task 1 ships Pattern-D error arms; no real codegen logic.
    • Pre-pass extension (prep.1 partner) → Task 1, Pattern C arm in qualify_workspace_term
  2. Placeholder scan: grepped for "TBD", "TODO", "implement later", "similar to Task", "add appropriate error handling". Pattern E (the synth stub) explicitly names "Task 3 replaces with the real logic" which is a structured forward-reference within the plan, not a placeholder. The 44+ arm-site table uses "Pattern A/B/…" shorthand but the patterns are inlined in the same task body. No literal placeholder strings.

  3. Type consistency: Term::New { type_name, args } is spelled identically across the AST addition (Task 1 Step 1), the 44+ arm-templates (Task 1 Step 3), the Form-A printer arm (Task 2), the synth arm (Task 3), the drift pin (Task 4), and the in-source tests (Task 3). NewArg::Type(Type) and NewArg::Value(Term) similarly. NewTypeNotConstructible { type_name, home_module } and NewArgKindMismatch { type_name, position, expected, got } use the same field names everywhere.

  4. Step granularity: Task 1's per-site sweep is the longest single step (~30-45 minutes across 44 sites with mechanical patterns). It's mechanical — each site takes 1-2 minutes. The OTHER steps are 2-5 minutes each. The Task-1 sweep is intentionally one step rather than 44 steps because each site is so mechanical that splitting would obscure the rhythm; the explicit per-site table keeps it tractable.

  5. No commit steps: zero git commit instructions appear. The implementer leaves work in the working tree; the Boss commits at iter close.

  6. Pin/replacement substring contiguity: N/A — this plan does not contain contains("...") assertions paired with verbatim text replacements. The drift-test pins (assert_eq!(json["t"], "new") etc.) are checked against the JSON-tag derivation, not against a separate verbatim file. Spec anchor strings ("(new" in form_a.md) are short identifiers; no line-wrap risk.

  7. Compile-gate vs. deferred-caller ordering: Task 1's variant addition is the canonical signature change that makes the WHOLE workspace fail to compile until all 44+ callers are updated. Task 1's Step 3 explicitly walks every caller in the same step; the compile-gate at Step 4 verifies cargo build --workspace 2>&1 | tail -15 shows 0 errors. The synth arm uses Pattern-E STUB in Task 1 (compiles cleanly because CheckError::Internal(String) exists); Task 3 replaces with real logic. No caller-threading is deferred past Task 1's compile gate.

  8. Verification-command filter strings:

    • cargo test --workspace -p ailang-surface round_trip_term_new — Task 2 Steps 2/7 — filter resolves to the new test round_trip_term_new_mixed_args (the round_trip_term_new prefix matches it).
    • cargo test --workspace -p ailang-check new_resolves_via_type_scope new_type_not_constructible new_arg_kind_mismatch — Task 3 Steps 2/5 — three filters; each matches a NEW test added in Step 1. After Step 1 lands, all three resolve.
    • cargo test --workspace -p ailang-core --test design_schema_drift — Task 4 Step 5 — --test design_schema_drift resolves to crates/ailang-core/tests/design_schema_drift.rs (verified file exists).
    • cargo test --workspace -p ailang-core --test spec_drift — Task 4 Step 5 — --test spec_drift resolves to crates/ailang-core/tests/spec_drift.rs (verified file exists).
    • cargo build --workspace 2>&1 | tail -15 — Task 1 Steps 2/4 — unfiltered build; count-based assessment via tail.
    • cargo test --workspace 2>&1 | tail -25 (and -30) — multiple steps — unfiltered suite; count-based assessment.

All filter strings resolve to ≥1 named target in the current tree post-Step-1 additions, or to unfiltered count-based outputs.

Handoff

Plan sits in the working tree at docs/plans/0102-prep.2-term-new-construct.md. Hand off to skills/implement with: path to this plan; no task-range focus (run all 4 tasks in sequence). Iter-completion commit body closes #32.