agileplus_trace_validator/
lib.rs1use agileplus_domain::intent_graph::*;
20use thiserror::Error;
21
22mod canonical;
23mod dag;
24mod edges;
25mod metadata;
26mod nodes;
27mod ontology;
28pub mod ontology_ext;
29
30pub use canonical::validate_canonical_map;
31pub use dag::{validate_dag_acyclicity, validate_dag_flow};
32pub use edges::validate_edge_constraints;
33pub use metadata::validate_metadata;
34pub use nodes::{validate_node_completeness, validate_node_ids_only};
35pub use ontology::{
36 DagFlow, DagFlowStage, DagValidationRules, EdgeConstraint, EdgeConstraintEntry,
37 EdgeConstraints, Ontology,
38};
39pub use ontology_ext::{
40 extended_relationship_of, validate_extended_edge, validate_extended_edge_constraints,
41 EXTENDED_RELATIONSHIP_PROPERTY,
42};
43
44#[derive(Error, Debug, Clone, PartialEq)]
46pub enum ValidationError {
47 #[error("Node ID format error: {0}")]
48 InvalidNodeId(String),
49 #[error("Missing required field: {0}")]
50 MissingRequiredField(String),
51 #[error("Edge constraint violation: {0}")]
52 EdgeConstraintViolation(String),
53 #[error("Cycle detected: {0}")]
54 CycleDetected(String),
55 #[error("DAG flow violation: {0}")]
56 DagFlowViolation(String),
57 #[error("Metadata error: {0}")]
58 MetadataError(String),
59 #[error("Canonical map error: {0}")]
60 CanonicalMapError(String),
61 #[error("Unknown node referenced in edge: {0}")]
62 UnknownNode(String),
63 #[error("Self-loop detected: {0}")]
64 SelfLoop(String),
65}
66
67pub type ValidationResult = Result<(), Vec<ValidationError>>;
69
70pub fn validate_intent_graph(graph: &IntentGraph) -> ValidationResult {
74 let mut errors = Vec::new();
75
76 errors.extend(validate_metadata(&graph.metadata).err().unwrap_or_default());
77 errors.extend(validate_node_completeness(graph).err().unwrap_or_default());
78 errors.extend(validate_edge_constraints(graph).err().unwrap_or_default());
79 errors.extend(validate_dag_acyclicity(graph).err().unwrap_or_default());
80 errors.extend(validate_dag_flow(graph).err().unwrap_or_default());
81 errors.extend(validate_canonical_map(graph).err().unwrap_or_default());
82
83 if errors.is_empty() {
84 Ok(())
85 } else {
86 Err(errors)
87 }
88}
89
90pub fn is_valid(graph: &IntentGraph) -> bool {
92 validate_intent_graph(graph).is_ok()
93}
94
95pub fn first_error(graph: &IntentGraph) -> Option<ValidationError> {
97 match validate_intent_graph(graph) {
98 Ok(()) => None,
99 Err(errors) => errors.into_iter().next(),
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 #![allow(unused_imports)]
106 use super::*;
107 use agileplus_domain::builder::*;
108 use agileplus_domain::intent_graph::*;
109
110 fn make_valid_graph() -> IntentGraph {
111 let mut builder = IntentGraphBuilder::new().with_default_metadata();
112
113 let intent = NodeBuilder::new()
114 .id("Intent#user-auth")
115 .node_type(NodeType::Intent)
116 .dag_stage(DagStage::Intent)
117 .title("User Authentication")
118 .status(Status::Active)
119 .with_meta("user-prompt")
120 .build()
121 .unwrap();
122
123 let feature = NodeBuilder::new()
124 .id("Feature#oauth2-login")
125 .node_type(NodeType::Feature)
126 .dag_stage(DagStage::Feature)
127 .title("OAuth2 Login")
128 .status(Status::Draft)
129 .with_meta("agent-inference")
130 .build()
131 .unwrap();
132
133 let test = NodeBuilder::new()
134 .id("Test#oauth2-login")
135 .node_type(NodeType::Test)
136 .dag_stage(DagStage::Test)
137 .title("OAuth2 Login Tests")
138 .status(Status::Draft)
139 .with_meta("agent-inference")
140 .build()
141 .unwrap();
142
143 builder = builder
144 .node(intent.clone())
145 .node(feature.clone())
146 .node(test.clone());
147
148 builder = builder.edge(
150 EdgeBuilder::new()
151 .auto_id()
152 .source("Intent#user-auth")
153 .target("Feature#oauth2-login")
154 .relationship_type(RelationshipType::Implements)
155 .with_meta("agent-inference")
156 .build()
157 .unwrap(),
158 );
159
160 builder = builder.edge(
162 EdgeBuilder::new()
163 .auto_id()
164 .source("Feature#oauth2-login")
165 .target("Test#oauth2-login")
166 .relationship_type(RelationshipType::Tests)
167 .with_meta("agent-inference")
168 .build()
169 .unwrap(),
170 );
171
172 builder.build().unwrap()
173 }
174
175 #[test]
176 fn test_valid_graph() {
177 let graph = make_valid_graph();
178 assert!(validate_intent_graph(&graph).is_ok());
179 }
180
181 #[test]
182 fn test_dag_acyclicity_cycle() {
183 let mut graph = make_valid_graph();
184 let cycle_edge = EdgeBuilder::new()
185 .auto_id()
186 .source("Feature#oauth2-login")
187 .target("Intent#user-auth")
188 .relationship_type(RelationshipType::DerivesFrom)
189 .with_meta("test")
190 .build()
191 .unwrap();
192 graph.add_edge(cycle_edge);
193
194 let result = validate_dag_acyclicity(&graph);
195 assert!(result.is_err());
196 let errs = result.unwrap_err();
197 assert!(errs
198 .iter()
199 .any(|e| matches!(e, ValidationError::CycleDetected(_))));
200 }
201
202 #[test]
203 fn test_edge_constraint_violation() {
204 let mut graph = make_valid_graph();
205 let bad_edge = EdgeBuilder::new()
206 .auto_id()
207 .source("Intent#user-auth")
208 .target("Feature#oauth2-login")
209 .relationship_type(RelationshipType::Tests)
210 .with_meta("test")
211 .build()
212 .unwrap();
213 graph.edges.push(bad_edge);
214
215 let result = validate_edge_constraints(&graph);
216 assert!(result.is_err());
217 let errs = result.unwrap_err();
218 assert!(errs
219 .iter()
220 .any(|e| matches!(e, ValidationError::EdgeConstraintViolation(_))));
221 }
222
223 #[test]
224 fn test_node_completeness_missing_title() {
225 let mut graph = make_valid_graph();
226 graph.nodes[0].title = "".to_string();
227 let result = validate_node_completeness(&graph);
228 assert!(result.is_err());
229 }
230
231 #[test]
232 fn test_metadata_empty_version() {
233 let mut graph = make_valid_graph();
234 graph.metadata.version = "".to_string();
235 let result = validate_metadata(&graph.metadata);
236 assert!(result.is_err());
237 }
238
239 #[test]
240 fn test_self_loop() {
241 let mut graph = make_valid_graph();
242 let self_loop = EdgeBuilder::new()
243 .auto_id()
244 .source("Intent#user-auth")
245 .target("Intent#user-auth")
246 .relationship_type(RelationshipType::TracesTo)
247 .with_meta("test")
248 .build()
249 .unwrap();
250 graph.add_edge(self_loop);
251
252 let result = validate_edge_constraints(&graph);
253 assert!(result.is_err());
254 let errs = result.unwrap_err();
255 assert!(errs.iter().any(|e| matches!(e, ValidationError::SelfLoop(_))));
256 }
257
258 #[test]
259 fn test_invalid_node_id() {
260 let mut graph = make_valid_graph();
261 graph.nodes[0].id = NodeId::new("invalid-id".to_string());
262 let result = validate_node_completeness(&graph);
263 assert!(result.is_err());
264 }
265
266 #[test]
267 fn test_dag_flow_root_must_be_intent() {
268 let mut graph = make_valid_graph();
269 graph.nodes.remove(0);
270 graph.edges.clear();
271 let result = validate_dag_flow(&graph);
272 assert!(result.is_err());
273 }
274
275 #[test]
276 fn test_backward_compat_is_valid() {
277 let graph = make_valid_graph();
278 assert!(is_valid(&graph));
279 assert!(first_error(&graph).is_none());
280 }
281}