Skip to main content

meerkat_mobkit/
governance.rs

1//! Governance validation for runtime configuration and deployment policies.
2
3use std::fmt;
4
5use serde::Deserialize;
6
7pub const STRICT_TRACEABILITY_STATUSES: &[&str] = &[
8    "TYPED",
9    "WIRED",
10    "VALIDATED",
11    "PROVISIONAL",
12    "MISSING",
13    "DEFERRED",
14    "STUBBED",
15];
16const REQUIRED_GOVERNANCE_STATE: &str = "realignment_in_progress";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum GovernanceValidationError {
20    MissingGovernanceState { file: String },
21    InvalidGovernanceState { file: String, found: String },
22    NoTraceabilityRows,
23    InvalidTraceabilityStatus { line: usize, status: String },
24    MissingTraceabilityEvidence { line: usize },
25    InvalidTraceabilityRow { line: usize },
26}
27
28impl fmt::Display for GovernanceValidationError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::MissingGovernanceState { file } => {
32                write!(f, "missing governance_state in {file}")
33            }
34            Self::InvalidGovernanceState { file, found } => write!(
35                f,
36                "invalid governance_state in {file}: expected {REQUIRED_GOVERNANCE_STATE}, found {found}"
37            ),
38            Self::NoTraceabilityRows => write!(f, "no traceability rows found"),
39            Self::InvalidTraceabilityStatus { line, status } => {
40                write!(f, "invalid traceability status at line {line}: {status}")
41            }
42            Self::MissingTraceabilityEvidence { line } => {
43                write!(f, "missing traceability evidence/link at line {line}")
44            }
45            Self::InvalidTraceabilityRow { line } => {
46                write!(f, "invalid traceability row format at line {line}")
47            }
48        }
49    }
50}
51
52impl std::error::Error for GovernanceValidationError {}
53
54#[derive(Debug, Deserialize)]
55struct TraceabilityDocument {
56    #[serde(default)]
57    rows: Vec<TraceabilityRow>,
58}
59
60#[derive(Debug, Deserialize)]
61struct TraceabilityRow {
62    status: String,
63    #[serde(default)]
64    evidence: Vec<String>,
65}
66
67pub fn validate_governance_state(
68    file_name: &str,
69    content: &str,
70) -> Result<(), GovernanceValidationError> {
71    let line = content
72        .lines()
73        .find(|line| line.trim_start().starts_with("governance_state:"))
74        .ok_or_else(|| GovernanceValidationError::MissingGovernanceState {
75            file: file_name.to_string(),
76        })?;
77
78    let found = line
79        .split_once(':')
80        .map(|(_, value)| value.trim())
81        .unwrap_or_default()
82        .to_string();
83
84    if found != REQUIRED_GOVERNANCE_STATE {
85        return Err(GovernanceValidationError::InvalidGovernanceState {
86            file: file_name.to_string(),
87            found,
88        });
89    }
90
91    Ok(())
92}
93
94pub fn validate_traceability_statuses(markdown: &str) -> Result<(), GovernanceValidationError> {
95    if looks_like_markdown_table(markdown) {
96        return validate_markdown_traceability_statuses(markdown);
97    }
98
99    validate_yaml_traceability_statuses(markdown)
100}
101
102fn validate_markdown_traceability_statuses(
103    markdown: &str,
104) -> Result<(), GovernanceValidationError> {
105    let mut seen_rows = false;
106    let mut status_column = None;
107    let mut evidence_or_link_column = None;
108    let mut header_line = None;
109
110    for (idx, line) in markdown.lines().enumerate() {
111        let trimmed = line.trim();
112        if !trimmed.starts_with('|') {
113            continue;
114        }
115
116        let columns = trimmed
117            .trim_start_matches('|')
118            .trim_end_matches('|')
119            .split('|')
120            .map(str::trim)
121            .collect::<Vec<_>>();
122
123        if columns.is_empty()
124            || columns
125                .iter()
126                .all(|column| !column.is_empty() && column.chars().all(|ch| ch == '-' || ch == ':'))
127        {
128            continue;
129        }
130
131        if status_column.is_none() {
132            header_line = Some(idx + 1);
133            status_column = columns
134                .iter()
135                .position(|column| column.eq_ignore_ascii_case("Status"));
136            evidence_or_link_column = columns
137                .iter()
138                .position(|column| is_evidence_or_link_column(column));
139            continue;
140        }
141
142        let Some(status_column) = status_column else {
143            continue;
144        };
145        let Some(evidence_or_link_column) = evidence_or_link_column else {
146            return Err(GovernanceValidationError::InvalidTraceabilityRow {
147                line: header_line.unwrap_or(idx + 1),
148            });
149        };
150        if columns.len() <= status_column || columns.len() <= evidence_or_link_column {
151            return Err(GovernanceValidationError::InvalidTraceabilityRow { line: idx + 1 });
152        }
153
154        seen_rows = true;
155        let status = columns[status_column].trim_matches('`');
156        if !STRICT_TRACEABILITY_STATUSES.contains(&status) {
157            return Err(GovernanceValidationError::InvalidTraceabilityStatus {
158                line: idx + 1,
159                status: status.to_string(),
160            });
161        }
162        let evidence = columns[evidence_or_link_column].trim_matches('`').trim();
163        if status_requires_evidence(status) && is_missing_evidence(evidence) {
164            return Err(GovernanceValidationError::MissingTraceabilityEvidence { line: idx + 1 });
165        }
166    }
167
168    if !seen_rows {
169        return Err(GovernanceValidationError::NoTraceabilityRows);
170    }
171
172    Ok(())
173}
174
175fn validate_yaml_traceability_statuses(yaml: &str) -> Result<(), GovernanceValidationError> {
176    let document: TraceabilityDocument = serde_yaml::from_str(yaml)
177        .map_err(|_| GovernanceValidationError::InvalidTraceabilityRow { line: 1 })?;
178
179    if document.rows.is_empty() {
180        return Err(GovernanceValidationError::NoTraceabilityRows);
181    }
182
183    for (index, row) in document.rows.iter().enumerate() {
184        if !STRICT_TRACEABILITY_STATUSES.contains(&row.status.as_str()) {
185            return Err(GovernanceValidationError::InvalidTraceabilityStatus {
186                line: index + 1,
187                status: row.status.clone(),
188            });
189        }
190        if status_requires_evidence(&row.status)
191            && row.evidence.iter().all(|entry| is_missing_evidence(entry))
192        {
193            return Err(GovernanceValidationError::MissingTraceabilityEvidence { line: index + 1 });
194        }
195    }
196
197    Ok(())
198}
199
200fn looks_like_markdown_table(content: &str) -> bool {
201    content
202        .lines()
203        .any(|line| line.trim_start().starts_with('|'))
204}
205
206fn is_evidence_or_link_column(column: &str) -> bool {
207    let normalized = column
208        .chars()
209        .filter(char::is_ascii_alphanumeric)
210        .collect::<String>()
211        .to_ascii_lowercase();
212    normalized.contains("evidence") || normalized.contains("link")
213}
214
215fn is_missing_evidence(value: &str) -> bool {
216    if value.is_empty() {
217        return true;
218    }
219
220    let normalized = value.to_ascii_lowercase();
221    matches!(
222        normalized.as_str(),
223        "-" | "--" | "n/a" | "na" | "none" | "null" | "todo" | "tbd" | "placeholder"
224    )
225}
226
227fn status_requires_evidence(status: &str) -> bool {
228    !matches!(status, "MISSING" | "DEFERRED" | "STUBBED")
229}
230
231pub fn validate_governance_contracts(
232    spec_yaml: &str,
233    plan_yaml: &str,
234    checklist_yaml: &str,
235    traceability_markdown: &str,
236) -> Result<(), GovernanceValidationError> {
237    validate_governance_state(".rct/spec.yaml", spec_yaml)?;
238    validate_governance_state(".rct/plan.yaml", plan_yaml)?;
239    validate_governance_state(".rct/checklist.yaml", checklist_yaml)?;
240    validate_traceability_statuses(traceability_markdown)?;
241    Ok(())
242}
243
244/// Deprecated alias — use [`validate_governance_contracts`] instead.
245#[deprecated(since = "0.4.11", note = "renamed to validate_governance_contracts")]
246pub fn validate_phase0_governance_contracts(
247    spec_yaml: &str,
248    plan_yaml: &str,
249    checklist_yaml: &str,
250    traceability_markdown: &str,
251) -> Result<(), GovernanceValidationError> {
252    validate_governance_contracts(spec_yaml, plan_yaml, checklist_yaml, traceability_markdown)
253}