iter rt.1: roundtrip-invariant audit tests — 3 new tests, all PASS first-shot
Three new reader-only tests pin the .ail.json / .ailx bijection plus AST-variant coverage. All three passed first observation across the current corpus — no roundtrip, schema-coverage, or CLI-drift gaps surfaced. - every_ailx_fixture_matches_its_json_counterpart (replaces the 3-pair handwritten exhibits): 57 paired .ailx fixtures pass. - every_ast_variant_is_observed_in_the_fixture_corpus (new): 34/34 AST variants exercised across 136 fixtures; exhaustive matches without _ wildcard so AST drift fails the build. - cli_render_then_parse_preserves_canonical_bytes_on_every_fixture (new): 136 fixtures round-trip through ail render → tempfile → ail parse with BLAKE3 identity on canonical bytes. No production code, no DESIGN.md changes (those follow in later iterations). Tests are pure readers of the repo per spec acceptance #7 — tempfile crate added as workspace dep for the CLI roundtrip's intermediate file outside the repo.
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
//! Schema-coverage audit: every AST enum variant of `Def`, `Term`,
|
||||
//! `Pattern`, `Literal`, `Type`, `ParamMode` is exercised by at
|
||||
//! least one fixture under `examples/`.
|
||||
//!
|
||||
//! Why this exists: the round-trip tests in `ailang-surface` are
|
||||
//! only as strong as the fixture corpus that drives them. A schema
|
||||
//! variant with zero fixture coverage round-trips trivially — there
|
||||
//! is nothing to round-trip. This test makes the corpus's coverage
|
||||
//! observable, so a gap surfaces as a fail-loud test, not as silent
|
||||
//! confidence.
|
||||
//!
|
||||
//! How it stays in sync with the AST: every match arm below is
|
||||
//! exhaustive (no `_ => ...` wildcard). When a new AST variant
|
||||
//! lands, the compiler rejects this file until the visitor and the
|
||||
//! `VariantTag` enum are extended in lockstep.
|
||||
//!
|
||||
//! Pure reader: this test loads fixtures but does not modify any
|
||||
//! committed content. A failure means a fixture must be added (in
|
||||
//! a separate iteration); the test must not be relaxed.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ailang_core::ast::{
|
||||
Def, InstanceMethod, Literal, Module, ParamMode, Pattern, Term, Type,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum VariantTag {
|
||||
// Def
|
||||
DefFn,
|
||||
DefConst,
|
||||
DefType,
|
||||
DefClass,
|
||||
DefInstance,
|
||||
// Term
|
||||
TermLit,
|
||||
TermVar,
|
||||
TermApp,
|
||||
TermLet,
|
||||
TermLetRec,
|
||||
TermIf,
|
||||
TermDo,
|
||||
TermCtor,
|
||||
TermMatch,
|
||||
TermLam,
|
||||
TermSeq,
|
||||
TermClone,
|
||||
TermReuseAs,
|
||||
// Pattern
|
||||
PatternWild,
|
||||
PatternVar,
|
||||
PatternLit,
|
||||
PatternCtor,
|
||||
// Literal
|
||||
LiteralInt,
|
||||
LiteralBool,
|
||||
LiteralStr,
|
||||
LiteralUnit,
|
||||
LiteralFloat,
|
||||
// Type
|
||||
TypeCon,
|
||||
TypeFn,
|
||||
TypeVar,
|
||||
TypeForall,
|
||||
// ParamMode
|
||||
ParamModeImplicit,
|
||||
ParamModeOwn,
|
||||
ParamModeBorrow,
|
||||
}
|
||||
|
||||
/// Lists every expected variant. Stored as a const array so that
|
||||
/// adding a new `VariantTag` here and forgetting to add the
|
||||
/// corresponding match arm in the visitor is a Rust compile error
|
||||
/// (and vice versa).
|
||||
const EXPECTED_VARIANTS: &[VariantTag] = &[
|
||||
VariantTag::DefFn,
|
||||
VariantTag::DefConst,
|
||||
VariantTag::DefType,
|
||||
VariantTag::DefClass,
|
||||
VariantTag::DefInstance,
|
||||
VariantTag::TermLit,
|
||||
VariantTag::TermVar,
|
||||
VariantTag::TermApp,
|
||||
VariantTag::TermLet,
|
||||
VariantTag::TermLetRec,
|
||||
VariantTag::TermIf,
|
||||
VariantTag::TermDo,
|
||||
VariantTag::TermCtor,
|
||||
VariantTag::TermMatch,
|
||||
VariantTag::TermLam,
|
||||
VariantTag::TermSeq,
|
||||
VariantTag::TermClone,
|
||||
VariantTag::TermReuseAs,
|
||||
VariantTag::PatternWild,
|
||||
VariantTag::PatternVar,
|
||||
VariantTag::PatternLit,
|
||||
VariantTag::PatternCtor,
|
||||
VariantTag::LiteralInt,
|
||||
VariantTag::LiteralBool,
|
||||
VariantTag::LiteralStr,
|
||||
VariantTag::LiteralUnit,
|
||||
VariantTag::LiteralFloat,
|
||||
VariantTag::TypeCon,
|
||||
VariantTag::TypeFn,
|
||||
VariantTag::TypeVar,
|
||||
VariantTag::TypeForall,
|
||||
VariantTag::ParamModeImplicit,
|
||||
VariantTag::ParamModeOwn,
|
||||
VariantTag::ParamModeBorrow,
|
||||
];
|
||||
|
||||
fn visit_def(def: &Def, observed: &mut HashSet<VariantTag>) {
|
||||
match def {
|
||||
Def::Fn(fd) => {
|
||||
observed.insert(VariantTag::DefFn);
|
||||
visit_type(&fd.ty, observed);
|
||||
visit_term(&fd.body, observed);
|
||||
}
|
||||
Def::Const(cd) => {
|
||||
observed.insert(VariantTag::DefConst);
|
||||
visit_type(&cd.ty, observed);
|
||||
visit_term(&cd.value, observed);
|
||||
}
|
||||
Def::Type(td) => {
|
||||
observed.insert(VariantTag::DefType);
|
||||
for ctor in &td.ctors {
|
||||
for field_ty in &ctor.fields {
|
||||
visit_type(field_ty, observed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Def::Class(cd) => {
|
||||
observed.insert(VariantTag::DefClass);
|
||||
for m in &cd.methods {
|
||||
visit_type(&m.ty, observed);
|
||||
if let Some(body) = &m.default {
|
||||
visit_term(body, observed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Def::Instance(id) => {
|
||||
observed.insert(VariantTag::DefInstance);
|
||||
visit_type(&id.type_, observed);
|
||||
for m in &id.methods {
|
||||
visit_instance_method(m, observed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_instance_method(m: &InstanceMethod, observed: &mut HashSet<VariantTag>) {
|
||||
visit_term(&m.body, observed);
|
||||
}
|
||||
|
||||
fn visit_term(t: &Term, observed: &mut HashSet<VariantTag>) {
|
||||
match t {
|
||||
Term::Lit { lit } => {
|
||||
observed.insert(VariantTag::TermLit);
|
||||
visit_literal(lit, observed);
|
||||
}
|
||||
Term::Var { .. } => {
|
||||
observed.insert(VariantTag::TermVar);
|
||||
}
|
||||
Term::App { callee, args, .. } => {
|
||||
observed.insert(VariantTag::TermApp);
|
||||
visit_term(callee, observed);
|
||||
for a in args {
|
||||
visit_term(a, observed);
|
||||
}
|
||||
}
|
||||
Term::Let { value, body, .. } => {
|
||||
observed.insert(VariantTag::TermLet);
|
||||
visit_term(value, observed);
|
||||
visit_term(body, observed);
|
||||
}
|
||||
Term::LetRec { ty, body, in_term, .. } => {
|
||||
observed.insert(VariantTag::TermLetRec);
|
||||
visit_type(ty, observed);
|
||||
visit_term(body, observed);
|
||||
visit_term(in_term, observed);
|
||||
}
|
||||
Term::If { cond, then, else_ } => {
|
||||
observed.insert(VariantTag::TermIf);
|
||||
visit_term(cond, observed);
|
||||
visit_term(then, observed);
|
||||
visit_term(else_, observed);
|
||||
}
|
||||
Term::Do { args, .. } => {
|
||||
observed.insert(VariantTag::TermDo);
|
||||
for a in args {
|
||||
visit_term(a, observed);
|
||||
}
|
||||
}
|
||||
Term::Ctor { args, .. } => {
|
||||
observed.insert(VariantTag::TermCtor);
|
||||
for a in args {
|
||||
visit_term(a, observed);
|
||||
}
|
||||
}
|
||||
Term::Match { scrutinee, arms } => {
|
||||
observed.insert(VariantTag::TermMatch);
|
||||
visit_term(scrutinee, observed);
|
||||
for arm in arms {
|
||||
visit_pattern(&arm.pat, observed);
|
||||
visit_term(&arm.body, observed);
|
||||
}
|
||||
}
|
||||
Term::Lam { param_tys, ret_ty, body, .. } => {
|
||||
observed.insert(VariantTag::TermLam);
|
||||
for ty in param_tys {
|
||||
visit_type(ty, observed);
|
||||
}
|
||||
visit_type(ret_ty, observed);
|
||||
visit_term(body, observed);
|
||||
}
|
||||
Term::Seq { lhs, rhs } => {
|
||||
observed.insert(VariantTag::TermSeq);
|
||||
visit_term(lhs, observed);
|
||||
visit_term(rhs, observed);
|
||||
}
|
||||
Term::Clone { value } => {
|
||||
observed.insert(VariantTag::TermClone);
|
||||
visit_term(value, observed);
|
||||
}
|
||||
Term::ReuseAs { source, body } => {
|
||||
observed.insert(VariantTag::TermReuseAs);
|
||||
visit_term(source, observed);
|
||||
visit_term(body, observed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_pattern(p: &Pattern, observed: &mut HashSet<VariantTag>) {
|
||||
match p {
|
||||
Pattern::Wild => {
|
||||
observed.insert(VariantTag::PatternWild);
|
||||
}
|
||||
Pattern::Var { .. } => {
|
||||
observed.insert(VariantTag::PatternVar);
|
||||
}
|
||||
Pattern::Lit { lit } => {
|
||||
observed.insert(VariantTag::PatternLit);
|
||||
visit_literal(lit, observed);
|
||||
}
|
||||
Pattern::Ctor { fields, .. } => {
|
||||
observed.insert(VariantTag::PatternCtor);
|
||||
for f in fields {
|
||||
visit_pattern(f, observed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_literal(l: &Literal, observed: &mut HashSet<VariantTag>) {
|
||||
match l {
|
||||
Literal::Int { .. } => {
|
||||
observed.insert(VariantTag::LiteralInt);
|
||||
}
|
||||
Literal::Bool { .. } => {
|
||||
observed.insert(VariantTag::LiteralBool);
|
||||
}
|
||||
Literal::Str { .. } => {
|
||||
observed.insert(VariantTag::LiteralStr);
|
||||
}
|
||||
Literal::Unit => {
|
||||
observed.insert(VariantTag::LiteralUnit);
|
||||
}
|
||||
Literal::Float { .. } => {
|
||||
observed.insert(VariantTag::LiteralFloat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_type(t: &Type, observed: &mut HashSet<VariantTag>) {
|
||||
match t {
|
||||
Type::Con { args, .. } => {
|
||||
observed.insert(VariantTag::TypeCon);
|
||||
for a in args {
|
||||
visit_type(a, observed);
|
||||
}
|
||||
}
|
||||
Type::Fn { params, param_modes, ret, ret_mode, .. } => {
|
||||
observed.insert(VariantTag::TypeFn);
|
||||
for p in params {
|
||||
visit_type(p, observed);
|
||||
}
|
||||
for m in param_modes {
|
||||
visit_param_mode(m, observed);
|
||||
}
|
||||
visit_type(ret, observed);
|
||||
visit_param_mode(ret_mode, observed);
|
||||
}
|
||||
Type::Var { .. } => {
|
||||
observed.insert(VariantTag::TypeVar);
|
||||
}
|
||||
Type::Forall { body, .. } => {
|
||||
observed.insert(VariantTag::TypeForall);
|
||||
visit_type(body, observed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_param_mode(m: &ParamMode, observed: &mut HashSet<VariantTag>) {
|
||||
match m {
|
||||
ParamMode::Implicit => {
|
||||
observed.insert(VariantTag::ParamModeImplicit);
|
||||
}
|
||||
ParamMode::Own => {
|
||||
observed.insert(VariantTag::ParamModeOwn);
|
||||
}
|
||||
ParamMode::Borrow => {
|
||||
observed.insert(VariantTag::ParamModeBorrow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_module(m: &Module, observed: &mut HashSet<VariantTag>) {
|
||||
for def in &m.defs {
|
||||
visit_def(def, observed);
|
||||
}
|
||||
}
|
||||
|
||||
fn examples_dir() -> PathBuf {
|
||||
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
crate_dir.parent().unwrap().parent().unwrap().join("examples")
|
||||
}
|
||||
|
||||
fn list_json_fixtures() -> Vec<PathBuf> {
|
||||
let dir = examples_dir();
|
||||
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display()))
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.ends_with(".ail.json"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_ast_variant_is_observed_in_the_fixture_corpus() {
|
||||
let fixtures = list_json_fixtures();
|
||||
assert!(
|
||||
!fixtures.is_empty(),
|
||||
"no .ail.json fixtures found under {}",
|
||||
examples_dir().display()
|
||||
);
|
||||
|
||||
let mut observed: HashSet<VariantTag> = HashSet::new();
|
||||
let mut load_failures = Vec::<String>::new();
|
||||
|
||||
for path in &fixtures {
|
||||
match ailang_core::load_module(path) {
|
||||
Ok(m) => visit_module(&m, &mut observed),
|
||||
Err(e) => load_failures.push(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
// Load failures fail the test on their own — a fixture that
|
||||
// does not load is a corpus bug, surface it loud.
|
||||
if !load_failures.is_empty() {
|
||||
panic!(
|
||||
"{} fixture(s) failed to load during coverage scan:\n{}",
|
||||
load_failures.len(),
|
||||
load_failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
let missing: Vec<VariantTag> = EXPECTED_VARIANTS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|v| !observed.contains(v))
|
||||
.collect();
|
||||
|
||||
if !missing.is_empty() {
|
||||
let names: Vec<String> = missing.iter().map(|v| format!("{v:?}")).collect();
|
||||
panic!(
|
||||
"{} AST variant(s) have NO fixture coverage in {}:\n {}\n\n\
|
||||
every variant must be exercised by at least one fixture; \
|
||||
add a fixture that uses each missing variant (do NOT \
|
||||
weaken this test).",
|
||||
missing.len(),
|
||||
examples_dir().display(),
|
||||
names.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"schema coverage ok: {} variants observed across {} fixtures",
|
||||
observed.len(),
|
||||
fixtures.len()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user