use std::path::Path;
use std::time::Instant;
use crate::ontology::sigma::{Sigma, SigmaError};
use super::finding::LintFinding;
use super::rules::RuleSeverity;
use super::sigma_symbols::{collect_formals, scan};
use super::{GateDetail, GateExtra, GateResult, Verdict};
#[derive(Debug)]
pub enum SigmaOutcome {
NoSigma,
Malformed(SigmaError),
Ran {
result: Box<GateResult>,
findings: Vec<LintFinding>,
},
}
#[must_use]
pub fn run_sigma_gate(contract_dir: &Path) -> SigmaOutcome {
let start = Instant::now();
let path = contract_dir.join("ontology.yaml");
let Ok(text) = std::fs::read_to_string(&path) else {
return SigmaOutcome::NoSigma;
};
let sigma = match Sigma::from_yaml(&text) {
Ok(s) => s,
Err(e) => return SigmaOutcome::Malformed(e),
};
if let Err(e) = sigma.check_integrity() {
return SigmaOutcome::Malformed(e);
}
let mut files = Vec::new();
super::collect_yaml_files(contract_dir, &mut files);
let mut findings = Vec::new();
let mut checked = 0usize;
let mut formal_prose = 0usize;
let mut formal_total = 0usize;
for file in &files {
if file == &path {
continue;
}
let Ok(raw) = std::fs::read_to_string(file) else {
continue;
};
let Ok(doc) = serde_yaml::from_str::<serde_yaml::Value>(&raw) else {
continue; };
checked += 1;
let stem = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
findings.extend(check_entity_type(&sigma, &doc, &stem, file));
findings.extend(check_roles(&sigma, &doc, &stem, file));
let symbols = scan(&sigma, &collect_formals(&doc));
formal_prose += symbols.prose_count;
formal_total += symbols.total;
for (path, _text, glyph) in symbols.undeclared {
let mut f = LintFinding::new(
"PV-ONT-003",
RuleSeverity::Error,
format!(
"`{path}.formal` carries `{glyph}`, which Σ does not declare — declare it in contracts/ontology.yaml, or mark the entry `prose: true`"
),
file.display().to_string(),
);
f.contract_stem = Some(stem.clone());
findings.push(f);
}
}
if let Some(baseline) = baseline_formal_prose(contract_dir) {
if formal_prose > baseline {
let mut f = LintFinding::new(
"PV-ONT-004",
RuleSeverity::Error,
format!(
"formal_prose rose {baseline} -> {formal_prose}: a `formal:` entry carrying no symbol Σ declares was added. The baseline in contracts/lint-baseline.json is shrink-only"
),
"contracts/lint-baseline.json".to_string(),
);
f.contract_stem = Some("lint-baseline".to_string());
findings.push(f);
}
}
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: "sigma".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::Sigma {
entity_types: sigma.entity_types.len(),
roles: sigma.roles.len(),
symbols: sigma.symbols.len(),
contracts_checked: checked,
violations,
formal_total,
formal_prose,
}),
};
SigmaOutcome::Ran {
result: Box::new(result),
findings,
}
}
fn baseline_formal_prose(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("ont")?
.get("formal_prose")?
.as_u64()
.and_then(|n| usize::try_from(n).ok())
}
fn check_entity_type(
sigma: &Sigma,
doc: &serde_yaml::Value,
stem: &str,
file: &Path,
) -> Vec<LintFinding> {
let Some(ty) = doc
.get("entity")
.and_then(|e| e.get("type"))
.and_then(serde_yaml::Value::as_str)
else {
return Vec::new();
};
if sigma.declares_entity_type(ty) {
return Vec::new();
}
let mut f = LintFinding::new(
"PV-ONT-001",
RuleSeverity::Error,
format!("entity.type `{ty}` is not in Σ entity_types (contracts/ontology.yaml)"),
file.display().to_string(),
);
f.contract_stem = Some(stem.to_string());
vec![f]
}
fn check_roles(
sigma: &Sigma,
doc: &serde_yaml::Value,
stem: &str,
file: &Path,
) -> Vec<LintFinding> {
let Some(map) = doc.get("relations").and_then(serde_yaml::Value::as_mapping) else {
return Vec::new();
};
let mut out = Vec::new();
for key in map.keys() {
let Some(role) = key.as_str() else { continue };
if sigma.declares_role(role) {
continue;
}
let mut f = LintFinding::new(
"PV-ONT-002",
RuleSeverity::Error,
format!("relation `{role}` is not a role Σ declares (contracts/ontology.yaml)"),
file.display().to_string(),
);
f.contract_stem = Some(stem.to_string());
out.push(f);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/ont")
.join(name)
}
#[test]
fn a_well_formed_corpus_passes() {
match run_sigma_gate(&fixture("sigma-ok")) {
SigmaOutcome::Ran { result, findings } => {
assert!(result.passed, "{findings:?}");
assert_eq!(result.verdict, Verdict::Pass);
assert!(findings.is_empty());
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn an_undeclared_entity_type_is_a_finding() {
match run_sigma_gate(&fixture("sigma-entity-type-unknown")) {
SigmaOutcome::Ran { result, findings } => {
assert!(!result.passed);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_id, "PV-ONT-001");
assert!(findings[0].message.contains("ghost-entity"));
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn an_undeclared_role_is_a_finding() {
match run_sigma_gate(&fixture("sigma-undeclared-role")) {
SigmaOutcome::Ran { result, findings } => {
assert!(!result.passed);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_id, "PV-ONT-002");
assert!(findings[0].message.contains("ghost_role"));
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn a_malformed_sigma_is_not_a_corpus_verdict() {
match run_sigma_gate(&fixture("sigma-malformed")) {
SigmaOutcome::Malformed(e) => {
assert!(e.to_string().contains("pv_contract"), "{e}");
}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn no_sigma_measures_nothing() {
assert!(matches!(
run_sigma_gate(&fixture("sigma-absent")),
SigmaOutcome::NoSigma
));
}
#[test]
fn a_rise_in_the_prose_debt_is_a_violation() {
match run_sigma_gate(&fixture("sigma-prose-ratchet")) {
SigmaOutcome::Ran { result, findings } => {
assert!(!result.passed, "a rise in the debt fails the gate");
assert!(
findings.iter().any(|f| f.rule_id == "PV-ONT-004"),
"{findings:?}"
);
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn a_fall_in_the_prose_debt_passes() {
match run_sigma_gate(&fixture("sigma-ok")) {
SigmaOutcome::Ran { result, .. } => assert!(result.passed),
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn sigma_itself_is_not_counted_as_a_contract() {
match run_sigma_gate(&fixture("sigma-ok")) {
SigmaOutcome::Ran { result, .. } => match result.extra {
Some(GateExtra::Sigma {
contracts_checked, ..
}) => assert_eq!(contracts_checked, 1),
other => panic!("expected the Sigma payload, got {other:?}"),
},
other => panic!("expected Ran, got {other:?}"),
}
}
}