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.
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 viacloses #32)For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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 returnCodegenError::Internal(...). Real codegen lands in theraw-bufmilestone. param-inenforcement on the type-arg ofTerm::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— addTerm::New { type_name, args }to the Term enum (line 405); addpub enum NewArg { Type(Type), Value(Term) }with tagged serde repr (after line 565). - Modify:
crates/ailang-core/src/desugar.rs— 9 exhaustive Term matches gainTerm::Newarms (Pattern A or B per site). - Modify:
crates/ailang-core/src/workspace.rs— 2 walker sites gainTerm::Newarms (Pattern A). - Modify:
crates/ailang-check/src/lib.rs—substitute_rigids_in_term(line 181, Pattern B),verify_tail_positions(line 2749, Pattern A),verify_loop_body(line 2862, Pattern A),synthTerm-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— addCheckError::NewTypeNotConstructible+CheckError::NewArgKindMismatchvariants (Task 3, afterTypeScopedReceiverNotATypeat line ~705); add tocode()(line ~759),ctx()(line ~837), and message-format arms. - Modify:
crates/ailang-check/src/lift.rs— 3 exhaustive Term matches gainTerm::Newarms (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.rs—lower_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), newparse_newmethod (Task 2). - Modify:
crates/ailang-surface/src/print.rs—write_term(line 420-597) gainsTerm::Newarm (Task 2). - Modify:
crates/ailang-prose/src/lib.rs— 3 sites (Pattern A — walker / printer per site). - Modify:
crates/ail/src/main.rs—walk_term+ rewrite_term inner (Pattern A). - Modify:
crates/ailang-core/tests/design_schema_drift.rs— exhaustive Term match arm (Task 1, Pattern F) + newterm_new_round_tripstest (Task 4). - Modify:
crates/ailang-core/tests/schema_coverage.rs—VariantTag::TermNewenum entry;EXPECTED_TAGSarray gains the entry;visit_termarm. - 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.rs—contains_xmod_show_varTerm 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 theNewArgshape 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 testsincrates/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::Newvariant andNewArgenum
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::Newarm 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 inlib.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-1631—is_term_head_kwhelper -
Modify:
crates/ailang-surface/src/parse.rs— newparse_newmethod (afterparse_recurat line ~1687) -
Modify:
crates/ailang-surface/src/print.rs:420-597—write_termTerm::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"tois_term_head_kwhelper
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_newmethod
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_termarm 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— addCheckError::NewTypeNotConstructible+NewArgKindMismatchvariants (alongside prep.1's two type-scoped variants) -
Modify:
crates/ailang-check/src/lib.rs:~759—code()arms -
Modify:
crates/ailang-check/src/lib.rs:~837—ctx()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 insynth'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
CheckErrorvariants
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
synthTerm::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-checktest 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 theterm_new_round_tripstest 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(newanchor 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 theNewArgshape definition -
Step 1: Add the
term_new_round_tripsschema-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
newkeyword
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::Newrow 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)
-
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
-
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.
-
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)andNewArg::Value(Term)similarly.NewTypeNotConstructible { type_name, home_module }andNewArgKindMismatch { type_name, position, expected, got }use the same field names everywhere. -
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.
-
No commit steps: zero
git commitinstructions appear. The implementer leaves work in the working tree; the Boss commits at iter close. -
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. -
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 -15shows 0 errors. The synth arm uses Pattern-E STUB in Task 1 (compiles cleanly becauseCheckError::Internal(String)exists); Task 3 replaces with real logic. No caller-threading is deferred past Task 1's compile gate. -
Verification-command filter strings:
cargo test --workspace -p ailang-surface round_trip_term_new— Task 2 Steps 2/7 — filter resolves to the new testround_trip_term_new_mixed_args(theround_trip_term_newprefix 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_driftresolves tocrates/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_driftresolves tocrates/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.