1use std::collections::{HashMap, HashSet};
7
8use crate::draft_package::{Artifact, ArtifactDisposition, DependencyKind};
9
10#[cfg(test)]
11use crate::draft_package::ChangeDependency;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ValidationResult {
16 pub valid: bool,
18 pub warnings: Vec<ValidationWarning>,
20 pub errors: Vec<ValidationError>,
22}
23
24impl ValidationResult {
25 pub fn valid() -> Self {
27 Self {
28 valid: true,
29 warnings: Vec::new(),
30 errors: Vec::new(),
31 }
32 }
33
34 pub fn has_warnings(&self) -> bool {
36 !self.warnings.is_empty()
37 }
38
39 pub fn has_errors(&self) -> bool {
41 !self.errors.is_empty()
42 }
43
44 pub fn add_warning(&mut self, warning: ValidationWarning) {
46 self.warnings.push(warning);
47 }
48
49 pub fn add_error(&mut self, error: ValidationError) {
51 self.valid = false;
52 self.errors.push(error);
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ValidationWarning {
59 CoupledRejection {
61 artifact: String,
62 required_by: Vec<String>,
63 },
64 BrokenDependency {
66 artifact: String,
67 depends_on_rejected: Vec<String>,
68 },
69 DiscussBlockingApproval {
71 artifact: String,
72 blocking: Vec<String>,
73 },
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ValidationError {
79 CyclicDependency { cycle: Vec<String> },
81 SelfDependency { artifact: String },
83}
84
85#[derive(Debug, Clone)]
87pub struct DependencyGraph {
88 pub depends_on: HashMap<String, HashSet<String>>,
90 pub depended_by: HashMap<String, HashSet<String>>,
92}
93
94impl DependencyGraph {
95 pub fn from_artifacts(artifacts: &[Artifact]) -> Self {
97 let mut depends_on: HashMap<String, HashSet<String>> = HashMap::new();
98 let mut depended_by: HashMap<String, HashSet<String>> = HashMap::new();
99
100 for artifact in artifacts {
101 let uri = artifact.resource_uri.clone();
102
103 depends_on.entry(uri.clone()).or_default();
105 depended_by.entry(uri.clone()).or_default();
106
107 for dep in &artifact.dependencies {
109 match dep.kind {
110 DependencyKind::DependsOn => {
111 depends_on
112 .entry(uri.clone())
113 .or_default()
114 .insert(dep.target_uri.clone());
115 depended_by
116 .entry(dep.target_uri.clone())
117 .or_default()
118 .insert(uri.clone());
119 }
120 DependencyKind::DependedBy => {
121 depended_by
122 .entry(uri.clone())
123 .or_default()
124 .insert(dep.target_uri.clone());
125 depends_on
126 .entry(dep.target_uri.clone())
127 .or_default()
128 .insert(uri.clone());
129 }
130 }
131 }
132 }
133
134 Self {
135 depends_on,
136 depended_by,
137 }
138 }
139
140 pub fn get_dependents(&self, uri: &str) -> Vec<String> {
142 self.depended_by
143 .get(uri)
144 .map(|set| set.iter().cloned().collect())
145 .unwrap_or_default()
146 }
147
148 pub fn get_dependencies(&self, uri: &str) -> Vec<String> {
150 self.depends_on
151 .get(uri)
152 .map(|set| set.iter().cloned().collect())
153 .unwrap_or_default()
154 }
155
156 pub fn detect_cycles(&self) -> Vec<Vec<String>> {
158 let mut visited = HashSet::new();
159 let mut rec_stack = HashSet::new();
160 let mut cycles = Vec::new();
161
162 for node in self.depends_on.keys() {
163 if !visited.contains(node) {
164 self.dfs_cycle_detect(
165 node,
166 &mut visited,
167 &mut rec_stack,
168 &mut Vec::new(),
169 &mut cycles,
170 );
171 }
172 }
173
174 cycles
175 }
176
177 fn dfs_cycle_detect(
178 &self,
179 node: &str,
180 visited: &mut HashSet<String>,
181 rec_stack: &mut HashSet<String>,
182 path: &mut Vec<String>,
183 cycles: &mut Vec<Vec<String>>,
184 ) {
185 visited.insert(node.to_string());
186 rec_stack.insert(node.to_string());
187 path.push(node.to_string());
188
189 if let Some(neighbors) = self.depends_on.get(node) {
190 for neighbor in neighbors {
191 if !visited.contains(neighbor) {
192 self.dfs_cycle_detect(neighbor, visited, rec_stack, path, cycles);
193 } else if rec_stack.contains(neighbor) {
194 if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
196 let cycle = path[start_idx..].to_vec();
197 cycles.push(cycle);
198 }
199 }
200 }
201 }
202
203 path.pop();
204 rec_stack.remove(node);
205 }
206
207 pub fn detect_self_dependencies(&self) -> Vec<String> {
209 let mut self_deps = Vec::new();
210
211 for (uri, deps) in &self.depends_on {
212 if deps.contains(uri) {
213 self_deps.push(uri.clone());
214 }
215 }
216
217 self_deps
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct PlanValidationResult {
224 pub has_artifacts: bool,
226 pub artifact_count: usize,
228 pub described_count: usize,
230 pub notes: Vec<String>,
232}
233
234impl PlanValidationResult {
235 pub fn is_well_described(&self) -> bool {
237 self.has_artifacts && self.described_count > 0
238 }
239}
240
241pub fn validate_against_plan(
246 artifacts: &[Artifact],
247 phase_id: &str,
248 phase_title: &str,
249) -> PlanValidationResult {
250 let artifact_count = artifacts.len();
251 let described_count = artifacts
252 .iter()
253 .filter(|a| {
254 a.explanation_tiers
255 .as_ref()
256 .map(|t| !t.summary.is_empty())
257 .unwrap_or(false)
258 || a.rationale.is_some()
259 })
260 .count();
261
262 let mut notes = Vec::new();
263
264 if artifact_count == 0 {
265 notes.push(format!(
266 "No artifacts found for phase {} — {}. Expected code changes.",
267 phase_id, phase_title
268 ));
269 }
270
271 if artifact_count > 0 && described_count == 0 {
272 notes.push(format!(
273 "None of the {} artifacts for phase {} have descriptions. Consider adding a change_summary.json.",
274 artifact_count, phase_id
275 ));
276 }
277
278 let undescribed = artifact_count.saturating_sub(described_count);
279 if undescribed > 0 && described_count > 0 {
280 notes.push(format!(
281 "{}/{} artifacts for phase {} lack descriptions.",
282 undescribed, artifact_count, phase_id
283 ));
284 }
285
286 PlanValidationResult {
287 has_artifacts: artifact_count > 0,
288 artifact_count,
289 described_count,
290 notes,
291 }
292}
293
294pub struct SupervisorAgent {
296 graph: DependencyGraph,
297}
298
299impl SupervisorAgent {
300 pub fn new(artifacts: &[Artifact]) -> Self {
302 Self {
303 graph: DependencyGraph::from_artifacts(artifacts),
304 }
305 }
306
307 pub fn validate(&self, artifacts: &[Artifact]) -> ValidationResult {
318 let mut result = ValidationResult::valid();
319
320 for cycle in self.graph.detect_cycles() {
322 result.add_error(ValidationError::CyclicDependency { cycle });
323 }
324
325 for self_dep in self.graph.detect_self_dependencies() {
326 result.add_error(ValidationError::SelfDependency { artifact: self_dep });
327 }
328
329 let dispositions: HashMap<String, ArtifactDisposition> = artifacts
331 .iter()
332 .map(|a| (a.resource_uri.clone(), a.disposition.clone()))
333 .collect();
334
335 for artifact in artifacts {
337 let uri = &artifact.resource_uri;
338 let disposition = &artifact.disposition;
339
340 match disposition {
341 ArtifactDisposition::Rejected => {
342 let dependents = self.graph.get_dependents(uri);
344 let affected: Vec<String> = dependents
345 .into_iter()
346 .filter(|dep_uri| {
347 matches!(
348 dispositions.get(dep_uri),
349 Some(ArtifactDisposition::Approved)
350 | Some(ArtifactDisposition::Discuss)
351 | Some(ArtifactDisposition::Pending)
352 )
353 })
354 .collect();
355
356 if !affected.is_empty() {
357 result.add_warning(ValidationWarning::CoupledRejection {
358 artifact: uri.clone(),
359 required_by: affected,
360 });
361 }
362 }
363 ArtifactDisposition::Approved => {
364 let dependencies = self.graph.get_dependencies(uri);
366 let rejected_deps: Vec<String> = dependencies
367 .into_iter()
368 .filter(|dep_uri| {
369 matches!(
370 dispositions.get(dep_uri),
371 Some(ArtifactDisposition::Rejected)
372 )
373 })
374 .collect();
375
376 if !rejected_deps.is_empty() {
377 result.add_warning(ValidationWarning::BrokenDependency {
378 artifact: uri.clone(),
379 depends_on_rejected: rejected_deps,
380 });
381 }
382 }
383 ArtifactDisposition::Discuss => {
384 let dependents = self.graph.get_dependents(uri);
386 let blocked: Vec<String> = dependents
387 .into_iter()
388 .filter(|dep_uri| {
389 matches!(
390 dispositions.get(dep_uri),
391 Some(ArtifactDisposition::Approved)
392 )
393 })
394 .collect();
395
396 if !blocked.is_empty() {
397 result.add_warning(ValidationWarning::DiscussBlockingApproval {
398 artifact: uri.clone(),
399 blocking: blocked,
400 });
401 }
402 }
403 ArtifactDisposition::Pending => {
404 }
406 }
407 }
408
409 result
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 fn make_artifact(
418 uri: &str,
419 disposition: ArtifactDisposition,
420 deps: Vec<(&str, DependencyKind)>,
421 ) -> Artifact {
422 Artifact {
423 resource_uri: uri.to_string(),
424 change_type: crate::draft_package::ChangeType::Modify,
425 diff_ref: "test".to_string(),
426 tests_run: Vec::new(),
427 disposition,
428 rationale: None,
429 dependencies: deps
430 .into_iter()
431 .map(|(target, kind)| ChangeDependency {
432 target_uri: target.to_string(),
433 kind,
434 })
435 .collect(),
436 explanation_tiers: None,
437 comments: None,
438 amendment: None,
439 kind: None,
440 }
441 }
442
443 #[test]
444 fn test_dependency_graph_simple() {
445 let artifacts = vec![
446 make_artifact(
447 "fs://workspace/a.rs",
448 ArtifactDisposition::Pending,
449 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
450 ),
451 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]),
452 ];
453
454 let graph = DependencyGraph::from_artifacts(&artifacts);
455
456 assert_eq!(
457 graph.get_dependencies("fs://workspace/a.rs"),
458 vec!["fs://workspace/b.rs"]
459 );
460 assert_eq!(
461 graph.get_dependents("fs://workspace/b.rs"),
462 vec!["fs://workspace/a.rs"]
463 );
464 }
465
466 #[test]
467 fn test_coupled_rejection_warning() {
468 let artifacts = vec![
469 make_artifact(
470 "fs://workspace/a.rs",
471 ArtifactDisposition::Approved,
472 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
473 ),
474 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Rejected, vec![]),
475 ];
476
477 let supervisor = SupervisorAgent::new(&artifacts);
478 let result = supervisor.validate(&artifacts);
479
480 assert!(result.valid);
481 assert_eq!(result.warnings.len(), 2);
482
483 assert!(result
485 .warnings
486 .iter()
487 .any(|w| matches!(w, ValidationWarning::CoupledRejection { .. })));
488 assert!(result
489 .warnings
490 .iter()
491 .any(|w| matches!(w, ValidationWarning::BrokenDependency { .. })));
492 }
493
494 #[test]
495 fn test_no_warning_when_consistent() {
496 let artifacts = vec![
497 make_artifact(
498 "fs://workspace/a.rs",
499 ArtifactDisposition::Approved,
500 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
501 ),
502 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Approved, vec![]),
503 ];
504
505 let supervisor = SupervisorAgent::new(&artifacts);
506 let result = supervisor.validate(&artifacts);
507
508 assert!(result.valid);
509 assert_eq!(result.warnings.len(), 0);
510 }
511
512 #[test]
513 fn test_self_dependency_error() {
514 let artifacts = vec![make_artifact(
515 "fs://workspace/a.rs",
516 ArtifactDisposition::Pending,
517 vec![("fs://workspace/a.rs", DependencyKind::DependsOn)],
518 )];
519
520 let supervisor = SupervisorAgent::new(&artifacts);
521 let result = supervisor.validate(&artifacts);
522
523 assert!(!result.valid);
524 assert!(!result.errors.is_empty());
526 assert!(result
527 .errors
528 .iter()
529 .any(|e| matches!(e, ValidationError::SelfDependency { .. })));
530 }
531
532 #[test]
533 fn test_cycle_detection() {
534 let artifacts = vec![
535 make_artifact(
536 "fs://workspace/a.rs",
537 ArtifactDisposition::Pending,
538 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
539 ),
540 make_artifact(
541 "fs://workspace/b.rs",
542 ArtifactDisposition::Pending,
543 vec![("fs://workspace/c.rs", DependencyKind::DependsOn)],
544 ),
545 make_artifact(
546 "fs://workspace/c.rs",
547 ArtifactDisposition::Pending,
548 vec![("fs://workspace/a.rs", DependencyKind::DependsOn)],
549 ),
550 ];
551
552 let supervisor = SupervisorAgent::new(&artifacts);
553 let result = supervisor.validate(&artifacts);
554
555 assert!(!result.valid);
556 assert_eq!(result.errors.len(), 1);
557 assert!(matches!(
558 result.errors[0],
559 ValidationError::CyclicDependency { .. }
560 ));
561 }
562
563 #[test]
564 fn test_discuss_blocking_approval() {
565 let artifacts = vec![
566 make_artifact(
567 "fs://workspace/a.rs",
568 ArtifactDisposition::Approved,
569 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
570 ),
571 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Discuss, vec![]),
572 ];
573
574 let supervisor = SupervisorAgent::new(&artifacts);
575 let result = supervisor.validate(&artifacts);
576
577 assert!(result.valid);
578 assert_eq!(result.warnings.len(), 1);
579 assert!(matches!(
580 result.warnings[0],
581 ValidationWarning::DiscussBlockingApproval { .. }
582 ));
583 }
584
585 #[test]
586 fn test_depended_by_relationship() {
587 let artifacts = vec![
588 make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]),
589 make_artifact(
590 "fs://workspace/b.rs",
591 ArtifactDisposition::Pending,
592 vec![("fs://workspace/a.rs", DependencyKind::DependedBy)],
593 ),
594 ];
595
596 let graph = DependencyGraph::from_artifacts(&artifacts);
597
598 assert_eq!(
600 graph.get_dependencies("fs://workspace/a.rs"),
601 vec!["fs://workspace/b.rs"]
602 );
603 assert_eq!(
604 graph.get_dependents("fs://workspace/b.rs"),
605 vec!["fs://workspace/a.rs"]
606 );
607 }
608
609 #[test]
610 fn test_transitive_dependency_chain() {
611 let artifacts = vec![
614 make_artifact(
615 "fs://workspace/a.rs",
616 ArtifactDisposition::Approved,
617 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
618 ),
619 make_artifact(
620 "fs://workspace/b.rs",
621 ArtifactDisposition::Approved,
622 vec![("fs://workspace/c.rs", DependencyKind::DependsOn)],
623 ),
624 make_artifact("fs://workspace/c.rs", ArtifactDisposition::Rejected, vec![]),
625 ];
626
627 let supervisor = SupervisorAgent::new(&artifacts);
628 let result = supervisor.validate(&artifacts);
629
630 assert!(result.valid);
632 assert_eq!(result.warnings.len(), 2);
633 }
634
635 #[test]
636 fn test_disconnected_subgraphs() {
637 let artifacts = vec![
639 make_artifact(
640 "fs://workspace/a.rs",
641 ArtifactDisposition::Approved,
642 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
643 ),
644 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Rejected, vec![]),
645 make_artifact(
646 "fs://workspace/c.rs",
647 ArtifactDisposition::Approved,
648 vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
649 ),
650 make_artifact("fs://workspace/d.rs", ArtifactDisposition::Approved, vec![]),
651 ];
652
653 let supervisor = SupervisorAgent::new(&artifacts);
654 let result = supervisor.validate(&artifacts);
655
656 assert!(result.valid);
658 assert_eq!(result.warnings.len(), 2); }
660
661 #[test]
662 fn test_mixed_dispositions() {
663 let artifacts = vec![
665 make_artifact(
666 "fs://workspace/a.rs",
667 ArtifactDisposition::Approved,
668 vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
669 ),
670 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Discuss, vec![]),
671 make_artifact(
672 "fs://workspace/c.rs",
673 ArtifactDisposition::Approved,
674 vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
675 ),
676 make_artifact("fs://workspace/d.rs", ArtifactDisposition::Pending, vec![]),
677 ];
678
679 let supervisor = SupervisorAgent::new(&artifacts);
680 let result = supervisor.validate(&artifacts);
681
682 assert!(result.valid);
684 assert_eq!(result.warnings.len(), 1);
685 assert!(matches!(
686 result.warnings[0],
687 ValidationWarning::DiscussBlockingApproval { .. }
688 ));
689 }
690
691 #[test]
692 fn test_empty_artifacts() {
693 let artifacts = vec![];
694 let supervisor = SupervisorAgent::new(&artifacts);
695 let result = supervisor.validate(&artifacts);
696
697 assert!(result.valid);
698 assert_eq!(result.warnings.len(), 0);
699 assert_eq!(result.errors.len(), 0);
700 }
701
702 #[test]
703 fn test_all_approved_no_dependencies() {
704 let artifacts = vec![
705 make_artifact("fs://workspace/a.rs", ArtifactDisposition::Approved, vec![]),
706 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Approved, vec![]),
707 make_artifact("fs://workspace/c.rs", ArtifactDisposition::Approved, vec![]),
708 ];
709
710 let supervisor = SupervisorAgent::new(&artifacts);
711 let result = supervisor.validate(&artifacts);
712
713 assert!(result.valid);
714 assert_eq!(result.warnings.len(), 0);
715 assert_eq!(result.errors.len(), 0);
716 }
717
718 #[test]
719 fn test_diamond_dependency() {
720 let artifacts = vec![
722 make_artifact(
723 "fs://workspace/a.rs",
724 ArtifactDisposition::Approved,
725 vec![
726 ("fs://workspace/b.rs", DependencyKind::DependsOn),
727 ("fs://workspace/c.rs", DependencyKind::DependsOn),
728 ],
729 ),
730 make_artifact(
731 "fs://workspace/b.rs",
732 ArtifactDisposition::Approved,
733 vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
734 ),
735 make_artifact(
736 "fs://workspace/c.rs",
737 ArtifactDisposition::Approved,
738 vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
739 ),
740 make_artifact("fs://workspace/d.rs", ArtifactDisposition::Rejected, vec![]),
741 ];
742
743 let supervisor = SupervisorAgent::new(&artifacts);
744 let result = supervisor.validate(&artifacts);
745
746 assert!(result.valid);
748 assert!(result.warnings.len() >= 3); }
750
751 #[test]
754 fn test_plan_validation_empty_artifacts() {
755 let result = validate_against_plan(&[], "v0.3.1", "Plan Lifecycle");
756 assert!(!result.has_artifacts);
757 assert!(!result.is_well_described());
758 assert_eq!(result.notes.len(), 1);
759 assert!(result.notes[0].contains("No artifacts found"));
760 }
761
762 #[test]
763 fn test_plan_validation_undescribed_artifacts() {
764 let artifacts = vec![
765 make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]),
766 make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]),
767 ];
768 let result = validate_against_plan(&artifacts, "v0.3.1", "Plan Lifecycle");
769 assert!(result.has_artifacts);
770 assert_eq!(result.artifact_count, 2);
771 assert_eq!(result.described_count, 0);
772 assert!(!result.is_well_described());
773 }
774
775 #[test]
776 fn test_plan_validation_described_artifacts() {
777 let mut a1 = make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]);
778 a1.explanation_tiers = Some(crate::draft_package::ExplanationTiers {
779 summary: "Added plan validation".to_string(),
780 explanation: String::new(),
781 tags: vec![],
782 related_artifacts: vec![],
783 });
784 let a2 = make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]);
785
786 let result = validate_against_plan(&[a1, a2], "v0.3.1", "Plan Lifecycle");
787 assert!(result.has_artifacts);
788 assert_eq!(result.described_count, 1);
789 assert!(result.is_well_described());
790 assert!(result.notes.iter().any(|n| n.contains("1/2")));
792 }
793
794 #[test]
795 fn test_plan_validation_all_described() {
796 let mut a1 = make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]);
797 a1.rationale = Some("Reason".to_string());
798 let mut a2 = make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]);
799 a2.explanation_tiers = Some(crate::draft_package::ExplanationTiers {
800 summary: "Summary".to_string(),
801 explanation: String::new(),
802 tags: vec![],
803 related_artifacts: vec![],
804 });
805
806 let result = validate_against_plan(&[a1, a2], "v0.3.1", "Plan Lifecycle");
807 assert!(result.is_well_described());
808 assert!(result.notes.is_empty());
809 }
810}