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
+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,
}),
],