//! Content-addressed hashing for definitions. //! //! Hash = BLAKE3 over the canonical JSON bytes of a [`Def`] (see //! [`crate::canonical`]). The hash function takes a [`Def`] by //! reference, so there is no `hash` field to strip — the in-memory //! struct does not carry one. //! //! The single entry point at this level is [`def_hash`]. The parallel //! entry point at module granularity is //! [`crate::workspace::module_hash`]. use crate::ast::Def; use crate::canonical; /// Content hash of a single [`Def`] — the 16-hex-char (64-bit) prefix /// of its BLAKE3 hash over canonical JSON bytes. /// /// 64 bits is wide enough to be unique across realistic AILang /// codebases and short enough to read at a glance in pretty-printed /// manifests. The hash is computed over the **canonical JSON byte /// pre-image**, not over the in-memory struct, so any change to the /// canonical form (new fields, different `skip_serializing_if` /// behaviour, key reordering bug) changes every hash. The Iter 13a /// regression test below pins concrete hashes for two example /// definitions to catch that. /// /// # Examples /// /// ```ignore /// use ailang_core::{ast::*, def_hash}; /// /// let def = Def::Const(ConstDef { /// name: "answer".into(), /// ty: Type::int(), /// value: Term::Lit { lit: Literal::Int { value: 42 } }, /// doc: None, /// }); /// /// // Stable across runs: same canonical bytes -> same hash. /// assert_eq!(def_hash(&def), def_hash(&def)); /// assert_eq!(def_hash(&def).len(), 16); /// ``` pub fn def_hash(def: &Def) -> String { let bytes = canonical::to_bytes(def); let h = blake3::hash(&bytes); let hex = h.to_hex(); hex.as_str()[..16].to_string() } #[cfg(test)] mod tests { use super::*; use crate::ast::*; fn sample_fn() -> Def { Def::Fn(FnDef { name: "add".into(), ty: Type::Fn { params: vec![Type::int(), Type::int()], ret: Box::new(Type::int()), effects: vec![], param_modes: vec![], ret_mode: ParamMode::Implicit, }, params: vec!["a".into(), "b".into()], body: Term::App { callee: Box::new(Term::Var { name: "+".into() }), args: vec![ Term::Var { name: "a".into() }, Term::Var { name: "b".into() }, ], tail: false, }, suppress: vec![], doc: None, }) } #[test] fn hash_is_stable() { let h1 = def_hash(&sample_fn()); let h2 = def_hash(&sample_fn()); assert_eq!(h1, h2); assert_eq!(h1.len(), 16); } #[test] fn hash_changes_with_content() { let mut def = sample_fn(); let h1 = def_hash(&def); if let Def::Fn(ref mut f) = def { f.name = "mul".into(); } let h2 = def_hash(&def); assert_ne!(h1, h2); } /// Iter 13a regression: adding `vars` to TypeDef and `args` to /// `Type::Con` must NOT change canonical-JSON hashes of any pre-13a /// definition. Recorded hashes were captured from on-disk modules /// before the schema extension; if this fires, a /// `skip_serializing_if` is missing or wrong. We deserialise the /// real example modules from disk to avoid drift between the test /// and the source-of-truth JSON. #[test] fn iter13a_schema_extension_preserves_pre_13a_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"); let list_src = std::fs::read(examples.join("list.ail.json")) .expect("examples/list.ail.json present"); let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap(); 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"); } /// Iter 22b.1 regression: adding `Def::Class` and `Def::Instance` /// must NOT change canonical-JSON hashes of any pre-22b def. The /// new variants are alternatives, not field additions — pre-22b /// fixtures simply do not produce a `Class` / `Instance` `Def`, /// and the existing `Fn` / `Const` / `Type` arms are unchanged. /// This re-asserts the same on-disk hashes the 13a / 19b /// regressions pinned, after the 22b.1 schema bump. #[test] fn iter22b1_schema_extension_preserves_pre_22b_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"); let list_src = std::fs::read(examples.join("list.ail.json")) .expect("examples/list.ail.json present"); let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap(); let int_list_def = list_mod .defs .iter() .find(|d| d.name() == "IntList") .unwrap(); assert_eq!(def_hash(int_list_def), "b082192bd0c99202"); } /// Iter 22b.1: a [`crate::ast::ClassDef`] with no doc, no /// superclass, and an empty methods list serialises to a stable /// canonical form. Two construction paths (default-elided /// optionals vs. JSON without those keys at all) hash /// bit-identically. If FAIL: a `skip_serializing_if` is missing /// on `superclass` or `doc`, or the parser produces different /// canonical bytes from the same logical value. #[test] fn iter22b1_classdef_empty_optionals_hash_stable() { let bare = Def::Class(crate::ast::ClassDef { name: "Empty".into(), param: "a".into(), superclass: None, methods: vec![], doc: None, }); let json = r#"{"kind":"class","name":"Empty","param":"a","methods":[]}"#; let parsed: Def = serde_json::from_str(json).unwrap(); assert_eq!( def_hash(&bare), def_hash(&parsed), "ClassDef with elided optionals must hash identically to construction with explicit None" ); } /// Iter 22b.2: adding `constraints` to [`crate::ast::Type::Forall`] /// must NOT alter canonical-JSON bytes of any pre-22b.2 polymorphic /// type. The `#[serde(default, skip_serializing_if = "Vec::is_empty")]` /// attribute on the field is what enforces this; if it is wrong, /// every existing fixture's hash drifts and workspace dedup keys /// based on the canonical bytes break. /// /// Property: a `Type::Forall` constructed without specifying /// `constraints` (i.e. empty vec) must serialise with no /// `constraints` key in the canonical JSON. #[test] fn forall_without_constraints_hashes_bit_identical_to_pre_22b2() { use crate::ast::Type; let t = Type::Forall { vars: vec!["a".into()], constraints: vec![], body: Box::new(Type::fn_implicit( vec![Type::Var { name: "a".into() }], Type::Var { name: "a".into() }, vec![], )), }; let bytes = crate::canonical::to_bytes(&t); let s = std::str::from_utf8(&bytes).unwrap(); assert!( !s.contains("constraints"), "Type::Forall serialised must omit `constraints` when empty; got: {s}" ); } }