use std::path::{Path, PathBuf};
use std::time::Instant;
use crate::discharge::summary;
use crate::ontology::extract::lean::base_under;
use crate::schema::{parse_contract, ContractKind, LeanStatus};
use super::finding::LintFinding;
use super::rules::RuleSeverity;
use super::{GateDetail, GateExtra, GateResult, Verdict};
use crate::ontology::verdict::Reason;
pub const UNPAIRED_KEY: &str = "unpaired_theorem_modules";
pub const WITHOUT_DEPENDS_ON_KEY: &str = "contracts_without_depends_on";
pub const UNDERIVED_KEY: &str = "underived_proved_claims";
pub const BOOK_ROOTS: [&str; 2] = ["book", "crates/aprender-contracts-staging/book"];
#[derive(Debug)]
pub enum RatchetOutcome {
Declined(String),
Ran {
result: Box<GateResult>,
findings: Vec<LintFinding>,
},
}
fn baseline_of(contract_dir: &Path, key: &str) -> Option<usize> {
let raw = std::fs::read_to_string(contract_dir.join("lint-baseline.json")).ok()?;
let doc: serde_json::Value = serde_json::from_str(&raw).ok()?;
doc.get(key)?.as_u64().and_then(|n| usize::try_from(n).ok())
}
fn repo_root(contract_dir: &Path) -> PathBuf {
match contract_dir.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn files_with_ext(dir: &Path, ext: &str, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files_with_ext(&path, ext, out);
} else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
out.push(path);
}
}
}
fn module_name(base: &Path, file: &Path) -> Option<String> {
let rel = file.strip_prefix(base).ok()?.with_extension("");
let parts: Vec<&str> = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
Some(parts.join("."))
}
fn is_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '\''
}
#[must_use]
pub fn mentions_module(text: &str, name: &str) -> bool {
let head = &name[..name.chars().next().map_or(0, char::len_utf8)];
text.match_indices(head).any(|(start, _)| {
if !text[start..].starts_with(name) {
return false;
}
let end = start + name.len();
let before_ok = text[..start]
.chars()
.next_back()
.is_none_or(|c| !is_ident_char(c) && c != '.');
let mut after = text[end..].chars();
let after_ok = match after.next() {
None => true,
Some('.') => after.next().is_none_or(|c| !is_ident_char(c)),
Some(c) => !is_ident_char(c),
};
before_ok && after_ok
})
}
fn ratchet_finding(
rule: &str,
key: &str,
baseline: Option<usize>,
now: usize,
fix: &str,
) -> Option<LintFinding> {
let baseline = baseline?;
if now <= baseline {
return None;
}
let mut f = LintFinding::new(
rule,
RuleSeverity::Error,
format!(
"{key} rose {baseline} -> {now}: {fix}. The baseline in contracts/lint-baseline.json is shrink-only; `make lint-ratchet` only lowers it"
),
"contracts/lint-baseline.json".to_string(),
);
f.contract_stem = Some("lint-baseline".to_string());
Some(f)
}
fn result_of(
name: &str,
checked: usize,
findings: &[LintFinding],
baseline: Option<usize>,
start: Instant,
extra: GateExtra,
) -> Box<GateResult> {
let violations = findings.len();
let judged = baseline.is_some();
let passed = judged && violations == 0;
Box::new(GateResult {
name: name.into(),
passed,
skipped: !judged,
verdict: if judged {
Verdict::from_gate(passed, false)
} else {
Verdict::Unknown(Reason::Report)
},
duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
detail: GateDetail::Validate {
contracts: checked,
errors: violations,
warnings: 0,
error_messages: findings.iter().map(|f| f.message.clone()).collect(),
},
extra: Some(extra),
})
}
#[must_use]
pub fn run_theorem_pairing_gate(contract_dir: &Path) -> RatchetOutcome {
let start = Instant::now();
let root = repo_root(contract_dir);
let Some(base) = base_under(&root) else {
return RatchetOutcome::Declined(format!(
"no Lean theorem base (ProvableContracts/Theorems) under {}",
root.display()
));
};
let mut lean = Vec::new();
files_with_ext(&base.join("ProvableContracts/Theorems"), "lean", &mut lean);
lean.sort();
let modules: Vec<String> = lean.iter().filter_map(|f| module_name(&base, f)).collect();
if modules.is_empty() {
return RatchetOutcome::Declined(format!("no theorem module under {}", base.display()));
}
let books: Vec<PathBuf> = BOOK_ROOTS
.iter()
.map(|b| root.join(b))
.filter(|p| p.is_dir())
.collect();
let mut pages = Vec::new();
for b in &books {
files_with_ext(b, "md", &mut pages);
}
if pages.is_empty() {
return RatchetOutcome::Declined(format!(
"no book page (*.md) under {} of {}: nothing to pair {} theorem module(s) against",
BOOK_ROOTS.join(" or "),
root.display(),
modules.len()
));
}
let baseline = baseline_of(contract_dir, UNPAIRED_KEY);
let texts: Vec<String> = pages
.iter()
.filter_map(|p| std::fs::read_to_string(p).ok())
.collect();
let unpaired: Vec<String> = modules
.iter()
.filter(|m| !texts.iter().any(|t| mentions_module(t, m)))
.cloned()
.collect();
let findings: Vec<LintFinding> = ratchet_finding(
"PV-RAT-001",
UNPAIRED_KEY,
baseline,
unpaired.len(),
"a Lean theorem module was added that no book page names — cite its full module name in book/",
)
.into_iter()
.collect();
let extra = GateExtra::TheoremPairing {
lean_base: base.display().to_string(),
book_pages: pages.len(),
theorem_modules: modules.len(),
paired: modules.len() - unpaired.len(),
unpaired_theorem_modules: unpaired.len(),
baseline,
unpaired,
violations: findings.len(),
};
RatchetOutcome::Ran {
result: result_of(
"theorem-pairing",
modules.len(),
&findings,
baseline,
start,
extra,
),
findings,
}
}
#[must_use]
pub fn run_depends_on_present_gate(contract_dir: &Path) -> RatchetOutcome {
let start = Instant::now();
let mut files = Vec::new();
super::collect_yaml_files(contract_dir, &mut files);
files.sort();
let (mut checked, mut kernels, mut without) = (0usize, 0usize, 0usize);
for file in &files {
let Ok(contract) = parse_contract(file) else {
continue;
};
checked += 1;
if contract.kind() == ContractKind::Kernel {
kernels += 1;
without += usize::from(contract.metadata.depends_on.is_empty());
}
}
if kernels == 0 {
return RatchetOutcome::Declined(format!(
"no kernel-kind contract in {checked} contract(s): nothing was measured"
));
}
let baseline = baseline_of(contract_dir, WITHOUT_DEPENDS_ON_KEY);
let findings: Vec<LintFinding> = ratchet_finding(
"PV-RAT-002",
WITHOUT_DEPENDS_ON_KEY,
baseline,
without,
"a kernel-kind contract with an empty `metadata.depends_on` was added — name what it composes",
)
.into_iter()
.collect();
let extra = GateExtra::DependsOnPresent {
contracts_checked: checked,
kernel_contracts: kernels,
contracts_without_depends_on: without,
baseline,
violations: findings.len(),
};
RatchetOutcome::Ran {
result: result_of(
"depends-on-present",
checked,
&findings,
baseline,
start,
extra,
),
findings,
}
}
#[must_use]
pub fn run_proved_is_derived_gate(contract_dir: &Path) -> RatchetOutcome {
let start = Instant::now();
let root = repo_root(contract_dir);
let path = base_under(&root).map(|b| summary::summary_path(&b));
let loaded = path
.as_deref()
.map_or_else(|| Err("no Lean theorem base".to_string()), summary::load);
let (green, derived) = match &loaded {
Ok(s) => (s.is_green(), s.derived()),
Err(_) => (false, std::collections::BTreeSet::new()),
};
let mut files = Vec::new();
super::collect_yaml_files(contract_dir, &mut files);
files.sort();
let (mut checked, mut claims) = (0usize, 0usize);
let mut underived = Vec::new();
for file in &files {
let Ok(contract) = parse_contract(file) else {
continue;
};
checked += 1;
let stem = file.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
for ob in &contract.proof_obligations {
let Some(lean) = ob.lean.as_ref().filter(|l| l.status == LeanStatus::Proved) else {
continue;
};
claims += 1;
if !summary::claim_derived(&derived, &lean.theorem) {
underived.push(format!("{stem}: {}", lean.theorem));
}
}
}
if checked == 0 {
return RatchetOutcome::Declined(format!(
"no contract parsed under {}: nothing was measured",
contract_dir.display()
));
}
let baseline = baseline_of(contract_dir, UNDERIVED_KEY);
let findings: Vec<LintFinding> = ratchet_finding(
"PV-RAT-003",
UNDERIVED_KEY,
baseline,
underived.len(),
"a `lean.status: proved` claim was added that no green discharge-summary.json derives — `status: proved` is \
read-only output of `pv discharge run`; write `status: sorry` until the summary lists the theorem",
)
.into_iter()
.collect();
let extra = GateExtra::ProvedIsDerived {
summary: path.map(|p| p.display().to_string()),
summary_error: loaded.as_ref().err().cloned(),
summary_green: green,
contracts_checked: checked,
proved_claims: claims,
underived_proved_claims: underived.len(),
baseline,
underived,
violations: findings.len(),
};
RatchetOutcome::Ran {
result: result_of(
"proved-is-derived",
checked,
&findings,
baseline,
start,
extra,
),
findings,
}
}
#[cfg(test)]
#[path = "ratchet_gates_tests.rs"]
mod tests;