use std::collections::{HashMap, HashSet};
use crate::draft_package::{Artifact, ArtifactDisposition, DependencyKind};
#[cfg(test)]
use crate::draft_package::ChangeDependency;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationResult {
pub valid: bool,
pub warnings: Vec<ValidationWarning>,
pub errors: Vec<ValidationError>,
}
impl ValidationResult {
pub fn valid() -> Self {
Self {
valid: true,
warnings: Vec::new(),
errors: Vec::new(),
}
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub fn add_warning(&mut self, warning: ValidationWarning) {
self.warnings.push(warning);
}
pub fn add_error(&mut self, error: ValidationError) {
self.valid = false;
self.errors.push(error);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationWarning {
CoupledRejection {
artifact: String,
required_by: Vec<String>,
},
BrokenDependency {
artifact: String,
depends_on_rejected: Vec<String>,
},
DiscussBlockingApproval {
artifact: String,
blocking: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
CyclicDependency { cycle: Vec<String> },
SelfDependency { artifact: String },
}
#[derive(Debug, Clone)]
pub struct DependencyGraph {
pub depends_on: HashMap<String, HashSet<String>>,
pub depended_by: HashMap<String, HashSet<String>>,
}
impl DependencyGraph {
pub fn from_artifacts(artifacts: &[Artifact]) -> Self {
let mut depends_on: HashMap<String, HashSet<String>> = HashMap::new();
let mut depended_by: HashMap<String, HashSet<String>> = HashMap::new();
for artifact in artifacts {
let uri = artifact.resource_uri.clone();
depends_on.entry(uri.clone()).or_default();
depended_by.entry(uri.clone()).or_default();
for dep in &artifact.dependencies {
match dep.kind {
DependencyKind::DependsOn => {
depends_on
.entry(uri.clone())
.or_default()
.insert(dep.target_uri.clone());
depended_by
.entry(dep.target_uri.clone())
.or_default()
.insert(uri.clone());
}
DependencyKind::DependedBy => {
depended_by
.entry(uri.clone())
.or_default()
.insert(dep.target_uri.clone());
depends_on
.entry(dep.target_uri.clone())
.or_default()
.insert(uri.clone());
}
}
}
}
Self {
depends_on,
depended_by,
}
}
pub fn get_dependents(&self, uri: &str) -> Vec<String> {
self.depended_by
.get(uri)
.map(|set| set.iter().cloned().collect())
.unwrap_or_default()
}
pub fn get_dependencies(&self, uri: &str) -> Vec<String> {
self.depends_on
.get(uri)
.map(|set| set.iter().cloned().collect())
.unwrap_or_default()
}
pub fn detect_cycles(&self) -> Vec<Vec<String>> {
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
let mut cycles = Vec::new();
for node in self.depends_on.keys() {
if !visited.contains(node) {
self.dfs_cycle_detect(
node,
&mut visited,
&mut rec_stack,
&mut Vec::new(),
&mut cycles,
);
}
}
cycles
}
fn dfs_cycle_detect(
&self,
node: &str,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
path.push(node.to_string());
if let Some(neighbors) = self.depends_on.get(node) {
for neighbor in neighbors {
if !visited.contains(neighbor) {
self.dfs_cycle_detect(neighbor, visited, rec_stack, path, cycles);
} else if rec_stack.contains(neighbor) {
if let Some(start_idx) = path.iter().position(|n| n == neighbor) {
let cycle = path[start_idx..].to_vec();
cycles.push(cycle);
}
}
}
}
path.pop();
rec_stack.remove(node);
}
pub fn detect_self_dependencies(&self) -> Vec<String> {
let mut self_deps = Vec::new();
for (uri, deps) in &self.depends_on {
if deps.contains(uri) {
self_deps.push(uri.clone());
}
}
self_deps
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanValidationResult {
pub has_artifacts: bool,
pub artifact_count: usize,
pub described_count: usize,
pub notes: Vec<String>,
}
impl PlanValidationResult {
pub fn is_well_described(&self) -> bool {
self.has_artifacts && self.described_count > 0
}
}
pub fn validate_against_plan(
artifacts: &[Artifact],
phase_id: &str,
phase_title: &str,
) -> PlanValidationResult {
let artifact_count = artifacts.len();
let described_count = artifacts
.iter()
.filter(|a| {
a.explanation_tiers
.as_ref()
.map(|t| !t.summary.is_empty())
.unwrap_or(false)
|| a.rationale.is_some()
})
.count();
let mut notes = Vec::new();
if artifact_count == 0 {
notes.push(format!(
"No artifacts found for phase {} — {}. Expected code changes.",
phase_id, phase_title
));
}
if artifact_count > 0 && described_count == 0 {
notes.push(format!(
"None of the {} artifacts for phase {} have descriptions. Consider adding a change_summary.json.",
artifact_count, phase_id
));
}
let undescribed = artifact_count.saturating_sub(described_count);
if undescribed > 0 && described_count > 0 {
notes.push(format!(
"{}/{} artifacts for phase {} lack descriptions.",
undescribed, artifact_count, phase_id
));
}
PlanValidationResult {
has_artifacts: artifact_count > 0,
artifact_count,
described_count,
notes,
}
}
pub struct SupervisorAgent {
graph: DependencyGraph,
}
impl SupervisorAgent {
pub fn new(artifacts: &[Artifact]) -> Self {
Self {
graph: DependencyGraph::from_artifacts(artifacts),
}
}
pub fn validate(&self, artifacts: &[Artifact]) -> ValidationResult {
let mut result = ValidationResult::valid();
for cycle in self.graph.detect_cycles() {
result.add_error(ValidationError::CyclicDependency { cycle });
}
for self_dep in self.graph.detect_self_dependencies() {
result.add_error(ValidationError::SelfDependency { artifact: self_dep });
}
let dispositions: HashMap<String, ArtifactDisposition> = artifacts
.iter()
.map(|a| (a.resource_uri.clone(), a.disposition.clone()))
.collect();
for artifact in artifacts {
let uri = &artifact.resource_uri;
let disposition = &artifact.disposition;
match disposition {
ArtifactDisposition::Rejected => {
let dependents = self.graph.get_dependents(uri);
let affected: Vec<String> = dependents
.into_iter()
.filter(|dep_uri| {
matches!(
dispositions.get(dep_uri),
Some(ArtifactDisposition::Approved)
| Some(ArtifactDisposition::Discuss)
| Some(ArtifactDisposition::Pending)
)
})
.collect();
if !affected.is_empty() {
result.add_warning(ValidationWarning::CoupledRejection {
artifact: uri.clone(),
required_by: affected,
});
}
}
ArtifactDisposition::Approved => {
let dependencies = self.graph.get_dependencies(uri);
let rejected_deps: Vec<String> = dependencies
.into_iter()
.filter(|dep_uri| {
matches!(
dispositions.get(dep_uri),
Some(ArtifactDisposition::Rejected)
)
})
.collect();
if !rejected_deps.is_empty() {
result.add_warning(ValidationWarning::BrokenDependency {
artifact: uri.clone(),
depends_on_rejected: rejected_deps,
});
}
}
ArtifactDisposition::Discuss => {
let dependents = self.graph.get_dependents(uri);
let blocked: Vec<String> = dependents
.into_iter()
.filter(|dep_uri| {
matches!(
dispositions.get(dep_uri),
Some(ArtifactDisposition::Approved)
)
})
.collect();
if !blocked.is_empty() {
result.add_warning(ValidationWarning::DiscussBlockingApproval {
artifact: uri.clone(),
blocking: blocked,
});
}
}
ArtifactDisposition::Pending => {
}
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_artifact(
uri: &str,
disposition: ArtifactDisposition,
deps: Vec<(&str, DependencyKind)>,
) -> Artifact {
Artifact {
resource_uri: uri.to_string(),
change_type: crate::draft_package::ChangeType::Modify,
diff_ref: "test".to_string(),
tests_run: Vec::new(),
disposition,
rationale: None,
dependencies: deps
.into_iter()
.map(|(target, kind)| ChangeDependency {
target_uri: target.to_string(),
kind,
})
.collect(),
explanation_tiers: None,
comments: None,
amendment: None,
kind: None,
}
}
#[test]
fn test_dependency_graph_simple() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Pending,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]),
];
let graph = DependencyGraph::from_artifacts(&artifacts);
assert_eq!(
graph.get_dependencies("fs://workspace/a.rs"),
vec!["fs://workspace/b.rs"]
);
assert_eq!(
graph.get_dependents("fs://workspace/b.rs"),
vec!["fs://workspace/a.rs"]
);
}
#[test]
fn test_coupled_rejection_warning() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Rejected, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 2);
assert!(result
.warnings
.iter()
.any(|w| matches!(w, ValidationWarning::CoupledRejection { .. })));
assert!(result
.warnings
.iter()
.any(|w| matches!(w, ValidationWarning::BrokenDependency { .. })));
}
#[test]
fn test_no_warning_when_consistent() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Approved, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 0);
}
#[test]
fn test_self_dependency_error() {
let artifacts = vec![make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Pending,
vec![("fs://workspace/a.rs", DependencyKind::DependsOn)],
)];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(!result.valid);
assert!(!result.errors.is_empty());
assert!(result
.errors
.iter()
.any(|e| matches!(e, ValidationError::SelfDependency { .. })));
}
#[test]
fn test_cycle_detection() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Pending,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact(
"fs://workspace/b.rs",
ArtifactDisposition::Pending,
vec![("fs://workspace/c.rs", DependencyKind::DependsOn)],
),
make_artifact(
"fs://workspace/c.rs",
ArtifactDisposition::Pending,
vec![("fs://workspace/a.rs", DependencyKind::DependsOn)],
),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(!result.valid);
assert_eq!(result.errors.len(), 1);
assert!(matches!(
result.errors[0],
ValidationError::CyclicDependency { .. }
));
}
#[test]
fn test_discuss_blocking_approval() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Discuss, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 1);
assert!(matches!(
result.warnings[0],
ValidationWarning::DiscussBlockingApproval { .. }
));
}
#[test]
fn test_depended_by_relationship() {
let artifacts = vec![
make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]),
make_artifact(
"fs://workspace/b.rs",
ArtifactDisposition::Pending,
vec![("fs://workspace/a.rs", DependencyKind::DependedBy)],
),
];
let graph = DependencyGraph::from_artifacts(&artifacts);
assert_eq!(
graph.get_dependencies("fs://workspace/a.rs"),
vec!["fs://workspace/b.rs"]
);
assert_eq!(
graph.get_dependents("fs://workspace/b.rs"),
vec!["fs://workspace/a.rs"]
);
}
#[test]
fn test_transitive_dependency_chain() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact(
"fs://workspace/b.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/c.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/c.rs", ArtifactDisposition::Rejected, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 2);
}
#[test]
fn test_disconnected_subgraphs() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Rejected, vec![]),
make_artifact(
"fs://workspace/c.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/d.rs", ArtifactDisposition::Approved, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 2); }
#[test]
fn test_mixed_dispositions() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/b.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Discuss, vec![]),
make_artifact(
"fs://workspace/c.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/d.rs", ArtifactDisposition::Pending, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 1);
assert!(matches!(
result.warnings[0],
ValidationWarning::DiscussBlockingApproval { .. }
));
}
#[test]
fn test_empty_artifacts() {
let artifacts = vec![];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 0);
assert_eq!(result.errors.len(), 0);
}
#[test]
fn test_all_approved_no_dependencies() {
let artifacts = vec![
make_artifact("fs://workspace/a.rs", ArtifactDisposition::Approved, vec![]),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Approved, vec![]),
make_artifact("fs://workspace/c.rs", ArtifactDisposition::Approved, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert_eq!(result.warnings.len(), 0);
assert_eq!(result.errors.len(), 0);
}
#[test]
fn test_diamond_dependency() {
let artifacts = vec![
make_artifact(
"fs://workspace/a.rs",
ArtifactDisposition::Approved,
vec![
("fs://workspace/b.rs", DependencyKind::DependsOn),
("fs://workspace/c.rs", DependencyKind::DependsOn),
],
),
make_artifact(
"fs://workspace/b.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
),
make_artifact(
"fs://workspace/c.rs",
ArtifactDisposition::Approved,
vec![("fs://workspace/d.rs", DependencyKind::DependsOn)],
),
make_artifact("fs://workspace/d.rs", ArtifactDisposition::Rejected, vec![]),
];
let supervisor = SupervisorAgent::new(&artifacts);
let result = supervisor.validate(&artifacts);
assert!(result.valid);
assert!(result.warnings.len() >= 3); }
#[test]
fn test_plan_validation_empty_artifacts() {
let result = validate_against_plan(&[], "v0.3.1", "Plan Lifecycle");
assert!(!result.has_artifacts);
assert!(!result.is_well_described());
assert_eq!(result.notes.len(), 1);
assert!(result.notes[0].contains("No artifacts found"));
}
#[test]
fn test_plan_validation_undescribed_artifacts() {
let artifacts = vec![
make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]),
make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]),
];
let result = validate_against_plan(&artifacts, "v0.3.1", "Plan Lifecycle");
assert!(result.has_artifacts);
assert_eq!(result.artifact_count, 2);
assert_eq!(result.described_count, 0);
assert!(!result.is_well_described());
}
#[test]
fn test_plan_validation_described_artifacts() {
let mut a1 = make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]);
a1.explanation_tiers = Some(crate::draft_package::ExplanationTiers {
summary: "Added plan validation".to_string(),
explanation: String::new(),
tags: vec![],
related_artifacts: vec![],
});
let a2 = make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]);
let result = validate_against_plan(&[a1, a2], "v0.3.1", "Plan Lifecycle");
assert!(result.has_artifacts);
assert_eq!(result.described_count, 1);
assert!(result.is_well_described());
assert!(result.notes.iter().any(|n| n.contains("1/2")));
}
#[test]
fn test_plan_validation_all_described() {
let mut a1 = make_artifact("fs://workspace/a.rs", ArtifactDisposition::Pending, vec![]);
a1.rationale = Some("Reason".to_string());
let mut a2 = make_artifact("fs://workspace/b.rs", ArtifactDisposition::Pending, vec![]);
a2.explanation_tiers = Some(crate::draft_package::ExplanationTiers {
summary: "Summary".to_string(),
explanation: String::new(),
tags: vec![],
related_artifacts: vec![],
});
let result = validate_against_plan(&[a1, a2], "v0.3.1", "Plan Lifecycle");
assert!(result.is_well_described());
assert!(result.notes.is_empty());
}
}