Iter 18c.2: linearity check + suggested_rewrites

Adds a per-fn linearity check that runs on every fn whose
param_modes are all explicit (Borrow or Own, no Implicit), tracks
each binder's consume/borrow state through the AST, and emits
two diagnostics with form-A `suggested_rewrites`:

- `use-after-consume` — a binder is referenced after it has
  already been consumed.
- `consume-while-borrowed` — a binder is consumed while a borrow
  of it is still live (sibling arg slot, or an enclosing-fn
  Borrow param).

Fns with any Implicit param skip the check entirely; that's the
back-compat lane that keeps every existing fixture green. Pure
diagnostic addition: no IR change, no codegen change, no runtime
change.

Each diagnostic carries a non-empty `suggested_rewrites` whose
`replacement` is form-A AILang text (typically `(clone <name>)`).
Adds two new public entrypoints to ailang-surface — `parse_term`
and `term_to_form_a` — so the round-trip
`parse_term(term_to_form_a(t)) ≡ t` is the contract every
emitted replacement must satisfy. Tests assert the contract.

Linearity pass runs only on modules that typechecked clean (any
upstream typecheck error suppresses it for that module — running
on partly-defined IR would produce noise).

Tests: 3 unit tests in `linearity::tests`, 3 integration tests
in `ailang-check/tests/workspace.rs`, 1 serialisation test in
`diagnostic.rs`. `cargo test --workspace` green.
This commit is contained in:
2026-05-08 10:06:14 +02:00
parent 8d704cd9b5
commit 09fb5bb113
9 changed files with 987 additions and 4 deletions
+62 -1
View File
@@ -41,6 +41,11 @@
//! - `module-hash-mismatch` — workspace loader (Iter 5b, in the CLI path)
//! - `tail-call-not-in-tail-position` (Iter 14e, see Decision 8)
//! - `ambiguous-ctor` — `ctx`: `{"ctor": "<n>", "candidates": ["m1.T", "m2.T"]}` (Iter 15a)
//! - `use-after-consume` — `ctx`: `{"binder": "<n>"}` (Iter 18c.2);
//! carries non-empty [`Diagnostic::suggested_rewrites`] showing how to
//! spell the fix in form-A AILang.
//! - `consume-while-borrowed` — `ctx`: `{"binder": "<n>"}` (Iter 18c.2);
//! ditto on `suggested_rewrites`.
use serde::Serialize;
@@ -83,6 +88,31 @@ pub struct Diagnostic {
pub def: Option<String>,
/// Free structured context. Empty = `{}`.
pub ctx: serde_json::Value,
/// Iter 18c.2: machine-applicable fix suggestions, each a snippet of
/// form-A AILang the author can paste back at the offending site.
/// Empty = no rewrite suggested. The list is always emitted (so JSON
/// consumers see a stable shape; they can branch on `.is_empty()`).
/// Currently populated by the linearity check (`use-after-consume` /
/// `consume-while-borrowed` codes); other diagnostics emit `[]`.
pub suggested_rewrites: Vec<SuggestedRewrite>,
}
/// One machine-applicable rewrite suggestion attached to a [`Diagnostic`].
///
/// Iter 18c.2: emitted by the linearity check to point the author (or an
/// LLM consumer of `ail check --json`) at the spelled fix. `replacement`
/// is form-A AILang text — parseable by `ailang_surface::parse_term`.
#[derive(Serialize, Debug, Clone)]
pub struct SuggestedRewrite {
/// One-line free-form description of what the rewrite does (e.g.
/// `"wrap the offending use in (clone X)"`). Not parsed by tooling.
pub description: String,
/// Form-A AILang snippet to substitute at the offending site. For
/// the linearity-check diagnostics this is typically `(clone <n>)`
/// or a small enclosing rewrite. The string MUST parse via the
/// surface `parse_term` entrypoint; [`crate::linearity`] tests
/// guard the round-trip.
pub replacement: String,
}
impl Diagnostic {
@@ -97,9 +127,25 @@ impl Diagnostic {
message: message.into(),
def: None,
ctx: serde_json::Value::Object(serde_json::Map::new()),
suggested_rewrites: Vec::new(),
}
}
/// Iter 18c.2: append a [`SuggestedRewrite`]. Builder-style. Used by
/// the linearity check to attach the form-A fix it computed. Other
/// diagnostics leave the field empty.
pub fn with_suggested_rewrite(
mut self,
description: impl Into<String>,
replacement: impl Into<String>,
) -> Self {
self.suggested_rewrites.push(SuggestedRewrite {
description: description.into(),
replacement: replacement.into(),
});
self
}
/// Sets the affected top-level def name. Builder-style: returns
/// `self`. Used by [`crate::CheckError::to_diagnostic`] when the
/// error was wrapped in [`crate::CheckError::Def`].
@@ -128,11 +174,13 @@ mod tests {
.with_def("main");
let s = serde_json::to_string(&d).unwrap();
// All fields must be present, severity lowercase, ctx is an
// empty object (not null, not omitted).
// empty object (not null, not omitted), suggested_rewrites is
// always-emitted as `[]` for non-linearity diagnostics.
assert!(s.contains("\"severity\":\"error\""), "{s}");
assert!(s.contains("\"code\":\"unbound-var\""), "{s}");
assert!(s.contains("\"def\":\"main\""), "{s}");
assert!(s.contains("\"ctx\":{}"), "{s}");
assert!(s.contains("\"suggested_rewrites\":[]"), "{s}");
}
#[test]
@@ -143,4 +191,17 @@ mod tests {
assert!(s.contains("\"expected\":\"Int\""), "{s}");
assert!(s.contains("\"actual\":\"Bool\""), "{s}");
}
/// Iter 18c.2: a diagnostic carrying a `SuggestedRewrite` serialises
/// the rewrite under `suggested_rewrites` with the description and
/// the form-A replacement.
#[test]
fn suggested_rewrite_serializes() {
let d = Diagnostic::error("use-after-consume", "use of `xs` after consume")
.with_suggested_rewrite("wrap earlier use in (clone)", "(clone xs)");
let s = serde_json::to_string(&d).unwrap();
assert!(s.contains("\"suggested_rewrites\":["), "{s}");
assert!(s.contains("\"description\":\"wrap earlier use in (clone)\""), "{s}");
assert!(s.contains("\"replacement\":\"(clone xs)\""), "{s}");
}
}