use std::collections::BTreeSet;
use std::path::Path;
use std::time::Instant;
use crate::schema::parse_contract;
use super::finding::LintFinding;
use super::ratchet_gates::{baseline_of, ratchet_finding, repo_root, result_of, RatchetOutcome};
use super::rules::RuleSeverity;
use super::GateExtra;
pub const GATE: &str = "references-resolve";
pub const DEAD_KEY: &str = "dead_references";
pub const DEAD_SET_KEY: &str = "dead_reference_set";
#[must_use]
pub fn repo_path_of(entry: &str, root: &Path) -> Option<String> {
let tok = entry.split_whitespace().next()?;
let tok = tok.trim_matches(|c| c == '"' || c == '\'' || c == '`');
let tok = tok.trim_end_matches([',', ';', '.', ')']);
let tok = tok.split("::").next()?;
let tok = match tok.rfind(':') {
Some(i)
if tok[i + 1..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit()) =>
{
&tok[..i]
}
_ => tok,
};
if tok.contains(['{', '}', '*', '?', '[', '<', ':'])
|| tok.contains("..")
|| tok.starts_with("../")
|| tok.starts_with('/')
{
return None;
}
let (first, rest) = tok.split_once('/')?;
let ext = rest.rsplit_once('.').map(|(_, e)| e)?;
if ext.is_empty() || ext.len() > 5 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
root.join(first).exists().then(|| tok.to_string())
}
fn baseline_set(contract_dir: &Path) -> Option<BTreeSet<String>> {
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()?;
let arr = doc.get(DEAD_SET_KEY)?.as_array()?;
Some(
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
)
}
fn dead_findings(
contract_dir: &Path,
baseline: Option<usize>,
dead: &[String],
) -> Vec<LintFinding> {
const FIX: &str = "a `metadata.references` repo path that does not exist was added — fix the path or drop the reference";
let count = ratchet_finding("PV-RAT-004", DEAD_KEY, baseline, dead.len(), FIX);
let Some(set) = baseline_set(contract_dir) else {
return count.into_iter().collect();
};
let members = dead.iter()
.filter(|d| !set.contains(*d))
.map(|d| {
let mut f = LintFinding::new(
"PV-RAT-004",
RuleSeverity::Error,
format!("new dead reference `{d}` is not in the {DEAD_SET_KEY} baseline: {FIX}. The baseline is shrink-only; `make lint-ratchet` removes members that now resolve"),
"contracts/lint-baseline.json".to_string(),
);
f.contract_stem = Some("lint-baseline".to_string());
f
});
count.into_iter().chain(members).collect()
}
#[must_use]
pub fn run_references_gate(contract_dir: &Path) -> RatchetOutcome {
let start = Instant::now();
let root = repo_root(contract_dir);
let mut files = Vec::new();
super::collect_yaml_files(contract_dir, &mut files);
files.sort();
let (mut checked, mut paths) = (0usize, 0usize);
let mut dead: Vec<String> = 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 r in &contract.metadata.references {
if let Some(p) = repo_path_of(r, &root) {
paths += 1;
if !root.join(&p).exists() {
dead.push(format!("{stem}: {p}"));
}
}
}
}
if paths == 0 {
return RatchetOutcome::Declined(format!(
"no repo-path reference in {checked} contract(s): nothing was measured"
));
}
let baseline = baseline_of(contract_dir, DEAD_KEY);
let findings = dead_findings(contract_dir, baseline, &dead);
let extra = GateExtra::ReferencesResolve {
contracts_checked: checked,
path_references: paths,
dead_references: dead.len(),
baseline,
dead,
violations: findings.len(),
};
RatchetOutcome::Ran {
result: result_of(GATE, checked, &findings, baseline, start, extra),
findings,
}
}
#[cfg(test)]
#[path = "references_gate_tests.rs"]
mod tests;