8ac8756682
Ships RawBuf end-to-end as a consumer of the raw-buf.3 scope-qualified
intrinsic mechanism, plus the general Term::New construction sugar.
Working subset committed; the drop-CALL ratification (no-leak) is
re-carved to raw-buf.5 (see b49f57d) — the flat drop FUNCTION ships here,
its call-insertion needs a codegen resolution mechanism.
What ships (all green):
- raw_buf kernel-tier submodule (crates/ailang-kernel/src/raw_buf/):
RawBuf TypeDef (param-in {Int,Float,Bool}, ctor B a) + four
(intrinsic) ops new/get/set/size. parse_raw_buf + workspace injection
(kernel-tier auto-imported); workspace count 4 -> 5.
- 12 scope-qualified INTERCEPTS entries RawBuf_{new,get,set,size}__{Int,
Float,Bool} + emit fns: new allocs an @ailang_rc_alloc slab
(8-byte i64 size header + n*width element bytes), get/set
getelementptr+load/store at offset 8 + i*width, size loads the header.
Mechanical on the raw-buf.3 naming + bijection machinery; bijection
green (4 markers -> 12 entries).
- Term::New desugar (crates/ailang-core/src/desugar.rs): (new T <types>
<values>) -> (app T.new <values>), runs before check so the
type-scoped callee flows through the raw-buf.3 scope threading to
RawBuf_new__T; drops the NewArg::Type (element type inferred from
use). Both codegen Term::New deferral arms removed (replaced with
unreachable!). Ratified by new_stubt_builds_and_runs.
- Flat intrinsic-storage drop FUNCTION @drop_raw_buf_RawBuf (single
@ailang_rc_dec on the slab), emitted for any TypeDef whose new op is
(intrinsic)-bodied — distinguishes RawBuf (intrinsic new) from StubT
(real-body new -> generic ADT drop).
- E2E: raw_buf_int (-> 60), raw_buf_float (-> 4.0), raw_buf_bool
(-> 42), raw_buf_param_in_reject (param-not-in-restricted-set).
In-scope additions beyond the literal plan (both sound, ratified):
- qualify_workspace_term now normalises a monomorphic cross-module
type-scoped callee (StubT.new) to <home>.f; without it
new_stubt_builds_and_runs cannot build (StubT.new is monomorphic, so
the raw-buf.3 poly-mono path mints no symbol). Same-module + polymorphic
callees carved out.
- diagnostic-behaviour: (new T ..) missing-new-op now surfaces
type-scoped-member-not-found (desugar runs before check, bypassing
synth's Term::New arm); new-arg-kind-mismatch obsoleted. Two unit
tests updated to the new behaviour. param-in reject unaffected.
The 5 .ll snapshots gained @drop_raw_buf_RawBuf (+ partial) — injecting
raw_buf emits its drop fn into every program; benign, regenerated to the
final IR. (The plan's "snapshots stay green" assumption was wrong.)
Verification (orchestrator, this session): cargo test --workspace 676
passed / 0 failed / 2 ignored. raw_buf_int_e2e prints 60; bijection,
round-trip, new_stubt, float/bool/reject all green. The raw_buf_no_leak
test is NOT in this commit — it moves to raw-buf.5 with the drop-call
resolution that makes it pass (the slab currently leaks at scope close;
tracked, fixed next iter; milestone not released until close).
795 lines
29 KiB
Rust
795 lines
29 KiB
Rust
//! 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 std::collections::BTreeMap;
|
|
|
|
use ailang_core::ast::{
|
|
ClassDef, ClassMethod, Constraint, ConstDef, Ctor, Def, FnDef, InstanceDef,
|
|
InstanceMethod, Literal, Module, NewArg, 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![] },
|
|
),
|
|
(
|
|
r#""t": "new""#,
|
|
Term::New {
|
|
type_name: "T".into(),
|
|
args: vec![],
|
|
},
|
|
),
|
|
(r#""t": "intrinsic""#, Term::Intrinsic),
|
|
];
|
|
|
|
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",
|
|
Term::New { .. } => "new",
|
|
Term::Intrinsic => "intrinsic",
|
|
};
|
|
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,
|
|
param_in: BTreeMap::new(),
|
|
};
|
|
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 the `implement` skill (mini-mode dispatch) 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::<Vec<_>>(),
|
|
);
|
|
assert!(
|
|
obj.contains_key("ret-type"),
|
|
"Term::Lam must emit `ret-type` key; got keys {:?}",
|
|
obj.keys().collect::<Vec<_>>(),
|
|
);
|
|
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}`"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// prep.2 (kernel-extension-mechanics): pin the JSON byte-shape of
|
|
/// `Term::New` with a single `NewArg::Value`. Property protected:
|
|
/// the tag is `t: "new"`, the type-name field is serialised as
|
|
/// `"type"` (not `"type_name"` or any camelCase variant), the args
|
|
/// list serialises each `NewArg::Value` as `{kind: "value", value:
|
|
/// <Term JSON>}`, and the whole shape round-trips through
|
|
/// `serde_json` without information loss. A future edit that
|
|
/// re-renames any of these keys breaks the pin and the data-model
|
|
/// document must be updated in lockstep.
|
|
#[test]
|
|
fn term_new_round_trips() {
|
|
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 Term::New");
|
|
assert_eq!(json["t"], "new", "Term discriminator must be `t: \"new\"`");
|
|
assert_eq!(json["type"], "Counter", "type_name must serialise as `\"type\"`");
|
|
let args = json["args"].as_array().expect("args must be an array");
|
|
assert_eq!(args.len(), 1);
|
|
assert_eq!(
|
|
args[0]["kind"], "value",
|
|
"NewArg::Value discriminator must be `kind: \"value\"`"
|
|
);
|
|
assert!(
|
|
args[0]["value"].is_object(),
|
|
"value-arg's payload is the inlined Term JSON object"
|
|
);
|
|
let recovered: Term =
|
|
serde_json::from_value(json).expect("Term::New round-trips through serde");
|
|
match recovered {
|
|
Term::New { type_name, args } => {
|
|
assert_eq!(type_name, "Counter");
|
|
assert_eq!(args.len(), 1);
|
|
assert!(matches!(args[0], NewArg::Value(_)));
|
|
}
|
|
other => panic!("expected Term::New after round-trip, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// prep.2 (kernel-extension-mechanics): pin the JSON byte-shape of
|
|
/// `NewArg::Type` (a Type-positional arg). Property protected: the
|
|
/// inner `value` carries a full `Type` JSON object (here a
|
|
/// `(k = "con", name = "Float")`), and the round-trip preserves both
|
|
/// the discriminator and the embedded Type's own discriminator. A
|
|
/// future edit that changes `NewArg`'s serde tag/content shape (or
|
|
/// renames `kind`/`value`) breaks the pin in lockstep with the
|
|
/// data-model document.
|
|
#[test]
|
|
fn term_new_type_arg_round_trips() {
|
|
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 Term::New with mixed args");
|
|
let args = json["args"].as_array().expect("args must be an array");
|
|
assert_eq!(args.len(), 2);
|
|
assert_eq!(args[0]["kind"], "type", "first arg is a Type-positional");
|
|
assert_eq!(args[0]["value"]["k"], "con", "Type-arg's value carries the Type tag");
|
|
assert_eq!(args[0]["value"]["name"], "Float");
|
|
assert_eq!(args[1]["kind"], "value", "second arg is a Value-positional");
|
|
let recovered: Term =
|
|
serde_json::from_value(json).expect("mixed Term::New round-trips through serde");
|
|
if let Term::New { args, .. } = recovered {
|
|
assert!(matches!(args[0], NewArg::Type(Type::Con { ref name, .. }) if name == "Float"));
|
|
assert!(matches!(args[1], NewArg::Value(_)));
|
|
} else {
|
|
panic!("expected Term::New after round-trip");
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// prep.3 (kernel-extension-mechanics): pin the JSON byte-shape of
|
|
/// `Module.kernel`. Property protected: when `true`, the key
|
|
/// serialises as the bare `"kernel": true` flag adjacent to
|
|
/// `"name"`; when `false`, it is omitted entirely from canonical
|
|
/// JSON (preserving the bit-identical hash of every pre-existing
|
|
/// fixture). A future edit that renames the field, drops the
|
|
/// `skip_serializing_if`, or changes its boolean shape breaks the
|
|
/// pin in lockstep with the data-model document.
|
|
#[test]
|
|
fn module_kernel_flag_round_trips() {
|
|
let m_on = Module {
|
|
schema: ailang_core::SCHEMA.to_string(),
|
|
name: "k".into(),
|
|
kernel: true,
|
|
imports: vec![],
|
|
defs: vec![],
|
|
};
|
|
let json_on = serde_json::to_value(&m_on).expect("serialise kernel: true");
|
|
assert_eq!(
|
|
json_on["kernel"], true,
|
|
"kernel: true must appear as a bare boolean key"
|
|
);
|
|
|
|
let m_off = Module {
|
|
schema: ailang_core::SCHEMA.to_string(),
|
|
name: "k".into(),
|
|
kernel: false,
|
|
imports: vec![],
|
|
defs: vec![],
|
|
};
|
|
let json_off = serde_json::to_value(&m_off).expect("serialise kernel: false");
|
|
assert!(
|
|
json_off.get("kernel").is_none(),
|
|
"kernel: false must be omitted from canonical JSON (hash-stability)"
|
|
);
|
|
|
|
let recovered: Module =
|
|
serde_json::from_value(json_on).expect("Module with kernel: true round-trips");
|
|
assert!(recovered.kernel);
|
|
}
|
|
|
|
/// prep.3 (kernel-extension-mechanics): pin the JSON byte-shape of
|
|
/// `TypeDef.param_in`. Property protected: when non-empty, the map
|
|
/// serialises under the kebab-case key `"param-in"`; when empty,
|
|
/// it is omitted entirely (preserving the bit-identical hash of
|
|
/// every fixture that does not restrict). The BTreeMap/BTreeSet
|
|
/// choice gives deterministic iteration order (alphabetical) so
|
|
/// the canonical-JSON bytes are reproducible. A future edit that
|
|
/// renames the field, drops the `skip_serializing_if`, or changes
|
|
/// the inner collection type breaks the pin in lockstep with the
|
|
/// data-model document.
|
|
#[test]
|
|
fn typedef_param_in_round_trips() {
|
|
use std::collections::BTreeSet;
|
|
|
|
let mut restrict: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
restrict.insert(
|
|
"a".into(),
|
|
["Int".to_string(), "Float".to_string()].into_iter().collect(),
|
|
);
|
|
|
|
let td_on = TypeDef {
|
|
name: "T".into(),
|
|
vars: vec!["a".into()],
|
|
ctors: vec![],
|
|
doc: None,
|
|
drop_iterative: false,
|
|
param_in: restrict.clone(),
|
|
};
|
|
let json_on = serde_json::to_value(&td_on).expect("serialise param_in non-empty");
|
|
let pi = json_on.get("param-in").expect(
|
|
"non-empty param_in serialises under kebab key `param-in`",
|
|
);
|
|
let allowed = pi["a"].as_array().expect("inner value is a Type-name array");
|
|
assert_eq!(allowed.len(), 2);
|
|
// BTreeSet iteration order is alphabetical:
|
|
assert_eq!(allowed[0], "Float");
|
|
assert_eq!(allowed[1], "Int");
|
|
|
|
let td_off = TypeDef {
|
|
name: "T".into(),
|
|
vars: vec![],
|
|
ctors: vec![],
|
|
doc: None,
|
|
drop_iterative: false,
|
|
param_in: BTreeMap::new(),
|
|
};
|
|
let json_off = serde_json::to_value(&td_off).expect("serialise param_in empty");
|
|
assert!(
|
|
json_off.get("param-in").is_none(),
|
|
"empty param_in must be omitted from canonical JSON (hash-stability)"
|
|
);
|
|
|
|
let recovered: TypeDef =
|
|
serde_json::from_value(json_on).expect("TypeDef with param_in round-trips");
|
|
assert_eq!(recovered.param_in, restrict);
|
|
}
|
|
|
|
/// prep.3 (kernel-extension-mechanics): pin the JSON byte-shape of
|
|
/// the kernel-stub module — exercises all three new schema items
|
|
/// in a single fixture (`Module.kernel = true`, one TypeDef with
|
|
/// `param-in`, one ctor). A future edit that breaks any of the new
|
|
/// additive schema fields will trip this pin in lockstep with the
|
|
/// per-field round-trip tests (`module_kernel_flag_round_trips`,
|
|
/// `typedef_param_in_round_trips`).
|
|
#[test]
|
|
fn kernel_stub_module_round_trips() {
|
|
let m = ailang_surface::parse_kernel_stub();
|
|
assert!(m.kernel, "stub module is kernel-tier");
|
|
assert_eq!(m.name, "kernel_stub");
|
|
|
|
let json = serde_json::to_value(&m).expect("serialise stub module");
|
|
assert_eq!(json["kernel"], true);
|
|
assert_eq!(json["name"], "kernel_stub");
|
|
|
|
let recovered: Module =
|
|
serde_json::from_value(json).expect("stub module round-trips through serde");
|
|
assert!(recovered.kernel);
|
|
assert_eq!(recovered.name, "kernel_stub");
|
|
|
|
// Locate the StubT TypeDef and confirm param_in is intact.
|
|
let td = recovered.defs.iter().find_map(|d| match d {
|
|
Def::Type(t) if t.name == "StubT" => Some(t),
|
|
_ => None,
|
|
}).expect("StubT TypeDef present in stub module");
|
|
let allowed = td.param_in.get("a").expect("StubT.a restricted");
|
|
assert!(allowed.contains("Int"));
|
|
assert!(allowed.contains("Float"));
|
|
|
|
// raw-buf.3: the type-scoped polymorphic intrinsic ratifier op
|
|
// round-trips and is an `(intrinsic)` marker.
|
|
let peek = recovered.defs.iter().find_map(|d| match d {
|
|
Def::Fn(f) if f.name == "peek" => Some(f),
|
|
_ => None,
|
|
}).expect("kernel_stub ships the peek ratifier op (raw-buf.3)");
|
|
assert!(matches!(peek.body, Term::Intrinsic), "peek is an (intrinsic) marker");
|
|
}
|
|
|
|
/// raw-buf.4: pin the JSON byte-shape of the raw_buf kernel-tier
|
|
/// base-extension module. Mirror of `kernel_stub_module_round_trips`.
|
|
#[test]
|
|
fn raw_buf_module_round_trips() {
|
|
let m = ailang_surface::parse_raw_buf();
|
|
assert!(m.kernel, "raw_buf is kernel-tier");
|
|
assert_eq!(m.name, "raw_buf");
|
|
let json = serde_json::to_value(&m).expect("serialise raw_buf");
|
|
let recovered: Module =
|
|
serde_json::from_value(json).expect("raw_buf round-trips through serde");
|
|
assert!(recovered.kernel);
|
|
assert_eq!(recovered.name, "raw_buf");
|
|
let td = recovered.defs.iter().find_map(|d| match d {
|
|
Def::Type(t) if t.name == "RawBuf" => Some(t),
|
|
_ => None,
|
|
}).expect("RawBuf TypeDef present");
|
|
let allowed = td.param_in.get("a").expect("RawBuf.a restricted");
|
|
assert!(allowed.contains("Int") && allowed.contains("Float") && allowed.contains("Bool"));
|
|
}
|