ail: embed Form-A spec in merge-prose prompt (iter 20f)

Closes a design hole shipped in 20d: the merge-prose prompt instructed
the LLM to emit JSON-AST and gave a 12-line schema-essentials reminder.
JSON-AST is the canonical hashable artefact, not a writing surface; the
reminder was not a language spec. Foreign LLMs had no realistic shot.

20f makes two coupled changes:

1) The LLM now emits Form-A (the canonical authoring surface fixed by
   Decision 6). merge-prose loads the original via ailang_core::load_module
   and re-renders via ailang_surface::print before embedding (round-trip
   is a gating contract on the surface crate, so this is lossless). The
   user runs `ail parse foo.new.ailx` to recover JSON, then `ail check`.

2) crates/ailang-core/specs/form_a.md is the complete LLM-targeted
   Form-A specification — grammar, every term/pattern/type/def keyword,
   schema invariants, pitfall catalogue, four few-shot modules from
   examples/*.ailx. Exported as ailang_core::FORM_A_SPEC via include_str!
   and embedded verbatim in every merge-prose prompt.

Drift detection in crates/ailang-core/tests/spec_drift.rs: every variant
of Term, Pattern, Type, Def, Literal is reached via exhaustive `match`.
The arms are not the assertion — adding a new variant without updating
the match is a compile error before the test runs. Once matched, an
anchor string is asserted to appear in FORM_A_SPEC. 8 tests, all green.

The hand-written + mechanical-drift-test combo addresses the user's
"distance to code is too big" concern about a docs-only spec. Generator
overkill rejected: structural drift is mechanically caught, but prose
explanation, schema-invariant catalogue, pitfall list, and few-shot
corpus cannot be emitted from AST shape alone.

Tests:
  - ailang-core: +8 spec_drift tests; existing 12 unchanged
  - ail unit (3): rewritten in lockstep — assert (own T)/(effects IO)
    landmarks, FORM-A SPECIFICATION header, FORM_A_SPEC body verbatim
  - ail e2e merge_prose_prints_framed_prompt: rewritten — assert
    `(module foo` + `FORM-A SPECIFICATION` instead of `ailang/v0`
  - Workspace: all green

Richer integration paths (LLM tool-use, MCP server, LSP) were named
in the design discussion and deferred per "kiss". All three layer
additively on the static-prompt path; static prompt remains the
lowest-common-denominator fallback.
This commit is contained in:
2026-05-08 23:31:27 +02:00
parent 8375eb81ed
commit 72626fa94f
8 changed files with 1070 additions and 197 deletions
+397
View File
@@ -0,0 +1,397 @@
# AILang Form-A — LLM authoring specification
Form-A is the canonical textual surface of AILang. It is the form that
LLMs generate when asked to produce or edit AILang code, and the form
that `ail parse <file>.ailx` reads. The inverse direction — printing
JSON-AST as Form-A — is `ail render <file>.ail.json`. Round-trip
through this pair is the gating contract: `parse(render(m)) == m`
for every well-formed module.
This document is the **complete LLM-targeted specification**. If you
are an LLM and this is in your context, you have everything you need
to produce valid Form-A. The file lives at
`crates/ailang-core/specs/form_a.md` next to the AST definitions, and
a unit test (`tests/spec_drift.rs`) walks every AST enum variant and
asserts its serde tag appears here — so this document cannot silently
fall behind the language.
## Why Form-A and not JSON
The hashable artefact is `.ail.json`, but no human or LLM should write
that directly. JSON-AST is a mechanical serialization with high
boilerplate (`{"k": "con", "name": "Int"}` per type reference, mandatory
field tags, every term wrapped). Form-A is the same information in a
Lisp-style S-expression dress that:
- omits structural noise (no field tags; positions carry meaning)
- has a real parser with positional error messages
- round-trips through `ail render``ail parse` losslessly
- is the form every existing `examples/*.ailx` is written in
LLMs generate Form-A; the toolchain converts to JSON.
## Conventions
- `NAME` is a bare identifier: letters, digits, `_`, `-`, `+`, `*`,
`/`, `<`, `>`, `=`, `!`, `?`, `%`. Cannot start with a digit.
- `STRING` is a double-quoted UTF-8 literal: `"hello"`. Backslash
escapes: `\"`, `\\`, `\n`, `\t`.
- `INT` is a signed decimal integer; leading `-` is allowed.
- Whitespace and `;`-prefixed line comments are insignificant.
- `?` after a clause means optional. `*` means zero or more.
## Module structure
```
(module NAME
IMPORT*
DEF*)
```
The module name MUST equal the file stem (`bench_list_sum.ailx`
`(module bench_list_sum ...)`).
## Imports
```
(import MODULE-NAME)
(import MODULE-NAME (as ALIAS))
```
The alias clause is optional. Imported modules are resolved relative
to the entry file's directory.
## Definitions
Three kinds, matched on the leading keyword:
### Function — `(fn ...)`
```
(fn NAME
(doc STRING)?
(suppress (code STRING) (because STRING))*
(type FN-TYPE)
(params NAME*)
(body TERM))
```
`doc` is optional but recommended — it appears in the prose projection
and helps downstream readers (human and LLM).
`suppress` clauses silence advisory diagnostics for this def. The
`code` MUST be one of the registered codes (today: `over-strict-mode`).
The `because` MUST be a non-empty justification — empty / whitespace-
only `because` is itself an error (`empty-suppress-reason`).
`type` is a `(fn-type ...)` (possibly wrapped in `(forall ...)` for
polymorphic defs). All parameters of a `(fn ...)` MUST carry a mode
annotation — see *Modes* below.
`params` is a list of bare names that bind the parameters in `body`.
The list length must match the number of params in `type`.
### Data type — `(data ...)`
```
(data NAME
(vars TYVAR+)?
(doc STRING)?
(ctor CTOR-NAME ARG-TYPE*)*)
```
`vars` makes the type polymorphic; absent means monomorphic. Each
`ctor` clause is one variant. `ARG-TYPE` is a `TYPE` — see below.
### Constant — `(const ...)`
```
(const NAME
(doc STRING)?
(type TYPE)
(body TERM))
```
## Types
Four shapes, all parenthesised except a bare type variable:
```
TYVAR-NAME ; type variable (e.g. `a`, `T`)
(con NAME TYPE-ARG*) ; type-constructor application
(fn-type (params PARAM*)
(ret RETURN-PARAM)
(effects EFFECT-NAME*)?) ; function type
(forall (vars TYVAR+) BODY-TYPE) ; polymorphic schema
```
`PARAM` and `RETURN-PARAM` are types, optionally wrapped in a mode
annotation:
```
TYPE ; implicit mode (DO NOT USE in new (fn ...) defs)
(own TYPE) ; caller transfers ownership; callee consumes
(borrow TYPE) ; caller retains ownership; callee must not consume
```
`EFFECT-NAME` is a bare identifier — currently `IO` and `Diverge`.
Effects are a set; order is irrelevant.
Built-in type-constructors: `Int`, `Bool`, `Str`, `Unit`. User ADTs
use the name from the `(data ...)` def.
Examples:
```
(con Int)
(con List (con Int))
(fn-type (params (own (con List)) (borrow (con Int))) (ret (con Int)))
(forall (vars a) (fn-type (params (con List a)) (ret (con Int))))
```
## Terms
Atom forms (no parens):
- `INT` — integer literal
- `STRING` — string literal
- `true`, `false` — bool literals
- `NAME` — variable reference (parameter, local, top-level def, or import alias)
Parenthesised forms:
```
(lit-unit) ; the unit value ()
(app FN ARG+) ; function application (≥1 arg)
(tail-app FN ARG+) ; tail-position application (Decision 8)
(do OP-NAME ARG*) ; effect operation
(tail-do OP-NAME ARG*) ; tail-position effect
(let NAME VALUE-TERM BODY-TERM) ; binding
(let-rec NAME (params NAME*) (type FN-TYPE) (body TERM)
(in BODY-TERM)) ; recursive let (fn-shaped)
(if COND-TERM THEN-TERM ELSE-TERM) ; conditional
(match SCRUTINEE-TERM (case PAT BODY)+) ; pattern match (≥1 arm)
(term-ctor TYPE-NAME CTOR-NAME ARG*) ; constructor application
(lam (params (typed NAME TYPE)*)
(ret RETURN-TYPE)
(effects EFFECT-NAME*)?
(body TERM)) ; anonymous function (Iter 8b)
(seq EFFECTFUL-TERM RESULT-TERM) ; sequence; lhs evaluated for effect
(clone TERM) ; explicit RC clone (Iter 18c.1)
(reuse-as SOURCE-TERM BODY-TERM) ; explicit reuse hint (Iter 18d.1)
```
Notes:
- `app` and `do` REQUIRE the right tag for the right thing.
Constructors are NEVER called with `app`; always use `term-ctor`.
- `tail-app` / `tail-do` mark the call as occurring in tail position
per Decision 8. Codegen lowers them to `musttail call`. Use the
tail variant whenever a recursive call is the final action of an
arm — it converts unbounded recursion into iteration. Non-tail
variants are otherwise indistinguishable in semantics.
- `seq` is `(seq A B)` — A is evaluated for its effects and result
discarded; B is the value of the whole expression. For pure A,
prefer `(let _ A B)` or just drop A.
- `reuse-as` requires `SOURCE-TERM` to be a bare variable reference
(a `NAME` in the term grammar). Anything else fails the linearity
check with `reuse-as-source-not-bare-var`.
## Patterns
```
_ ; wildcard; matches anything, binds nothing
NAME ; variable; binds the value to NAME
(pat-lit LIT-FORM) ; literal match: integer, true/false, string
(pat-ctor CTOR-NAME FIELD*) ; constructor; FIELD is itself a pattern
```
`LIT-FORM` is `INT`, `true`, `false`, or `STRING` — the same atoms used
as terms.
A pattern variable may bind at most once per arm. Pattern-binders are
in scope inside the arm body.
## Schema invariants enforced by `ail check`
The parser will accept syntactically valid Form-A that violates these;
the typechecker will not. Producing Form-A that obeys them yields
checked code on the first try.
1. **Mode annotations on every `(fn ...)` parameter.** Every type in
the `(params ...)` clause of a `(fn ...)` definition's
`(fn-type ...)` MUST be wrapped in `(own T)` or `(borrow T)`. The
return type MUST also carry a mode whenever the type is heap-shaped
(i.e. anything other than `(con Int)`, `(con Bool)`, `(con Unit)`,
`(con Str)`). Implicit mode on a `(fn ...)` def is rejected.
2. **Constructors via `term-ctor`.** `Cons(1, Nil)` becomes
`(term-ctor List Cons 1 (term-ctor List Nil))`, never
`(app Cons 1 (app Nil))`.
3. **Effects on side-effecting fns.** A function whose body uses `(do ...)`
MUST list every effect operation's effect in its `(effects ...)`
clause. `IO` for `io/print_*`; `Diverge` for `diverge/*`.
4. **Tail correctness.** `(tail-app f x)` MUST appear in tail position
— i.e. as the body of a fn, the last expression of a `(seq ...)`,
the chosen arm of an `(if ...)` or `(match ...)`, or the body of
a `(let ...)`. A `tail-app` outside a tail position is rejected.
5. **Linearity for own/borrow.** A parameter declared `(own T)` must
be consumed exactly once on every reachable path; a `(borrow T)`
must never be consumed. The diagnostic catalog has named codes
for the typical violations.
## Pitfalls
LLMs without prior AILang exposure tend to make the following errors.
Reading these once before generating Form-A reduces re-roll cost
significantly.
- **Bare type names instead of `(con T)`.** Writing `Int` where a type
is expected does NOT work — types live inside `(con ...)`. Only
`TYVAR-NAME` (a single ident) parses as a type without parens, and
it is interpreted as a type variable. So `(fn-type (params Int) ...)`
parses as "function with one type-variable parameter named Int",
which is almost certainly not what was meant.
- **Forgetting mode annotations.** `(fn-type (params (con List)) ...)`
is accepted by the parser but rejected by the checker. Wrap every
`(fn ...)` parameter in `(own ...)` or `(borrow ...)`.
- **Using `app` for constructors.** Constructors are NOT first-class
functions. `(app Cons 1 Nil)` is interpreted as "apply variable
`Cons` to ...", which then fails because `Cons` is not a fn.
- **Forgetting `tail-`.** A non-tail call in tail position works, but
three million stack frames will overflow. For recursive fns where
the recursive call is the final action, use `tail-app`.
- **Wrong arity in `(case (pat-ctor C f1 f2 ...) ...)`.** The number
of pattern fields must equal the constructor's declared arity. The
checker catches this but the message is clearer if you do too.
- **Strings inside `(suppress (because ...))` must be non-empty.** Empty
is an error. Whitespace-only is an error. Write a real reason.
## Few-shot corpus
These four modules are real `examples/*.ailx` content. Each one is
parseable and typechecks clean. Pattern-match against them when
generating new code.
### 1 — `hello.ailx`: minimal IO program
```
(module hello
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (do io/print_str "Hello, AILang."))))
```
### 2 — `borrow_own_demo.ailx`: mode annotations on a recursive list
```
(module borrow_own_demo
(data List
(doc "Monomorphic singly-linked Int list — boxed, recursive.")
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn list_length
(doc "Borrow xs, count its elements.")
(type
(fn-type
(params (borrow (con List)))
(ret (con Int))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + 1 (app list_length t))))))
(fn sum_list
(doc "Consume xs, sum its elements.")
(type
(fn-type
(params (own (con List)))
(ret (con Int))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + h (app sum_list t))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let xs
(term-ctor List Cons 1
(term-ctor List Cons 2
(term-ctor List Cons 3
(term-ctor List Nil))))
(seq
(do io/print_int (app list_length xs))
(do io/print_int (app sum_list xs)))))))
```
### 3 — `lit_pat.ailx`: literal patterns and nested ctor patterns
```
(module lit_pat
(data IntList
(ctor Nil)
(ctor Cons (con Int) (con IntList)))
(fn classify
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body
(match n
(case (pat-lit 0) 100)
(case (pat-lit 1) 200)
(case _ 999))))
(fn categorize_first
(type (fn-type (params (own (con IntList))) (ret (con Int))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) -1)
(case (pat-ctor Cons (pat-lit 0) _) 0)
(case (pat-ctor Cons h _) h)))))
```
### 4 — Tail-recursive sum (the canonical big-N pattern)
```
(module sum_demo
(data IntList
(ctor INil)
(ctor ICons (con Int) (con IntList)))
(fn sum_acc
(doc "Tail-recursive accumulator.")
(type
(fn-type
(params (own (con IntList)) (con Int))
(ret (con Int))))
(params xs acc)
(body
(match xs
(case (pat-ctor INil) acc)
(case (pat-ctor ICons h t)
(tail-app sum_acc t (app + acc h))))))
(fn sum_list
(type
(fn-type
(params (own (con IntList)))
(ret (con Int))))
(params xs)
(body
(app sum_acc xs 0))))
```
Notice in (4): the recursive `sum_acc` call is `tail-app`, the addition
is plain `app`. The accumulator parameter is `(con Int)` (no mode —
`Int` is a primitive value type, not heap-shaped, so modes do not
apply to it).
+12
View File
@@ -96,6 +96,18 @@ pub type Result<T> = std::result::Result<T, Error>;
/// loading fails with [`Error::SchemaMismatch`].
pub const SCHEMA: &str = "ailang/v0";
/// Iter 20f: complete LLM-targeted specification of Form-A — the
/// canonical authoring surface (Decision 6). Embedded verbatim into
/// any prompt that asks an LLM to produce or edit AILang code; in
/// particular, `ail merge-prose` includes it in the round-trip
/// prompt template. The string is the raw bytes of
/// `specs/form_a.md`, co-located with this crate so the spec sits
/// next to the AST it describes; a drift test
/// (`tests/spec_drift.rs`) walks every AST enum variant and asserts
/// its serde tag appears in this string. Adding a new variant
/// without updating the spec fails the test suite.
pub const FORM_A_SPEC: &str = include_str!("../specs/form_a.md");
/// Load a single module file, validate its schema, and deserialize it
/// into a [`Module`].
///
+316
View File
@@ -0,0 +1,316 @@
//! Iter 20f: drift detection between the AST and `specs/form_a.md`.
//!
//! The spec is hand-curated, but it cannot silently fall behind the
//! language. Every AST enum (`Term`, `Pattern`, `Type`, `Def`, `Literal`,
//! `ParamMode`) discriminates on a `#[serde(rename = "...")]` tag.
//! These tests construct a sample of every variant, then check that the
//! corresponding tag string (or its parenthesised Form-A keyword) appears
//! in the spec.
//!
//! The exhaustive `match` is the load-bearing piece: adding a new variant
//! without a spec entry fails compilation here long before the test runs.
//! Once the variant is matched, the test asserts the spec mentions it.
use ailang_core::ast::{
ConstDef, Ctor, Def, FnDef, Literal, Pattern, Suppress, Term, Type, TypeDef,
};
use ailang_core::FORM_A_SPEC;
/// Every `Term` variant must be reachable from the spec. The Form-A
/// keyword for each variant is what the spec is supposed to teach an
/// LLM; if it is missing here, the LLM cannot produce that term.
#[test]
fn spec_mentions_every_term_variant() {
let exemplars: Vec<(&str, Term)> = vec![
("(lit-unit", Term::Lit { lit: Literal::Unit }),
// The Var form has no parenthesised keyword (a bare ident is a
// var-ref). The spec calls it out under "Atom forms"; we look for
// that anchor.
("Atom forms", Term::Var { name: "x".into() }),
(
"(app",
Term::App {
callee: Box::new(Term::Var { name: "f".into() }),
args: vec![Term::Var { name: "x".into() }],
tail: false,
},
),
(
"(let ",
Term::Let {
name: "x".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
body: Box::new(Term::Var { name: "x".into() }),
},
),
(
"(let-rec",
Term::LetRec {
name: "f".into(),
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
params: vec![],
body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
in_term: Box::new(Term::Var { name: "f".into() }),
},
),
(
"(if",
Term::If {
cond: Box::new(Term::Lit { lit: Literal::Bool { value: true } }),
then: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
else_: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
},
),
(
"(do ",
Term::Do {
op: "io/print_int".into(),
args: vec![],
tail: false,
},
),
(
"(term-ctor",
Term::Ctor {
type_name: "List".into(),
ctor: "Nil".into(),
args: vec![],
},
),
(
"(match",
Term::Match {
scrutinee: Box::new(Term::Var { name: "x".into() }),
arms: vec![],
},
),
(
"(lam",
Term::Lam {
params: vec![],
param_tys: vec![],
ret_ty: Box::new(Type::int()),
effects: vec![],
body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
},
),
(
"(seq",
Term::Seq {
lhs: Box::new(Term::Var { name: "a".into() }),
rhs: Box::new(Term::Var { name: "b".into() }),
},
),
(
"(clone",
Term::Clone {
value: Box::new(Term::Var { name: "x".into() }),
},
),
(
"(reuse-as",
Term::ReuseAs {
source: Box::new(Term::Var { name: "x".into() }),
body: Box::new(Term::Var { name: "y".into() }),
},
),
];
for (anchor, term) in exemplars {
// Force the exhaustive match: the body is just a tag string that
// we will not actually use, but the compiler will refuse to
// compile this file once a new Term variant is added without a
// matching arm.
let _: &'static str = match term {
Term::Lit { .. } => "lit",
Term::Var { .. } => "var",
Term::App { .. } => "app",
Term::Let { .. } => "let",
Term::LetRec { .. } => "letrec",
Term::If { .. } => "if",
Term::Do { .. } => "do",
Term::Ctor { .. } => "ctor",
Term::Match { .. } => "match",
Term::Lam { .. } => "lam",
Term::Seq { .. } => "seq",
Term::Clone { .. } => "clone",
Term::ReuseAs { .. } => "reuse-as",
};
assert!(
FORM_A_SPEC.contains(anchor),
"spec is missing anchor `{anchor}` for a Term variant — \
update crates/ailang-core/specs/form_a.md"
);
}
}
/// Every `Pattern` variant must appear in the spec.
#[test]
fn spec_mentions_every_pattern_variant() {
let exemplars: Vec<(&str, Pattern)> = vec![
("_", Pattern::Wild),
// pat-var is again the bare-ident form. The spec discusses it
// under "Patterns". Use the heading as the anchor.
("## Patterns", Pattern::Var { name: "x".into() }),
("(pat-lit", Pattern::Lit { lit: Literal::Int { value: 0 } }),
(
"(pat-ctor",
Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
),
];
for (anchor, pat) in exemplars {
let _: &'static str = match pat {
Pattern::Wild => "wild",
Pattern::Var { .. } => "var",
Pattern::Lit { .. } => "lit",
Pattern::Ctor { .. } => "ctor",
};
assert!(
FORM_A_SPEC.contains(anchor),
"spec is missing anchor `{anchor}` for a Pattern variant"
);
}
}
/// Every `Type` variant must appear in the spec.
#[test]
fn spec_mentions_every_type_variant() {
let exemplars: Vec<(&str, Type)> = vec![
("(con ", Type::int()),
(
"(fn-type",
Type::fn_implicit(vec![], Type::unit(), vec![]),
),
(
"TYVAR-NAME",
Type::Var { name: "a".into() },
),
(
"(forall",
Type::Forall {
vars: vec!["a".into()],
body: Box::new(Type::Var { name: "a".into() }),
},
),
];
for (anchor, ty) in exemplars {
let _: &'static str = match ty {
Type::Con { .. } => "con",
Type::Fn { .. } => "fn",
Type::Var { .. } => "var",
Type::Forall { .. } => "forall",
};
assert!(
FORM_A_SPEC.contains(anchor),
"spec is missing anchor `{anchor}` for a Type variant"
);
}
}
/// Every `Literal` variant must appear in the spec.
#[test]
fn spec_mentions_every_literal_variant() {
// Anchors describe how the literal renders in Form-A.
let exemplars: Vec<(&str, Literal)> = vec![
("`INT`", Literal::Int { value: 0 }),
("`true`, `false`", Literal::Bool { value: true }),
("`STRING`", Literal::Str { value: "x".into() }),
("(lit-unit)", Literal::Unit),
];
for (anchor, lit) in exemplars {
let _: &'static str = match lit {
Literal::Int { .. } => "int",
Literal::Bool { .. } => "bool",
Literal::Str { .. } => "str",
Literal::Unit => "unit",
};
assert!(
FORM_A_SPEC.contains(anchor),
"spec is missing anchor `{anchor}` for a Literal variant"
);
}
}
/// Every `Def` kind must appear in the spec.
#[test]
fn spec_mentions_every_def_kind() {
let fn_def = FnDef {
name: "f".into(),
doc: None,
suppress: vec![],
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
params: vec![],
body: Term::Lit { lit: Literal::Int { value: 0 } },
};
let const_def = ConstDef {
name: "k".into(),
doc: None,
ty: Type::int(),
value: Term::Lit { lit: Literal::Int { value: 0 } },
};
let type_def = TypeDef {
name: "T".into(),
doc: None,
vars: vec![],
ctors: vec![Ctor {
name: "C".into(),
fields: vec![],
}],
drop_iterative: false,
};
let exemplars: Vec<(&str, Def)> = vec![
("(fn ", Def::Fn(fn_def)),
("(const ", Def::Const(const_def)),
("(data ", Def::Type(type_def)),
];
for (anchor, def) in exemplars {
let _: &'static str = match def {
Def::Fn(_) => "fn",
Def::Const(_) => "const",
Def::Type(_) => "type",
};
assert!(
FORM_A_SPEC.contains(anchor),
"spec is missing anchor `{anchor}` for a Def kind"
);
}
}
/// The mode keywords (Decision 10) must appear so an LLM knows the
/// Form-A wrapper syntax.
#[test]
fn spec_mentions_mode_keywords() {
assert!(FORM_A_SPEC.contains("(own"), "spec missing `(own ...)`");
assert!(FORM_A_SPEC.contains("(borrow"), "spec missing `(borrow ...)`");
}
/// `tail-app` and `tail-do` are distinct keywords from `app`/`do`. The
/// spec must mention both, otherwise an LLM cannot produce tail-correct
/// code at scale.
#[test]
fn spec_mentions_tail_variants() {
assert!(FORM_A_SPEC.contains("tail-app"), "spec missing `tail-app`");
assert!(FORM_A_SPEC.contains("tail-do"), "spec missing `tail-do`");
}
/// `suppress` is part of the surface and the LLM must know how to
/// preserve it. Empty-because is itself a diagnostic; the spec calls
/// it out so the LLM does not produce empty justifications.
#[test]
fn spec_mentions_suppress_clause() {
assert!(FORM_A_SPEC.contains("(suppress"), "spec missing `(suppress ...)`");
assert!(
FORM_A_SPEC.contains("empty-suppress-reason"),
"spec missing the empty-suppress-reason diagnostic"
);
// Make sure `Suppress` in the AST can still be constructed — the
// exhaustive-match property carries through to the surface.
let _ = Suppress {
code: "x".into(),
because: "y".into(),
};
}