check: mode-strict-because suppression (iter 19b)

Closes the 19a/19a.1/19b arc. Corpus signal from 19a.1 (5/65
fixtures fire over-strict-mode, all deliberate RC codegen-test
fixtures) justified shipping the suppress mechanism end-to-end.

Schema: Suppress { code, because } on FnDef. Pre-19b hashes
bit-identical via skip_serializing_if. Typechecker drops matching
diagnostics; empty 'because' is Error severity; wrong codes are
silent no-ops (open-set registry).

Form-A: (suppress (code "...") (because "...")) clause, parser
+ printer round-trip clean. Form-B: '// @suppress <code>: <reason>'
above the doc string, lossless contract metadata.

5 RC fixtures migrated (.ail.json + .ailx + .prose.txt). Corpus
signal: 5/65 -> 0/65. Lint still fires on any future fn that's
accidentally over-strict without an authored reason.

Test counts: ailang-check 55->61, ailang-core 26->28, ailang-surface
21->26, ailang-prose 49->52, e2e 70 unchanged.

Known debt: .ailx comment headers lost on regen (ail render's
contract excludes comments); parse_suppress_attr accepts
duplicate code/because attrs without diagnose (bounded by canonical
print order).
This commit is contained in:
2026-05-08 19:36:04 +02:00
parent 9f3f10ce6e
commit 50b68267fe
30 changed files with 942 additions and 304 deletions
+8
View File
@@ -54,6 +54,14 @@
//! [`SuggestedRewrite`] whose `replacement` is the relaxed
//! `(fn-type ...)` in form-A. Pure advisory; the typechecker still
//! accepts the original signature.
//! - `empty-suppress-reason` (Iter 19b) — `severity: error`. Emitted
//! by the per-module suppress filter when a `(suppress (code ...)
//! (because ""))` entry on an `FnDef` has an empty or
//! whitespace-only `because`. `ctx`: `{"code": "<suppressed-code>"}`.
//! Hard-Error: a suppression without a reason is unreviewable, so
//! the build refuses it. Note that an invalid suppression does NOT
//! suppress its target diagnostic — the original still fires
//! alongside this error.
use serde::Serialize;
+18 -3
View File
@@ -39,6 +39,7 @@ use std::collections::{BTreeMap, BTreeSet};
mod linearity;
mod reuse_shape;
mod suppress_filter;
pub mod uniqueness;
/// Metavariable substitution. Maps fresh metavar ids (from `$m<id>` in
@@ -656,8 +657,13 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
let m = &ws.modules[name];
let typecheck_errors = check_in_workspace(m, ws, &module_globals);
let had_typecheck_errors = !typecheck_errors.is_empty();
// Per-module diagnostic accumulator. The suppress filter
// (Iter 19b) runs at the end of the per-module block, so
// we keep this module's diagnostics in their own vec while
// accumulating, then merge into the workspace-wide list.
let mut module_diags: Vec<Diagnostic> = Vec::new();
for e in typecheck_errors {
diagnostics.push(e.to_diagnostic());
module_diags.push(e.to_diagnostic());
}
// Iter 18c.2: linearity check runs only on modules that
// typechecked clean. Running it on a body that already has a
@@ -667,7 +673,7 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
// all-explicit-mode fn are exactly the surface the check is
// designed to inspect.
if !had_typecheck_errors {
diagnostics.extend(linearity::check_module(m));
module_diags.extend(linearity::check_module(m));
// Iter 18d.2: reuse-as shape compatibility check. Runs on
// the same activation gate as linearity (all-explicit-mode
// fns only). The check resolves each `(reuse-as <var>
@@ -676,8 +682,15 @@ pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
// after linearity so its output appears after linearity's
// diagnostics for the same def — stable ordering for the
// JSON consumer.
diagnostics.extend(reuse_shape::check_module(m));
module_diags.extend(reuse_shape::check_module(m));
}
// Iter 19b: apply per-fn `suppress` filter. Runs after every
// diagnostic source has appended so any code the author
// listed can actually be matched. Drops matching diagnostics
// and pushes `empty-suppress-reason` (Error) for malformed
// entries. See [`crate::suppress_filter`] for details.
suppress_filter::apply(m, &mut module_diags);
diagnostics.extend(module_diags);
}
diagnostics
}
@@ -2056,6 +2069,7 @@ mod tests {
ty,
params: params.into_iter().map(|s| s.into()).collect(),
body,
suppress: vec![],
doc: None,
})
}
@@ -2616,6 +2630,7 @@ mod tests {
body: Term::Var { name: "x".into() },
}],
},
suppress: vec![],
doc: None,
});
// Use unbox at Int and at Bool — both must succeed and not
+1
View File
@@ -627,6 +627,7 @@ impl<'a> Lifter<'a> {
ty: augmented_ty.clone(),
params: augmented_params,
body: body_full,
suppress: vec![],
doc: Some(doc),
}));
+3
View File
@@ -1007,6 +1007,7 @@ mod tests {
},
params: (0..modes.len()).map(|i| format!("p{i}")).collect(),
body,
suppress: vec![],
doc: None,
})
}
@@ -1159,6 +1160,7 @@ mod tests {
},
params: vec!["xs".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![],
doc: None,
});
let f_body = Term::App {
@@ -1324,6 +1326,7 @@ mod tests {
},
params: vec!["xs".into()],
body,
suppress: vec![],
doc: None,
})
}
+1
View File
@@ -584,6 +584,7 @@ mod tests {
},
params: (0..modes.len()).map(|i| format!("p{i}")).collect(),
body,
suppress: vec![],
doc: None,
})
}
+263
View File
@@ -0,0 +1,263 @@
//! Iter 19b: per-module post-process that consumes
//! [`ailang_core::ast::FnDef::suppress`] entries.
//!
//! For each `Def::Fn(f)` and each `Suppress { code, because }` in
//! `f.suppress`:
//!
//! 1. If `because.trim()` is empty, push an `empty-suppress-reason`
//! `Error` diagnostic (the typechecker rejects the suppression
//! itself; the original diagnostic still fires unmasked, because
//! a malformed suppression cannot suppress anything).
//! 2. Otherwise, drop every diagnostic from the accumulated list whose
//! `def == Some(f.name)` AND `code == s.code`.
//!
//! The check runs once per module after every other diagnostic source
//! (typecheck → linearity → reuse-shape) has appended its output.
//! That way every diagnostic the suppression might mask is already in
//! the list when we filter.
//!
//! ## Why a wrong code does not error
//!
//! The diagnostic registry is open-set — codes are added per iter
//! and consumers (LLM tooling, future review tools) treat the set
//! as growing. A `(suppress (code "<unknown>") ...)` entry simply
//! matches no diagnostic and therefore suppresses nothing; the
//! typechecker stays silent about it. This is intentionally
//! permissive: the cost of a typo is "the warning still fires", not
//! a hard build break, so authors who mis-type a code get the
//! original signal back instead of a confusing meta-error.
//!
//! ## Why empty `because` is hard-Error
//!
//! `because` is the only thing keeping a suppress from being a
//! drive-by silence: without a reason, a future maintainer (likely
//! a future LLM) reading the def has no way to judge whether to
//! keep, tighten, or remove the suppression. Refusing to typecheck
//! the def at all forces the explicit-intent contract.
use crate::diagnostic::{Diagnostic, Severity};
use ailang_core::ast::{Def, Module};
/// Apply the suppress filter to `diags` (mutates in place) for every
/// `Def::Fn` in `m`. New diagnostics (`empty-suppress-reason`) are
/// pushed onto `diags`.
pub(crate) fn apply(m: &Module, diags: &mut Vec<Diagnostic>) {
// Walk fns; collect (def_name, code) pairs to drop, plus any
// empty-reason errors to add. We can do this in one pass per fn
// because a wrong code cannot drop anything (no match) and an
// empty reason explicitly does not drop the diagnostic it points
// to (per the module-level rationale).
let mut to_drop: Vec<(String, String)> = Vec::new();
let mut to_add: Vec<Diagnostic> = Vec::new();
for def in &m.defs {
let f = match def {
Def::Fn(f) => f,
_ => continue,
};
for s in &f.suppress {
if s.because.trim().is_empty() {
to_add.push(make_empty_suppress_reason(&f.name, &s.code));
// Do NOT add to `to_drop`: an invalid suppression does
// not suppress anything — see module rationale.
} else {
to_drop.push((f.name.clone(), s.code.clone()));
}
}
}
if !to_drop.is_empty() {
diags.retain(|d| {
let key = match &d.def {
Some(name) => (name.clone(), d.code.clone()),
None => return true,
};
!to_drop.contains(&key)
});
}
diags.extend(to_add);
}
/// Build the `empty-suppress-reason` diagnostic. Severity is
/// hard-Error: a suppression with no reason is unreviewable, so the
/// build refuses it. The `ctx` carries the `code` string so a tool
/// downstream can see exactly which suppression was malformed
/// (independent of the human-readable `message`).
fn make_empty_suppress_reason(def: &str, code: &str) -> Diagnostic {
let mut d = Diagnostic {
severity: Severity::Error,
code: "empty-suppress-reason".into(),
message: format!(
"suppress entry for diagnostic `{code}` requires a non-empty `because` reason"
),
def: Some(def.to_string()),
ctx: serde_json::json!({"code": code}),
suggested_rewrites: Vec::new(),
};
// Defensive: keep the field always-emitted shape (the Diagnostic
// builder also leaves it `[]`, but constructing the struct
// directly above means we set it explicitly here for clarity).
d.suggested_rewrites.clear();
d
}
#[cfg(test)]
mod tests {
use super::*;
use ailang_core::ast::{
Def, FnDef, Literal, Module, ParamMode, Suppress, Term, Type,
};
fn module_with_one_fn(name: &str, suppress: Vec<Suppress>) -> Module {
Module {
schema: ailang_core::SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: name.into(),
ty: Type::Fn {
params: vec![],
param_modes: vec![],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec![],
body: Term::Lit { lit: Literal::Int { value: 0 } },
doc: None,
suppress,
})],
}
}
/// Iter 19b: a suppress entry with a matching `(def, code)` pair
/// drops the diagnostic from the accumulated list.
#[test]
fn matching_suppress_drops_the_diagnostic() {
let m = module_with_one_fn(
"f",
vec![Suppress {
code: "over-strict-mode".into(),
because: "RC test fixture".into(),
}],
);
let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
.with_def("f")];
apply(&m, &mut diags);
assert!(diags.is_empty(), "diagnostic should have been dropped: {diags:?}");
}
/// Iter 19b: an empty `because` produces an `empty-suppress-reason`
/// Error AND does NOT drop the original diagnostic.
#[test]
fn empty_because_errors_and_does_not_suppress() {
let m = module_with_one_fn(
"f",
vec![Suppress {
code: "over-strict-mode".into(),
because: "".into(),
}],
);
let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
.with_def("f")];
apply(&m, &mut diags);
// Original diag still present; new error appended.
assert_eq!(diags.len(), 2, "expected original + new error: {diags:?}");
let has_orig = diags.iter().any(|d| d.code == "over-strict-mode");
let empty_err: Vec<_> = diags
.iter()
.filter(|d| d.code == "empty-suppress-reason")
.collect();
assert!(has_orig, "original over-strict-mode must still fire");
assert_eq!(empty_err.len(), 1, "exactly one empty-suppress-reason");
assert_eq!(empty_err[0].severity, Severity::Error);
assert_eq!(empty_err[0].def.as_deref(), Some("f"));
assert_eq!(
empty_err[0].ctx,
serde_json::json!({"code": "over-strict-mode"})
);
}
/// Iter 19b: whitespace-only `because` is treated the same as
/// empty — the trimmed text is what matters.
#[test]
fn whitespace_only_because_errors() {
let m = module_with_one_fn(
"f",
vec![Suppress {
code: "over-strict-mode".into(),
because: " \t \n ".into(),
}],
);
let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
.with_def("f")];
apply(&m, &mut diags);
assert!(
diags
.iter()
.any(|d| d.code == "empty-suppress-reason"),
"whitespace-only because should fire empty-suppress-reason: {diags:?}"
);
assert!(
diags.iter().any(|d| d.code == "over-strict-mode"),
"original diagnostic must still fire when suppress is invalid"
);
}
/// Iter 19b: a suppress entry whose `code` does not match any
/// diagnostic in the list is silently a no-op (the diagnostic
/// registry is open-set; a typo costs "warning still fires", not
/// a meta-error).
#[test]
fn unmatched_code_is_silent_no_op() {
let m = module_with_one_fn(
"f",
vec![Suppress {
code: "type-mismatch".into(),
because: "test".into(),
}],
);
let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")
.with_def("f")];
apply(&m, &mut diags);
// The original is untouched; no empty-suppress-reason is added.
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].code, "over-strict-mode");
}
/// Iter 19b: a diagnostic on a *different* def is not affected by
/// a suppression on this fn.
#[test]
fn suppression_only_drops_diagnostics_on_same_def() {
let m = module_with_one_fn(
"f",
vec![Suppress {
code: "over-strict-mode".into(),
because: "test".into(),
}],
);
let mut diags = vec![
Diagnostic::warning("over-strict-mode", "f msg").with_def("f"),
Diagnostic::warning("over-strict-mode", "g msg").with_def("g"),
];
apply(&m, &mut diags);
assert_eq!(diags.len(), 1, "only `f`'s diagnostic should drop: {diags:?}");
assert_eq!(diags[0].def.as_deref(), Some("g"));
}
/// Iter 19b: a diagnostic with `def == None` is never dropped by
/// any suppression (no def-name to match against).
#[test]
fn def_none_diagnostic_is_never_dropped() {
let m = module_with_one_fn(
"f",
vec![Suppress {
code: "over-strict-mode".into(),
because: "test".into(),
}],
);
let mut diags = vec![Diagnostic::warning("over-strict-mode", "msg")];
apply(&m, &mut diags);
assert_eq!(diags.len(), 1, "{diags:?}");
}
}
+1
View File
@@ -413,6 +413,7 @@ mod tests {
},
params: params.into_iter().map(|s| s.to_string()).collect(),
body,
suppress: vec![],
doc: None,
})
}
+8
View File
@@ -152,6 +152,7 @@ fn body_errors_accumulate_across_defs() {
],
tail: false,
},
suppress: vec![],
doc: None,
});
let bad_b = Def::Fn(FnDef {
@@ -167,6 +168,7 @@ fn body_errors_accumulate_across_defs() {
body: Term::Var {
name: "this_does_not_exist".into(),
},
suppress: vec![],
doc: None,
});
@@ -222,6 +224,7 @@ fn use_after_consume_on_own_param_is_reported() {
},
params: vec!["ys".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![],
doc: None,
});
@@ -256,6 +259,7 @@ fn use_after_consume_on_own_param_is_reported() {
],
tail: false,
},
suppress: vec![],
doc: None,
});
@@ -347,6 +351,7 @@ fn consume_while_borrowed_in_sibling_arg_is_reported() {
},
params: vec!["a".into(), "b".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![],
doc: None,
});
@@ -368,6 +373,7 @@ fn consume_while_borrowed_in_sibling_arg_is_reported() {
],
tail: false,
},
suppress: vec![],
doc: None,
});
@@ -505,6 +511,7 @@ fn reuse_as_happy_path_in_map_inc_is_linearity_clean() {
ty: map_inc_ty,
params: vec!["xs".into()],
body: map_inc_body,
suppress: vec![],
doc: None,
});
@@ -615,6 +622,7 @@ fn reuse_as_shape_mismatch_is_reported_on_cons_to_nil() {
ty: f_ty,
params: vec!["xs".into()],
body,
suppress: vec![],
doc: None,
});
+7
View File
@@ -849,6 +849,7 @@ impl<'a> Emitter<'a> {
ty: mono_ty,
params: fdef.params.clone(),
body: mono_body,
suppress: vec![],
doc: fdef.doc.clone(),
};
// Specialised def belongs to the polymorphic def's owner
@@ -2582,6 +2583,7 @@ mod tests {
],
tail: false,
},
suppress: vec![],
doc: None,
}),
// Entry module needs a `main`, otherwise
@@ -2597,6 +2599,7 @@ mod tests {
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
suppress: vec![],
doc: None,
}),
],
@@ -2660,6 +2663,7 @@ mod tests {
}),
body: Box::new(Term::Lit { lit: Literal::Unit }),
},
suppress: vec![],
doc: None,
}),
],
@@ -2710,6 +2714,7 @@ mod tests {
body: Box::new(Term::Lit { lit: Literal::Unit }),
}),
},
suppress: vec![],
doc: None,
})],
};
@@ -2742,6 +2747,7 @@ mod tests {
body: Term::Lit {
lit: Literal::Int { value: 1 },
},
suppress: vec![],
doc: None,
})],
};
@@ -2811,6 +2817,7 @@ mod tests {
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
suppress: vec![],
doc: None,
}),
],
+35
View File
@@ -183,6 +183,41 @@ pub struct FnDef {
/// Optional source-level documentation string.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
/// Iter 19b: structured-diagnostic suppressions opted into for this
/// fn. Each entry names a diagnostic code and an author-asserted
/// reason. Currently the only consumer is `over-strict-mode`
/// (Iter 19a / 19a.1) but the mechanism is generic across codes.
/// `because` must be non-empty — the typechecker emits
/// `empty-suppress-reason` (Error) otherwise.
///
/// Serialised with `skip_serializing_if = "Vec::is_empty"` so
/// every pre-19b fixture's canonical-JSON hash stays bit-identical.
/// The same additive-schema pattern is used by [`TypeDef::vars`]
/// (Iter 13a) and [`Type::Con::args`] (Iter 13a).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suppress: Vec<Suppress>,
}
/// Iter 19b: one entry in [`FnDef::suppress`]. Marks a structured
/// diagnostic the author has consciously decided to allow on this
/// def, with a mandatory reason.
///
/// `because` is non-empty by schema rule — the typechecker emits
/// `empty-suppress-reason` (Error severity) when it is empty or
/// whitespace-only, and a wrong/unknown `code` simply matches no
/// diagnostic and therefore suppresses nothing (the original
/// diagnostic still fires unmasked).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suppress {
/// The diagnostic code being suppressed (e.g.
/// `"over-strict-mode"`). Matched against
/// [`crate::SCHEMA`]-side codes; an unknown code suppresses
/// nothing but is not itself an error (the diagnostic registry
/// is open-set).
pub code: String,
/// The author's stated reason. Must be non-empty — the
/// typechecker emits `empty-suppress-reason` (Error) otherwise.
pub because: String,
}
/// A constant (top-level binding to a value).
+16
View File
@@ -848,6 +848,7 @@ impl Desugarer {
ty: augmented_ty,
params: augmented_params,
body: body_full,
suppress: vec![],
doc: None,
}));
in_full
@@ -1583,6 +1584,7 @@ mod tests {
},
params: vec!["xs".into()],
body: body_match,
suppress: vec![],
doc: None,
})],
};
@@ -1638,6 +1640,7 @@ mod tests {
},
params: vec!["xs".into()],
body: original.clone(),
suppress: vec![],
doc: None,
})],
};
@@ -1694,6 +1697,7 @@ mod tests {
},
params: vec![],
body: fact_letrec_term(),
suppress: vec![],
doc: None,
})],
};
@@ -1781,6 +1785,7 @@ mod tests {
tail: false,
}),
},
suppress: vec![],
doc: None,
})],
};
@@ -1905,6 +1910,7 @@ mod tests {
value: Box::new(Term::Lit { lit: Literal::Int { value: 7 } }),
body: Box::new(letrec),
},
suppress: vec![],
doc: None,
})],
};
@@ -2018,6 +2024,7 @@ mod tests {
},
params: vec!["p".into()],
body: outer_body,
suppress: vec![],
doc: None,
}),
],
@@ -2095,6 +2102,7 @@ mod tests {
},
params: vec![],
body: letrec,
suppress: vec![],
doc: None,
})],
};
@@ -2155,6 +2163,7 @@ mod tests {
},
params: vec![],
body: letrec,
suppress: vec![],
doc: None,
})],
};
@@ -2291,6 +2300,7 @@ mod tests {
},
params: vec!["x".into()],
body: letrec,
suppress: vec![],
doc: None,
})],
};
@@ -2392,6 +2402,7 @@ mod tests {
},
params: vec!["x".into()],
body: letrec,
suppress: vec![],
doc: None,
})],
};
@@ -2472,6 +2483,7 @@ mod tests {
},
params: vec![],
body: outer,
suppress: vec![],
doc: None,
})],
};
@@ -2551,6 +2563,7 @@ mod tests {
},
params: vec![],
body: outer,
suppress: vec![],
doc: None,
})],
};
@@ -2671,6 +2684,7 @@ mod tests {
},
params: vec!["n".into()],
body: body_match,
suppress: vec![],
doc: None,
})],
};
@@ -2743,6 +2757,7 @@ mod tests {
},
params: vec!["xs".into()],
body: body_match,
suppress: vec![],
doc: None,
})],
};
@@ -2823,6 +2838,7 @@ mod tests {
},
params: vec!["n".into()],
body: body_match,
suppress: vec![],
doc: None,
})],
};
+63
View File
@@ -71,6 +71,7 @@ mod tests {
],
tail: false,
},
suppress: vec![],
doc: None,
})
}
@@ -118,4 +119,66 @@ mod tests {
let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap();
assert_eq!(def_hash(int_list_def), "b082192bd0c99202");
}
/// Iter 19b regression: adding `suppress` to [`crate::ast::FnDef`]
/// must NOT change canonical-JSON hashes of any pre-19b fn whose
/// `suppress` is empty. The `skip_serializing_if = "Vec::is_empty"`
/// predicate on the field is what enforces this; if it is wrong,
/// every existing fixture's hash drifts and `ail diff` /
/// `ail manifest` output breaks.
///
/// We construct two FnDefs that differ only in `suppress` (one
/// empty, one missing the field). They must hash bit-identically:
/// the canonical-JSON of both is the same byte sequence because
/// the empty Vec is elided.
#[test]
fn iter19b_empty_suppress_preserves_pre_19b_hashes() {
let with_empty_suppress = sample_fn();
// Mutate the bare sample to set suppress explicitly to a
// non-empty Vec, then mutate it back to empty: two distinct
// construction paths that should still hash identically.
let mut with_explicit_empty = sample_fn();
if let Def::Fn(ref mut f) = with_explicit_empty {
f.suppress = vec![];
}
assert_eq!(
def_hash(&with_empty_suppress),
def_hash(&with_explicit_empty),
"two FnDefs with empty suppress must hash identically"
);
// And: a FnDef with a *non-empty* suppress hashes
// *differently* (sanity check that the field is in fact in
// the canonical bytes when present).
let mut with_suppress = sample_fn();
if let Def::Fn(ref mut f) = with_suppress {
f.suppress = vec![crate::ast::Suppress {
code: "over-strict-mode".into(),
because: "test reason".into(),
}];
}
assert_ne!(
def_hash(&with_empty_suppress),
def_hash(&with_suppress),
"non-empty suppress must produce a distinct hash"
);
}
/// Iter 19b: an on-disk pre-19b fixture must still load cleanly
/// (no `suppress` field present in the JSON) and produce its
/// canonical-byte hash unchanged. The hash for `sum.sum` was
/// recorded by the 13a regression and must stay 16 hex chars
/// equal to `db33f57cb329935e` — this test re-asserts it after
/// the 19b schema bump.
#[test]
fn iter19b_schema_extension_preserves_pre_19b_hashes() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let examples = manifest_dir.join("../../examples");
let sum_src = std::fs::read(examples.join("sum.ail.json"))
.expect("examples/sum.ail.json present");
let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap();
let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap();
assert_eq!(def_hash(sum_def), "db33f57cb329935e");
}
}
+1
View File
@@ -183,6 +183,7 @@ mod tests {
],
tail: false,
},
suppress: vec![],
doc: None,
}),
],
+130 -1
View File
@@ -25,7 +25,8 @@
//! never fails on a well-formed AST.
use ailang_core::ast::{
ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Term, Type, TypeDef,
ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term, Type,
TypeDef,
};
/// Render a [`Module`] as human-readable prose.
@@ -79,6 +80,26 @@ fn write_def(out: &mut String, def: &Def, level: usize) {
}
}
/// Iter 19b: render the [`FnDef::suppress`] list as one
/// `// @suppress <code>: <reason>` line per entry, indented to
/// `level`. Multiple entries stack in declaration order; an empty
/// list produces no output (and therefore no leading blank line).
///
/// The render is intentionally lossless. The LLM-reader of the
/// prose form needs to see *why* an annotation that looks
/// over-strict is correct on this def; eliding suppressions would
/// hide that contract metadata.
fn write_suppress_lines(out: &mut String, suppress: &[Suppress], level: usize) {
for s in suppress {
indent(out, level);
out.push_str("// @suppress ");
out.push_str(&s.code);
out.push_str(": ");
out.push_str(&s.because);
out.push('\n');
}
}
fn write_doc(out: &mut String, doc: &Option<String>, level: usize) {
if let Some(d) = doc {
// Polish 4 (Iter 20b): wrap long lines at 80 columns.
@@ -203,6 +224,12 @@ fn write_ctor(out: &mut String, c: &Ctor) {
}
fn write_fn_def(out: &mut String, fd: &FnDef, level: usize) {
// Iter 19b: render `suppress` entries as `// @suppress <code>: <reason>`
// comment lines **above** the doc string. They are contract metadata
// that the LLM-reader needs to see to understand why an annotation
// that looks over-strict is correct here. One line per entry, in
// declaration order; never elided.
write_suppress_lines(out, &fd.suppress, level);
write_doc(out, &fd.doc, level);
// The FnDef carries `params` (names) plus the type. The signature
// surface combines them slot-for-slot. `fd.ty` is either a
@@ -1043,6 +1070,7 @@ mod tests {
},
params: vec!["xs".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![],
doc: None,
};
let mut out = String::new();
@@ -1058,6 +1086,7 @@ mod tests {
ty: Type::fn_implicit(vec![], Type::unit(), vec!["IO".into()]),
params: vec![],
body: Term::Lit { lit: Literal::Unit },
suppress: vec![],
doc: None,
};
let mut out = String::new();
@@ -1065,6 +1094,106 @@ mod tests {
assert!(out.contains("() -> Unit with IO {"), "got:\n{out}");
}
/// Iter 19b: a FnDef with a single `Suppress` entry renders the
/// `// @suppress <code>: <reason>` line **above** the doc string,
/// preserving both the suppression visibility and the doc content.
/// The suppress line must appear before the `///`-prefixed doc
/// lines, not after; the contract metadata leads.
#[test]
fn fn_def_renders_single_suppress_above_doc() {
let fd = FnDef {
name: "head_or_zero".into(),
ty: Type::Fn {
params: vec![Type::Con {
name: "IntList".into(),
args: vec![],
}],
param_modes: vec![ailang_core::ast::ParamMode::Own],
ret: Box::new(Type::int()),
ret_mode: ailang_core::ast::ParamMode::Implicit,
effects: vec![],
},
params: vec!["xs".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![ailang_core::ast::Suppress {
code: "over-strict-mode".into(),
because: "RC codegen test fixture".into(),
}],
doc: Some("Take ownership.".into()),
};
let mut out = String::new();
write_fn_def(&mut out, &fd, 0);
// Suppress line is the FIRST line, before the doc.
let first_line = out.lines().next().unwrap();
assert_eq!(
first_line, "// @suppress over-strict-mode: RC codegen test fixture",
"first line must be the suppress comment; got:\n{out}"
);
// Doc string follows on the next line.
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[1], "/// Take ownership.", "doc line should follow; got:\n{out}");
// Fn signature follows the doc.
assert!(
out.contains("fn head_or_zero(xs: own IntList) -> Int"),
"fn signature should follow; got:\n{out}"
);
}
/// Iter 19b: multiple `Suppress` entries render as one
/// `// @suppress <code>: <reason>` line each, in declaration
/// order, all above the doc string.
#[test]
fn fn_def_renders_multiple_suppress_in_order() {
let fd = FnDef {
name: "f".into(),
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
params: vec![],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![
ailang_core::ast::Suppress {
code: "over-strict-mode".into(),
because: "first reason".into(),
},
ailang_core::ast::Suppress {
code: "some-other-code".into(),
because: "second reason".into(),
},
],
doc: None,
};
let mut out = String::new();
write_fn_def(&mut out, &fd, 0);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "// @suppress over-strict-mode: first reason");
assert_eq!(lines[1], "// @suppress some-other-code: second reason");
// Order: declaration order is preserved.
}
/// Iter 19b: a FnDef with an empty `suppress` Vec emits no
/// `// @suppress` line at all (and therefore no extra leading
/// blank line). Pre-19b prose snapshots stay byte-identical when
/// re-rendered through the 19b code.
#[test]
fn fn_def_with_empty_suppress_emits_no_suppress_lines() {
let fd = FnDef {
name: "f".into(),
ty: Type::fn_implicit(vec![], Type::int(), vec![]),
params: vec![],
body: Term::Lit { lit: Literal::Int { value: 0 } },
suppress: vec![],
doc: Some("just a doc".into()),
};
let mut out = String::new();
write_fn_def(&mut out, &fd, 0);
assert!(
!out.contains("// @suppress"),
"no @suppress line for empty Vec; got:\n{out}"
);
// The doc still leads.
let first_line = out.lines().next().unwrap();
assert_eq!(first_line, "/// just a doc");
}
// ---- Doc string lines ----
#[test]
+212 -4
View File
@@ -14,7 +14,9 @@
//! doc-attr ::= "(" "doc" string ")"
//!
//! fn-def ::= "(" "fn" ident fn-attr* ")"
//! fn-attr ::= doc-attr | type-attr | params-attr | body-attr
//! fn-attr ::= doc-attr | suppress-attr | type-attr | params-attr | body-attr
//! suppress-attr ::= "(" "suppress" "(" "code" string ")"
//! "(" "because" string ")" ")"
//! type-attr ::= "(" "type" type ")"
//! params-attr ::= "(" "params" ident* ")"
//! body-attr ::= "(" "body" term ")"
@@ -83,8 +85,8 @@
//! [`ailang_core::ast::Import::alias`].
use ailang_core::ast::{
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Term, Type,
TypeDef,
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term,
Type, TypeDef,
};
use ailang_core::SCHEMA;
use thiserror::Error;
@@ -474,9 +476,13 @@ impl<'a> Parser<'a> {
let mut ty: Option<Type> = None;
let mut params: Option<Vec<String>> = None;
let mut body: Option<Term> = None;
// Iter 19b: every `(suppress ...)` clause appends one entry. Order
// is preserved (matches the on-disk JSON-AST order).
let mut suppress: Vec<Suppress> = Vec::new();
loop {
match self.peek_head_ident() {
Some("doc") => doc = Some(self.parse_doc()?),
Some("suppress") => suppress.push(self.parse_suppress_attr()?),
Some("type") => {
let t = self.parse_type_attr()?;
ty = Some(t);
@@ -494,7 +500,7 @@ impl<'a> Parser<'a> {
return Err(ParseError::Production {
production: "fn-def",
message: format!(
"unknown fn attribute `{other}`; expected `doc`, `type`, `params`, or `body`"
"unknown fn attribute `{other}`; expected `doc`, `suppress`, `type`, `params`, or `body`"
),
pos,
});
@@ -524,9 +530,82 @@ impl<'a> Parser<'a> {
params,
body,
doc,
suppress,
})
}
/// Iter 19b: parse one `(suppress (code "<c>") (because "<r>"))`
/// clause, returning a [`Suppress`] entry. Unknown sub-keywords
/// inside the clause are rejected with [`ParseError::Production`].
/// The `because` text is allowed to be empty here (the typechecker
/// emits `empty-suppress-reason` instead of the parser, so the
/// invalid form round-trips through the surface for diagnostic
/// purposes).
fn parse_suppress_attr(&mut self) -> Result<Suppress, ParseError> {
self.expect_lparen("suppress-attr")?;
self.expect_keyword("suppress")?;
let mut code: Option<String> = None;
let mut because: Option<String> = None;
loop {
match self.peek_head_ident() {
Some("code") => {
self.expect_lparen("suppress.code")?;
self.expect_keyword("code")?;
code = Some(self.expect_string("code body")?);
self.expect_rparen("suppress.code")?;
}
Some("because") => {
self.expect_lparen("suppress.because")?;
self.expect_keyword("because")?;
because = Some(self.expect_string("because body")?);
self.expect_rparen("suppress.because")?;
}
Some(other) => {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
return Err(ParseError::Production {
production: "suppress-attr",
message: format!(
"unknown suppress sub-attribute `{other}`; expected `code` or `because`"
),
pos,
});
}
None => break,
}
}
self.expect_rparen("suppress-attr")?;
let code = code.ok_or_else(|| ParseError::Production {
production: "suppress-attr",
message: "suppress is missing required `(code ...)`".into(),
pos: 0,
})?;
let because = because.ok_or_else(|| ParseError::Production {
production: "suppress-attr",
message: "suppress is missing required `(because ...)`".into(),
pos: 0,
})?;
Ok(Suppress { code, because })
}
/// Iter 19b: helper — consume one string-literal token. Used by
/// [`Self::parse_suppress_attr`].
fn expect_string(&mut self, ctx: &'static str) -> Result<String, ParseError> {
match self.peek().cloned() {
Some(Token { tok: Tok::Str(s), .. }) => {
self.cur += 1;
Ok(s)
}
Some(t) => Err(ParseError::Unexpected {
expected: format!("string literal ({ctx})"),
got: tok_label(&t.tok),
pos: t.span.start,
}),
None => Err(ParseError::UnexpectedEof {
expected: format!("string literal ({ctx})"),
}),
}
}
fn parse_type_attr(&mut self) -> Result<Type, ParseError> {
self.expect_lparen("type-attr")?;
self.expect_keyword("type")?;
@@ -1632,4 +1711,133 @@ mod tests {
other => panic!("expected LetRec, got {other:?}"),
}
}
/// Iter 19b: a `(fn ...)` carrying a single
/// `(suppress (code "...") (because "..."))` clause parses into
/// [`FnDef::suppress`] with the corresponding [`Suppress`] entry.
#[test]
fn parses_single_suppress_clause_on_fn_def() {
let m = crate::parse::parse(
r#"
(module t
(fn f
(suppress (code "over-strict-mode") (because "test reason"))
(type (fn-type (params) (ret (con Int))))
(params)
(body 0)))
"#,
)
.unwrap();
match &m.defs[0] {
Def::Fn(fd) => {
assert_eq!(fd.suppress.len(), 1);
assert_eq!(fd.suppress[0].code, "over-strict-mode");
assert_eq!(fd.suppress[0].because, "test reason");
}
_ => panic!("expected fn"),
}
}
/// Iter 19b: multiple `(suppress ...)` clauses accumulate into
/// `FnDef::suppress` in declaration order. A second clause does
/// NOT overwrite the first.
#[test]
fn parses_multiple_suppress_clauses_in_order() {
let m = crate::parse::parse(
r#"
(module t
(fn f
(suppress (code "over-strict-mode") (because "first"))
(suppress (code "other-code") (because "second"))
(type (fn-type (params) (ret (con Int))))
(params)
(body 0)))
"#,
)
.unwrap();
match &m.defs[0] {
Def::Fn(fd) => {
assert_eq!(fd.suppress.len(), 2);
assert_eq!(fd.suppress[0].code, "over-strict-mode");
assert_eq!(fd.suppress[0].because, "first");
assert_eq!(fd.suppress[1].code, "other-code");
assert_eq!(fd.suppress[1].because, "second");
}
_ => panic!("expected fn"),
}
}
/// Iter 19b: a `(fn ...)` without a `(suppress ...)` clause has
/// `FnDef::suppress` empty (the default — round-trip identity
/// with pre-19b fixtures).
#[test]
fn parses_fn_def_without_suppress_has_empty_vec() {
let m = crate::parse::parse(
r#"
(module t
(fn f
(type (fn-type (params) (ret (con Int))))
(params)
(body 0)))
"#,
)
.unwrap();
match &m.defs[0] {
Def::Fn(fd) => {
assert!(fd.suppress.is_empty());
}
_ => panic!("expected fn"),
}
}
/// Iter 19b: a `(suppress ...)` with `(because "")` (empty
/// reason) still parses cleanly — the parser is intentionally
/// permissive; the typechecker is what emits
/// `empty-suppress-reason`. This keeps the surface symmetric
/// (a malformed input round-trips through the printer for
/// diagnostic display).
#[test]
fn parses_suppress_with_empty_because_string() {
let m = crate::parse::parse(
r#"
(module t
(fn f
(suppress (code "over-strict-mode") (because ""))
(type (fn-type (params) (ret (con Int))))
(params)
(body 0)))
"#,
)
.unwrap();
match &m.defs[0] {
Def::Fn(fd) => {
assert_eq!(fd.suppress.len(), 1);
assert_eq!(fd.suppress[0].because, "");
}
_ => panic!("expected fn"),
}
}
/// Iter 19b: an unknown sub-attribute inside `(suppress ...)`
/// produces a `ParseError::Production` naming the bad keyword and
/// listing the legal ones (`code` / `because`).
#[test]
fn rejects_suppress_with_unknown_subattribute() {
let err = crate::parse::parse(
r#"
(module t
(fn f
(suppress (code "over-strict-mode") (because "ok") (oops "x"))
(type (fn-type (params) (ret (con Int))))
(params)
(body 0)))
"#,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("oops") && msg.contains("code") && msg.contains("because"),
"error should name the bad keyword and the legal ones; got: {msg}"
);
}
}
+21 -2
View File
@@ -8,8 +8,8 @@
//! per level. Comments are NOT emitted.
use ailang_core::ast::{
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Term, Type,
TypeDef,
Arm, ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Suppress, Term,
Type, TypeDef,
};
/// Print a module in form (A).
@@ -153,6 +153,13 @@ fn write_fn_def(out: &mut String, fd: &FnDef, level: usize) {
write_string_lit(out, doc);
out.push(')');
}
// Iter 19b: emit one `(suppress ...)` clause per entry, after the
// doc string and before the type. Round-trip stable: parser
// re-reads each clause back into [`FnDef::suppress`].
for s in &fd.suppress {
out.push('\n');
write_suppress(out, s, level + 1);
}
out.push('\n');
indent(out, level + 1);
out.push_str("(type ");
@@ -174,6 +181,18 @@ fn write_fn_def(out: &mut String, fd: &FnDef, level: usize) {
out.push(')');
}
/// Iter 19b: print one `(suppress (code "<c>") (because "<r>"))`
/// clause. Indentation matches the rest of the fn-def (one level
/// deeper than the `(fn ...)` head).
fn write_suppress(out: &mut String, s: &Suppress, level: usize) {
indent(out, level);
out.push_str("(suppress (code ");
write_string_lit(out, &s.code);
out.push_str(") (because ");
write_string_lit(out, &s.because);
out.push_str("))");
}
fn write_const_def(out: &mut String, cd: &ConstDef, level: usize) {
indent(out, level);
out.push_str("(const ");