use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::Instant;
use crate::ontology::arming::ArmedShapes;
use crate::ontology::extract::release_inputs::Subject;
use crate::ontology::extract::{
self, apr_model, code, gguf, json, lean, parity_receipt, pv_contract, release_evidence,
ExtractFailure,
};
use crate::ontology::rdf::{iri, Graph, Term, RDF_TYPE};
use crate::ontology::receipts;
use crate::ontology::shapes::{self, NodeShape, Report, Severity, ShapeError};
use crate::ontology::verdict::Reason;
use crate::ontology::w3c;
use super::finding::LintFinding;
use super::rules::RuleSeverity;
use super::{GateDetail, GateExtra, GateResult, Verdict};
pub const PLANT_ID: &str = "__pc_shape__";
#[derive(Debug)]
pub enum ShapesOutcome {
Unsupported(ShapeError),
ExtractFailed(ExtractFailure),
NoShapes { contracts_checked: usize },
NoFocus { shapes_n: usize },
NoReceipts { shapes_n: usize, dir: String },
WrongCorpus {
shapes_n: usize,
expected: usize,
found: usize,
refused: Vec<String>,
},
PositiveControlFailed {
shapes_n: usize,
focus_nodes_n: usize,
which: String,
},
Differential {
shapes_n: usize,
focus_nodes_n: usize,
passed: usize,
n: usize,
failed: Vec<String>,
},
Ran {
result: Box<GateResult>,
findings: Vec<LintFinding>,
},
}
pub fn collect_shapes(
contract_dir: &Path,
) -> Result<(Vec<(NodeShape, String)>, usize), ShapeError> {
let mut shapes: Vec<(NodeShape, String)> = Vec::new();
let mut checked = 0usize;
for (stem, rel, doc) in pv_contract::documents(contract_dir) {
checked += 1;
for shape in shapes::parse_shapes(&stem, &doc)? {
if let Some((_, other)) = shapes.iter().find(|(s, _)| s.id == shape.id) {
return Err(ShapeError::Malformed {
shape: shape.id.clone(),
what: format!("declared twice: in {other} and in {rel}"),
});
}
shapes.push((shape, rel.clone()));
}
}
Ok((shapes, checked))
}
pub fn declared_shapes(contract_dir: &Path) -> Result<Vec<(String, String)>, ShapeError> {
Ok(collect_shapes(contract_dir)?
.0
.into_iter()
.map(|(s, f)| (s.id, f))
.collect())
}
fn armed_shapes_of(contract_dir: &Path) -> Result<ArmedShapes, ShapeError> {
let text = std::fs::read_to_string(contract_dir.join("lint-baseline.json")).ok();
ArmedShapes::from_baseline(text.as_deref()).map_err(|e| ShapeError::Malformed {
shape: "lint-baseline.json".into(),
what: e.to_string(),
})
}
fn parity_refusal(
parity: &extract::parity_receipt::ParityStats,
shapes_n: usize,
) -> Option<ShapesOutcome> {
let refused = || parity.errors.iter().map(ToString::to_string).collect();
if let Some((expected, found)) = parity.wrong_corpus() {
return Some(ShapesOutcome::WrongCorpus {
shapes_n,
expected,
found,
refused: refused(),
});
}
if !parity.errors.is_empty() {
return Some(ShapesOutcome::WrongCorpus {
shapes_n,
expected: parity.expected.unwrap_or(parity.records),
found: parity.records,
refused: refused(),
});
}
None
}
fn carry_extract_warnings(report: &mut Report, warnings: &[extract::json::Warning]) {
for w in warnings {
report.results.push(shapes::ValidationResult {
severity: Severity::Warning,
focus: iri("json", &w.contract),
shape: w.contract.clone(),
path: Some(w.path.clone()),
component: "extract:json",
message: w.to_string(),
});
}
}
fn w3c_checked(shapes_n: usize, focus_nodes_n: usize) -> Result<w3c::W3cRun, ShapesOutcome> {
let w3c_run = w3c::run_all();
let failed = w3c_run.failed();
if failed.is_empty() {
return Ok(w3c_run);
}
Err(ShapesOutcome::Differential {
shapes_n,
focus_nodes_n,
passed: w3c_run.passed(),
n: w3c_run.results.len(),
failed,
})
}
#[derive(Debug, Clone, Default)]
pub struct ShapesOptions {
pub only: Option<String>,
pub release: Option<Subject>,
}
fn select_family(
declared: Vec<(NodeShape, String)>,
only: Option<&str>,
) -> Result<Vec<NodeShape>, ShapeError> {
let Some(id) = only else {
return Ok(declared.into_iter().map(|(s, _)| s).collect());
};
let prefix = format!("{id}.");
let family: Vec<NodeShape> = declared
.into_iter()
.map(|(s, _)| s)
.filter(|s| s.id == id || s.id.starts_with(&prefix))
.collect();
if family.iter().any(|s| s.id == id) {
Ok(family)
} else {
Err(ShapeError::Malformed {
shape: id.to_string(),
what: "--shape names no declared shape".into(),
})
}
}
#[must_use]
pub fn run_shapes_gate(contract_dir: &Path) -> ShapesOutcome {
run_shapes_gate_with(contract_dir, &ShapesOptions::default())
}
#[must_use]
pub fn run_shapes_gate_with(contract_dir: &Path, opts: &ShapesOptions) -> ShapesOutcome {
let start = Instant::now();
let (shapes, arming, checked) = match prepare(contract_dir, opts) {
Ok(x) => x,
Err(answer) => return answer,
};
let extraction = match extract::all_with(contract_dir, opts.release.as_ref()) {
Ok(x) => x,
Err(e) => return ShapesOutcome::ExtractFailed(e),
};
if let Some(refusal) = parity_refusal(&extraction.parity, shapes.len()) {
return refusal;
}
let graph = &extraction.graph;
let (mut report, plant_violations) = validate_with_plant(graph, &shapes, &arming);
if opts.only.is_some() {
order_by_family(&mut report, &shapes);
}
carry_extract_warnings(&mut report, &extraction.warnings);
let pc_extract = extract_controls();
let unmeasured = needs_receipts(&shapes) && extraction.receipts.is_empty();
if let Some(d) = decline(
shapes.len(),
&report,
unmeasured,
plant_violations,
&pc_extract,
) {
return d;
}
let w3c_run = match w3c_checked(shapes.len(), report.focus_nodes_n) {
Ok(run) => run,
Err(differential) => return differential,
};
let counted = findings_of(
&report,
&arming,
graph,
&extraction.gguf,
&extraction.apr_model,
);
let passed = counted.violations == 0;
let verdict = verdict_of(&counted);
let by_shape = by_shape(graph, &shapes);
let (armed_names, not_armed): (Vec<String>, Vec<String>) = shapes
.iter()
.map(|s| s.id.clone())
.partition(|id| arming.is_armed(id));
let by_entity_type = by_entity_type(&extraction);
let duration = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
let result = GateResult {
name: "shapes".into(),
passed,
skipped: false,
verdict,
duration_ms: duration,
detail: GateDetail::Validate {
contracts: checked,
errors: counted.violations,
warnings: counted.warnings,
error_messages: counted
.findings
.iter()
.filter(|f| f.severity == RuleSeverity::Error)
.map(|f| f.message.clone())
.collect(),
},
extra: Some(GateExtra::Shapes {
shapes_n: shapes.len(),
focus_nodes_n: report.focus_nodes_n,
pc_shape: "fired".into(),
plant_violations,
violations: counted.violations,
warnings: counted.warnings,
by_shape,
triples: graph.len(),
armed_shapes: armed_names,
not_armed_shapes: not_armed,
unarmed_violations: counted.unarmed_violations,
by_entity_type,
pc_extract,
receipts: extraction.resolve.receipts,
witnesses: extraction.resolve.witnesses,
hex_mismatches: extraction.resolve.hex_mismatches,
unmeasured_rows: extraction.resolve.unmeasured_rows,
w3c_cases_passed: w3c_run.passed(),
w3c_cases_n: w3c_run.results.len(),
symbols_resolved: extraction.code.resolved,
symbols_unresolved: extraction.code.unresolved,
lean_statements: extraction.lean.statements,
lean_refs_unresolved: extraction.lean.refs_unresolved.len(),
release: extraction.release.clone().map(Box::new),
}),
};
ShapesOutcome::Ran {
result: Box::new(result),
findings: counted.findings,
}
}
fn prepare(
contract_dir: &Path,
opts: &ShapesOptions,
) -> Result<(Vec<NodeShape>, ArmedShapes, usize), ShapesOutcome> {
let (declared, checked) = collect_shapes(contract_dir).map_err(ShapesOutcome::Unsupported)?;
if declared.is_empty() {
return Err(ShapesOutcome::NoShapes {
contracts_checked: checked,
});
}
let baseline = armed_shapes_of(contract_dir).map_err(ShapesOutcome::Unsupported)?;
let arming = if opts.only.is_some() {
ArmedShapes::All
} else {
baseline
};
let shapes =
select_family(declared, opts.only.as_deref()).map_err(ShapesOutcome::Unsupported)?;
Ok((shapes, arming, checked))
}
fn order_by_family(report: &mut Report, shapes: &[NodeShape]) {
let rank = |id: &str| shapes.iter().position(|s| s.id == id).unwrap_or(usize::MAX);
report.results.sort_by_key(|r| rank(&r.shape));
}
fn needs_receipts(shapes: &[NodeShape]) -> bool {
shapes.iter().any(|s| {
s.properties
.iter()
.any(|p| p.resolves.as_deref() == Some("receipt"))
})
}
fn verdict_of(counted: &Counted) -> Verdict {
if counted.violations > 0 {
Verdict::Fail
} else if counted.warnings > 0 {
Verdict::Unknown(Reason::Warn)
} else {
Verdict::Pass
}
}
fn by_shape(graph: &Graph, shapes: &[NodeShape]) -> Vec<String> {
let mut out: Vec<String> = shapes
.iter()
.map(|s| {
let n = shapes::instances_closed(graph, &s.target_class).len();
format!("{}={}", s.id, n)
})
.collect();
out.sort();
out
}
fn by_entity_type(extraction: &extract::Extraction) -> BTreeMap<String, usize> {
[
(
"pv-contract",
extraction
.graph
.instances_of(&crate::ontology::rdf::ont("Contract"))
.len(),
),
(
"gguf",
extraction.gguf.rungs.len() + extraction.gguf.files_read,
),
("apr-model", extraction.apr_model.files_read),
("parity-receipt", extraction.parity.records),
("code", extraction.code.symbols),
("lean", extraction.lean.statements),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()
}
fn decline(
shapes_n: usize,
report: &Report,
unmeasured: bool,
plant_violations: usize,
pc_extract: &BTreeMap<String, String>,
) -> Option<ShapesOutcome> {
if report.focus_nodes_n == 0 {
return Some(ShapesOutcome::NoFocus { shapes_n });
}
if unmeasured {
return Some(ShapesOutcome::NoReceipts {
shapes_n,
dir: receipts::EVIDENCE_DIR.to_string(),
});
}
let silent = if plant_violations == 0 {
Some("pc_shape".to_string())
} else {
pc_extract
.iter()
.find(|(_, v)| v.as_str() != "fired")
.map(|(k, _)| format!("pc_extract.{k}"))
};
silent.map(|which| ShapesOutcome::PositiveControlFailed {
shapes_n,
focus_nodes_n: report.focus_nodes_n,
which,
})
}
fn extract_controls() -> BTreeMap<String, String> {
let apr_sample = apr_model::minimal_container(2);
[
("pv-contract", pv_contract::positive_control()),
("json", json::positive_control()),
("gguf", gguf::positive_control()),
("apr-model", apr_model::positive_control(&apr_sample)),
("code", code::positive_control()),
("lean", lean::positive_control()),
(
"parity-receipt",
parity_receipt::positive_control(&parity_receipt::control_sample()),
),
("release-evidence", release_evidence::positive_control()),
]
.into_iter()
.map(|(k, fired)| {
(
k.to_string(),
if fired { "fired" } else { "not-fired" }.to_string(),
)
})
.collect()
}
struct Counted {
findings: Vec<LintFinding>,
violations: usize,
warnings: usize,
unarmed_violations: usize,
}
fn findings_of(
report: &Report,
arming: &ArmedShapes,
graph: &Graph,
gguf_stats: &gguf::GgufStats,
apr_stats: &apr_model::AprStats,
) -> Counted {
let mut c = Counted {
findings: Vec::new(),
violations: 0,
warnings: 0,
unarmed_violations: 0,
};
for r in &report.results {
let armed = arming.is_armed(&r.shape);
let severity = match (armed, r.severity) {
(true, Severity::Violation) => {
c.violations += 1;
RuleSeverity::Error
}
(true, Severity::Warning) => {
c.warnings += 1;
RuleSeverity::Warning
}
(false, Severity::Violation) => {
c.unarmed_violations += 1;
RuleSeverity::Warning
}
(false, Severity::Warning) => RuleSeverity::Warning,
};
let rung = graph
.objects(&r.focus, &gguf::model("id"))
.first()
.and_then(|t| t.as_literal())
.map(|(id, _)| format!(" (rung {id})"))
.unwrap_or_default();
let mut f = LintFinding::new(
"PV-ONT-011",
severity,
format!(
"{}{rung} violates shape `{}`{} ({}): {}",
shapes::short(&r.focus),
r.shape,
if armed { "" } else { " [not armed]" },
r.component,
r.message
),
format!("contracts/{}.yaml", r.shape),
);
f.contract_stem = Some(r.shape.clone());
c.findings.push(f);
}
for e in gguf_stats.errors.iter().chain(apr_stats.errors.iter()) {
c.violations += 1;
let mut f = LintFinding::new(
"PV-ONT-012",
RuleSeverity::Error,
format!("extractor refused {}: {}", e.file, e.what),
e.file.clone(),
);
f.contract_stem = None;
c.findings.push(f);
}
c
}
#[must_use]
pub fn validate_with_plant(
graph: &Graph,
shapes: &[NodeShape],
arming: &ArmedShapes,
) -> (Report, usize) {
let mut planted = graph.clone();
let plant = iri("contract", PLANT_ID);
let classes: BTreeSet<&str> = shapes
.iter()
.filter(|s| !s.target_class.is_empty())
.map(|s| s.target_class.as_str())
.collect();
for c in classes {
planted.insert(plant.clone(), RDF_TYPE, Term::iri(c));
}
let mut report = shapes::validate(&planted, shapes);
let plant_violations = report
.results
.iter()
.filter(|r| {
r.focus == plant && r.severity == Severity::Violation && arming.is_armed(&r.shape)
})
.count();
report.results.retain(|r| r.focus != plant);
report.focus_nodes_n = report.focus_nodes_n.saturating_sub(1);
(report, plant_violations)
}
#[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)
}
fn extra(result: &GateResult) -> (usize, usize, usize, Vec<String>, Vec<String>) {
match &result.extra {
Some(GateExtra::Shapes {
shapes_n,
focus_nodes_n,
plant_violations,
armed_shapes,
not_armed_shapes,
..
}) => (
*shapes_n,
*focus_nodes_n,
*plant_violations,
armed_shapes.clone(),
not_armed_shapes.clone(),
),
other => panic!("{other:?}"),
}
}
#[test]
fn a_conforming_fixture_corpus_passes_and_the_plant_fires() {
match run_shapes_gate(&fixture("shapes-ok")) {
ShapesOutcome::Ran { result, findings } => {
assert!(result.passed, "{findings:?}");
assert_eq!(result.verdict, Verdict::Pass);
let (shapes_n, focus, plant, armed, not_armed) = extra(&result);
assert_eq!(shapes_n, 1);
assert_eq!(focus, 3, "the plant is not counted");
assert_eq!(plant, 1, "one minCount property → exactly one");
assert_eq!(
armed,
vec!["shape-holder".to_string()],
"no baseline key → all armed"
);
assert!(not_armed.is_empty());
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn a_corpus_violation_fails_naming_focus_node_and_shape() {
match run_shapes_gate(&fixture("shapes-violation")) {
ShapesOutcome::Ran { result, findings } => {
assert!(!result.passed);
assert_eq!(result.verdict, Verdict::Fail);
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(
findings[0].message.contains("ont:contract/bad-KIND"),
"{}",
findings[0].message
);
assert!(
findings[0].message.contains("shape `shape-holder`"),
"{}",
findings[0].message
);
assert_eq!(findings[0].rule_id, "PV-ONT-011");
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn warnings_only_is_unknown_warn_not_pass() {
match run_shapes_gate(&fixture("shapes-warn")) {
ShapesOutcome::Ran { result, .. } => {
assert_eq!(result.verdict, Verdict::Unknown(Reason::Warn));
assert!(result.passed, "a warning is not a failure");
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn no_shapes_no_focus_and_unsupported_are_not_corpus_verdicts() {
assert!(matches!(
run_shapes_gate(&fixture("sigma-ok")),
ShapesOutcome::NoShapes { .. }
));
assert!(matches!(
run_shapes_gate(&fixture("shapes-nofocus")),
ShapesOutcome::NoFocus { shapes_n: 1 }
));
match run_shapes_gate(&fixture("shapes-unsupported")) {
ShapesOutcome::Unsupported(ShapeError::Unsupported { component, .. }) => {
assert_eq!(component, "targetNode");
}
other => panic!("expected Unsupported, got {other:?}"),
}
}
#[test]
fn a_shape_set_that_cannot_fire_is_a_positive_control_failure() {
assert!(matches!(
run_shapes_gate(&fixture("shapes-noplant")),
ShapesOutcome::PositiveControlFailed { .. }
));
}
fn ladder(name: &str) -> ShapesOutcome {
run_shapes_gate(&fixture(name).join("contracts"))
}
#[test]
fn an_all_green_ladder_passes_with_measured_armed_and_green_reported_only() {
match ladder("ladder-green") {
ShapesOutcome::Ran { result, findings } => {
assert_eq!(result.verdict, Verdict::Pass, "{findings:?}");
let (_, _, plant, armed, not_armed) = extra(&result);
assert!(plant >= 1);
assert_eq!(
armed,
vec!["ladder-measured".to_string(), "ont-ladder-v1".to_string()]
.into_iter()
.filter(|a| armed.contains(a))
.collect::<Vec<_>>()
);
assert!(armed.contains(&"ladder-measured".to_string()));
assert_eq!(not_armed, vec!["ladder-green".to_string()]);
match &result.extra {
Some(GateExtra::Shapes {
by_entity_type,
pc_extract,
witnesses,
unarmed_violations,
..
}) => {
assert_eq!(by_entity_type.get("gguf"), Some(&2));
assert_eq!(pc_extract.get("gguf").map(String::as_str), Some("fired"));
assert_eq!(
pc_extract.get("apr-model").map(String::as_str),
Some("fired")
);
assert_eq!(*witnesses, 4, "two rungs × two hosts");
assert_eq!(*unarmed_violations, 0, "all green: ladder-green holds too");
}
other => panic!("{other:?}"),
}
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn a_fallback_backend_is_reported_by_the_unarmed_green_shape_and_does_not_fail_the_gate() {
match ladder("ladder-fallback") {
ShapesOutcome::Ran { result, findings } => {
assert_eq!(
result.verdict,
Verdict::Pass,
"ladder-green is not armed here"
);
let named: Vec<&LintFinding> = findings
.iter()
.filter(|f| f.message.contains("[not armed]"))
.collect();
assert_eq!(named.len(), 1, "{findings:?}");
assert!(
named[0].message.contains("missingGreenHost"),
"{}",
named[0].message
);
assert!(
named[0].message.contains("gx10"),
"names the host: {}",
named[0].message
);
match &result.extra {
Some(GateExtra::Shapes {
unarmed_violations, ..
}) => assert_eq!(*unarmed_violations, 1),
other => panic!("{other:?}"),
}
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn arming_the_green_shape_on_the_fallback_fixture_fails_naming_rung_and_host() {
match ladder("ladder-fallback-armed") {
ShapesOutcome::Ran { result, findings } => {
assert_eq!(result.verdict, Verdict::Fail);
let f = findings
.iter()
.find(|f| f.message.contains("ladder-green"))
.expect("the green shape fired");
assert!(f.message.contains("gx10"), "{}", f.message);
assert!(!f.message.contains("[not armed]"));
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn a_wrong_hex_in_a_receipt_row_fails_ladder_measured_naming_both() {
match ladder("ladder-wronghex") {
ShapesOutcome::Ran { result, findings } => {
assert_eq!(result.verdict, Verdict::Fail);
let f = findings
.iter()
.find(|f| f.message.contains("receiptHexMismatch"))
.expect("the mismatch fired");
assert!(f.message.contains("ladder-measured"), "{}", f.message);
assert!(f.message.contains("lambda.json"), "{}", f.message);
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn a_row_without_sha256_is_not_a_witness_so_a_rung_with_only_such_rows_fails_measured() {
match ladder("ladder-nosha") {
ShapesOutcome::Ran { result, findings } => {
assert_eq!(result.verdict, Verdict::Fail);
assert!(
findings.iter().any(|f| f.message.contains("parityReceipt")),
"{findings:?}"
);
match &result.extra {
Some(GateExtra::Shapes {
witnesses,
unmeasured_rows,
..
}) => {
assert_eq!(*witnesses, 0);
assert!(*unmeasured_rows >= 1);
}
other => panic!("{other:?}"),
}
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn no_receipt_file_at_all_is_a_decline_naming_the_directory() {
match ladder("ladder-noreceipts") {
ShapesOutcome::NoReceipts { dir, .. } => assert_eq!(dir, receipts::EVIDENCE_DIR),
other => panic!("expected NoReceipts, got {other:?}"),
}
}
#[test]
fn a_foreign_receipt_schema_is_refused_by_name() {
match ladder("ladder-badschema") {
ShapesOutcome::ExtractFailed(ExtractFailure::Receipt(e)) => {
assert!(e.file.ends_with("lambda.json"), "{}", e.file);
assert!(e.what.contains("refused by name"), "{}", e.what);
}
other => panic!("expected ExtractFailed, got {other:?}"),
}
}
#[test]
fn a_lying_apr_header_is_an_extractor_rejection_naming_the_file() {
match ladder("ladder-lyingapr") {
ShapesOutcome::Ran { result, findings } => {
assert_eq!(result.verdict, Verdict::Fail);
let f = findings
.iter()
.find(|f| f.rule_id == "PV-ONT-012")
.expect("PV-ONT-012");
assert!(f.message.contains("lying.apr"), "{}", f.message);
assert!(f.message.contains("header says 3"), "{}", f.message);
}
other => panic!("expected Ran, got {other:?}"),
}
}
#[test]
fn the_plant_must_fire_from_an_armed_shape() {
assert!(matches!(
ladder("ladder-plantunarmed"),
ShapesOutcome::PositiveControlFailed { .. }
));
}
#[test]
fn every_implemented_entity_type_in_sigma_has_an_extract_control_and_it_fires() {
let sigma_path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts/ontology.yaml");
let text = std::fs::read_to_string(&sigma_path).expect("contracts/ontology.yaml");
let sigma = crate::ontology::sigma::Sigma::from_yaml(&text).expect("Σ parses");
let implemented: BTreeSet<String> = sigma
.entity_types
.iter()
.filter(|e| e.implemented)
.map(|e| e.name.clone())
.collect();
assert!(
!implemented.is_empty(),
"Σ implements nothing — the comparison would be vacuous"
);
let controls = extract_controls();
let controlled: BTreeSet<String> = controls.keys().cloned().collect();
assert_eq!(
controlled, implemented,
"pc_extract keys (left) must equal Σ's implemented entity types (right)"
);
for (k, v) in &controls {
assert_eq!(v, "fired", "pc_extract.{k}");
}
}
}