Skip to main content

chant/spec/
parse.rs

1//! Spec parsing functions.
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8use super::frontmatter::{SpecFrontmatter, SpecStatus};
9use crate::spec::normalize_model_name;
10
11#[derive(Debug, Clone)]
12pub struct Spec {
13    pub id: String,
14    pub frontmatter: SpecFrontmatter,
15    pub title: Option<String>,
16    pub body: String,
17}
18
19/// Split content into frontmatter and body.
20///
21/// If the content starts with `---`, extracts the YAML frontmatter between
22/// the first and second `---` delimiters, and returns the body after.
23/// Otherwise returns None for frontmatter and the entire content as body.
24pub fn split_frontmatter(content: &str) -> (Option<String>, &str) {
25    let content = content.trim();
26
27    if !content.starts_with("---") {
28        return (None, content);
29    }
30
31    let rest = &content[3..];
32    if let Some(end) = rest.find("---") {
33        let frontmatter = rest[..end].to_string();
34        let body = rest[end + 3..].trim_start();
35        (Some(frontmatter), body)
36    } else {
37        (None, content)
38    }
39}
40
41fn extract_title(body: &str) -> Option<String> {
42    for line in body.lines() {
43        let trimmed = line.trim();
44        if let Some(title) = trimmed.strip_prefix("# ") {
45            return Some(title.to_string());
46        }
47    }
48    None
49}
50
51fn branch_exists(branch: &str) -> Result<bool> {
52    let output = Command::new("git")
53        .args(["rev-parse", "--verify", branch])
54        .output()
55        .context("Failed to check if branch exists")?;
56
57    Ok(output.status.success())
58}
59
60fn read_spec_from_branch(spec_id: &str, branch: &str) -> Result<Spec> {
61    let spec_path = format!(".chant/specs/{}.md", spec_id);
62
63    // Read spec content from branch
64    let output = Command::new("git")
65        .args(["show", &format!("{}:{}", branch, spec_path)])
66        .output()
67        .context(format!("Failed to read spec from branch {}", branch))?;
68
69    if !output.status.success() {
70        let stderr = String::from_utf8_lossy(&output.stderr);
71        anyhow::bail!("git show failed: {}", stderr);
72    }
73
74    let content =
75        String::from_utf8(output.stdout).context("Failed to parse spec content as UTF-8")?;
76
77    Spec::parse(spec_id, &content)
78}
79
80impl Spec {
81    /// Set the spec status using validated transitions.
82    /// This method validates the transition through the state machine.
83    ///
84    /// INTERNAL USE ONLY: Use transition helpers in state_machine.rs from cmd/ modules.
85    pub(crate) fn set_status(
86        &mut self,
87        new_status: SpecStatus,
88    ) -> Result<(), super::state_machine::TransitionError> {
89        super::state_machine::TransitionBuilder::new(self).to(new_status)
90    }
91
92    /// Parse a spec from file content.
93    pub fn parse(id: &str, content: &str) -> Result<Self> {
94        let (frontmatter_str, body) = split_frontmatter(content);
95
96        let mut frontmatter: SpecFrontmatter = if let Some(fm) = frontmatter_str {
97            serde_yaml::from_str(&fm).context("Failed to parse spec frontmatter")?
98        } else {
99            SpecFrontmatter::default()
100        };
101
102        // Normalize model name if present
103        if let Some(model) = &frontmatter.model {
104            frontmatter.model = Some(normalize_model_name(model));
105        }
106
107        // Extract title from first heading
108        let title = extract_title(body);
109
110        Ok(Self {
111            id: id.to_string(),
112            frontmatter,
113            title,
114            body: body.to_string(),
115        })
116    }
117
118    /// Load a spec from a file path.
119    pub fn load(path: &Path) -> Result<Self> {
120        let content = fs::read_to_string(path)
121            .with_context(|| format!("Failed to read spec from {}", path.display()))?;
122
123        let id = path
124            .file_stem()
125            .and_then(|s| s.to_str())
126            .ok_or_else(|| anyhow::anyhow!("Invalid spec filename"))?;
127
128        Self::parse(id, &content)
129    }
130
131    /// Load only the frontmatter from a spec file, without parsing the body.
132    /// This is more efficient for operations that only need metadata (status, dependencies, etc.)
133    /// and don't need the full spec body content.
134    pub fn load_frontmatter_only(path: &Path) -> Result<Self> {
135        use std::io::{BufRead, BufReader};
136
137        let file = fs::File::open(path)
138            .with_context(|| format!("Failed to read spec from {}", path.display()))?;
139        let reader = BufReader::new(file);
140
141        let id = path
142            .file_stem()
143            .and_then(|s| s.to_str())
144            .ok_or_else(|| anyhow::anyhow!("Invalid spec filename"))?;
145
146        let mut frontmatter_str = String::new();
147        let mut in_frontmatter = false;
148        let mut delimiter_count = 0;
149
150        // Read until we find the second "---" delimiter
151        for line in reader.lines() {
152            let line = line?;
153
154            if line.trim() == "---" {
155                delimiter_count += 1;
156                if delimiter_count == 2 {
157                    break;
158                }
159                in_frontmatter = true;
160                continue;
161            }
162
163            if in_frontmatter {
164                frontmatter_str.push_str(&line);
165                frontmatter_str.push('\n');
166            }
167        }
168
169        let mut frontmatter: SpecFrontmatter = if delimiter_count == 2 {
170            serde_yaml::from_str(&frontmatter_str).context("Failed to parse spec frontmatter")?
171        } else {
172            SpecFrontmatter::default()
173        };
174
175        // Normalize model name if present
176        if let Some(model) = &frontmatter.model {
177            frontmatter.model = Some(normalize_model_name(model));
178        }
179
180        // For frontmatter-only loading, we don't need the body or title
181        Ok(Self {
182            id: id.to_string(),
183            frontmatter,
184            title: None,
185            body: String::new(),
186        })
187    }
188
189    /// Load a spec, optionally resolving from its working branch.
190    ///
191    /// If the spec is in_progress and has a branch (frontmatter.branch or chant/{id}),
192    /// attempt to read the spec content from that branch for live progress.
193    pub fn load_with_branch_resolution(spec_path: &Path) -> Result<Self> {
194        let spec = Self::load(spec_path)?;
195
196        // Only resolve for in_progress specs
197        if spec.frontmatter.status != SpecStatus::InProgress {
198            return Ok(spec);
199        }
200
201        // Try to find the working branch
202        let branch_name = spec
203            .frontmatter
204            .branch
205            .clone()
206            .unwrap_or_else(|| format!("chant/{}", spec.id));
207
208        // Check if branch exists
209        if !branch_exists(&branch_name)? {
210            return Ok(spec);
211        }
212
213        // Read spec from branch
214        match read_spec_from_branch(&spec.id, &branch_name) {
215            Ok(branch_spec) => Ok(branch_spec),
216            Err(_) => Ok(spec), // Fall back to main version
217        }
218    }
219
220    /// Save the spec to a file.
221    pub fn save(&self, path: &Path) -> Result<()> {
222        let frontmatter = serde_yaml::to_string(&self.frontmatter)?;
223        let content = format!("---\n{}---\n{}", frontmatter, self.body);
224        let tmp_path = path.with_extension("md.tmp");
225        fs::write(&tmp_path, &content)?;
226        fs::rename(&tmp_path, path)?;
227        Ok(())
228    }
229
230    /// Count unchecked checkboxes (`- [ ]`) in the Acceptance Criteria section only.
231    /// Returns the count of unchecked items in that section, skipping code fences.
232    /// Uses the LAST `## Acceptance Criteria` heading outside code fences.
233    pub fn count_unchecked_checkboxes(&self) -> usize {
234        let acceptance_criteria_marker = "## Acceptance Criteria";
235
236        // First pass: find the line number of the LAST AC heading outside code fences
237        let mut in_code_fence = false;
238        let mut last_ac_line: Option<usize> = None;
239
240        for (line_num, line) in self.body.lines().enumerate() {
241            let trimmed = line.trim_start();
242
243            if trimmed.starts_with("```") {
244                in_code_fence = !in_code_fence;
245                continue;
246            }
247
248            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
249                last_ac_line = Some(line_num);
250            }
251        }
252
253        let Some(ac_start) = last_ac_line else {
254            return 0;
255        };
256
257        // Second pass: count checkboxes from the AC section until next ## heading
258        let mut in_code_fence = false;
259        let mut in_ac_section = false;
260        let mut count = 0;
261
262        for (line_num, line) in self.body.lines().enumerate() {
263            let trimmed = line.trim_start();
264
265            if trimmed.starts_with("```") {
266                in_code_fence = !in_code_fence;
267                continue;
268            }
269
270            if in_code_fence {
271                continue;
272            }
273
274            // Start counting at the last AC heading we found
275            if line_num == ac_start {
276                in_ac_section = true;
277                continue;
278            }
279
280            // Stop at the next ## heading after our AC section
281            if in_ac_section && trimmed.starts_with("## ") {
282                break;
283            }
284
285            if in_ac_section && line.contains("- [ ]") {
286                count += line.matches("- [ ]").count();
287            }
288        }
289
290        count
291    }
292
293    /// Count total checkboxes (both checked and unchecked) in the Acceptance Criteria section.
294    /// Used to assess spec complexity.
295    pub fn count_total_checkboxes(&self) -> usize {
296        let acceptance_criteria_marker = "## Acceptance Criteria";
297
298        // First pass: find the line number of the LAST AC heading outside code fences
299        let mut in_code_fence = false;
300        let mut last_ac_line: Option<usize> = None;
301
302        for (line_num, line) in self.body.lines().enumerate() {
303            let trimmed = line.trim_start();
304
305            if trimmed.starts_with("```") {
306                in_code_fence = !in_code_fence;
307                continue;
308            }
309
310            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
311                last_ac_line = Some(line_num);
312            }
313        }
314
315        let Some(ac_start) = last_ac_line else {
316            return 0;
317        };
318
319        // Second pass: count all checkboxes from the AC section until next ## heading
320        let mut in_code_fence = false;
321        let mut in_ac_section = false;
322        let mut count = 0;
323
324        for (line_num, line) in self.body.lines().enumerate() {
325            let trimmed = line.trim_start();
326
327            if trimmed.starts_with("```") {
328                in_code_fence = !in_code_fence;
329                continue;
330            }
331
332            if in_code_fence {
333                continue;
334            }
335
336            if line_num == ac_start {
337                in_ac_section = true;
338                continue;
339            }
340
341            if in_ac_section && trimmed.starts_with("## ") {
342                break;
343            }
344
345            // Count both unchecked and checked checkboxes
346            if in_ac_section {
347                count += line.matches("- [ ]").count();
348                count += line.matches("- [x]").count();
349                count += line.matches("- [X]").count();
350            }
351        }
352
353        count
354    }
355
356    /// Add derived fields to the spec's frontmatter.
357    /// Updates the frontmatter with the provided derived fields.
358    pub fn add_derived_fields(&mut self, fields: std::collections::HashMap<String, String>) {
359        let mut derived_field_names = Vec::new();
360
361        for (key, value) in fields {
362            // Track which fields were derived
363            derived_field_names.push(key.clone());
364
365            // Handle specific known derived fields that map to frontmatter
366            match key.as_str() {
367                "labels" => {
368                    let label_vec = value.split(',').map(|s| s.trim().to_string()).collect();
369                    self.frontmatter.labels = Some(label_vec);
370                }
371                "context" => {
372                    let context_vec = value.split(',').map(|s| s.trim().to_string()).collect();
373                    self.frontmatter.context = Some(context_vec);
374                }
375                _ => {
376                    if self.frontmatter.context.is_none() {
377                        self.frontmatter.context = Some(vec![]);
378                    }
379                    if let Some(ref mut ctx) = self.frontmatter.context {
380                        ctx.push(format!("derived_{}={}", key, value));
381                    }
382                }
383            }
384        }
385
386        // Update the derived_fields tracking
387        if !derived_field_names.is_empty() {
388            self.frontmatter.derived_fields = Some(derived_field_names);
389        }
390    }
391
392    /// Auto-check all acceptance criteria checkboxes in the spec body.
393    /// Replaces all `- [ ]` with `- [x]` in the Acceptance Criteria section.
394    /// Returns true if any checkboxes were checked, false otherwise.
395    pub fn auto_check_acceptance_criteria(&mut self) -> bool {
396        let acceptance_criteria_marker = "## Acceptance Criteria";
397        let mut in_code_fence = false;
398        let mut last_ac_line: Option<usize> = None;
399
400        // Find the last AC heading outside code fences
401        for (line_num, line) in self.body.lines().enumerate() {
402            let trimmed = line.trim_start();
403            if trimmed.starts_with("```") {
404                in_code_fence = !in_code_fence;
405                continue;
406            }
407            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
408                last_ac_line = Some(line_num);
409            }
410        }
411
412        let Some(ac_start) = last_ac_line else {
413            return false;
414        };
415
416        // Replace unchecked boxes in the AC section
417        let mut in_code_fence = false;
418        let mut in_ac_section = false;
419        let mut modified = false;
420        let mut new_body = String::new();
421
422        for (line_num, line) in self.body.lines().enumerate() {
423            let trimmed = line.trim_start();
424
425            if trimmed.starts_with("```") {
426                in_code_fence = !in_code_fence;
427            }
428
429            if line_num == ac_start {
430                in_ac_section = true;
431            }
432
433            if in_ac_section && !in_code_fence && trimmed.starts_with("## ") && line_num != ac_start
434            {
435                in_ac_section = false;
436            }
437
438            if in_ac_section && !in_code_fence && line.contains("- [ ]") {
439                new_body.push_str(&line.replace("- [ ]", "- [x]"));
440                modified = true;
441            } else {
442                new_body.push_str(line);
443            }
444            new_body.push('\n');
445        }
446
447        if modified {
448            self.body = new_body.trim_end().to_string();
449        }
450
451        modified
452    }
453
454    /// Check if this spec has acceptance criteria.
455    /// Returns true if the spec body contains an "## Acceptance Criteria" section
456    /// with at least one checkbox item.
457    pub fn has_acceptance_criteria(&self) -> bool {
458        let acceptance_criteria_marker = "## Acceptance Criteria";
459        let mut in_ac_section = false;
460        let mut in_code_fence = false;
461
462        for line in self.body.lines() {
463            let trimmed = line.trim_start();
464
465            if trimmed.starts_with("```") {
466                in_code_fence = !in_code_fence;
467            }
468
469            if !in_code_fence && trimmed.starts_with(acceptance_criteria_marker) {
470                in_ac_section = true;
471                continue;
472            }
473
474            if in_ac_section && trimmed.starts_with("## ") {
475                break;
476            }
477
478            if in_ac_section
479                && (trimmed.starts_with("- [ ] ")
480                    || trimmed.starts_with("- [x] ")
481                    || trimmed.starts_with("- [X] "))
482            {
483                return true;
484            }
485        }
486
487        false
488    }
489
490    /// Check if this spec has unmet dependencies or approval requirements that would block it.
491    pub fn is_blocked(&self, all_specs: &[Spec]) -> bool {
492        // Check the spec's own dependencies
493        if let Some(deps) = &self.frontmatter.depends_on {
494            for dep_id in deps {
495                let dep = all_specs.iter().find(|s| s.id == *dep_id);
496                match dep {
497                    Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
498                    Some(d) if d.frontmatter.status == SpecStatus::Cancelled => continue,
499                    _ => return true,
500                }
501            }
502        }
503
504        // If this is a member spec, also check the driver's dependencies
505        if let Some(driver_id) = crate::spec_group::extract_driver_id(&self.id) {
506            if let Some(driver_spec) = all_specs.iter().find(|s| s.id == driver_id) {
507                if let Some(driver_deps) = &driver_spec.frontmatter.depends_on {
508                    for dep_id in driver_deps {
509                        // Skip dependencies that are siblings or self (members of the same driver)
510                        if crate::spec_group::is_member_of(dep_id, &driver_id) {
511                            continue;
512                        }
513
514                        let dep = all_specs.iter().find(|s| s.id == *dep_id);
515                        match dep {
516                            Some(d) if d.frontmatter.status == SpecStatus::Completed => continue,
517                            Some(d) if d.frontmatter.status == SpecStatus::Cancelled => continue,
518                            _ => return true,
519                        }
520                    }
521                }
522            }
523        }
524
525        if self.frontmatter.status == SpecStatus::Pending && self.requires_approval() {
526            return true;
527        }
528
529        false
530    }
531
532    /// Check if this spec is ready to execute.
533    pub fn is_ready(&self, all_specs: &[Spec]) -> bool {
534        use crate::spec_group::{all_prior_siblings_completed, is_member_of};
535
536        // Allow both Pending and Failed specs to be considered "ready"
537        // Failed specs can be retried if their dependencies are met
538        if self.frontmatter.status != SpecStatus::Pending
539            && self.frontmatter.status != SpecStatus::Failed
540        {
541            return false;
542        }
543
544        if self.is_blocked(all_specs) {
545            return false;
546        }
547
548        if !all_prior_siblings_completed(&self.id, all_specs) {
549            return false;
550        }
551
552        let members: Vec<_> = all_specs
553            .iter()
554            .filter(|s| is_member_of(&s.id, &self.id))
555            .collect();
556
557        if !members.is_empty() && self.has_acceptance_criteria() {
558            for member in members {
559                if member.frontmatter.status != SpecStatus::Completed {
560                    return false;
561                }
562            }
563        }
564
565        true
566    }
567
568    /// Get the list of dependencies that are blocking this spec.
569    pub fn get_blocking_dependencies(
570        &self,
571        all_specs: &[Spec],
572        _specs_dir: &Path,
573    ) -> Vec<super::frontmatter::BlockingDependency> {
574        use super::frontmatter::BlockingDependency;
575        use crate::spec_group::{extract_driver_id, extract_member_number};
576        use std::collections::HashMap;
577
578        let mut blockers = Vec::new();
579
580        // Build a HashMap index from the already-loaded all_specs slice
581        let spec_map: HashMap<&str, &Spec> = all_specs.iter().map(|s| (s.id.as_str(), s)).collect();
582
583        if let Some(deps) = &self.frontmatter.depends_on {
584            for dep_id in deps {
585                if let Some(&spec) = spec_map.get(dep_id.as_str()) {
586                    // Only add if not completed
587                    if spec.frontmatter.status != SpecStatus::Completed {
588                        blockers.push(BlockingDependency {
589                            spec_id: spec.id.clone(),
590                            title: spec.title.clone(),
591                            status: spec.frontmatter.status.clone(),
592                            completed_at: spec.frontmatter.completed_at.clone(),
593                            is_sibling: false,
594                        });
595                    }
596                } else {
597                    blockers.push(BlockingDependency {
598                        spec_id: dep_id.clone(),
599                        title: None,
600                        status: SpecStatus::Pending,
601                        completed_at: None,
602                        is_sibling: false,
603                    });
604                }
605            }
606        }
607
608        if let Some(driver_id) = extract_driver_id(&self.id) {
609            if let Some(member_num) = extract_member_number(&self.id) {
610                for i in 1..member_num {
611                    let sibling_id = format!("{}.{}", driver_id, i);
612                    if let Some(&spec) = spec_map.get(sibling_id.as_str()) {
613                        if spec.frontmatter.status != SpecStatus::Completed {
614                            blockers.push(BlockingDependency {
615                                spec_id: spec.id.clone(),
616                                title: spec.title.clone(),
617                                status: spec.frontmatter.status.clone(),
618                                completed_at: spec.frontmatter.completed_at.clone(),
619                                is_sibling: true,
620                            });
621                        }
622                    } else {
623                        blockers.push(BlockingDependency {
624                            spec_id: sibling_id,
625                            title: None,
626                            status: SpecStatus::Pending,
627                            completed_at: None,
628                            is_sibling: true,
629                        });
630                    }
631                }
632            }
633        }
634
635        blockers
636    }
637
638    /// Check if the spec's frontmatter contains a specific field.
639    pub fn has_frontmatter_field(&self, field: &str) -> bool {
640        match field {
641            "type" => true,
642            "status" => true,
643            "depends_on" => self.frontmatter.depends_on.is_some(),
644            "labels" => self.frontmatter.labels.is_some(),
645            "target_files" => self.frontmatter.target_files.is_some(),
646            "context" => self.frontmatter.context.is_some(),
647            "prompt" => self.frontmatter.prompt.is_some(),
648            "branch" => self.frontmatter.branch.is_some(),
649            "commits" => self.frontmatter.commits.is_some(),
650            "completed_at" => self.frontmatter.completed_at.is_some(),
651            "model" => self.frontmatter.model.is_some(),
652            "tracks" => self.frontmatter.tracks.is_some(),
653            "informed_by" => self.frontmatter.informed_by.is_some(),
654            "origin" => self.frontmatter.origin.is_some(),
655            "schedule" => self.frontmatter.schedule.is_some(),
656            "source_branch" => self.frontmatter.source_branch.is_some(),
657            "target_branch" => self.frontmatter.target_branch.is_some(),
658            "conflicting_files" => self.frontmatter.conflicting_files.is_some(),
659            "blocked_specs" => self.frontmatter.blocked_specs.is_some(),
660            "original_spec" => self.frontmatter.original_spec.is_some(),
661            "last_verified" => self.frontmatter.last_verified.is_some(),
662            "verification_status" => self.frontmatter.verification_status.is_some(),
663            "verification_failures" => self.frontmatter.verification_failures.is_some(),
664            "replayed_at" => self.frontmatter.replayed_at.is_some(),
665            "replay_count" => self.frontmatter.replay_count.is_some(),
666            "original_completed_at" => self.frontmatter.original_completed_at.is_some(),
667            "approval" => self.frontmatter.approval.is_some(),
668            "members" => self.frontmatter.members.is_some(),
669            "output_schema" => self.frontmatter.output_schema.is_some(),
670            _ => false,
671        }
672    }
673
674    /// Check if this spec requires approval before work can begin.
675    pub fn requires_approval(&self) -> bool {
676        use super::frontmatter::ApprovalStatus;
677
678        if let Some(ref approval) = self.frontmatter.approval {
679            approval.required && approval.status != ApprovalStatus::Approved
680        } else {
681            false
682        }
683    }
684
685    /// Check if this spec has been approved.
686    pub fn is_approved(&self) -> bool {
687        use super::frontmatter::ApprovalStatus;
688
689        if let Some(ref approval) = self.frontmatter.approval {
690            approval.status == ApprovalStatus::Approved
691        } else {
692            true
693        }
694    }
695
696    /// Check if this spec has been rejected.
697    pub fn is_rejected(&self) -> bool {
698        use super::frontmatter::ApprovalStatus;
699
700        if let Some(ref approval) = self.frontmatter.approval {
701            approval.status == ApprovalStatus::Rejected
702        } else {
703            false
704        }
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use crate::spec::SpecType;
712
713    #[test]
714    fn test_is_blocked_circular_dependency_with_driver_members() {
715        // Reproduce the bug: driver depends on its own members, causing circular blocking
716
717        // Create a driver spec that depends on its member (as set by chant split)
718        let driver = Spec {
719            id: "2026-02-13-001-abc".to_string(),
720            frontmatter: SpecFrontmatter {
721                status: SpecStatus::Pending,
722                r#type: SpecType::Group,
723                depends_on: Some(vec!["2026-02-13-001-abc.1".to_string()]),
724                ..Default::default()
725            },
726            title: Some("Driver spec".to_string()),
727            body: "# Driver spec\n\nBody.".to_string(),
728        };
729
730        // Create a member spec with no dependencies
731        let member = Spec {
732            id: "2026-02-13-001-abc.1".to_string(),
733            frontmatter: SpecFrontmatter {
734                status: SpecStatus::Pending,
735                depends_on: None,
736                ..Default::default()
737            },
738            title: Some("Member spec".to_string()),
739            body: "# Member spec\n\nBody.".to_string(),
740        };
741
742        let all_specs = vec![driver.clone(), member.clone()];
743
744        // Member should NOT be blocked by the driver's dependency on itself
745        assert!(
746            !member.is_blocked(&all_specs),
747            "Member spec should not be blocked by driver's self-reference"
748        );
749    }
750
751    #[test]
752    fn test_is_blocked_external_driver_dependency() {
753        // Member specs should still be blocked by external dependencies on the driver
754
755        // Create an external spec that the driver depends on
756        let external = Spec {
757            id: "2026-02-13-000-xyz".to_string(),
758            frontmatter: SpecFrontmatter {
759                status: SpecStatus::Pending,
760                ..Default::default()
761            },
762            title: Some("External spec".to_string()),
763            body: "# External spec\n\nBody.".to_string(),
764        };
765
766        // Create a driver spec that depends on both an external spec and its member
767        let driver = Spec {
768            id: "2026-02-13-001-abc".to_string(),
769            frontmatter: SpecFrontmatter {
770                status: SpecStatus::Pending,
771                r#type: SpecType::Group,
772                depends_on: Some(vec![
773                    "2026-02-13-000-xyz".to_string(),   // external dependency
774                    "2026-02-13-001-abc.1".to_string(), // member (should be ignored)
775                ]),
776                ..Default::default()
777            },
778            title: Some("Driver spec".to_string()),
779            body: "# Driver spec\n\nBody.".to_string(),
780        };
781
782        // Create a member spec
783        let member = Spec {
784            id: "2026-02-13-001-abc.1".to_string(),
785            frontmatter: SpecFrontmatter {
786                status: SpecStatus::Pending,
787                depends_on: None,
788                ..Default::default()
789            },
790            title: Some("Member spec".to_string()),
791            body: "# Member spec\n\nBody.".to_string(),
792        };
793
794        let all_specs = vec![external.clone(), driver.clone(), member.clone()];
795
796        // Member SHOULD be blocked by external dependency
797        assert!(
798            member.is_blocked(&all_specs),
799            "Member spec should be blocked by driver's external dependency"
800        );
801
802        // Now complete the external dependency
803        let external_completed = Spec {
804            id: "2026-02-13-000-xyz".to_string(),
805            frontmatter: SpecFrontmatter {
806                status: SpecStatus::Completed,
807                ..Default::default()
808            },
809            title: Some("External spec".to_string()),
810            body: "# External spec\n\nBody.".to_string(),
811        };
812
813        let all_specs_updated = vec![external_completed, driver.clone(), member.clone()];
814
815        // Member should NOT be blocked anymore
816        assert!(
817            !member.is_blocked(&all_specs_updated),
818            "Member spec should not be blocked after external dependency is completed"
819        );
820    }
821
822    #[test]
823    fn test_is_blocked_multiple_siblings() {
824        // Driver depends on multiple members - none should block each other
825
826        let driver = Spec {
827            id: "2026-02-13-002-def".to_string(),
828            frontmatter: SpecFrontmatter {
829                status: SpecStatus::Pending,
830                r#type: SpecType::Group,
831                depends_on: Some(vec![
832                    "2026-02-13-002-def.1".to_string(),
833                    "2026-02-13-002-def.2".to_string(),
834                    "2026-02-13-002-def.3".to_string(),
835                ]),
836                ..Default::default()
837            },
838            title: Some("Driver spec".to_string()),
839            body: "# Driver spec\n\nBody.".to_string(),
840        };
841
842        let member1 = Spec {
843            id: "2026-02-13-002-def.1".to_string(),
844            frontmatter: SpecFrontmatter {
845                status: SpecStatus::Pending,
846                depends_on: None,
847                ..Default::default()
848            },
849            title: Some("Member 1".to_string()),
850            body: "# Member 1\n\nBody.".to_string(),
851        };
852
853        let member2 = Spec {
854            id: "2026-02-13-002-def.2".to_string(),
855            frontmatter: SpecFrontmatter {
856                status: SpecStatus::Pending,
857                depends_on: None,
858                ..Default::default()
859            },
860            title: Some("Member 2".to_string()),
861            body: "# Member 2\n\nBody.".to_string(),
862        };
863
864        let member3 = Spec {
865            id: "2026-02-13-002-def.3".to_string(),
866            frontmatter: SpecFrontmatter {
867                status: SpecStatus::Pending,
868                depends_on: None,
869                ..Default::default()
870            },
871            title: Some("Member 3".to_string()),
872            body: "# Member 3\n\nBody.".to_string(),
873        };
874
875        let all_specs = vec![
876            driver.clone(),
877            member1.clone(),
878            member2.clone(),
879            member3.clone(),
880        ];
881
882        // None of the members should be blocked by the driver's dependency on siblings
883        assert!(
884            !member1.is_blocked(&all_specs),
885            "Member 1 should not be blocked by driver's dependency on siblings"
886        );
887        assert!(
888            !member2.is_blocked(&all_specs),
889            "Member 2 should not be blocked by driver's dependency on siblings"
890        );
891        assert!(
892            !member3.is_blocked(&all_specs),
893            "Member 3 should not be blocked by driver's dependency on siblings"
894        );
895    }
896}