26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
144 lines
5.4 KiB
Rust
144 lines
5.4 KiB
Rust
//! 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<u8> = {
|
|
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<String> = 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<String> = 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<String, String> = 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<String, String> =
|
|
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"
|
|
);
|
|
}
|