meerkat_mobkit/
governance.rs1use 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(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}