Iter 6: deps hardening, multi-diagnose, DESIGN audit
- ail deps now filters builtins, fn params, and let/match-pattern bindings. New value_names() in ailang-check::builtins is the single source of truth shared with the typechecker install path. walk_term threads a scope set; qualified `prefix.def` refs pass through. - check_in_workspace returns Vec<CheckError>; check_workspace accumulates body diagnostics across defs and modules. Pass-1 (top-level symbol table) and per-module type-def setup stay fail-fast — corrupt env would taint later diagnostics. - DESIGN.md "What the MVP is NOT" was lying (ADTs, strings landed in Iter 2/3). Renamed to "What is not (yet) supported" and split into "not yet" + supported/smoke-tested. JOURNAL Iter 6 entry records the architecture self-check. Tests: 47 green (was 44). +2 deps filter tests in e2e, +1 multi-diagnose test in ailang-check workspace integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+85
-15
@@ -735,11 +735,32 @@ fn workspace_error_to_diagnostic(
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the *external* references of a definition: everything its body
|
||||
/// depends on that lives outside the def itself. Filtered out:
|
||||
///
|
||||
/// - **Built-ins** (`+`, `-`, `==`, `not`, …): operators provided by the
|
||||
/// runtime, not the user. The list is owned by `ailang_check::builtins`.
|
||||
/// - **Parameters** of the surrounding `fn`.
|
||||
/// - **Local bindings** introduced by `let` and by pattern variables in
|
||||
/// `match` arms.
|
||||
///
|
||||
/// Cross-module references (`<prefix>.<def>`) are always kept — a local
|
||||
/// binding can never shadow a dotted name (the typechecker rejects defs
|
||||
/// with a dot, see DESIGN.md "qualified cross-module references").
|
||||
fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet<String> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
let builtins: std::collections::HashSet<&'static str> =
|
||||
ailang_check::builtins::value_names().into_iter().collect();
|
||||
match def {
|
||||
ailang_core::Def::Fn(f) => walk_term(&f.body, &mut out),
|
||||
ailang_core::Def::Const(c) => walk_term(&c.value, &mut out),
|
||||
ailang_core::Def::Fn(f) => {
|
||||
let mut scope: std::collections::HashSet<String> =
|
||||
f.params.iter().cloned().collect();
|
||||
walk_term(&f.body, &mut out, &builtins, &mut scope);
|
||||
}
|
||||
ailang_core::Def::Const(c) => {
|
||||
let mut scope = std::collections::HashSet::new();
|
||||
walk_term(&c.value, &mut out, &builtins, &mut scope);
|
||||
}
|
||||
ailang_core::Def::Type(td) => {
|
||||
// A type def references the types of its fields.
|
||||
for c in &td.ctors {
|
||||
@@ -754,54 +775,103 @@ fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet<String> {
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_term(t: &ailang_core::Term, out: &mut std::collections::BTreeSet<String>) {
|
||||
fn walk_term(
|
||||
t: &ailang_core::Term,
|
||||
out: &mut std::collections::BTreeSet<String>,
|
||||
builtins: &std::collections::HashSet<&'static str>,
|
||||
scope: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
use ailang_core::Term;
|
||||
match t {
|
||||
Term::Lit { .. } => {}
|
||||
Term::Var { name } => {
|
||||
// Qualified (`prefix.def`) — always external; the typechecker
|
||||
// forbids dots in def names, so no shadowing risk.
|
||||
if name.contains('.') {
|
||||
out.insert(name.clone());
|
||||
return;
|
||||
}
|
||||
if scope.contains(name) || builtins.contains(name.as_str()) {
|
||||
return;
|
||||
}
|
||||
out.insert(name.clone());
|
||||
}
|
||||
Term::App { callee, args } => {
|
||||
walk_term(callee, out);
|
||||
walk_term(callee, out, builtins, scope);
|
||||
for a in args {
|
||||
walk_term(a, out);
|
||||
walk_term(a, out, builtins, scope);
|
||||
}
|
||||
}
|
||||
Term::Let { value, body, .. } => {
|
||||
walk_term(value, out);
|
||||
walk_term(body, out);
|
||||
Term::Let { name, value, body } => {
|
||||
// `value` is in the outer scope; only `body` sees the binding.
|
||||
walk_term(value, out, builtins, scope);
|
||||
let inserted = scope.insert(name.clone());
|
||||
walk_term(body, out, builtins, scope);
|
||||
if inserted {
|
||||
scope.remove(name);
|
||||
}
|
||||
}
|
||||
Term::If { cond, then, else_ } => {
|
||||
walk_term(cond, out);
|
||||
walk_term(then, out);
|
||||
walk_term(else_, out);
|
||||
walk_term(cond, out, builtins, scope);
|
||||
walk_term(then, out, builtins, scope);
|
||||
walk_term(else_, out, builtins, scope);
|
||||
}
|
||||
Term::Do { op, args } => {
|
||||
// Mark effect ops as `effect:io/print_int` so they can be
|
||||
// separated from normal function calls.
|
||||
out.insert(format!("effect:{op}"));
|
||||
for a in args {
|
||||
walk_term(a, out);
|
||||
walk_term(a, out, builtins, scope);
|
||||
}
|
||||
}
|
||||
Term::Ctor { type_name, ctor, args } => {
|
||||
out.insert(format!("ctor:{type_name}/{ctor}"));
|
||||
for a in args {
|
||||
walk_term(a, out);
|
||||
walk_term(a, out, builtins, scope);
|
||||
}
|
||||
}
|
||||
Term::Match { scrutinee, arms } => {
|
||||
walk_term(scrutinee, out);
|
||||
walk_term(scrutinee, out, builtins, scope);
|
||||
for arm in arms {
|
||||
if let ailang_core::ast::Pattern::Ctor { ctor, .. } = &arm.pat {
|
||||
out.insert(format!("ctor:{ctor}"));
|
||||
}
|
||||
walk_term(&arm.body, out);
|
||||
let bound = bind_pattern(&arm.pat, scope);
|
||||
walk_term(&arm.body, out, builtins, scope);
|
||||
for n in bound {
|
||||
scope.remove(&n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the variable bindings introduced by a pattern to `scope` and
|
||||
/// returns the names that were freshly inserted (so the caller can roll
|
||||
/// them back). MVP-restricted: nested ctor patterns only contain
|
||||
/// `Var`/`Wild` — see DESIGN.md.
|
||||
fn bind_pattern(
|
||||
p: &ailang_core::ast::Pattern,
|
||||
scope: &mut std::collections::HashSet<String>,
|
||||
) -> Vec<String> {
|
||||
use ailang_core::ast::Pattern;
|
||||
let mut added = Vec::new();
|
||||
match p {
|
||||
Pattern::Wild | Pattern::Lit { .. } => {}
|
||||
Pattern::Var { name } => {
|
||||
if scope.insert(name.clone()) {
|
||||
added.push(name.clone());
|
||||
}
|
||||
}
|
||||
Pattern::Ctor { fields, .. } => {
|
||||
for sub in fields {
|
||||
added.extend(bind_pattern(sub, scope));
|
||||
}
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
// --- ail diff -------------------------------------------------------------
|
||||
|
||||
/// Purely structural module diff. Top-level defs are identified by
|
||||
|
||||
@@ -349,6 +349,73 @@ fn describe_workspace_resolves_qualified_name() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Guards Iter 6: `ail deps` no longer surfaces built-ins, params, or
|
||||
/// let-bindings as edges. The single-module `sum` def in `sum.ail.json`
|
||||
/// uses `+`, `-`, `==`, the param `n`, and the recursive call `sum`.
|
||||
/// Only the recursive call must remain.
|
||||
#[test]
|
||||
fn deps_filters_builtins_params_locals() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let src = workspace.join("examples").join("sum.ail.json");
|
||||
|
||||
let output = Command::new(ail_bin())
|
||||
.args(["deps", src.to_str().unwrap(), "--of", "sum", "--json"])
|
||||
.output()
|
||||
.expect("ail deps failed to run");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"ail deps exited non-zero; stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
|
||||
let arr = v.as_array().expect("array of {name, refs}");
|
||||
let entry = arr
|
||||
.iter()
|
||||
.find(|e| e["name"].as_str() == Some("sum"))
|
||||
.expect("sum entry present");
|
||||
let refs: Vec<&str> = entry["refs"]
|
||||
.as_array()
|
||||
.expect("refs array")
|
||||
.iter()
|
||||
.filter_map(|x| x.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
refs,
|
||||
vec!["sum"],
|
||||
"expected only the recursive call `sum`; got {refs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Guards Iter 6 in workspace mode: edges into built-ins (`ws_lib.+`)
|
||||
/// and into the params (`ws_lib.a`, `ws_lib.b`) of `ws_lib.add` must be
|
||||
/// gone after the deps refactor.
|
||||
#[test]
|
||||
fn deps_workspace_filters_builtins_and_params() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
|
||||
let entry = workspace.join("examples").join("ws_main.ail.json");
|
||||
|
||||
let output = Command::new(ail_bin())
|
||||
.args(["deps", entry.to_str().unwrap(), "--workspace", "--json"])
|
||||
.output()
|
||||
.expect("ail deps --workspace failed to run");
|
||||
assert!(output.status.success());
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
|
||||
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
|
||||
let edges = v["edges"].as_array().expect("edges array");
|
||||
|
||||
for e in edges {
|
||||
let to_def = e.get("to_def").and_then(|x| x.as_str()).unwrap_or("");
|
||||
assert_ne!(to_def, "+", "built-in `+` must not appear: {e}");
|
||||
assert_ne!(to_def, "a", "param `a` must not appear: {e}");
|
||||
assert_ne!(to_def, "b", "param `b` must not appear: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Guards Iter 5d: `ail deps --workspace` contains a cross-module edge
|
||||
/// `ws_main.main -> ws_lib.add`, because `ws_main` calls `ws_lib.add(2,3)`.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user