//! Contract-freeze CI gate (GL #394, CONTRACTS.md § Stability matrix). //! //! Two invariants, enforced on every CI run: //! //! 1. **Completeness** — every `docs/contracts/*.md` file is classified in //! `core::contracts::contract_docs()` (frozen / stable / experimental) and //! every classified entry actually exists on disk. No contract can stay //! unclassified (pattern: `feature_keys_partition` in plans.rs). //! 2. **Immutability** — the content hash of every `frozen` contract doc //! matches the committed snapshot `docs/contracts/frozen-hashes.json`. A //! drifting hash means someone edited a frozen artifact: semantic changes //! must land as a new `-v2.md` file instead. Deliberate typo fixes update //! the snapshot via `LEANCTX_UPDATE_FROZEN_HASHES=1 cargo test --test //! contracts_frozen` and must be justified in the PR. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use lean_ctx::core::contracts::{ContractStatus, contract_docs}; use sha2::{Digest, Sha256}; fn contracts_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/contracts") } fn snapshot_path() -> PathBuf { contracts_dir().join("frozen-hashes.json") } /// Hash with CRLF normalized away so Windows checkouts (autocrlf) agree with /// the committed snapshot. fn content_hash(path: &Path) -> String { let raw = std::fs::read(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); let normalized: Vec = { let mut out = Vec::with_capacity(raw.len()); let mut iter = raw.iter().peekable(); while let Some(&b) = iter.next() { if b == b'\r' && iter.peek() == Some(&&b'\n') { continue; } out.push(b); } out }; let mut hasher = Sha256::new(); hasher.update(&normalized); use std::fmt::Write; hasher.finalize().iter().fold(String::new(), |mut s, b| { let _ = write!(s, "{b:02x}"); s }) } #[test] fn every_contract_doc_is_classified() { let dir = contracts_dir(); let mut on_disk: Vec = std::fs::read_dir(&dir) .unwrap_or_else(|e| panic!("cannot list {}: {e}", dir.display())) .filter_map(Result::ok) .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| { std::path::Path::new(n) .extension() .is_some_and(|e| e.eq_ignore_ascii_case("md")) }) .collect(); on_disk.sort(); let mut classified: Vec = contract_docs() .iter() .map(|d| d.doc_file.to_string()) .collect(); classified.sort(); let unclassified: Vec<_> = on_disk.iter().filter(|f| !classified.contains(f)).collect(); assert!( unclassified.is_empty(), "unclassified contract docs (add them to core::contracts::contract_docs() \ with a frozen/stable/experimental status): {unclassified:?}" ); let missing: Vec<_> = classified.iter().filter(|f| !on_disk.contains(f)).collect(); assert!( missing.is_empty(), "contract_docs() lists files that do not exist in docs/contracts/: {missing:?}" ); } #[test] fn frozen_contract_docs_are_immutable() { let dir = contracts_dir(); let current: BTreeMap = contract_docs() .iter() .filter(|d| d.status == ContractStatus::Frozen) .map(|d| (d.doc_file.to_string(), content_hash(&dir.join(d.doc_file)))) .collect(); assert!(!current.is_empty(), "freeze gate without frozen contracts"); let snap_path = snapshot_path(); if std::env::var_os("LEANCTX_UPDATE_FROZEN_HASHES").is_some() { let json = serde_json::to_string_pretty(¤t).expect("serialize snapshot"); std::fs::write(&snap_path, json + "\n").expect("write snapshot"); eprintln!("frozen-hashes.json regenerated — justify this in the PR"); return; } let snapshot: BTreeMap = serde_json::from_str(&std::fs::read_to_string(&snap_path).unwrap_or_else(|e| { panic!( "missing {} — generate it once via \ LEANCTX_UPDATE_FROZEN_HASHES=1 cargo test --test contracts_frozen ({e})", snap_path.display() ) })) .expect("frozen-hashes.json is valid JSON"); for (file, hash) in ¤t { match snapshot.get(file) { None => panic!( "{file} is frozen but missing from frozen-hashes.json — \ regenerate via LEANCTX_UPDATE_FROZEN_HASHES=1 cargo test --test contracts_frozen" ), Some(expected) if expected != hash => panic!( "FROZEN CONTRACT MODIFIED: docs/contracts/{file} changed.\n\ Frozen contracts are immutable (CONTRACTS.md § Contract file rule).\n\ → semantic change: create the next version file (e.g. -v2.md) and classify it; \ leave the v1 file untouched.\n\ → deliberate typo fix: LEANCTX_UPDATE_FROZEN_HASHES=1 cargo test --test \ contracts_frozen, and justify the edit in the PR." ), Some(_) => {} } } let stale: Vec<_> = snapshot .keys() .filter(|k| !current.contains_key(*k)) .collect(); assert!( stale.is_empty(), "frozen-hashes.json lists files that are no longer frozen/present: {stale:?} — regenerate the snapshot" ); }