use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
pub const LEAN_COVERAGE_SCHEMA_VERSION: &str = "1.0.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoverageRowType {
SemanticRule,
Invariant,
RefinementObligation,
OperationalGate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CoverageStatus {
NotStarted,
InProgress,
Blocked,
Proven,
ValidatedInCi,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BlockerCode {
#[serde(rename = "BLK_PROOF_MISSING_LEMMA")]
ProofMissingLemma,
#[serde(rename = "BLK_PROOF_SHAPE_MISMATCH")]
ProofShapeMismatch,
#[serde(rename = "BLK_MODEL_GAP")]
ModelGap,
#[serde(rename = "BLK_IMPL_DIVERGENCE")]
ImplDivergence,
#[serde(rename = "BLK_TOOLCHAIN_FAILURE")]
ToolchainFailure,
#[serde(rename = "BLK_EXTERNAL_DEPENDENCY")]
ExternalDependency,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageBlocker {
pub code: BlockerCode,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theorem_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proof_artifact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ci_job: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewer: Option<String>,
}
impl CoverageEvidence {
fn is_empty(&self) -> bool {
self.theorem_name.is_none()
&& self.file_path.is_none()
&& self.line.is_none()
&& self.proof_artifact.is_none()
&& self.ci_job.is_none()
&& self.reviewer.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageRow {
pub id: String,
pub title: String,
pub row_type: CoverageRowType,
pub status: CoverageStatus,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocker: Option<CoverageBlocker>,
#[serde(default)]
pub evidence: Vec<CoverageEvidence>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeanCoverageMatrix {
pub schema_version: String,
pub matrix_id: String,
pub title: String,
pub scope: String,
pub rows: Vec<CoverageRow>,
}
impl LeanCoverageMatrix {
pub fn from_json_str(input: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(input)
}
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if self.schema_version != LEAN_COVERAGE_SCHEMA_VERSION {
errors.push(format!(
"schema_version must be '{LEAN_COVERAGE_SCHEMA_VERSION}' (got '{}')",
self.schema_version
));
}
if !is_valid_stable_id(&self.matrix_id) {
errors.push(format!(
"matrix_id '{}' must be lowercase stable-id format [a-z0-9._-]",
self.matrix_id
));
}
let mut ids = HashSet::new();
let mut all_ids = BTreeSet::new();
for row in &self.rows {
if !is_valid_stable_id(&row.id) {
errors.push(format!(
"row '{}' has invalid id format; use lowercase stable-id [a-z0-9._-]",
row.title
));
}
if !ids.insert(row.id.clone()) {
errors.push(format!("duplicate row id '{}'", row.id));
}
all_ids.insert(row.id.clone());
}
for row in &self.rows {
for dep in &row.depends_on {
if !all_ids.contains(dep) {
errors.push(format!(
"row '{}' depends_on missing row id '{}'",
row.id, dep
));
}
if dep == &row.id {
errors.push(format!("row '{}' cannot depend on itself", row.id));
}
}
if let Some(blocker) = &row.blocker
&& blocker.detail.trim().is_empty()
{
errors.push(format!(
"row '{}' has blocker '{:?}' with empty detail",
row.id, blocker.code
));
}
match row.status {
CoverageStatus::Blocked => {
if row.blocker.is_none() {
errors.push(format!(
"row '{}' is blocked but missing blocker payload",
row.id
));
}
}
CoverageStatus::Proven | CoverageStatus::ValidatedInCi => {
if row.evidence.is_empty() {
errors.push(format!(
"row '{}' is {:?} but has no evidence entries",
row.id, row.status
));
}
if row.blocker.is_some() {
errors.push(format!(
"row '{}' is {:?} but still has blocker payload",
row.id, row.status
));
}
}
CoverageStatus::NotStarted | CoverageStatus::InProgress => {
if row.blocker.is_some() {
errors.push(format!(
"row '{}' has blocker payload but status is {:?}",
row.id, row.status
));
}
}
}
if row.status == CoverageStatus::ValidatedInCi
&& !row.evidence.iter().any(|e| e.ci_job.is_some())
{
errors.push(format!(
"row '{}' is validated-in-ci but has no ci_job in evidence",
row.id
));
}
for (index, evidence) in row.evidence.iter().enumerate() {
if evidence.is_empty() {
errors.push(format!(
"row '{}' evidence[{index}] is empty; at least one evidence field is required",
row.id
));
}
if evidence.line.is_some() && evidence.file_path.is_none() {
errors.push(format!(
"row '{}' evidence[{index}] has line but missing file_path",
row.id
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
fn is_valid_stable_id(id: &str) -> bool {
if id.is_empty() {
return false;
}
id.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'_' || b == b'-'
})
}
#[cfg(test)]
mod tests {
use super::{
BlockerCode, CoverageBlocker, CoverageEvidence, CoverageRow, CoverageRowType,
CoverageStatus, LEAN_COVERAGE_SCHEMA_VERSION, LeanCoverageMatrix,
};
fn valid_matrix() -> LeanCoverageMatrix {
LeanCoverageMatrix {
schema_version: LEAN_COVERAGE_SCHEMA_VERSION.to_string(),
matrix_id: "lean.coverage.v1".to_string(),
title: "Lean Coverage".to_string(),
scope: "Spec to implementation coverage".to_string(),
rows: vec![
CoverageRow {
id: "sem.cancel.request.wf".to_string(),
title: "cancel request preserves wf".to_string(),
row_type: CoverageRowType::SemanticRule,
status: CoverageStatus::ValidatedInCi,
description: "requestCancel branch preserves well-formedness".to_string(),
owner: Some("MagentaBridge".to_string()),
depends_on: vec![],
tags: vec!["cancel".to_string()],
blocker: None,
evidence: vec![CoverageEvidence {
theorem_name: Some("requestCancel_preserves_wf".to_string()),
file_path: Some("formal/lean/Asupersync.lean".to_string()),
line: Some(2321),
proof_artifact: Some("target/lean-e2e/manifest.json".to_string()),
ci_job: Some("proof-checks".to_string()),
reviewer: Some("FoggyMarsh".to_string()),
}],
},
CoverageRow {
id: "inv.no_obligation_leak".to_string(),
title: "No obligation leaks".to_string(),
row_type: CoverageRowType::Invariant,
status: CoverageStatus::Blocked,
description: "No leaked obligations after close".to_string(),
owner: None,
depends_on: vec!["sem.cancel.request.wf".to_string()],
tags: vec![],
blocker: Some(CoverageBlocker {
code: BlockerCode::ProofMissingLemma,
detail: "Need helper lemma for resolve obligation map".to_string(),
}),
evidence: vec![],
},
],
}
}
#[test]
fn valid_matrix_passes_validation() {
let matrix = valid_matrix();
assert!(matrix.validate().is_ok());
}
#[test]
fn duplicate_ids_fail_validation() {
let mut matrix = valid_matrix();
matrix.rows[1].id = matrix.rows[0].id.clone();
let errors = matrix.validate().expect_err("should fail");
assert!(errors.iter().any(|e| e.contains("duplicate row id")));
}
#[test]
fn missing_dependency_fails_validation() {
let mut matrix = valid_matrix();
matrix.rows[1].depends_on = vec!["missing.row.id".to_string()];
let errors = matrix.validate().expect_err("should fail");
assert!(
errors
.iter()
.any(|e| e.contains("depends_on missing row id"))
);
}
#[test]
fn blocked_requires_blocker_payload() {
let mut matrix = valid_matrix();
matrix.rows[1].blocker = None;
let errors = matrix.validate().expect_err("should fail");
assert!(errors.iter().any(|e| e.contains("missing blocker payload")));
}
#[test]
fn validated_in_ci_requires_ci_job_evidence() {
let mut matrix = valid_matrix();
matrix.rows[0].evidence[0].ci_job = None;
let errors = matrix.validate().expect_err("should fail");
assert!(
errors
.iter()
.any(|e| e.contains("validated-in-ci but has no ci_job"))
);
}
}