Skip to main content

speccoder_core/
graph.rs

1//! Task dependencies from what `tasks.md` documents, and which tasks are ready.
2//!
3//! Edges come only from structure and from IDs written in task text; nothing
4//! is inferred:
5//!
6//! - **Phases.** Foundational waits on all earlier phases; each user-story
7//!   phase waits on the earlier *non-story* phases only, so stories are
8//!   independent of each other; polish, convergence and unrecognised phases
9//!   wait on all earlier phases.
10//! - **Subsections.** A `###` subsection waits on the earlier subsections of
11//!   its phase (tests before implementation).
12//! - **`[P]` runs.** A `[P]` task waits on the previous non-`[P]` task; a
13//!   non-`[P]` task waits on the whole preceding `[P]` run, or on the previous
14//!   task when there is no run.
15//! - **Explicit.** `depends on T012` / `after T012` in the task text.
16//!
17//! In **declared** mode (the Format line documents `(depends on …)`) the
18//! written references are the complete order within a phase:
19//! subsection and `[P]` edges are dropped, phase order is kept.
20//!
21//! In **chain** mode (OpenSpec) a group waits on every earlier group and a
22//! task on the one before it in its group; written references are added.
23//!
24//! Dangling references and cycles are errors; the tasks involved are blocked.
25
26use std::collections::{BTreeSet, HashMap};
27
28use crate::snapshot::{
29    Dependency, DependencyKind, DependencyMode, Diagnostic, ParsedTasks, Schedule, ScheduleTotals,
30    ScheduledTask, Severity, Task, TaskState,
31};
32
33/// What a phase heading says the phase is.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PhaseKind {
36    /// Shared infrastructure.
37    Setup,
38    /// Blocking prerequisites of every story.
39    Foundational,
40    /// One user story; independent of the other stories.
41    Story,
42    /// Cross-cutting work after the stories.
43    Polish,
44    /// Tasks appended by `/speckit-converge`.
45    Convergence,
46    /// Anything else; waits on everything before it.
47    Other,
48}
49
50/// Classifies a phase by its heading and whether its tasks carry story labels.
51#[must_use]
52pub fn classify(title: &str, has_story_labels: bool) -> PhaseKind {
53    let t = title.to_lowercase();
54    if t.contains("convergence") {
55        PhaseKind::Convergence
56    } else if t.contains("polish") {
57        PhaseKind::Polish
58    } else if has_story_labels || names_a_user_story(&t) {
59        PhaseKind::Story
60    } else if t.contains("foundational") {
61        PhaseKind::Foundational
62    } else if t.contains("setup") {
63        PhaseKind::Setup
64    } else {
65        PhaseKind::Other
66    }
67}
68
69fn names_a_user_story(lower: &str) -> bool {
70    lower.match_indices("user story").any(|(i, m)| {
71        lower[i + m.len()..]
72            .trim_start()
73            .starts_with(|c: char| c.is_ascii_digit())
74    })
75}
76
77/// A run of consecutive tasks sharing a phase heading.
78struct Group {
79    kind: PhaseKind,
80    /// Task indices, split into consecutive subsection blocks.
81    blocks: Vec<Vec<usize>>,
82}
83
84/// While grouping: (phase title, subsection blocks, current subsection).
85type OpenGroup<'t> = (Option<&'t str>, Vec<Vec<usize>>, Option<&'t str>);
86
87fn groups(tasks: &[Task]) -> Vec<Group> {
88    let mut out: Vec<OpenGroup<'_>> = Vec::new();
89    for (i, t) in tasks.iter().enumerate() {
90        let phase = t.phase.as_deref();
91        let sub = t.subsection.as_deref();
92        match out.last_mut() {
93            Some((p, blocks, last_sub)) if *p == phase => {
94                if *last_sub == sub {
95                    if let Some(b) = blocks.last_mut() {
96                        b.push(i);
97                    }
98                } else {
99                    blocks.push(vec![i]);
100                    *last_sub = sub;
101                }
102            }
103            _ => out.push((phase, vec![vec![i]], sub)),
104        }
105    }
106    out.into_iter()
107        .map(|(phase, blocks, _)| {
108            let labelled = blocks.iter().flatten().any(|&i| tasks[i].story.is_some());
109            Group {
110                kind: classify(phase.unwrap_or(""), labelled),
111                blocks,
112            }
113        })
114        .collect()
115}
116
117/// Builds the schedule of `parsed`, with `graph.*` diagnostics.
118#[must_use]
119pub fn schedule(parsed: &ParsedTasks) -> (Schedule, Vec<Diagnostic>) {
120    let tasks = &parsed.tasks;
121    let mut diags = Vec::new();
122    let index: HashMap<&str, usize> = tasks
123        .iter()
124        .enumerate()
125        .rev() // the first occurrence of a duplicate wins
126        .map(|(i, t)| (t.id.as_str(), i))
127        .collect();
128
129    // Structural dependencies, as task indices.
130    let mut structural: Vec<Vec<usize>> = vec![Vec::new(); tasks.len()];
131    let declared = parsed.dependency_mode == DependencyMode::Declared;
132    let chain = parsed.dependency_mode == DependencyMode::Chain;
133    let groups = groups(tasks);
134    for (g, group) in groups.iter().enumerate() {
135        if chain {
136            let earlier: Vec<usize> = groups[..g]
137                .iter()
138                .flat_map(|e| e.blocks.iter().flatten().copied())
139                .collect();
140            let mut previous: Option<usize> = None;
141            for &i in group.blocks.iter().flatten() {
142                let mut deps = earlier.clone();
143                deps.extend(previous);
144                structural[i] = deps;
145                previous = Some(i);
146            }
147            continue;
148        }
149        let phase_barrier: Vec<usize> = groups[..g]
150            .iter()
151            .filter(|earlier| group.kind != PhaseKind::Story || earlier.kind != PhaseKind::Story)
152            .flat_map(|earlier| earlier.blocks.iter().flatten().copied())
153            .collect();
154        for (b, block) in group.blocks.iter().enumerate() {
155            let sub_barrier: Vec<usize> = group.blocks[..b].iter().flatten().copied().collect();
156            let mut last_serial: Option<usize> = None;
157            let mut run: Vec<usize> = Vec::new();
158            for &i in block {
159                let mut deps = phase_barrier.clone();
160                if declared {
161                    structural[i] = deps;
162                    continue;
163                }
164                deps.extend(&sub_barrier);
165                if tasks[i].parallel {
166                    deps.extend(last_serial);
167                    run.push(i);
168                } else {
169                    if run.is_empty() {
170                        deps.extend(last_serial);
171                    } else {
172                        deps.append(&mut run);
173                    }
174                    last_serial = Some(i);
175                }
176                structural[i] = deps;
177            }
178        }
179    }
180
181    // Direct dependencies with kinds; explicit ones resolved by ID.
182    let mut direct: Vec<Vec<Dependency>> = Vec::with_capacity(tasks.len());
183    let mut edges: Vec<Vec<usize>> = Vec::with_capacity(tasks.len());
184    for (i, t) in tasks.iter().enumerate() {
185        let mut seen = BTreeSet::new();
186        let mut deps = Vec::new();
187        let mut resolved = Vec::new();
188        for &d in &structural[i] {
189            if seen.insert(tasks[d].id.as_str()) {
190                deps.push(Dependency {
191                    id: tasks[d].id.clone(),
192                    kind: DependencyKind::Structural,
193                    missing: false,
194                });
195                resolved.push(d);
196            }
197        }
198        for id in &t.depends_on {
199            if !seen.insert(id.as_str()) {
200                continue;
201            }
202            match index.get(id.as_str()) {
203                Some(&d) => resolved.push(d),
204                None => diags.push(
205                    Diagnostic::new(
206                        Severity::Error,
207                        "graph.dangling",
208                        format!("{} depends on {id}, which does not exist", t.id),
209                    )
210                    .at(&parsed.path)
211                    .line(t.line)
212                    .hint("fix the task ID or remove the reference"),
213                ),
214            }
215            deps.push(Dependency {
216                id: id.clone(),
217                kind: DependencyKind::Explicit,
218                missing: !index.contains_key(id.as_str()),
219            });
220        }
221        direct.push(deps);
222        edges.push(resolved);
223    }
224
225    // Cycles: strongly connected components with more than one task, or a self-loop.
226    let component = strongly_connected(&edges);
227    let mut members: HashMap<usize, Vec<usize>> = HashMap::new();
228    for (i, &c) in component.iter().enumerate() {
229        members.entry(c).or_default().push(i);
230    }
231    let in_cycle = |i: usize| members[&component[i]].len() > 1 || edges[i].contains(&i);
232    let mut reported = BTreeSet::new();
233    for i in 0..tasks.len() {
234        if in_cycle(i) && reported.insert(component[i]) {
235            let ids: Vec<&str> = members[&component[i]]
236                .iter()
237                .map(|&m| tasks[m].id.as_str())
238                .collect();
239            diags.push(
240                Diagnostic::new(
241                    Severity::Error,
242                    "graph.cycle",
243                    format!("dependency cycle: {}", ids.join(" → ")),
244                )
245                .at(&parsed.path)
246                .line(tasks[i].line)
247                .hint("remove one of the `depends on` references"),
248            );
249        }
250    }
251
252    // States.
253    let mut out = Vec::with_capacity(tasks.len());
254    let mut totals = ScheduleTotals::default();
255    for (i, t) in tasks.iter().enumerate() {
256        let mut blockers: Vec<(usize, String)> = Vec::new();
257        for (dep, &d) in direct[i]
258            .iter()
259            .zip(resolved_or_missing(&direct[i], &index).iter())
260        {
261            match d {
262                None => blockers.push((usize::MAX, dep.id.clone())),
263                Some(d) => {
264                    let cyclic = in_cycle(i) && component[d] == component[i];
265                    if !tasks[d].done || cyclic {
266                        blockers.push((d, dep.id.clone()));
267                    }
268                }
269            }
270        }
271        blockers.sort();
272        blockers.dedup();
273        let state = if t.done {
274            totals.done += 1;
275            TaskState::Done
276        } else if blockers.is_empty() && !in_cycle(i) {
277            totals.ready += 1;
278            TaskState::Ready
279        } else {
280            totals.blocked += 1;
281            TaskState::Blocked
282        };
283        out.push(ScheduledTask {
284            id: t.id.clone(),
285            state,
286            depends_on: direct[i].clone(),
287            blocked_by: if t.done {
288                Vec::new()
289            } else {
290                blockers.into_iter().map(|(_, id)| id).collect()
291            },
292        });
293    }
294    (Schedule { tasks: out, totals }, diags)
295}
296
297fn resolved_or_missing(deps: &[Dependency], index: &HashMap<&str, usize>) -> Vec<Option<usize>> {
298    deps.iter()
299        .map(|d| index.get(d.id.as_str()).copied())
300        .collect()
301}
302
303/// Iterative Tarjan: the component number of every node.
304fn strongly_connected(edges: &[Vec<usize>]) -> Vec<usize> {
305    const UNSEEN: usize = usize::MAX;
306    let n = edges.len();
307    let mut order = vec![UNSEEN; n];
308    let mut low = vec![0; n];
309    let mut on_stack = vec![false; n];
310    let mut component = vec![UNSEEN; n];
311    let mut stack = Vec::new();
312    let mut next_order = 0;
313    let mut next_component = 0;
314
315    for root in 0..n {
316        if order[root] != UNSEEN {
317            continue;
318        }
319        // (node, index of the next edge to visit)
320        let mut work = vec![(root, 0usize)];
321        while let Some(&mut (v, ref mut e)) = work.last_mut() {
322            if *e == 0 && order[v] == UNSEEN {
323                order[v] = next_order;
324                low[v] = next_order;
325                next_order += 1;
326                stack.push(v);
327                on_stack[v] = true;
328            }
329            if let Some(&w) = edges[v].get(*e) {
330                *e += 1;
331                if order[w] == UNSEEN {
332                    work.push((w, 0));
333                } else if on_stack[w] {
334                    low[v] = low[v].min(order[w]);
335                }
336                continue;
337            }
338            work.pop();
339            if let Some(&(parent, _)) = work.last() {
340                low[parent] = low[parent].min(low[v]);
341            }
342            if low[v] == order[v] {
343                while let Some(w) = stack.pop() {
344                    on_stack[w] = false;
345                    component[w] = next_component;
346                    if w == v {
347                        break;
348                    }
349                }
350                next_component += 1;
351            }
352        }
353    }
354    component
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::snapshot::Totals;
361
362    /// `spec`: (id, phase, subsection, parallel, story, done, explicit deps)
363    type Spec<'a> = (
364        &'a str,
365        &'a str,
366        Option<&'a str>,
367        bool,
368        Option<&'a str>,
369        bool,
370        &'a [&'a str],
371    );
372
373    fn parsed(spec: &[Spec<'_>]) -> ParsedTasks {
374        parsed_in(DependencyMode::Structural, spec)
375    }
376
377    fn parsed_in(dependency_mode: DependencyMode, spec: &[Spec<'_>]) -> ParsedTasks {
378        let tasks: Vec<Task> = spec
379            .iter()
380            .enumerate()
381            .map(|(n, &(id, phase, sub, parallel, story, done, deps))| Task {
382                id: id.to_owned(),
383                done,
384                parallel,
385                story: story.map(str::to_owned),
386                title: String::new(),
387                depends_on: deps.iter().map(|d| (*d).to_owned()).collect(),
388                phase: Some(phase.to_owned()),
389                subsection: sub.map(str::to_owned),
390                line: u32::try_from(n + 1).unwrap(),
391                byte_range: [0, 0],
392                line_hash: String::new(),
393            })
394            .collect();
395        ParsedTasks {
396            path: "tasks.md".to_owned(),
397            phases: Vec::new(),
398            totals: Totals::default(),
399            tasks,
400            dependency_mode,
401        }
402    }
403
404    fn states(s: &Schedule) -> Vec<(&str, TaskState, Vec<&str>)> {
405        s.tasks
406            .iter()
407            .map(|t| {
408                (
409                    t.id.as_str(),
410                    t.state,
411                    t.blocked_by.iter().map(String::as_str).collect(),
412                )
413            })
414            .collect()
415    }
416
417    use TaskState::{Blocked, Done, Ready};
418
419    const SETUP: &str = "Phase 1: Setup";
420    const FOUND: &str = "Phase 2: Foundational";
421    const US1: &str = "Phase 3: User Story 1 - A";
422    const US2: &str = "Phase 4: User Story 2 - B";
423    const POLISH: &str = "Phase 5: Polish & Cross-Cutting Concerns";
424
425    #[test]
426    fn classifies_spec_kit_headings() {
427        assert_eq!(
428            classify("Phase 1: Setup (Shared Infrastructure)", false),
429            PhaseKind::Setup
430        );
431        assert_eq!(
432            classify("Phase 2: Foundational (Blocking Prerequisites)", false),
433            PhaseKind::Foundational
434        );
435        assert_eq!(
436            classify("Phase 3: User Story 1 - Login (Priority: P1) 🎯 MVP", false),
437            PhaseKind::Story
438        );
439        assert_eq!(
440            classify("Phase 9: Polish & Cross-Cutting Concerns", false),
441            PhaseKind::Polish
442        );
443        assert_eq!(
444            classify("Phase 10: Convergence", false),
445            PhaseKind::Convergence
446        );
447        assert_eq!(classify("Phase 4: Login", true), PhaseKind::Story);
448        assert_eq!(classify("User stories overview", false), PhaseKind::Other);
449    }
450
451    #[test]
452    fn open_setup_blocks_everything_after_it() {
453        let p = parsed(&[
454            ("T001", SETUP, None, false, None, false, &[]),
455            ("T002", FOUND, None, false, None, false, &[]),
456            ("T003", US1, None, false, Some("US1"), false, &[]),
457        ]);
458        let (s, d) = schedule(&p);
459        assert!(d.is_empty());
460        assert_eq!(
461            states(&s),
462            [
463                ("T001", Ready, vec![]),
464                ("T002", Blocked, vec!["T001"]),
465                ("T003", Blocked, vec!["T001", "T002"]),
466            ]
467        );
468    }
469
470    #[test]
471    fn stories_are_independent_and_polish_waits_for_all() {
472        let p = parsed(&[
473            ("T001", SETUP, None, false, None, true, &[]),
474            ("T002", FOUND, None, false, None, true, &[]),
475            ("T003", US1, None, false, Some("US1"), false, &[]),
476            ("T004", US2, None, false, Some("US2"), false, &[]),
477            ("T005", POLISH, None, false, None, false, &[]),
478        ]);
479        let (s, _) = schedule(&p);
480        assert_eq!(
481            states(&s),
482            [
483                ("T001", Done, vec![]),
484                ("T002", Done, vec![]),
485                ("T003", Ready, vec![]),
486                ("T004", Ready, vec![]),
487                ("T005", Blocked, vec!["T003", "T004"]),
488            ]
489        );
490        assert_eq!(
491            s.totals,
492            ScheduleTotals {
493                done: 2,
494                ready: 2,
495                blocked: 1
496            }
497        );
498    }
499
500    #[test]
501    fn parallel_runs_fork_and_join() {
502        let p = parsed(&[
503            ("T001", SETUP, None, false, None, true, &[]),
504            ("T002", SETUP, None, true, None, false, &[]),
505            ("T003", SETUP, None, true, None, false, &[]),
506            ("T004", SETUP, None, false, None, false, &[]),
507        ]);
508        let (s, _) = schedule(&p);
509        assert_eq!(
510            states(&s),
511            [
512                ("T001", Done, vec![]),
513                ("T002", Ready, vec![]),
514                ("T003", Ready, vec![]),
515                ("T004", Blocked, vec!["T002", "T003"]),
516            ]
517        );
518    }
519
520    #[test]
521    fn tests_subsection_comes_before_implementation() {
522        let p = parsed(&[
523            (
524                "T001",
525                US1,
526                Some("Tests for User Story 1"),
527                true,
528                Some("US1"),
529                false,
530                &[],
531            ),
532            (
533                "T002",
534                US1,
535                Some("Implementation for User Story 1"),
536                true,
537                Some("US1"),
538                false,
539                &[],
540            ),
541        ]);
542        let (s, _) = schedule(&p);
543        assert_eq!(states(&s)[1], ("T002", Blocked, vec!["T001"]));
544    }
545
546    #[test]
547    fn explicit_dependency_crosses_stories() {
548        let p = parsed(&[
549            ("T001", US1, None, false, Some("US1"), false, &[]),
550            ("T002", US2, None, false, Some("US2"), false, &["T001"]),
551        ]);
552        let (s, _) = schedule(&p);
553        assert_eq!(states(&s)[1], ("T002", Blocked, vec!["T001"]));
554        assert_eq!(s.tasks[1].depends_on[0].kind, DependencyKind::Explicit);
555    }
556
557    #[test]
558    fn declared_mode_keeps_phase_order_but_drops_structure_within_a_phase() {
559        let p = parsed_in(
560            DependencyMode::Declared,
561            &[
562                ("T001", SETUP, None, false, None, true, &[]),
563                ("T002", SETUP, None, false, None, false, &["T001"]),
564                ("T003", SETUP, None, true, None, false, &["T001"]),
565                (
566                    "T004",
567                    US1,
568                    Some("Tests"),
569                    true,
570                    Some("US1"),
571                    false,
572                    &["T002"],
573                ),
574                (
575                    "T005",
576                    US1,
577                    Some("Implementation"),
578                    true,
579                    Some("US1"),
580                    false,
581                    &[],
582                ),
583            ],
584        );
585        let (s, d) = schedule(&p);
586        assert!(d.is_empty());
587        assert_eq!(
588            states(&s),
589            [
590                ("T001", Done, vec![]),
591                ("T002", Ready, vec![]),
592                ("T003", Ready, vec![]),
593                ("T004", Blocked, vec!["T002", "T003"]),
594                ("T005", Blocked, vec!["T002", "T003"]),
595            ]
596        );
597    }
598
599    #[test]
600    fn dangling_reference_is_an_error_and_blocks() {
601        let p = parsed(&[("T001", SETUP, None, false, None, false, &["T099"])]);
602        let (s, d) = schedule(&p);
603        assert_eq!(d[0].code, "graph.dangling");
604        assert_eq!(d[0].line, Some(1));
605        assert_eq!(states(&s)[0], ("T001", Blocked, vec!["T099"]));
606        assert!(s.tasks[0].depends_on[0].missing);
607    }
608
609    #[test]
610    fn cycles_and_self_references_are_errors_and_never_ready() {
611        // T002 waits on T001 structurally; T001 explicitly waits on T002.
612        let p = parsed(&[
613            ("T001", SETUP, None, false, None, false, &["T002"]),
614            ("T002", SETUP, None, false, None, true, &[]),
615            ("T003", POLISH, None, false, None, false, &["T003"]),
616        ]);
617        let (s, d) = schedule(&p);
618        let codes: Vec<_> = d
619            .iter()
620            .map(|d| (d.code.as_str(), d.message.as_str()))
621            .collect();
622        assert_eq!(
623            codes,
624            [
625                ("graph.cycle", "dependency cycle: T001 → T002"),
626                ("graph.cycle", "dependency cycle: T003"),
627            ]
628        );
629        assert_eq!(s.tasks[0].state, Blocked);
630        assert_eq!(s.tasks[2].state, Blocked);
631    }
632
633    mod properties {
634        use super::*;
635        use proptest::prelude::*;
636
637        /// Owned form of [`Spec`] while generating.
638        type OwnedSpec = (
639            String,
640            &'static str,
641            Option<String>,
642            bool,
643            Option<&'static str>,
644            bool,
645            Vec<String>,
646        );
647
648        const PHASES: [&str; 6] = [SETUP, FOUND, US1, US2, "Phase 6: Story notes", POLISH];
649
650        /// Random task lists: phases in template order, subsections, `[P]`,
651        /// done flags, and explicit references only to earlier tasks.
652        fn task_lists() -> impl Strategy<Value = ParsedTasks> {
653            prop::collection::vec(
654                (
655                    0..PHASES.len(),
656                    0..3usize,
657                    any::<bool>(),
658                    any::<bool>(),
659                    prop::option::of(0..64usize),
660                ),
661                1..40,
662            )
663            .prop_map(|mut rows| {
664                rows.sort_by_key(|r| (r.0, r.1));
665                let ids: Vec<String> = (1..=rows.len()).map(|n| format!("T{n:03}")).collect();
666                let spec: Vec<OwnedSpec> = rows
667                    .iter()
668                    .enumerate()
669                    .map(|(i, &(ph, sub, par, done, back))| {
670                        let story = match ph {
671                            2 => Some("US1"),
672                            3 => Some("US2"),
673                            _ => None,
674                        };
675                        let deps = back
676                            .filter(|_| i > 0)
677                            .map(|b| vec![ids[b % i].clone()])
678                            .unwrap_or_default();
679                        (
680                            ids[i].clone(),
681                            PHASES[ph],
682                            Some(format!("S{sub}")),
683                            par,
684                            story,
685                            done,
686                            deps,
687                        )
688                    })
689                    .collect();
690                let owned: Vec<Vec<&str>> = spec
691                    .iter()
692                    .map(|r| r.6.iter().map(String::as_str).collect())
693                    .collect();
694                let rows: Vec<Spec<'_>> = spec
695                    .iter()
696                    .zip(&owned)
697                    .map(|(r, d)| {
698                        (
699                            r.0.as_str(),
700                            r.1,
701                            r.2.as_deref(),
702                            r.3,
703                            r.4,
704                            r.5,
705                            d.as_slice(),
706                        )
707                    })
708                    .collect();
709                parsed(&rows)
710            })
711        }
712
713        proptest! {
714            #[test]
715            fn ready_means_every_dependency_is_done(p in task_lists()) {
716                let (s, _) = schedule(&p);
717                let done: HashMap<&str, bool> = p.tasks.iter().map(|t| (t.id.as_str(), t.done)).collect();
718                for t in &s.tasks {
719                    if t.state == Ready {
720                        prop_assert!(t.depends_on.iter().all(|d| done[d.id.as_str()]));
721                    }
722                }
723            }
724
725            #[test]
726            fn open_tasks_with_done_dependencies_are_ready(p in task_lists()) {
727                let (s, _) = schedule(&p);
728                let done: HashMap<&str, bool> = p.tasks.iter().map(|t| (t.id.as_str(), t.done)).collect();
729                for (t, task) in s.tasks.iter().zip(&p.tasks) {
730                    if !task.done && t.depends_on.iter().all(|d| done[d.id.as_str()]) {
731                        prop_assert_eq!(t.state, Ready);
732                    }
733                }
734            }
735
736            #[test]
737            fn stories_never_depend_structurally_on_each_other(p in task_lists()) {
738                let (s, _) = schedule(&p);
739                let story: HashMap<&str, Option<&str>> =
740                    p.tasks.iter().map(|t| (t.id.as_str(), t.story.as_deref())).collect();
741                for t in &s.tasks {
742                    for d in t.depends_on.iter().filter(|d| d.kind == DependencyKind::Structural) {
743                        if let (Some(a), Some(b)) = (story[t.id.as_str()], story[d.id.as_str()]) {
744                            prop_assert_eq!(a, b);
745                        }
746                    }
747                }
748            }
749
750            #[test]
751            fn backward_references_never_create_cycles(p in task_lists()) {
752                let (s, d) = schedule(&p);
753                prop_assert!(d.is_empty(), "{:?}", d);
754                prop_assert_eq!(s.totals.done + s.totals.ready + s.totals.blocked, p.tasks.len());
755            }
756        }
757    }
758}