Files
AILang/crates/ailang-check/tests/structural_recursion_pin.rs
T
Brummel a4be1e58a3 iter it.2: structural-guardedness checker + first real Diverge effect
Iteration-discipline milestone, 2 of 3. Strictly additive (nothing
tail-related removed; that is it.3). New whole-body pass
verify_structural_recursion sibling of verify_tail_positions
(DD-1): smaller-set algorithm with implicit candidate inference +
unconstrained accumulators (DD-2, foldl=structural), self/mutual
via inline ADT-family union-find (DD-3), it.2-only tail==false
grandfather. CheckError::NonStructuralRecursion. term_contains_loop
(stops at Term::Lam, DD-4) injects Diverge so existing
UndeclaredEffect enforces it, no new variant; lam-arrow + LetRec
sub-effect sites wired. DESIGN.md Decision 3 synced. Four it.1
loop fixtures gained !Diverge.

Two spec-premise boundary defects surfaced + resolved within the
additive invariant (corpus clean, check not weakened), recorded as
corrected it.3 corpus-migration scope: (1) the "21 tail-app
fixtures" grandfather premise under-counts the corpus —
no-ADT-candidate counter recursions have no structural position to
verify, deferred to it.3; (2) two RC-regression fixtures joined the
spec's transitional tail-app grandfather as the other 20 do (RC==GC
guards verified still green). cargo test --workspace 622/0; 9
acceptance pins non-vacuous. Spec fda9b78, plan bc9f512.
2026-05-15 15:29:43 +02:00

130 lines
4.5 KiB
Rust

//! Iter it.2: pin tests for the structural-recursion guardedness
//! pass + the it.2-only `tail:true` grandfather + the first real
//! `Diverge` effect injection.
//!
//! Spec: `docs/specs/2026-05-15-iteration-discipline.md`. Mirrors the
//! it.1 `loop_recur_pin.rs` harness verbatim (same inline
//! `check_fixture` helper, no `mod common`).
use ailang_check::check_workspace;
use ailang_surface::load_workspace;
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
let manifest = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest)
.parent().expect("CARGO_MANIFEST_DIR has a parent (crates/ailang-check)")
.parent().expect("CARGO_MANIFEST_DIR has a grandparent (crates/)")
.join("examples")
}
/// Load the named fixture under `examples/`, run check_workspace, and
/// return the diagnostic code list (one entry per Diagnostic).
fn check_fixture(fixture_name: &str) -> Vec<String> {
let path = examples_dir().join(fixture_name);
let ws = load_workspace(&path)
.unwrap_or_else(|e| panic!("workspace `{fixture_name}` must load: {e:?}"));
let diags = check_workspace(&ws);
diags.iter().map(|d| d.code.clone()).collect()
}
#[test]
fn structural_list_len_is_clean() {
// Plain non-tail recursion on a Cons sub-component typechecks
// clean: structurally guarded ⇒ pure + total, no diagnostics.
assert!(
check_fixture("struct_rec_list_len.ail").is_empty(),
"expected zero diagnostics for structural list length"
);
}
#[test]
fn foldl_accumulator_is_structural_and_clean() {
// A foldl-shape accumulator walk (structural on the tail,
// unconstrained accumulator) classifies as structural recursion
// and stays clean + Diverge-free (spec D1).
assert!(
check_fixture("struct_rec_foldl_sum.ail").is_empty(),
"expected zero diagnostics for foldl-shape accumulator walk"
);
}
#[test]
fn non_structural_self_call_is_rejected() {
// A self-call passing the un-decreased parameter at every
// candidate position, not `tail`-marked, fires
// `non-structural-recursion`.
assert!(
check_fixture("test_non_structural_recursion.ail.json")
.contains(&"non-structural-recursion".to_string()),
"expected non-structural-recursion diagnostic"
);
}
#[test]
fn tree_forest_mutual_is_clean() {
// Mutual tree/forest recursion over one ADT family (the
// cross-reference edge unions {Tree, Forest}) classifies as a
// mutual structural group: clean, Diverge-free.
assert!(
check_fixture("struct_rec_tree_forest.ail").is_empty(),
"expected zero diagnostics for same-family mutual recursion"
);
}
#[test]
fn mutual_cross_family_is_rejected() {
// Mutual recursion whose members' structural params lie in two
// unrelated ADT families is not a valid mutual structural group
// (DD-3): the cross-call is an unguarded recursive call.
assert!(
check_fixture("test_mutual_cross_family.ail.json")
.contains(&"non-structural-recursion".to_string()),
"expected non-structural-recursion for cross-family mutual recursion"
);
}
#[test]
fn loop_fn_declaring_diverge_is_clean() {
// A loop-bearing fn that declares `!Diverge` in its effect row
// reconciles clean (DD-4 / D2).
assert!(
check_fixture("loop_needs_diverge.ail").is_empty(),
"expected zero diagnostics for loop fn declaring !Diverge"
);
}
#[test]
fn loop_fn_missing_diverge_is_rejected() {
// A loop-bearing fn missing `!Diverge` raises the existing
// `undeclared-effect` (no new diagnostic variant; DD-4).
assert!(
check_fixture("test_loop_missing_diverge.ail.json")
.contains(&"undeclared-effect".to_string()),
"expected undeclared-effect for loop fn missing !Diverge"
);
}
#[test]
fn structural_recursion_is_diverge_free() {
// Structural recursion injects no effect: a structural list walk
// with no `loop` and no declared `!Diverge` stays clean.
assert!(
check_fixture("struct_rec_list_len.ail").is_empty(),
"structural recursion must be Diverge-free"
);
}
#[test]
fn non_structural_recursion_code_is_registered() {
// A CheckError::NonStructuralRecursion must map to the kebab code.
// This step only pins the code() arm exists and returns the exact
// string; fixture-level behaviour is pinned in Task 2+.
use ailang_check::CheckError;
let e = CheckError::NonStructuralRecursion {
callee: "f".into(),
arg: "n".into(),
};
assert_eq!(e.code(), "non-structural-recursion");
}