use std::collections::BTreeMap;
use std::path::Path;
use std::time::Instant;
use crate::ontology::sigma::{Sigma, SigmaError};
use crate::schema::{parse_contract, ContractKind};
use super::finding::LintFinding;
use super::rules::RuleSeverity;
use super::{GateDetail, GateExtra, GateResult, Verdict};
pub const VALID_UNDER_KEYS: [&str; 5] = ["world", "toolchain", "host_class", "backend", "features"];
pub const DEFAULT_WORLD: &str = "committed";
pub const BASELINE_KEY: &str = "contracts_without_valid_under";
#[derive(Debug)]
pub enum ValidUnderOutcome {
NoSigma,
Malformed(SigmaError),
NoKernels {
contracts_checked: usize,
},
Ran {
result: Box<GateResult>,
findings: Vec<LintFinding>,
},
}
#[must_use]
pub fn run_valid_under_gate(contract_dir: &Path) -> ValidUnderOutcome {
let start = Instant::now();
let sigma_path = contract_dir.join("ontology.yaml");
let Ok(text) = std::fs::read_to_string(&sigma_path) else {
return ValidUnderOutcome::NoSigma;
};
let sigma = match Sigma::from_yaml(&text) {
Ok(s) => s,
Err(e) => return ValidUnderOutcome::Malformed(e),
};
if let Err(e) = sigma.check_integrity() {
return ValidUnderOutcome::Malformed(e);
}
let mut c = Census::default();
let mut files = Vec::new();
super::collect_yaml_files(contract_dir, &mut files);
files.sort();
for file in files.iter().filter(|f| **f != sigma_path) {
c.observe(&sigma, file);
}
if c.kernels == 0 && c.carrying == 0 {
return ValidUnderOutcome::NoKernels {
contracts_checked: c.checked,
};
}
let baseline = baseline_without_valid_under(contract_dir);
c.findings
.extend(ratchet_finding(baseline, c.kernels_without));
let Census {
findings,
checked,
kernels,
kernels_without,
carrying,
by_world,
} = c;
let violations = findings.len();
let passed = violations == 0;
let duration = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let result = GateResult {
name: "valid-under".into(),
passed,
skipped: false,
verdict: Verdict::from_gate(passed, false),
duration_ms: duration,
detail: GateDetail::Validate {
contracts: checked,
errors: violations,
warnings: 0,
error_messages: findings.iter().map(|f| f.message.clone()).collect(),
},
extra: Some(GateExtra::ValidUnder {
worlds: sigma.worlds.keys().cloned().collect(),
contracts_checked: checked,
kernel_contracts: kernels,
contracts_with_valid_under: carrying,
contracts_without_valid_under: kernels_without,
baseline,
by_world: by_world.iter().map(|(w, n)| format!("{w}={n}")).collect(),
violations,
}),
};
ValidUnderOutcome::Ran {
result: Box::new(result),
findings,
}
}
#[derive(Default)]
struct Census {
findings: Vec<LintFinding>,
checked: usize,
kernels: usize,
kernels_without: usize,
carrying: usize,
by_world: BTreeMap<String, usize>,
}
impl Census {
fn observe(&mut self, sigma: &Sigma, file: &Path) {
let Some(doc) = std::fs::read_to_string(file)
.ok()
.and_then(|raw| serde_yaml::from_str::<serde_yaml::Value>(&raw).ok())
else {
return;
};
let Ok(contract) = parse_contract(file) else {
return;
};
self.checked += 1;
let is_kernel = contract.kind() == ContractKind::Kernel && !contract.is_registry();
self.kernels += usize::from(is_kernel);
let Some(v) = doc.get("metadata").and_then(|m| m.get("valid_under")) else {
self.kernels_without += usize::from(is_kernel);
return;
};
self.carrying += 1;
let stem = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let before = self.findings.len();
check_valid_under(sigma, v, &stem, file, &mut self.findings);
if self.findings.len() == before {
let w = v
.get("world")
.and_then(serde_yaml::Value::as_str)
.unwrap_or(DEFAULT_WORLD);
*self.by_world.entry(w.to_string()).or_default() += 1;
}
}
}
fn ratchet_finding(baseline: Option<usize>, without: usize) -> Option<LintFinding> {
let b = baseline?;
if without <= b {
return None;
}
let mut f = LintFinding::new(
"PV-ONT-016",
RuleSeverity::Error,
format!(
"{BASELINE_KEY} rose {b} -> {without}: a kernel-kind contract without `metadata.valid_under` was added. The baseline in contracts/lint-baseline.json is shrink-only — give the new contract a world"
),
"contracts/lint-baseline.json".to_string(),
);
f.contract_stem = Some("lint-baseline".to_string());
Some(f)
}
fn finding(rule: &str, msg: String, stem: &str, file: &Path) -> LintFinding {
let mut f = LintFinding::new(rule, RuleSeverity::Error, msg, file.display().to_string());
f.contract_stem = Some(stem.to_string());
f
}
fn check_valid_under(
sigma: &Sigma,
v: &serde_yaml::Value,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) {
let Some(map) = v.as_mapping() else {
out.push(finding(
"PV-ONT-013",
"`metadata.valid_under` must be a mapping — `world:` (omitted = committed) and/or the qualifiers toolchain, host_class, backend, features".to_string(),
stem,
file,
));
return;
};
for key in map.keys() {
let name = key.as_str().unwrap_or("<non-string key>");
if !VALID_UNDER_KEYS.contains(&name) {
out.push(finding(
"PV-ONT-013",
format!(
"`metadata.valid_under.{name}` is not a valid_under key (closed set: {})",
VALID_UNDER_KEYS.join(", ")
),
stem,
file,
));
}
}
if map.is_empty() {
out.push(finding(
"PV-ONT-013",
"`metadata.valid_under` is empty — name a `world:` or a qualifier, or remove the key"
.to_string(),
stem,
file,
));
return;
}
check_valid_under_world(sigma, map, stem, file, out);
check_valid_under_qualifiers(map, stem, file, out);
}
fn check_valid_under_world(
sigma: &Sigma,
map: &serde_yaml::Mapping,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) {
match map.get("world").map(|w| w.as_str()) {
None if !sigma.worlds.contains_key(DEFAULT_WORLD) => out.push(finding(
"PV-ONT-014",
format!("`metadata.valid_under` names no `world:` and Σ declares no `{DEFAULT_WORLD}` world to default to"),
stem,
file,
)),
None => {}
Some(None) => out.push(finding(
"PV-ONT-014",
"`metadata.valid_under.world` must be a string naming a world in contracts/ontology.yaml".to_string(),
stem,
file,
)),
Some(Some(w)) if !sigma.worlds.contains_key(w) => out.push(finding(
"PV-ONT-014",
format!(
"`metadata.valid_under.world: {w}` is not a world Σ declares (worlds: {}) — declare it in contracts/ontology.yaml",
sigma.worlds.keys().cloned().collect::<Vec<_>>().join(", ")
),
stem,
file,
)),
Some(Some(_)) => {}
}
}
fn check_valid_under_qualifiers(
map: &serde_yaml::Mapping,
stem: &str,
file: &Path,
out: &mut Vec<LintFinding>,
) {
if let Some(tc) = map.get("toolchain") {
let ok = tc.as_mapping().is_some_and(|m| {
!m.is_empty()
&& m.iter().all(|(k, v)| {
k.as_str().is_some_and(|s| !s.is_empty())
&& v.as_str().is_some_and(|s| !s.is_empty())
})
});
if !ok {
out.push(finding(
"PV-ONT-015",
"`metadata.valid_under.toolchain` must be a non-empty map of tool -> version strings (e.g. `{ rust: \"1.93\" }`)".to_string(),
stem,
file,
));
}
}
for key in ["host_class", "backend", "features"] {
if let Some(list) = map.get(key) {
let ok = list.as_sequence().is_some_and(|s| {
!s.is_empty() && s.iter().all(|x| x.as_str().is_some_and(|s| !s.is_empty()))
});
if !ok {
out.push(finding(
"PV-ONT-015",
format!("`metadata.valid_under.{key}` must be a non-empty list of non-empty strings"),
stem,
file,
));
}
}
}
}
fn baseline_without_valid_under(contract_dir: &Path) -> 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(BASELINE_KEY)?
.as_u64()
.and_then(|n| usize::try_from(n).ok())
}
#[cfg(test)]
#[path = "valid_under_gate_tests.rs"]
mod tests;