//! Drift detection between ast.rs and `design/contracts/0002-data-model.md`. //! //! `design/contracts/0002-data-model.md` is the canonical schema //! source-of-truth. Every AST enum (`Term`, `Pattern`, `Type`, `Def`, //! `Literal`, `ParamMode`) must have a JSON-schema anchor (e.g. //! `"t": "lit"`, `"k": "fn"`) present in that document. These tests //! enforce the property. //! //! The exhaustive `match` per enum is the load-bearing mechanism: adding a //! new variant without a matching arm fails compilation before the test runs. //! Once the variant is matched, the test asserts the anchor is present in //! `design/contracts/0002-data-model.md`. The whole file is the data-model //! contract, so the file boundary now bounds the section — the former //! `## Data model` … `## Pipeline` slicer is no longer needed (the split //! gave the section its own file). use ailang_core::ast::{ ClassDef, ClassMethod, Constraint, ConstDef, Ctor, Def, FnDef, InstanceDef, InstanceMethod, Literal, Pattern, ParamMode, Suppress, Term, Type, TypeDef, }; const DATA_MODEL: &str = include_str!("../../../design/contracts/0002-data-model.md"); /// Scoped substring match: returns `true` iff `anchor` appears inside a /// fenced code block (``` or ~~~) of `md`. Info-string `jsonc` / `json` / /// unspecified all count — fence-toggling is by fence-marker line alone, /// mirroring the inverse `strip_fences` pattern in /// `crates/ailang-core/tests/design_index_pin.rs`. The whole-file /// `.contains()` was fidelity-widened: anchors mentioned in surrounding /// prose (footnotes, inline references, historical notes) counted as /// present, so a future edit could delete the canonical schema entry for /// a variant from inside a fenced block and the drift test would still /// pass. This helper closes that surface. fn anchor_in_jsonc_block(md: &str, anchor: &str) -> bool { let mut in_fence = false; for line in md.lines() { let t = line.trim_start(); if t.starts_with("```") || t.starts_with("~~~") { in_fence = !in_fence; continue; } if in_fence && line.contains(anchor) { return true; } } false } /// Every `Term` variant must have its JSON-schema anchor present in /// design/contracts/0002-data-model.md. An LLM author cannot produce a term variant /// whose `"t"` tag is absent from the canonical schema document. #[test] fn design_md_anchors_every_term_variant() { let exemplars: Vec<(&str, Term)> = vec![ ( r#""t": "lit""#, Term::Lit { lit: Literal::Unit }, ), ( r#""t": "var""#, Term::Var { name: "x".into() }, ), ( r#""t": "app""#, Term::App { callee: Box::new(Term::Var { name: "f".into() }), args: vec![], tail: false, }, ), ( r#""t": "let""#, Term::Let { name: "x".into(), value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), body: Box::new(Term::Var { name: "x".into() }), }, ), ( r#""t": "letrec""#, 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() }), }, ), ( r#""t": "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 } }), }, ), ( r#""t": "do""#, Term::Do { op: "io/print_str".into(), args: vec![], tail: false, }, ), ( r#""t": "ctor""#, Term::Ctor { type_name: "List".into(), ctor: "Nil".into(), args: vec![], }, ), ( r#""t": "match""#, Term::Match { scrutinee: Box::new(Term::Var { name: "x".into() }), arms: vec![], }, ), ( r#""t": "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 } }), }, ), ( r#""t": "seq""#, Term::Seq { lhs: Box::new(Term::Var { name: "a".into() }), rhs: Box::new(Term::Var { name: "b".into() }), }, ), ( r#""t": "clone""#, Term::Clone { value: Box::new(Term::Var { name: "x".into() }), }, ), ( r#""t": "reuse-as""#, Term::ReuseAs { source: Box::new(Term::Var { name: "x".into() }), body: Box::new(Term::Var { name: "y".into() }), }, ), ( r#""t": "loop""#, Term::Loop { binders: Vec::new(), body: Box::new(Term::Lit { lit: Literal::Unit }), }, ), ( r#""t": "recur""#, Term::Recur { args: vec![] }, ), ]; for (anchor, term) in exemplars { // Exhaustive match: compiler rejects this file if a new Term // variant lacks an arm, catching drift at compile time. 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", }; assert!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing anchor `{anchor}` for a Term variant — \ add it to design/contracts/0002-data-model.md" ); } // The two Term::Lam compound-key tags are kebab-case (closes #30). // Pin the spellings inside data-model.md's fenced jsonc blocks so a // future edit cannot silently revert the contract document to // camelCase while ast.rs ships kebab. for anchor in [r#""param-types""#, r#""ret-type""#] { assert!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing Term::Lam kebab-key anchor `{anchor}` — \ check the `lam` fenced JSON block" ); } } /// Every `Pattern` variant must have its JSON-schema anchor present in /// design/contracts/0002-data-model.md. Missing anchors mean an LLM cannot produce /// the corresponding pattern form. #[test] fn design_md_anchors_every_pattern_variant() { let exemplars: Vec<(&str, Pattern)> = vec![ (r#""p": "wild""#, Pattern::Wild), (r#""p": "var""#, Pattern::Var { name: "x".into() }), (r#""p": "lit""#, Pattern::Lit { lit: Literal::Int { value: 0 } }), ( r#""p": "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!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing anchor `{anchor}` for a Pattern variant" ); } } /// Every `Type` variant must have its JSON-schema anchor present in /// design/contracts/0002-data-model.md. The `"k"` discriminator is load-bearing for /// the codegen and typechecker; an LLM must know all four forms. #[test] fn design_md_anchors_every_type_variant() { let exemplars: Vec<(&str, Type)> = vec![ (r#""k": "con""#, Type::int()), (r#""k": "fn""#, Type::fn_implicit(vec![], Type::unit(), vec![])), (r#""k": "var""#, Type::Var { name: "a".into() }), ( r#""k": "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!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing anchor `{anchor}` for a Type variant" ); } } /// Every `Literal` variant must have its JSON-schema anchor present in /// design/contracts/0002-data-model.md. The `"kind"` discriminator identifies the /// literal type; an LLM cannot produce a literal it hasn't seen in the /// schema. #[test] fn design_md_anchors_every_literal_variant() { let exemplars: Vec<(&str, Literal)> = vec![ (r#""kind": "int""#, Literal::Int { value: 0 }), (r#""kind": "bool""#, Literal::Bool { value: true }), (r#""kind": "str""#, Literal::Str { value: "x".into() }), (r#""kind": "unit""#, Literal::Unit), (r#""kind": "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!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing anchor `{anchor}` for a Literal variant" ); } } /// Every `Def` kind must have its JSON-schema anchor present in /// design/contracts/0002-data-model.md. All five kinds (`fn`, `const`, `type`, /// `class`, `instance`) must be documented so an LLM can write /// any kind of top-level definition. #[test] fn design_md_anchors_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, }; let class_def = ClassDef { name: "Show".into(), param: "a".into(), superclass: None, methods: vec![ClassMethod { name: "show".into(), ty: Type::fn_implicit(vec![Type::Var { name: "a".into() }], Type::str_(), vec![]), default: None, }], doc: None, }; let instance_def = InstanceDef { class: "Show".into(), type_: Type::int(), methods: vec![InstanceMethod { name: "show".into(), body: Term::Lit { lit: Literal::Str { value: "0".into() } }, }], doc: None, }; let exemplars: Vec<(&str, Def)> = vec![ (r#""kind": "fn""#, Def::Fn(fn_def)), (r#""kind": "const""#, Def::Const(const_def)), (r#""kind": "type""#, Def::Type(type_def)), (r#""kind": "class""#, Def::Class(class_def)), (r#""kind": "instance""#, Def::Instance(instance_def)), ]; for (anchor, def) in exemplars { let _: &'static str = match def { Def::Fn(_) => "fn", Def::Const(_) => "const", Def::Type(_) => "type", Def::Class(_) => "class", Def::Instance(_) => "instance", }; assert!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing anchor `{anchor}` for a Def kind" ); } } /// Every `ParamMode` variant must have its serialized string form present /// in design/contracts/0002-data-model.md. The mode annotations are load-bearing for /// ownership checking; an LLM author must know all three forms. #[test] fn design_md_anchors_every_parammode_variant() { let exemplars: Vec<(&str, ParamMode)> = vec![ (r#""implicit""#, ParamMode::Implicit), (r#""own""#, ParamMode::Own), (r#""borrow""#, ParamMode::Borrow), ]; for (anchor, mode) in exemplars { let _: &'static str = match mode { ParamMode::Implicit => "implicit", ParamMode::Own => "own", ParamMode::Borrow => "borrow", }; assert!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing anchor `{anchor}` for a ParamMode variant" ); } } /// The anchor-presence check must scope its substring match to ```jsonc``` /// (and ``` / ```json) fenced code blocks. The whole-file `.contains()` is /// fidelity-widened: a future edit can delete the canonical schema entry for /// a variant from inside a fenced block while leaving the anchor string /// mentioned in surrounding prose (e.g. a "design rationale" footnote, an /// inline reference like `the "t": "lit" form`, or a historical-note /// section). The drift test would silently pass, and downstream LLM authors /// would lose the canonical schema for a variant the AST still produces. /// /// This test pins the property in two directions: /// 1. A markdown string with the anchor present ONLY in prose (no fenced /// block) must report ABSENT under the scoped helper. The unscoped /// `.contains()` reports PRESENT for the same input — that mismatch is /// the bug surface. /// 2. A markdown string with the anchor present ONLY inside a ```jsonc``` /// block must report PRESENT under the scoped helper. /// /// Gitea issue #10. The helper `anchor_in_jsonc_block` does not yet exist — /// this test is RED until `skills/implement` mini-mode adds it and re-routes /// the six existing call sites onto it. #[test] fn anchor_presence_check_is_scoped_to_jsonc_blocks() { // Anchor mentioned only in prose — the false-pass surface. The whole // sentence after the heading is plain markdown body text; no fenced // block exists in this fixture at all. let prose_only = r#"# Data model The `"t": "lit"` form used to be the canonical literal anchor. See historical note below. ## Historical note Older drafts pinned the literal schema as `"t": "lit"`; the current schema uses a different shape (see commit log). "#; // Anchor present only inside a fenced jsonc block — the true-positive // surface. Prose outside the block does not mention the anchor. let jsonc_only = r#"# Data model The literal form is canonical. ```jsonc { "t": "lit", "lit": Literal } ``` End. "#; // Unscoped substring match — what the file does today. Both inputs // report PRESENT, and that is the bug: prose-only must not count. assert!(prose_only.contains(r#""t": "lit""#)); assert!(jsonc_only.contains(r#""t": "lit""#)); // Scoped helper — what the file must do. Prose-only is ABSENT, // jsonc-only is PRESENT. This call is the RED: the helper does not // yet exist, so this test fails to compile until `implement` mini-mode // factors it out and re-routes the six call sites onto it. assert!( !anchor_in_jsonc_block(prose_only, r#""t": "lit""#), "scoped helper must NOT count an anchor that lives only in prose" ); assert!( anchor_in_jsonc_block(jsonc_only, r#""t": "lit""#), "scoped helper MUST count an anchor that lives inside a ```jsonc``` block" ); // Live document: the helper must also report PRESENT for the live // data-model.md (proves the helper does not over-narrow and break // the existing six checks). assert!( anchor_in_jsonc_block(DATA_MODEL, r#""t": "lit""#), "scoped helper must find live anchors inside data-model.md ```jsonc``` blocks" ); } /// Schema-shape pin: the JSON serialisation of `Term::Lam` must /// emit kebab-case tags `"param-types"` and `"ret-type"`, and /// must NOT emit the old camelCase `"paramTypes"` / `"retType"`. /// Pinned because the closes-#30 milestone made the rename a /// schema-stability invariant: a future regression that flips /// the `#[serde(rename)]` string back to camelCase must fire RED /// here, not slip through. #[test] fn lam_serialises_with_kebab_keys() { let lam = Term::Lam { params: vec!["x".into()], param_tys: vec![Type::int()], ret_ty: Box::new(Type::int()), effects: vec![], body: Box::new(Term::Var { name: "x".into() }), }; let v = serde_json::to_value(&lam).expect("Term::Lam serialises"); let obj = v.as_object().expect("Term::Lam serialises as object"); assert!( obj.contains_key("param-types"), "Term::Lam must emit `param-types` key; got keys {:?}", obj.keys().collect::>(), ); assert!( obj.contains_key("ret-type"), "Term::Lam must emit `ret-type` key; got keys {:?}", obj.keys().collect::>(), ); assert!( !obj.contains_key("paramTypes"), "Term::Lam must NOT emit old camelCase `paramTypes` key", ); assert!( !obj.contains_key("retType"), "Term::Lam must NOT emit old camelCase `retType` key", ); } /// Nested struct key anchors must be present in design/contracts/0002-data-model.md. /// These keys appear inside `Suppress`, `ClassMethod`, `InstanceMethod`, /// and `Type::Forall` — they are not discriminators but they ARE part /// of the canonical JSON schema and must remain documented. #[test] fn design_md_anchors_nested_struct_keys() { // Constructors exercised here ensure ast.rs field names are correct. let _ = Suppress { code: "x".into(), because: "y".into() }; let _ = ClassMethod { name: "m".into(), ty: Type::fn_implicit(vec![], Type::int(), vec![]), default: None, }; let _ = InstanceMethod { name: "m".into(), body: Term::Lit { lit: Literal::Unit }, }; let _ = Constraint { class: "Show".into(), type_: Type::int(), }; let anchors = [ r#""code""#, r#""because""#, r#""methods""#, r#""constraints""#, ]; for anchor in anchors { assert!( anchor_in_jsonc_block(DATA_MODEL, anchor), "design/contracts/0002-data-model.md is missing nested-struct-key anchor `{anchor}`" ); } }