Skip to main content

ta_changeset/
supervisor.rs

1// supervisor.rs — Supervisor agent for dependency graph analysis and validation.
2//
3// The supervisor validates artifact dispositions against their dependency graph,
4// warning about coupled rejections and broken dependencies before apply.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::draft_package::{Artifact, ArtifactDisposition, DependencyKind};
9
10#[cfg(test)]
11use crate::draft_package::ChangeDependency;
12
13/// Result of supervisor validation with warnings and errors.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ValidationResult {
16    /// Whether the configuration is valid (no hard errors).
17    pub valid: bool,
18    /// Non-blocking warnings (e.g., rejecting an artifact others depend on).
19    pub warnings: Vec<ValidationWarning>,
20    /// Blocking errors (e.g., cycles in dependency graph).
21    pub errors: Vec<ValidationError>,
22}
23
24impl ValidationResult {
25    /// Create a valid result with no issues.
26    pub fn valid() -> Self {
27        Self {
28            valid: true,
29            warnings: Vec::new(),
30            errors: Vec::new(),
31        }
32    }
33
34    /// Check if there are any warnings.
35    pub fn has_warnings(&self) -> bool {
36        !self.warnings.is_empty()
37    }
38
39    /// Check if there are any errors.
40    pub fn has_errors(&self) -> bool {
41        !self.errors.is_empty()
42    }
43
44    /// Add a warning to the result.
45    pub fn add_warning(&mut self, warning: ValidationWarning) {
46        self.warnings.push(warning);
47    }
48
49    /// Add an error to the result (sets valid = false).
50    pub fn add_error(&mut self, error: ValidationError) {
51        self.valid = false;
52        self.errors.push(error);
53    }
54}
55
56/// Warning about potentially problematic dispositions.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ValidationWarning {
59    /// Rejecting an artifact that others depend on.
60    CoupledRejection {
61        artifact: String,
62        required_by: Vec<String>,
63    },
64    /// Approving an artifact that depends on rejected ones.
65    BrokenDependency {
66        artifact: String,
67        depends_on_rejected: Vec<String>,
68    },
69    /// An artifact marked "discuss" is blocking others.
70    DiscussBlockingApproval {
71        artifact: String,
72        blocking: Vec<String>,
73    },
74}
75
76/// Hard errors in the dependency graph or configuration.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ValidationError {
79    /// Circular dependency detected.
80    CyclicDependency { cycle: Vec<String> },
81    /// Self-dependency (artifact depends on itself).
82    SelfDependency { artifact: String },
83}
84
85/// Dependency graph built from artifact dependencies.
86#[derive(Debug, Clone)]
87pub struct DependencyGraph {
88    /// Adjacency list: artifact URI -> set of artifacts it depends on.
89    pub depends_on: HashMap<String, HashSet<String>>,
90    /// Reverse adjacency list: artifact URI -> set of artifacts that depend on it.
91    pub depended_by: HashMap<String, HashSet<String>>,
92}
93
94impl DependencyGraph {
95    /// Build a dependency graph from a list of artifacts.
96    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            // Initialize entries for this artifact
104            depends_on.entry(uri.clone()).or_default();
105            depended_by.entry(uri.clone()).or_default();
106
107            // Process dependencies
108            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    /// Get all artifacts that directly depend on the given artifact.
141    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    /// Get all artifacts that the given artifact directly depends on.
149    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    /// Detect cycles in the dependency graph using DFS.
157    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                    // Found a cycle - extract it from path
195                    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    /// Check for self-dependencies (artifact depends on itself).
208    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/// Result of plan validation — checking if completed work matches plan expectations.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct PlanValidationResult {
224    /// Whether the draft has content (at least one artifact).
225    pub has_artifacts: bool,
226    /// Number of artifacts in the draft.
227    pub artifact_count: usize,
228    /// Number of artifacts with descriptions (what/rationale populated).
229    pub described_count: usize,
230    /// Informational messages about plan alignment.
231    pub notes: Vec<String>,
232}
233
234impl PlanValidationResult {
235    /// Check if the draft has enough described artifacts to be considered complete.
236    pub fn is_well_described(&self) -> bool {
237        self.has_artifacts && self.described_count > 0
238    }
239}
240
241/// Validate artifacts against plan expectations.
242///
243/// Checks that the draft has meaningful content and that artifacts are described.
244/// This is called by `ta draft build` when a goal has a plan_phase.
245pub 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
294/// Supervisor agent that validates artifact dispositions against dependencies.
295pub struct SupervisorAgent {
296    graph: DependencyGraph,
297}
298
299impl SupervisorAgent {
300    /// Create a new supervisor from a list of artifacts.
301    pub fn new(artifacts: &[Artifact]) -> Self {
302        Self {
303            graph: DependencyGraph::from_artifacts(artifacts),
304        }
305    }
306
307    /// Validate artifact dispositions against the dependency graph.
308    ///
309    /// Returns a ValidationResult with warnings about:
310    /// - Rejecting artifacts that others depend on (coupled rejections)
311    /// - Approving artifacts that depend on rejected ones (broken dependencies)
312    /// - "Discuss" artifacts blocking approvals
313    ///
314    /// And errors for:
315    /// - Cyclic dependencies
316    /// - Self-dependencies
317    pub fn validate(&self, artifacts: &[Artifact]) -> ValidationResult {
318        let mut result = ValidationResult::valid();
319
320        // Check for structural errors first
321        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        // Build disposition map for quick lookup
330        let dispositions: HashMap<String, ArtifactDisposition> = artifacts
331            .iter()
332            .map(|a| (a.resource_uri.clone(), a.disposition.clone()))
333            .collect();
334
335        // Check for coupled rejections and broken dependencies
336        for artifact in artifacts {
337            let uri = &artifact.resource_uri;
338            let disposition = &artifact.disposition;
339
340            match disposition {
341                ArtifactDisposition::Rejected => {
342                    // Check if any approved/discuss artifacts depend on this one
343                    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                    // Check if this artifact depends on any rejected ones
365                    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                    // Check if any approved artifacts depend on this discuss item
385                    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                    // Pending is neutral - no validation needed
405                }
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        // Should warn about both: rejecting B that A depends on, and approving A that depends on rejected B
484        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        // Self-dependency is detected as both a self-dep and a cycle
525        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        // b.rs is depended by a.rs means a.rs depends on b.rs
599        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        // A → B → C: rejecting C should warn about B (direct dependency)
612        // A won't be warned because its direct dependency (B) is approved
613        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        // Should have 2 warnings: C coupled rejection (breaks B) + B broken dependency (depends on rejected C)
631        assert!(result.valid);
632        assert_eq!(result.warnings.len(), 2);
633    }
634
635    #[test]
636    fn test_disconnected_subgraphs() {
637        // Two independent chains: A→B and C→D
638        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        // Should only warn about A→B, not C→D
657        assert!(result.valid);
658        assert_eq!(result.warnings.len(), 2); // B coupled rejection + A broken dependency
659    }
660
661    #[test]
662    fn test_mixed_dispositions() {
663        // Complex scenario: some approved, some rejected, some pending, some discuss
664        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        // Should warn about discuss blocking approval
683        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        // Diamond pattern: A→B, A→C, B→D, C→D
721        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        // Should warn about D being rejected but depended on by B and C
747        assert!(result.valid);
748        assert!(result.warnings.len() >= 3); // At least coupled rejection for D and broken deps for B, C
749    }
750
751    // ── Plan validation tests ──
752
753    #[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        // Should note that 1/2 artifacts lack descriptions.
791        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}