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
+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}"
);
}
}