use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::binding::ImplStatus;
use crate::ontology::extract::code::{positive_control, resolve_all};
use crate::ontology::verdict::Verdict;
use super::finding::LintFinding;
use super::ratchet_gates::RatchetOutcome;
use super::rules::RuleSeverity;
use super::{GateDetail, GateExtra, GateResult};
pub const GATE: &str = "bindings";
pub const ALLOWLIST: &str = "binding-allowlist.json";
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AllowEntry {
pub symbol: String,
pub reason: String,
pub ticket: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AllowFile {
entries: Vec<AllowEntry>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct BindingsCounters {
pub registries: usize,
pub checked: usize,
pub resolved: usize,
pub allowlisted: usize,
pub ghosts: usize,
pub allowlisted_absent_leaf: usize,
pub allowlisted_no_module: usize,
pub allowlisted_not_member: usize,
pub allowlisted_other: usize,
pub file_path_form: usize,
pub stale_allowlist: usize,
pub crates_scanned: usize,
pub files_parsed: usize,
pub pc_resolver: String,
pub pc_extract: String,
pub unresolved: usize,
pub violations: usize,
}
fn read_allowlist(contract_dir: &Path) -> Result<Vec<AllowEntry>, String> {
let path = contract_dir.join(ALLOWLIST);
let Ok(raw) = std::fs::read_to_string(&path) else {
return Ok(Vec::new());
};
let file: AllowFile = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
let mut seen = BTreeSet::new();
for e in &file.entries {
if e.symbol.trim().is_empty() || e.reason.trim().is_empty() || e.ticket.trim().is_empty() {
return Err(format!(
"entry `{}` has an empty symbol, reason or ticket",
e.symbol
));
}
if !seen.insert(e.symbol.as_str()) {
return Err(format!("`{}` is listed twice", e.symbol));
}
}
Ok(file.entries)
}
#[must_use]
pub fn is_file_path_form(module_path: &str) -> bool {
module_path
.split("::")
.enumerate()
.any(|(i, seg)| (i > 0 && seg == "src") || seg == "mod" || seg.ends_with(".rs"))
}
fn count_allowlisted(c: &mut BindingsCounters, reason: &str) {
c.allowlisted += 1;
if reason.contains("is not a workspace member") {
c.allowlisted_not_member += 1;
} else if reason.starts_with("no `fn ") {
c.allowlisted_absent_leaf += 1;
} else if reason.starts_with("no `mod ") {
c.allowlisted_no_module += 1;
} else {
c.allowlisted_other += 1;
}
}
fn load_allowlist(contract_dir: &Path, findings: &mut Vec<LintFinding>) -> Vec<AllowEntry> {
match read_allowlist(contract_dir) {
Ok(a) => a,
Err(why) => {
findings.push(LintFinding::new(
"PV-ONT-030",
RuleSeverity::Error,
format!("{ALLOWLIST} is malformed: {why}"),
format!("contracts/{ALLOWLIST}"),
));
Vec::new()
}
}
}
fn check_file_path_form(
b: &crate::ontology::extract::code::Bound,
symbol: &str,
is_allowed: bool,
c: &mut BindingsCounters,
findings: &mut Vec<LintFinding>,
reported: &mut BTreeSet<String>,
) {
if is_allowed || !is_file_path_form(&b.module_path) {
return;
}
c.file_path_form += 1;
if reported.insert(format!("031 {symbol}")) {
findings.push(LintFinding::new(
"PV-ONT-031",
RuleSeverity::Error,
format!(
"`{}` ({} {}) is a file path, not a module path — write the path the code is reached by \
(drop `src`, `mod` and `.rs`)",
b.module_path, b.contract, b.equation
),
b.contract.clone(),
));
}
}
fn record_resolution(
b: &crate::ontology::extract::code::Bound,
symbol: &str,
found: &Result<
crate::ontology::extract::code::Resolved,
crate::ontology::extract::code::Unresolved,
>,
is_allowed: bool,
c: &mut BindingsCounters,
findings: &mut Vec<LintFinding>,
reported: &mut BTreeSet<String>,
) {
match found {
Ok(_) => c.resolved += 1,
Err(u) if is_allowed => {
count_allowlisted(c, &u.reason);
}
Err(u) => {
c.ghosts += 1;
if reported.insert(symbol.to_string()) {
findings.push(LintFinding::new(
"PV-ONT-028",
RuleSeverity::Error,
format!(
"`{symbol}` ({} {}) is bound `{}` and does not resolve: {}",
b.contract,
b.equation,
format!("{:?}", b.status).to_lowercase(),
u.reason
),
b.contract.clone(),
));
}
}
}
}
fn check_stale_allowlist(
allow: &[AllowEntry],
unresolved: &BTreeSet<String>,
c: &mut BindingsCounters,
findings: &mut Vec<LintFinding>,
) {
for e in allow {
if !unresolved.contains(&e.symbol) {
c.stale_allowlist += 1;
findings.push(LintFinding::new(
"PV-ONT-029",
RuleSeverity::Error,
format!(
"{ALLOWLIST} names `{}` ({}), which no implemented/partial binding leaves unresolved — remove the entry",
e.symbol, e.ticket
),
format!("contracts/{ALLOWLIST}"),
));
}
}
}
fn finish(start: Instant, mut c: BindingsCounters, findings: Vec<LintFinding>) -> RatchetOutcome {
c.unresolved = c.ghosts;
c.violations = findings.len();
let verdict = if findings.is_empty() {
Verdict::Pass
} else {
Verdict::Fail
};
let result = GateResult {
name: GATE.into(),
passed: verdict == Verdict::Pass,
skipped: false,
verdict,
duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
detail: GateDetail::Validate {
contracts: c.checked,
errors: findings.len(),
warnings: 0,
error_messages: findings.iter().map(|f| f.message.clone()).collect(),
},
extra: Some(GateExtra::Bindings(Box::new(c))),
};
RatchetOutcome::Ran {
result: Box::new(result),
findings,
}
}
#[must_use]
pub fn run_bindings_gate(contract_dir: &Path) -> RatchetOutcome {
let start = Instant::now();
if !positive_control() {
return RatchetOutcome::Declined(
"positive control pc_resolver did not fire: the resolver cannot tell a present item from an absent one"
.into(),
);
}
let r = resolve_all(contract_dir);
if !r.at_workspace_root {
return RatchetOutcome::Declined(format!(
"not at a workspace root: `{}` has no [workspace] Cargo.toml beside it, so no binding can be resolved",
contract_dir.display()
));
}
let claimed: Vec<_> = r
.symbols
.iter()
.filter(|(b, _)| matches!(b.status, ImplStatus::Implemented | ImplStatus::Partial))
.collect();
if claimed.is_empty() {
return RatchetOutcome::Declined(
"no binding.yaml binds an implemented or partial symbol — nothing was measured".into(),
);
}
let mut findings = Vec::new();
let allow = load_allowlist(contract_dir, &mut findings);
let allowed: BTreeMap<&str, &AllowEntry> =
allow.iter().map(|e| (e.symbol.as_str(), e)).collect();
let mut c = BindingsCounters {
registries: r.stats.registries,
checked: claimed.len(),
crates_scanned: r.stats.crates_scanned,
files_parsed: r.stats.files_parsed,
pc_resolver: crate::ontology::witness::FIRED.to_string(),
pc_extract: crate::ontology::witness::FIRED.to_string(),
..BindingsCounters::default()
};
let mut unresolved: BTreeSet<String> = BTreeSet::new();
let mut reported: BTreeSet<String> = BTreeSet::new();
for (b, found) in claimed {
let symbol = format!("{}::{}", b.module_path, b.function);
let is_allowed = allowed.contains_key(symbol.as_str());
check_file_path_form(b, &symbol, is_allowed, &mut c, &mut findings, &mut reported);
record_resolution(
b,
&symbol,
found,
is_allowed,
&mut c,
&mut findings,
&mut reported,
);
if found.is_err() {
unresolved.insert(symbol);
}
}
check_stale_allowlist(&allow, &unresolved, &mut c, &mut findings);
finish(start, c, findings)
}
#[cfg(test)]
#[path = "bindings_gate_tests.rs"]
mod tests;