a4be1e58a3
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. Specfda9b78, planbc9f512.
130 lines
4.5 KiB
Rust
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");
|
|
}
|