Files
AILang/crates/ailang-core/tests/design_schema_drift.rs
T
Brummel a355fd861e fix(drift): scope anchor-presence check to jsonc fenced blocks
Adds `anchor_in_jsonc_block(md, anchor) -> bool` to
`design_schema_drift.rs` — a line-walking helper that toggles on
``` / ~~~ fence markers (any info-string: jsonc, json, or
unspecified — all count) and returns true only if the anchor
appears inside fenced content. Re-routes the seven
`DATA_MODEL.contains(anchor)` assertion sites in the file
(Term, Pattern, Type, Literal, Def, ParamMode, nested struct
keys) onto it.

The carrier from `debug` named six sites; in the implement phase
the orchestrator flagged a seventh — `design_md_anchors_nested_
struct_keys` — that exhibited the identical scope-widening bug and
was structurally identical to the named six. Routing it uniformly
is the cohesive shape of the fix; leaving it as `.contains()`
would have preserved a known false-positive surface inside the
same file the fix targets. The "minimal fix, no surrounding
cleanup" constraint blocks opportunistic refactor, not the
uniform application of the named one-line substitution.

Verified: 8/8 in `design_schema_drift` green (was 7 + 1 compile-
blocking RED at c8c30d5); full ailang-core suite green; full
workspace `cargo test` green, no regressions.

Mirrors the inverse `strip_fences` pattern in
`crates/ailang-core/tests/design_index_pin.rs`.

closes #10
2026-05-20 18:15:44 +02:00

492 lines
17 KiB
Rust

//! Drift detection between ast.rs and `design/contracts/data-model.md`.
//!
//! `design/contracts/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/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/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/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/data-model.md is missing anchor `{anchor}` for a Term variant — \
add it to design/contracts/data-model.md"
);
}
}
/// Every `Pattern` variant must have its JSON-schema anchor present in
/// design/contracts/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/data-model.md is missing anchor `{anchor}` for a Pattern variant"
);
}
}
/// Every `Type` variant must have its JSON-schema anchor present in
/// design/contracts/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/data-model.md is missing anchor `{anchor}` for a Type variant"
);
}
}
/// Every `Literal` variant must have its JSON-schema anchor present in
/// design/contracts/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/data-model.md is missing anchor `{anchor}` for a Literal variant"
);
}
}
/// Every `Def` kind must have its JSON-schema anchor present in
/// design/contracts/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/data-model.md is missing anchor `{anchor}` for a Def kind"
);
}
}
/// Every `ParamMode` variant must have its serialized string form present
/// in design/contracts/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/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"
);
}
/// Nested struct key anchors must be present in design/contracts/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/data-model.md is missing nested-struct-key anchor `{anchor}`"
);
}
}