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:
2026-05-07 12:31:20 +02:00
parent efd209779c
commit 1a448309fa
7 changed files with 393 additions and 42 deletions
+85 -15
View File
@@ -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,53 +775,102 @@ 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 -------------------------------------------------------------
+67
View File
@@ -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]
+15
View File
@@ -61,6 +61,21 @@ pub fn install(env: &mut crate::Env) {
);
}
/// Names of value-level built-ins (operators, `not`) — the ones that
/// show up as `Term::Var { name }` references in user code. Effect ops
/// are excluded because they reach codegen via `Term::Do`, not `Var`.
///
/// Single source of truth: derived from `list()` by filtering out the
/// effect-op rows. Kept as a function (not a `const`) because `list()`
/// already allocates; this is only consulted by tooling (`ail deps`).
pub fn value_names() -> Vec<&'static str> {
list()
.into_iter()
.filter(|(_, sig)| !sig.contains("[effect op]"))
.map(|(n, _)| n)
.collect()
}
/// Returns the list of all registered built-ins. Useful for the CLI subcommand
/// `ail builtins`, when the LLM wants to check expected signatures.
pub fn list() -> Vec<(&'static str, &'static str)> {
+45 -18
View File
@@ -211,9 +211,16 @@ impl CheckError {
/// Top-level API for structured diagnostics.
///
/// Empty Vec = green. In the current state, processing aborts on the first
/// error, so the Vec contains either 0 or 1 element. Multiple diagnostics
/// per run are a future feature; the format already allows them.
/// Empty Vec = green. From Iter 6 onwards the body-check phase is
/// multi-diagnose: each def in each module is checked independently and
/// failures accumulate, so a single `ail check` run reports every
/// independent body error in the workspace.
///
/// Pass-1 errors (top-level symbol-table construction:
/// `invalid-def-name`, `duplicate-def`) are still fail-fast — those
/// errors corrupt the symbol table, and any further diagnostic would be
/// unreliable. Likewise, the type-def installation is fail-fast within
/// a single module, but other modules continue being checked.
///
/// Backwards compatibility: a bare `&Module` is internally lifted into a
/// trivial workspace (`modules = {m.name: m}`, `entry = m.name`) so that
@@ -241,8 +248,10 @@ pub fn check_module(m: &Module) -> Vec<Diagnostic> {
/// as `<prefix>.<def>`; `<prefix>` is an import alias (or the module name,
/// if imported without an alias).
///
/// As with `check_module`: at most **one** diagnostic per run
/// (single-shot). Multi-diagnostic is a future feature.
/// Multi-diagnose: pass-2 collects diagnostics per def across all modules.
/// Pass-1 (symbol table) stays fail-fast — see `check_module`.
/// Module iteration order is deterministic: entry first, then the rest in
/// BTreeMap order, so output ordering is stable.
pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
// Pass 1: build per-module top-level symbol table — without checking
// bodies. This lets module A access defs from module B even when B
@@ -255,10 +264,8 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
};
// Pass 2: body-check per module. `check_in_workspace` builds the env
// with additional cross-module globals and an import map.
// Iteration order: entry module first, then the rest in
// BTreeMap order. This makes the first reported diagnostic for
// workspaces deterministic and close to the entry.
// with additional cross-module globals and an import map. Errors
// accumulate across modules.
let mut order: Vec<&String> = Vec::new();
if ws.modules.contains_key(&ws.entry) {
order.push(&ws.entry);
@@ -268,13 +275,14 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
order.push(name);
}
}
let mut diagnostics: Vec<Diagnostic> = Vec::new();
for name in order {
let m = &ws.modules[name];
if let Err(e) = check_in_workspace(m, ws, &module_globals) {
return vec![e.to_diagnostic()];
for e in check_in_workspace(m, ws, &module_globals) {
diagnostics.push(e.to_diagnostic());
}
}
Vec::new()
diagnostics
}
/// Result of typechecking a module: mapping from symbol name to
@@ -294,7 +302,12 @@ pub fn check(m: &Module) -> Result<CheckedModule> {
root_dir: std::path::PathBuf::from("."),
};
let module_globals = build_module_globals(&ws)?;
check_in_workspace(m, &ws, &module_globals)?;
// `check` keeps single-error semantics for callers (snapshot tests,
// legacy code). Multi-diagnose is exposed via `check_module` /
// `check_workspace`.
if let Some(first) = check_in_workspace(m, &ws, &module_globals).into_iter().next() {
return Err(first);
}
// Collect symbols for the return value (existing semantics).
let mut symbols = IndexMap::new();
for def in &m.defs {
@@ -354,27 +367,38 @@ fn build_module_globals(
/// Assumption: `module_globals` already contains the top-level symbol
/// tables for **all** modules of the workspace (including `m`) — built
/// by `build_module_globals`.
///
/// Returns **all** errors found in this module, in def declaration order:
///
/// - The type-def setup phase is fail-fast within the module (duplicate
/// type or ctor names corrupt the env, so we abort *this* module after
/// the first such error and let the outer loop continue with others).
/// - The body-check phase is multi-diagnose: each def is checked
/// independently against the assembled env; a failure is recorded and
/// the next def is attempted.
fn check_in_workspace(
m: &Module,
ws: &Workspace,
module_globals: &BTreeMap<String, IndexMap<String, Type>>,
) -> Result<()> {
) -> Vec<CheckError> {
let mut env = Env::new();
builtins::install(&mut env);
let mut errors: Vec<CheckError> = Vec::new();
// Register type defs (local per module; cross-module ADT sharing is
// explicitly not part of 5b).
for def in &m.defs {
if let Def::Type(td) = def {
if env.types.contains_key(&td.name) {
return Err(CheckError::Def(
errors.push(CheckError::Def(
td.name.clone(),
Box::new(CheckError::DuplicateType(td.name.clone())),
));
return errors;
}
for c in &td.ctors {
if let Some(prev) = env.ctor_index.get(&c.name) {
return Err(CheckError::Def(
errors.push(CheckError::Def(
td.name.clone(),
Box::new(CheckError::DuplicateCtor {
ctor: c.name.clone(),
@@ -382,6 +406,7 @@ fn check_in_workspace(
b: td.name.clone(),
}),
));
return errors;
}
env.ctor_index.insert(
c.name.clone(),
@@ -421,9 +446,11 @@ fn check_in_workspace(
let _ = ws;
for def in &m.defs {
check_def(def, &env).map_err(|e| CheckError::Def(def.name().to_string(), Box::new(e)))?;
if let Err(e) = check_def(def, &env) {
errors.push(CheckError::Def(def.name().to_string(), Box::new(e)));
}
Ok(())
}
errors
}
fn check_def(def: &Def, env: &Env) -> Result<()> {
+69
View File
@@ -99,3 +99,72 @@ fn invalid_def_name_with_dot_is_reported() {
Some("contains-dot")
);
}
/// Iter 6: body-check is multi-diagnose. A module with two independent
/// errors in different defs must produce two diagnostics — the first
/// error must not short-circuit the second.
#[test]
fn body_errors_accumulate_across_defs() {
use ailang_core::ast::*;
use std::collections::BTreeMap;
// Two fns, each with a different body error:
// bad_a: arity mismatch — calls `+` with three arguments.
// bad_b: references a name that does not exist anywhere.
let bad_a = Def::Fn(FnDef {
name: "bad_a".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec![],
body: Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Lit { lit: Literal::Int { value: 1 } },
Term::Lit { lit: Literal::Int { value: 2 } },
Term::Lit { lit: Literal::Int { value: 3 } },
],
},
doc: None,
});
let bad_b = Def::Fn(FnDef {
name: "bad_b".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec![],
body: Term::Var {
name: "this_does_not_exist".into(),
},
doc: None,
});
let m = Module {
schema: ailang_core::SCHEMA.into(),
name: "two_errors".into(),
imports: vec![],
defs: vec![bad_a, bad_b],
};
let mut modules = BTreeMap::new();
modules.insert(m.name.clone(), m.clone());
let ws = ailang_core::Workspace {
entry: m.name.clone(),
modules,
root_dir: std::path::PathBuf::from("."),
};
let diags = check_workspace(&ws);
assert_eq!(diags.len(), 2, "want both errors, got: {:#?}", diags);
// Each diagnostic carries the offending def name as `def`.
let defs: Vec<&str> = diags
.iter()
.filter_map(|d| d.def.as_deref())
.collect();
assert!(defs.contains(&"bad_a"), "missing bad_a; got {defs:?}");
assert!(defs.contains(&"bad_b"), "missing bad_b; got {defs:?}");
}
+27 -9
View File
@@ -227,14 +227,32 @@ ail build <module> — full pipeline → binary
hash.
5. **CI pin** of the outputs in `tests/expected/`.
## What the MVP is NOT
## What is not (yet) supported
- No ADTs / pattern matching (Phase 2).
- No closures / higher-order functions (Phase 2).
- No effect handlers (Phase 3).
- No refinements / SMT (Phase 4).
- No module system beyond imports (Phase 2).
- No first-class strings. Only ints + bools + unit.
Snapshot of the boundary at the end of Iter 6. Items move out of this list
as iterations land; the JOURNAL records the exact iteration.
The MVP is successful when `examples/sum.ail.json` produces a binary that
prints the sum 1..10 = 55.
- No closures / higher-order functions. Will require a typed IR stage (TIR)
before lowering.
- No effect handlers — only the built-in IO and Diverge ops.
- No refinements / SMT escalation.
- No cross-module ADTs. ADTs are local to a module; ctor names must be
unique within their module but may collide across modules.
- No visibility rules in imports. Every top-level def of an imported module
is reachable; there is no `pub` / `priv`.
- No GC. The `Ctor` heap layout leaks. Acceptable for current example
programs; required before any longer-running program.
What **is** supported (and used as the smoke test for the pipeline):
- Int, Bool, Unit, **Str** as primitive types.
- `if`, `let`, function calls, recursion.
- Effects on function signatures, with `do op(args)` for direct effect
ops (`io/print_int`, `io/print_bool`, `io/print_str`).
- **ADTs + flat pattern matching** (Iter 3). Sub-patterns of a Ctor
pattern are restricted to `Var` / `Wild`.
- **Imports + qualified cross-module references** via dotted names
(Iter 5).
Pipeline regression smoke test: `examples/sum.ail.json` produces a
binary that prints 55.
+85
View File
@@ -345,3 +345,88 @@ Architect agent invoked. Findings:
Order: 1 first (doc triviality), then 2 before 3 (deps is a tooling-
truth fix, multi-diag is a structural extension).
## 2026-05-07 — Iter 6 done: deps hardening + multi-diagnose + DESIGN audit
Three things landed together. All small, all KISS — no architecture move,
just paying off recorded debt.
**1. `ail deps` filters builtins, params, and let/match bindings (#22).**
Before: `sum -> +, -, ==, n, sum` and `ws_lib.add -> ws_lib.+`,
`ws_lib.a`, `ws_lib.b`. After: `sum -> sum`, `ws_lib.add -> ws_lib.add`
gone (no real deps; only the cross-module call from `ws_main` remains).
Implementation:
- New helper `ailang_check::builtins::value_names()`: derives the
Var-level builtin names (`+ - * / % == != < <= > >= not`) from
`list()`, so the install-list and the deps-filter share one source of
truth.
- `walk_term` in `crates/ail/src/main.rs` now threads a `scope` set:
fn-params seed it; `Let` adds the bound name for the body only;
`Match` arms add their pattern variables (`bind_pattern` helper, MVP
rule "ctor sub-patterns are Var/Wild") and roll them back after. Var
refs that hit `scope` or `builtins` are dropped; qualified names
(`prefix.def`) are passed through unconditionally — the typechecker
forbids dots in def names, so no shadowing risk.
- Tests added: `deps_filters_builtins_params_locals`,
`deps_workspace_filters_builtins_and_params`. The Iter 5d test
(`deps_workspace_includes_cross_module`) keeps passing — the only
edge it asserted is the legitimate one.
**2. `check_module` / `check_workspace` are multi-diagnose (#20).**
`check_in_workspace` returns `Vec<CheckError>` instead of `Result<()>`.
Pass-1 (top-level symbol table) stays fail-fast — corrupt globals would
taint every later diagnostic. Type-def installation is fail-fast within
a module (env corruption) but the outer module loop continues. The
body-check loop is the multi-diagnose layer: each def is checked against
the assembled env, errors accumulate, the next def is attempted.
Test: `body_errors_accumulate_across_defs` — one module with two
independent body errors (arity mismatch + unknown var) yields two
diagnostics with the right `def` field. The legacy single-error `check`
keeps working by `.into_iter().next()`-ing the Vec, so internal snapshot
tests in `crates/ailang-check/src/lib.rs` are unchanged.
Out of scope: intra-def collection. A single fn body with three type
errors still reports one. The "see all broken defs at once" goal is met;
intra-def will require unification deferral and isn't due now.
**3. DESIGN.md `What the MVP is NOT` audit (#24).**
The section was lying: it claimed "No ADTs / pattern matching" (delivered
Iter 3) and "Only ints + bools + unit" (strings landed Iter 2). Renamed
to `What is not (yet) supported`, restructured into "not yet" + "what is
supported (smoke-tested)". New invariant: this section is meant to be
the truth at the **end of the latest iteration**, not a 2026-05-07-day-0
scope statement.
**Architecture check (the user-asked self-questioning):**
- *Would I use this language now?* For non-recursive arithmetic + ADT
programs over int/bool/str: yes, comfortably. For anything that needs
mapping, folding, generic data structures: no, closures are the
blocker. That's the next big sprint, not Iter 7.
- *Consistency:* DESIGN.md, JOURNAL.md, code, and CLI output now agree
on what the language can do. The "What is not (yet) supported" block
is the canonical truth surface.
- *Visualisation:* `ail deps --workspace --json` is now a clean
cross-module call graph (no builtin noise). Good enough for an
external graph renderer to consume; a built-in DOT/ASCII renderer is
*possible future tooling*, not "we need it now". KISS.
- *Documentation:* the agents/ directory is the sub-prompt layer, the
JOURNAL is the iteration log, DESIGN.md is the contract. No new doc
axes needed at this scale.
**Tests:** 47 green (previously 44). +2 deps tests in `e2e.rs`, +1
multi-diag test in `crates/ailang-check/tests/workspace.rs`.
**Plan iteration 7:**
Closures + higher-order functions. This is the big jump that
DESIGN.md / Day 0 has been pointing at: it requires a typed IR (TIR)
stage, closure conversion in lowering, and a heap-aware ABI. The
multi-diag refactor in Iter 6 was scoped intentionally minimal — when
TIR lands, intra-def diagnostics become structurally cheap and Task #20
gets revisited.