Files
AILang/crates/ailang-core/tests/spec_drift.rs
T
Brummel 9339279181 iter prep.3-kernel-tier-modules (DONE 9/9): kernel-tier modules + param-in + stub crate — closes #33
Terminal iteration of the kernel-extension-mechanics milestone. Ships
the four language-level mechanisms named in the spec's § Goal:
Module.kernel + TypeDef.param-in schema, their Form-A surface,
flag-driven kernel-tier auto-injection, and generic param-in checker
enforcement with a new diagnostic.

Schema (Tasks 1+2). Module gains a `kernel: bool` field
(skip_serializing_if = is_false), TypeDef gains a
`param_in: BTreeMap<String, BTreeSet<String>>` field
(skip-if-empty, kebab-renamed to "param-in"). Both fields are
strictly additive — every pre-existing fixture's canonical-JSON hash
is bit-stable except `prelude.ail`, which intentionally gains
`(kernel)`. The struct-literal sweep covered ~104 Module sites and
~35 TypeDef sites across the workspace; the additive serde-default
covers JSON deserialise paths, only Rust struct literals broke.

Form-A surface (Tasks 3+4). `(kernel)` is a bare module-header
attribute; `(param-in (a Int Float) (b Str))` is one outer
TypeDef-body clause carrying one or more inner var-lists (OQ1
decision — mirrors `(ctors …)`, one parser arm, deterministic
BTreeMap iteration). Both round-trip Form-A → JSON → Form-A
bit-identical.

Workspace-load migration (Task 5). The hardcoded `&["prelude"]`
literal at loader.rs:108 became a `modules.values().filter(|m|
m.kernel)` derivation; `parse_prelude()` injection stays because
the prelude has no on-disk manifest in user workspaces. Prelude
now carries `(kernel)` in its source, so the new filter picks it
up automatically. Code-path migration only — observable behaviour
is identical (prelude_free_fns.rs stays green). prelude hash
re-pinned (af372f28c726f29f) with Honesty-Rule provenance comment.
WorkspaceLoadError::ReservedModuleName diagnostic prose
repurposed: any built-in kernel module name is reserved
(currently prelude + kernel_stub), not specifically prelude. CLI
mapping at main.rs updated in lockstep.

Stub crate (Task 6). New `crates/ailang-kernel-stub/` is a
zero-dependency leaf crate carrying only `pub const STUB_AIL:
&str` with the Form-A source of the kernel_stub module (one
parametric TypeDef with param-in, one ctor). The parse hop —
`parse_kernel_stub()` — lives in ailang-surface next to
parse_prelude, keeping the crate-dependency graph acyclic
(`ailang-surface → ailang-kernel-stub → ailang-core`, no
back-edge). The stub is injected unconditionally in all builds as
the ratifying fixture for the kernel-extension mechanism; future
base extensions may add more or retire the stub. Drift-pinned by
`kernel_stub_module_round_trips`.

Checker (Task 7). New `CheckError::ParamNotInRestrictedSet`
variant + code() + ctx() arms + enforcement in
`check_type_well_formed`'s Type::Con arm — generic, data-driven
from the TypeDef, mentions no specific extension type. Two
in-source tests pin both the rejection (`Str` outside `{Int,
Float}`) and the acceptance (`Int` inside) paths.

Workspace-load integration tests (Task 8). New
`workspace_kernel.rs` integration-test crate with three tests:
auto-import without explicit `(import …)` declaration, two
kernel-tier modules co-load, explicit-import-overrides-auto-
import precedence preserved. Loader is import-tree-only so the
auto-import tests use a bridge module that brings the kernel
module into the workspace via the import graph — docstring
captures the reachability nuance for future readers.

Doc-state transitions (Task 9). INDEX.md kernel-extensions row
annotation transitions from "design accepted 2026-05-28; impl in
progress" to "mechanisms milestone closed 2026-05-28; raw-buf and
series milestones pending". Whitepaper STATUS + auto-import +
param-in sections transitioned forward→present for shipped
mechanisms; forward-tense survives only in sections describing
the still-pending raw-buf/series milestones (per Honesty-Rule).
data-model contract gains anchor blocks for both new schema
fields.

Side-effect: every binary's IR snapshot now contains ~52 lines
for `drop_kernel_stub_StubT` because the stub is auto-injected
into every workspace load. Snapshots refreshed; e2e expects 4
modules per workspace (prelude + kernel_stub + entry + zero or
more user modules) instead of the previous 3.

Plan defects scrubbed in the implementation (folded back into
the planner template via the planner's self-review checklist
next time): Task 4 sample test src used fictional
`(ctors (MkT a))` list form (project grammar is per-`(ctor MkT
a)`); Task 6 original wiring would have created a cycle
ailang-surface → ailang-kernel-stub → ailang-surface (inverted —
stub crate is zero-dep, parse hop lives in surface); Task 7 in-
source tests referenced a fictional `check_type_in_module`
helper (used the existing Workspace + check_workspace
convention); Task 8 first integration test expected loader to
auto-load kernel modules from disk (loader is import-tree-only;
tests use a bridge module).

Concern-5 fix folded in pre-commit: workspace.rs ReservedModuleName
doc-prose initially said "in test/dev builds" for kernel_stub —
but stub is unconditionally injected in all builds. Doc copy
tightened to present-state per Honesty-Rule.

Stats: 0 spec-review-loops, 0 quality-review-loops, 2 sweep-script
retries on Task 2 (brace-depth bug on nested vec![Ctor{…}],
recovered via per-file checkout + rewritten anchor-on-existing-
field sweep), 1 e2e-snapshot refresh on Task 6.
2026-05-28 18:43:42 +02:00

355 lines
12 KiB
Rust

//! 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 std::collections::BTreeMap;
use ailang_core::ast::{
ConstDef, Ctor, Def, FnDef, Literal, NewArg, 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_str".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() }),
},
),
(
"(loop",
Term::Loop {
binders: Vec::new(),
body: Box::new(Term::Lit { lit: Literal::Unit }),
},
),
(
"(recur",
Term::Recur { args: vec![] },
),
(
"(new",
Term::New {
type_name: "T".into(),
args: vec![NewArg::Value(Term::Lit {
lit: Literal::Int { value: 0 },
})],
},
),
];
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",
Term::Loop { .. } => "loop",
Term::Recur { .. } => "recur",
Term::New { .. } => "new",
};
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()],
constraints: vec![],
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),
("`FLOAT`", Literal::Float { bits: 0 }),
];
for (anchor, lit) in exemplars {
let _: &'static str = match lit {
Literal::Int { .. } => "int",
Literal::Bool { .. } => "bool",
Literal::Str { .. } => "str",
Literal::Unit => "unit",
Literal::Float { .. } => "float",
};
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 } },
export: None,
};
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,
param_in: BTreeMap::new(),
};
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",
// class/instance Def variants exist but are
// not yet anchored in the prose-spec block. Once 22b.4
// adds prose projection for them, the FORM_A_SPEC text
// gains `(class ` / `(instance ` anchors and this match
// will be exercised. For 22b.1 the exemplars list above
// does not produce these variants.
Def::Class(_) => "class",
Def::Instance(_) => "instance",
};
assert!(
FORM_A_SPEC.contains(anchor),
"spec is missing anchor `{anchor}` for a Def kind"
);
}
}
/// The mode keywords (the RC memory model) 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(),
};
}