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:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
})],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ mod tests {
|
||||
],
|
||||
tail: false,
|
||||
},
|
||||
suppress: vec![],
|
||||
doc: None,
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user