Skip to main content

majordomus_cli/devtask/
graph.rs

1//! The milestone as a typed dependency graph, with the partitions a work surface would
2//! otherwise compute for itself.
3//!
4//! # What is derived here and what is not
5//!
6//! The graph itself is not derived here. [`crate::plan`] already walks the canonical records
7//! and produces the edges, the waves, the statuses, the dependents and the findings; this
8//! module reads that answer and computes the four things a plan surface needs and the plan
9//! does not carry:
10//!
11//! * **the partitions by readiness** — ready, blocked, waiting, in progress, review,
12//!   completion-blocked, complete, cancelled. Grouping is a derivation, and a derivation
13//!   done in a template is a derivation done differently in every template.
14//! * **the critical blockers** — which unfinished issues hold back the most downstream work,
15//!   measured as the transitive closure of the dependents relation. What to unblock first
16//!   is the question a milestone exists to answer.
17//! * **the parallelizable subsets** — of the startable work, which issues may genuinely run
18//!   at the same time: same wave, each `parallel_safe`, and no two sharing a scope path. The
19//!   plan warns about a scope conflict; this partitions around it.
20//! * **the cycles** — the plan reports that a cycle exists and names every issue stuck
21//!   behind one. That is the right diagnostic and the wrong shape for a reader who wants to
22//!   fix it: the strongly connected components are what a person edits.
23//!
24//! # Determinism
25//!
26//! Every list here is ordered by [`crate::order::natural_cmp`] over the canonical issue id,
27//! which is the repository's canonical order and is total — identities are unique, so no
28//! comparison ever ends in a tie. Nothing is ordered by a hash-map walk, by discovery order
29//! or by insertion order, so two runs over the same records, on two machines, produce the
30//! same bytes. `test/cases/129_executable_issues.sh` asserts that byte-for-byte.
31//!
32//! The graph algorithms below are deterministic for the same reason: the depth-first walk of
33//! [`cycles`] visits roots in canonical order, and the greedy partition of
34//! [`parallel_sets`] considers candidates in canonical order.
35
36use std::collections::{BTreeMap, BTreeSet};
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40
41use crate::order::natural_cmp;
42use crate::plan::{overlap, Plan, PlanIssue};
43
44use super::readiness::TaskReadiness;
45use super::task::{DevTaskDiagnostic, RecordRef};
46use super::{AttestedCount, AttestedList, AttestedText, FieldProvenance};
47
48/// One issue of a milestone, as a node of its graph.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct MilestoneNode {
51    /// The issue id.
52    pub issue: String,
53    /// One line naming the outcome.
54    pub title: String,
55    /// Where it stands as work.
56    pub readiness: TaskReadiness,
57    /// The canonical status it refines.
58    pub canonical_status: String,
59    /// The execution wave, absent when a cycle prevents it from having one.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub wave: Option<u32>,
62    /// `p0` … `p3`.
63    pub priority: String,
64    /// Whether it may run beside another issue of its wave.
65    pub parallel_safe: bool,
66    /// What is not `DONE` yet, plus the milestone gate when it applies.
67    pub blocked_by: Vec<String>,
68    /// The issues that wait on it directly.
69    pub dependents: Vec<String>,
70    /// How many issues wait on it directly or through another.
71    pub transitive_dependents: u32,
72    /// The paths it touches.
73    pub scope: Vec<String>,
74}
75
76/// One dependency edge of the milestone's graph, `from` before `to`.
77#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
78pub struct MilestoneEdge {
79    /// The issue that must be real first.
80    pub from: String,
81    /// The issue that waits for it.
82    pub to: String,
83    /// True when one end is outside this milestone. Such an edge is why a milestone can be
84    /// entirely blocked with nothing wrong inside it, so it is marked rather than dropped.
85    pub external: bool,
86}
87
88/// One unfinished issue that holds back downstream work, and how much.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90pub struct CriticalBlocker {
91    /// The issue.
92    pub issue: String,
93    /// Where it stands, which is what says whether unblocking it is work or a decision.
94    pub readiness: TaskReadiness,
95    /// The unfinished issues that wait on it, directly or through another, in canonical
96    /// order.
97    pub blocks: Vec<String>,
98    /// How many that is. The number the list is ordered by.
99    pub weight: u32,
100}
101
102/// Two issues of one wave that cannot run together, and the path that stops them.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104pub struct ScopeConflict {
105    /// The issue already in the set.
106    pub held: String,
107    /// The issue that could not join it.
108    pub excluded: String,
109    /// The scope path they share, as one of them declares it.
110    pub path: String,
111}
112
113/// One subset of the startable work that may genuinely run at the same time.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub struct ParallelSet {
116    /// The wave every issue in it belongs to.
117    pub wave: u32,
118    /// The issues, in canonical order.
119    pub issues: Vec<String>,
120    /// Why the set is not larger: the candidates of the same wave that had to be serialised
121    /// against something already in it.
122    pub serialised: Vec<ScopeConflict>,
123}
124
125/// One milestone's issues counted by readiness, plus the two denominators.
126#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
127pub struct MilestoneCounts {
128    /// Every issue naming this milestone.
129    pub total: u32,
130    /// Total less cancelled: the denominator a progress figure uses.
131    pub required: u32,
132    /// One entry per readiness state that has at least one issue, keyed by its word.
133    pub by_readiness: BTreeMap<String, u32>,
134}
135
136/// One milestone as an executable dependency graph.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
138pub struct MilestoneGraph {
139    /// The milestone id, as asked for.
140    pub milestone: String,
141    /// Whether the canonical model declares it. False is answered, not refused.
142    pub declared: bool,
143    /// Repository-relative path of the canonical record, when there is one.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub record: Option<String>,
146    /// One line naming the outcome, as authored.
147    pub title: AttestedText,
148    /// What is true once it is real, as authored.
149    pub outcome: AttestedText,
150    /// The derived milestone status.
151    pub status: AttestedText,
152    /// Its layer in the milestone graph: what orders the roadmap.
153    pub rank: AttestedCount,
154    /// The milestones it requires, as authored.
155    pub depends_on: AttestedList,
156    /// Those of them that are not `DONE`. Non-empty means every issue here is held back
157    /// whatever its own dependencies say.
158    pub blocked_by: AttestedList,
159    /// The milestones that require it.
160    pub dependents: AttestedList,
161    /// Every issue of the milestone, in canonical order.
162    pub nodes: Vec<MilestoneNode>,
163    /// Every dependency edge with at least one end in this milestone, sorted.
164    pub edges: Vec<MilestoneEdge>,
165    /// The issues a worker may pick up now.
166    pub ready: Vec<String>,
167    /// The issues waiting on a peer.
168    pub blocked: Vec<String>,
169    /// The issues waiting only on the milestone gate.
170    pub waiting: Vec<String>,
171    /// The issues somebody has started.
172    pub active: Vec<String>,
173    /// The issues awaiting confirmation.
174    pub review: Vec<String>,
175    /// The issues declared complete whose evidence does not back that up.
176    pub completion_blocked: Vec<String>,
177    /// The issues finished on their own terms.
178    pub complete: Vec<String>,
179    /// The issues withdrawn.
180    pub cancelled: Vec<String>,
181    /// What to unblock first, most downstream work first.
182    pub critical_blockers: Vec<CriticalBlocker>,
183    /// The startable work partitioned into subsets that may run at the same time.
184    pub parallelizable: Vec<ParallelSet>,
185    /// Every dependency cycle with an issue of this milestone in it, each as its strongly
186    /// connected component in canonical order.
187    pub cycles: Vec<Vec<String>>,
188    /// Everything wrong with the records of this milestone or of its issues.
189    pub diagnostics: Vec<DevTaskDiagnostic>,
190    /// The issues counted by readiness.
191    pub counts: MilestoneCounts,
192}
193
194/// Every issue that waits on `id`, directly or through another, in canonical order.
195///
196/// A breadth-first walk of the plan's own `dependents` relation. `id` itself is never in the
197/// answer, even when a cycle leads back to it.
198///
199/// ```
200/// use majordomus_cli::devtask::graph::transitive_dependents;
201/// # use majordomus_cli::plan::PlanIssue;
202/// # fn node(id: &str, dependents: &[&str]) -> PlanIssue {
203/// #     PlanIssue { id: id.into(), milestone: "m".into(), status: "READY".into(), wave: 0,
204/// #         priority: "p1".into(), profile: String::new(), parallel_safe: true,
205/// #         title: String::new(), slug: String::new(), depends_on: vec![],
206/// #         blocked_by: vec![], dependents: dependents.iter().map(|s| (*s).into()).collect(),
207/// #         scope: vec![], serves: vec![], objective: String::new(), evidence_have: 0, evidence_need: 0,
208/// #         started_at: String::new(), verified_at: String::new(), completed_at: String::new() }
209/// # }
210/// let issues = vec![node("A", &["B"]), node("B", &["C"]), node("C", &[])];
211/// assert_eq!(transitive_dependents(&issues, "A"), ["B", "C"]);
212/// assert!(transitive_dependents(&issues, "C").is_empty());
213/// ```
214pub fn transitive_dependents(issues: &[PlanIssue], id: &str) -> Vec<String> {
215    let by_id: BTreeMap<&str, &PlanIssue> = issues.iter().map(|i| (i.id.as_str(), i)).collect();
216    let mut seen: BTreeSet<String> = BTreeSet::new();
217    let mut queue: Vec<&str> = vec![id];
218    while let Some(next) = queue.pop() {
219        let Some(node) = by_id.get(next) else {
220            continue;
221        };
222        for d in &node.dependents {
223            if d != id && seen.insert(d.clone()) {
224                queue.push(d.as_str());
225            }
226        }
227    }
228    let mut out: Vec<String> = seen.into_iter().collect();
229    crate::order::canonical_strings(&mut out);
230    out
231}
232
233/// Every dependency cycle of the issue graph, each as its strongly connected component in
234/// canonical order, the components themselves in canonical order.
235///
236/// Tarjan's algorithm, with the roots visited in canonical order so the answer is the same
237/// on every run and on every machine. A component of one node is a cycle only when the issue
238/// depends on itself; the plan reports that separately as `self_dependency` and it is
239/// included here too, because a reader fixing cycles wants both.
240///
241/// ```
242/// use majordomus_cli::devtask::graph::cycles;
243/// # use majordomus_cli::plan::PlanIssue;
244/// # fn node(id: &str, depends_on: &[&str]) -> PlanIssue {
245/// #     PlanIssue { id: id.into(), milestone: "m".into(), status: "READY".into(), wave: 0,
246/// #         priority: "p1".into(), profile: String::new(), parallel_safe: true,
247/// #         title: String::new(), slug: String::new(),
248/// #         depends_on: depends_on.iter().map(|s| (*s).into()).collect(),
249/// #         blocked_by: vec![], dependents: vec![], scope: vec![], serves: vec![],
250/// #         objective: String::new(),
251/// #         evidence_have: 0, evidence_need: 0, started_at: String::new(),
252/// #         verified_at: String::new(), completed_at: String::new() }
253/// # }
254/// let issues = vec![node("A", &["B"]), node("B", &["A"]), node("C", &[])];
255/// assert_eq!(cycles(&issues), vec![vec!["A".to_string(), "B".to_string()]]);
256/// ```
257pub fn cycles(issues: &[PlanIssue]) -> Vec<Vec<String>> {
258    // canonical order of the nodes, so the walk is reproducible
259    let mut order: Vec<&str> = issues.iter().map(|i| i.id.as_str()).collect();
260    crate::order::canonical_strings(&mut order);
261    let position: BTreeMap<&str, usize> =
262        order.iter().enumerate().map(|(n, id)| (*id, n)).collect();
263    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); order.len()];
264    for i in issues {
265        let Some(&from) = position.get(i.id.as_str()) else {
266            continue;
267        };
268        let mut targets: Vec<usize> = i
269            .depends_on
270            .iter()
271            .filter_map(|d| position.get(d.as_str()).copied())
272            .collect();
273        targets.sort_unstable();
274        targets.dedup();
275        adjacency[from] = targets;
276    }
277
278    let n = order.len();
279    let unvisited = usize::MAX;
280    let mut index = vec![unvisited; n];
281    let mut low = vec![0usize; n];
282    let mut on_stack = vec![false; n];
283    let mut stack: Vec<usize> = Vec::new();
284    let mut next: usize = 0;
285    let mut components: Vec<Vec<String>> = Vec::new();
286
287    // an explicit work stack: a deep dependency chain must not overflow the call stack
288    for root in 0..n {
289        if index[root] != unvisited {
290            continue;
291        }
292        let mut work: Vec<(usize, usize)> = vec![(root, 0)];
293        index[root] = next;
294        low[root] = next;
295        next += 1;
296        stack.push(root);
297        on_stack[root] = true;
298        while let Some((v, edge)) = work.pop() {
299            if edge < adjacency[v].len() {
300                work.push((v, edge + 1));
301                let w = adjacency[v][edge];
302                if index[w] == unvisited {
303                    index[w] = next;
304                    low[w] = next;
305                    next += 1;
306                    stack.push(w);
307                    on_stack[w] = true;
308                    work.push((w, 0));
309                } else if on_stack[w] {
310                    low[v] = low[v].min(index[w]);
311                }
312                continue;
313            }
314            // v is finished: fold its low-link into its parent, then close a root
315            if let Some(&(parent, _)) = work.last() {
316                low[parent] = low[parent].min(low[v]);
317            }
318            if low[v] == index[v] {
319                let mut component: Vec<String> = Vec::new();
320                while let Some(w) = stack.pop() {
321                    on_stack[w] = false;
322                    component.push(order[w].to_string());
323                    if w == v {
324                        break;
325                    }
326                }
327                let cyclic = component.len() > 1 || adjacency[v].contains(&v);
328                if cyclic {
329                    crate::order::canonical_strings(&mut component);
330                    components.push(component);
331                }
332            }
333        }
334    }
335    components.sort_by(|a, b| natural_cmp(&a[0], &b[0]));
336    components
337}
338
339/// Partition startable work into subsets that may genuinely run at the same time.
340///
341/// The rule is the repository's own: two issues may run together when they are in the same
342/// wave, both declare `parallel_safe`, and no scope path of one contains a scope path of the
343/// other — [`crate::plan::overlap`] is the same containment test the plan's own
344/// `scope_conflict` warning uses, so the two can never disagree. An issue that declares
345/// itself not parallel-safe gets a set of its own.
346///
347/// Greedy, over candidates in canonical order, so the partition is deterministic. It is not
348/// the smallest possible partition — that is graph colouring — and it does not need to be: a
349/// worker asks what can be fanned out now, and a conservative answer is a correct one.
350pub fn parallel_sets(nodes: &[MilestoneNode]) -> Vec<ParallelSet> {
351    let mut by_wave: BTreeMap<u32, Vec<&MilestoneNode>> = BTreeMap::new();
352    for node in nodes.iter().filter(|n| n.readiness.is_startable()) {
353        let Some(wave) = node.wave else { continue };
354        by_wave.entry(wave).or_default().push(node);
355    }
356    let mut out: Vec<ParallelSet> = Vec::new();
357    for (wave, mut candidates) in by_wave {
358        candidates.sort_by(|a, b| natural_cmp(&a.issue, &b.issue));
359        let mut sets: Vec<(Vec<&MilestoneNode>, Vec<ScopeConflict>)> = Vec::new();
360        for candidate in candidates {
361            if !candidate.parallel_safe {
362                sets.push((vec![candidate], Vec::new()));
363                continue;
364            }
365            let mut refused: Vec<ScopeConflict> = Vec::new();
366            let mut placed = false;
367            for (members, conflicts) in &mut sets {
368                if members.iter().any(|m| !m.parallel_safe) {
369                    continue;
370                }
371                match first_conflict(members, candidate) {
372                    Some(conflict) => refused.push(conflict),
373                    None => {
374                        members.push(candidate);
375                        conflicts.append(&mut refused);
376                        placed = true;
377                        break;
378                    }
379                }
380            }
381            if !placed {
382                sets.push((vec![candidate], refused));
383            }
384        }
385        for (members, serialised) in sets {
386            let mut issues: Vec<String> = members.iter().map(|m| m.issue.clone()).collect();
387            crate::order::canonical_strings(&mut issues);
388            out.push(ParallelSet {
389                wave,
390                issues,
391                serialised,
392            });
393        }
394    }
395    out
396}
397
398/// The first member of the set the candidate shares a scope path with.
399fn first_conflict(members: &[&MilestoneNode], candidate: &MilestoneNode) -> Option<ScopeConflict> {
400    for member in members {
401        for held in &member.scope {
402            for wanted in &candidate.scope {
403                if overlap(held, wanted) {
404                    return Some(ScopeConflict {
405                        held: member.issue.clone(),
406                        excluded: candidate.issue.clone(),
407                        path: held.clone(),
408                    });
409                }
410            }
411        }
412    }
413    None
414}
415
416impl MilestoneGraph {
417    /// The capability every derived field points a reader at.
418    const PLAN: &'static str = "plan.model";
419
420    /// Build one milestone's graph out of the derived plan.
421    ///
422    /// Pure: a walk of values the caller already has. Nothing is read, and no derivation the
423    /// plan owns is repeated — the statuses, waves, edges and findings below are the plan's
424    /// own, and only the partitions are computed here.
425    #[allow(clippy::too_many_lines)] // one composition of a graph, section by section
426    pub fn build(plan: &Plan, milestone: &str, record: Option<RecordRef<'_>>) -> MilestoneGraph {
427        let derived = plan.milestone(milestone);
428        let absent = "the canonical model declares no milestone with this id";
429        let authored = |key: &str| -> AttestedText {
430            let source = record.map_or_else(
431                || format!(".ai/repo/project/milestones#{key}"),
432                |r| format!("{}#{key}", r.path),
433            );
434            match record.map(|r| r.metadata).and_then(|m| m.get(key)) {
435                Some(v) if !v.is_null() => {
436                    AttestedText::explicit(crate::plan::scalar(Some(v)), source)
437                }
438                _ if record.is_none() => AttestedText::unknown(source, absent),
439                _ => AttestedText::unknown(source, format!("the record declares no `{key}`")),
440            }
441        };
442        let authored_list = |key: &str| -> AttestedList {
443            let source = record.map_or_else(
444                || format!(".ai/repo/project/milestones#{key}"),
445                |r| format!("{}#{key}", r.path),
446            );
447            match record.map(|r| r.metadata).and_then(|m| m.get(key)) {
448                Some(serde_json::Value::Array(items)) => AttestedList::explicit(
449                    items.iter().map(|v| crate::plan::scalar(Some(v))).collect(),
450                    source,
451                ),
452                Some(v) if !v.is_null() => {
453                    AttestedList::explicit(vec![crate::plan::scalar(Some(v))], source)
454                }
455                _ if record.is_none() => AttestedList::unknown(source, absent),
456                _ => AttestedList::unknown(source, format!("the record declares no `{key}`")),
457            }
458        };
459
460        let mut nodes: Vec<MilestoneNode> = plan
461            .issues
462            .iter()
463            .filter(|i| i.milestone == milestone)
464            .map(|i| Self::node(plan, i))
465            .collect();
466        nodes.sort_by(|a, b| natural_cmp(&a.issue, &b.issue));
467
468        let inside: BTreeSet<&str> = nodes.iter().map(|n| n.issue.as_str()).collect();
469        let mut edges: Vec<MilestoneEdge> = plan
470            .edges
471            .iter()
472            .filter(|e| inside.contains(e.from.as_str()) || inside.contains(e.to.as_str()))
473            .map(|e| MilestoneEdge {
474                from: e.from.clone(),
475                to: e.to.clone(),
476                external: !inside.contains(e.from.as_str()) || !inside.contains(e.to.as_str()),
477            })
478            .collect();
479        edges.sort();
480
481        let of = |state: TaskReadiness| -> Vec<String> {
482            nodes
483                .iter()
484                .filter(|n| n.readiness == state)
485                .map(|n| n.issue.clone())
486                .collect()
487        };
488        let mut counts = MilestoneCounts {
489            total: u32::try_from(nodes.len()).unwrap_or(u32::MAX),
490            required: 0,
491            by_readiness: BTreeMap::new(),
492        };
493        for n in &nodes {
494            *counts
495                .by_readiness
496                .entry(n.readiness.as_str().to_string())
497                .or_insert(0) += 1;
498        }
499        counts.required = counts.total
500            - counts
501                .by_readiness
502                .get(TaskReadiness::Cancelled.as_str())
503                .copied()
504                .unwrap_or(0);
505
506        let mut critical: Vec<CriticalBlocker> = nodes
507            .iter()
508            .filter(|n| !n.readiness.is_terminal())
509            .filter_map(|n| {
510                let blocks: Vec<String> = transitive_dependents(&plan.issues, &n.issue)
511                    .into_iter()
512                    .filter(|d| {
513                        plan.issue(d)
514                            .is_some_and(|i| !matches!(i.status.as_str(), "DONE" | "CANCELLED"))
515                    })
516                    .collect();
517                if blocks.is_empty() {
518                    return None;
519                }
520                Some(CriticalBlocker {
521                    issue: n.issue.clone(),
522                    readiness: n.readiness,
523                    weight: u32::try_from(blocks.len()).unwrap_or(u32::MAX),
524                    blocks,
525                })
526            })
527            .collect();
528        // most downstream work first; canonical order inside one weight, so the sequence is
529        // total and no two runs disagree
530        critical.sort_by(|a, b| {
531            b.weight
532                .cmp(&a.weight)
533                .then_with(|| natural_cmp(&a.issue, &b.issue))
534        });
535
536        let all_cycles = cycles(&plan.issues);
537        let here: Vec<Vec<String>> = all_cycles
538            .into_iter()
539            .filter(|c| c.iter().any(|id| inside.contains(id.as_str())))
540            .collect();
541
542        let diagnostics: Vec<DevTaskDiagnostic> = plan
543            .findings
544            .iter()
545            .filter(|f| {
546                f.subject == milestone
547                    || inside.contains(f.subject.as_str())
548                    || f.message
549                        .split_whitespace()
550                        .any(|w| inside.contains(w.trim_end_matches(',')))
551            })
552            .map(|f| DevTaskDiagnostic {
553                level: f.level.clone(),
554                code: f.code.clone(),
555                message: format!("{}: {}", f.subject, f.message),
556                reproduce: "majordomus plan validate".into(),
557            })
558            .collect();
559
560        MilestoneGraph {
561            milestone: milestone.to_string(),
562            declared: derived.is_some(),
563            record: record.map(|r| r.path.to_string()),
564            title: authored("title"),
565            outcome: authored("outcome"),
566            status: derived.map_or_else(
567                || AttestedText::unknown(Self::PLAN, absent),
568                |m| AttestedText::derived(&m.status, Self::PLAN),
569            ),
570            rank: derived.map_or_else(
571                || AttestedCount::unknown(Self::PLAN, absent),
572                |m| AttestedCount::derived(m.rank, Self::PLAN),
573            ),
574            depends_on: authored_list("depends_on"),
575            blocked_by: derived.map_or_else(
576                || AttestedList::unknown(Self::PLAN, absent),
577                |m| AttestedList::derived(m.blocked_by.clone(), Self::PLAN),
578            ),
579            dependents: derived.map_or_else(
580                || AttestedList::unknown(Self::PLAN, absent),
581                |m| AttestedList::derived(m.dependents.clone(), Self::PLAN),
582            ),
583            ready: of(TaskReadiness::Ready),
584            blocked: of(TaskReadiness::Blocked),
585            waiting: of(TaskReadiness::Waiting),
586            active: of(TaskReadiness::InProgress),
587            review: of(TaskReadiness::Review),
588            completion_blocked: of(TaskReadiness::CompletionBlocked),
589            complete: of(TaskReadiness::Complete),
590            cancelled: of(TaskReadiness::Cancelled),
591            critical_blockers: critical,
592            parallelizable: parallel_sets(&nodes),
593            cycles: here,
594            diagnostics,
595            counts,
596            nodes,
597            edges,
598        }
599    }
600
601    fn node(plan: &Plan, i: &PlanIssue) -> MilestoneNode {
602        let cyclic = plan
603            .findings
604            .iter()
605            .any(|f| f.code == "cycle" && f.message.split_whitespace().any(|w| w == i.id));
606        let covered = i.evidence_have >= i.evidence_need;
607        MilestoneNode {
608            issue: i.id.clone(),
609            title: i.title.clone(),
610            readiness: TaskReadiness::derive(
611                &i.status,
612                &i.blocked_by,
613                !i.completed_at.is_empty(),
614                covered,
615            ),
616            canonical_status: i.status.clone(),
617            wave: if cyclic { None } else { Some(i.wave) },
618            priority: i.priority.clone(),
619            parallel_safe: i.parallel_safe,
620            blocked_by: i.blocked_by.clone(),
621            dependents: i.dependents.clone(),
622            transitive_dependents: u32::try_from(transitive_dependents(&plan.issues, &i.id).len())
623                .unwrap_or(u32::MAX),
624            scope: i.scope.clone(),
625        }
626    }
627
628    /// Is every partition consistent with the nodes: each issue in exactly one readiness
629    /// bucket, and every bucket naming an issue of this milestone?
630    ///
631    /// The check a surface would otherwise have to trust. A node counted twice or in no
632    /// bucket at all is the failure mode of grouping by hand, which is exactly what this
633    /// type exists to remove from every template.
634    pub fn is_partitioned(&self) -> bool {
635        let mut counted = 0usize;
636        for bucket in [
637            &self.ready,
638            &self.blocked,
639            &self.waiting,
640            &self.active,
641            &self.review,
642            &self.completion_blocked,
643            &self.complete,
644            &self.cancelled,
645        ] {
646            counted += bucket.len();
647            if !bucket
648                .iter()
649                .all(|id| self.nodes.iter().any(|n| &n.issue == id))
650            {
651                return false;
652            }
653        }
654        counted == self.nodes.len()
655    }
656
657    /// Whether anything about this milestone's records is a failure rather than a warning.
658    pub fn has_failures(&self) -> bool {
659        self.diagnostics.iter().any(|d| d.level == "FAIL")
660    }
661
662    /// Is every attested field's invariant held?
663    pub fn is_consistent(&self) -> bool {
664        [&self.title, &self.outcome, &self.status].iter().all(|f| {
665            f.is_consistent() && (f.provenance != FieldProvenance::Unknown || f.reason.is_some())
666        })
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use crate::plan::{
674        PlanCounts, PlanEdge, PlanFinding, PlanMilestone, PlanProject, PlanVocabulary,
675    };
676
677    fn node(id: &str, status: &str, depends_on: &[&str], scope: &[&str]) -> PlanIssue {
678        PlanIssue {
679            id: id.into(),
680            milestone: "m1".into(),
681            status: status.into(),
682            wave: 0,
683            priority: "p1".into(),
684            profile: "implementation".into(),
685            parallel_safe: true,
686            title: format!("{id} title"),
687            slug: String::new(),
688            depends_on: depends_on.iter().map(|s| (*s).into()).collect(),
689            blocked_by: Vec::new(),
690            dependents: Vec::new(),
691            scope: scope.iter().map(|s| (*s).into()).collect(),
692            serves: Vec::new(),
693            objective: String::new(),
694            evidence_have: 0,
695            evidence_need: 0,
696            started_at: String::new(),
697            verified_at: String::new(),
698            completed_at: String::new(),
699        }
700    }
701
702    /// Fill in the `dependents` and `blocked_by` the plan would have derived, so a fixture
703    /// graph behaves the way a real one does without restating the derivation.
704    fn close(mut issues: Vec<PlanIssue>) -> Vec<PlanIssue> {
705        let done: BTreeSet<String> = issues
706            .iter()
707            .filter(|i| i.status == "DONE")
708            .map(|i| i.id.clone())
709            .collect();
710        let edges: Vec<(String, String)> = issues
711            .iter()
712            .flat_map(|i| i.depends_on.iter().map(|d| (d.clone(), i.id.clone())))
713            .collect();
714        for i in &mut issues {
715            i.dependents = edges
716                .iter()
717                .filter(|(from, _)| from == &i.id)
718                .map(|(_, to)| to.clone())
719                .collect();
720            i.blocked_by = i
721                .depends_on
722                .iter()
723                .filter(|d| !done.contains(*d))
724                .cloned()
725                .collect();
726            if !i.blocked_by.is_empty() && i.status == "READY" {
727                i.status = "BLOCKED".into();
728            }
729        }
730        issues
731    }
732
733    fn plan_of(issues: Vec<PlanIssue>, findings: Vec<PlanFinding>) -> Plan {
734        let issues = close(issues);
735        let edges: Vec<PlanEdge> = {
736            let mut e: Vec<PlanEdge> = issues
737                .iter()
738                .flat_map(|i| {
739                    i.depends_on.iter().map(|d| PlanEdge {
740                        from: d.clone(),
741                        to: i.id.clone(),
742                    })
743                })
744                .collect();
745            e.sort();
746            e
747        };
748        Plan {
749            project: PlanProject {
750                name: "fixture".into(),
751                repository: "owner/fixture".into(),
752                default_branch: "master".into(),
753                active_milestone: "m1".into(),
754            },
755            statuses: PlanVocabulary {
756                issue: crate::plan::ISSUE_STATUSES
757                    .iter()
758                    .map(|s| (*s).to_string())
759                    .collect(),
760                milestone: crate::plan::MILESTONE_STATUSES
761                    .iter()
762                    .map(|s| (*s).to_string())
763                    .collect(),
764            },
765            milestones: vec![PlanMilestone {
766                id: "m1".into(),
767                status: "ACTIVE".into(),
768                order: 1,
769                priority: "p1".into(),
770                title: "The milestone".into(),
771                slug: "m1".into(),
772                version: String::new(),
773                rank: 0,
774                depends_on: Vec::new(),
775                blocked_by: Vec::new(),
776                dependents: Vec::new(),
777                claims: Vec::new(),
778                counts: PlanCounts {
779                    total: u32::try_from(issues.len()).unwrap_or(0),
780                    required: u32::try_from(issues.len()).unwrap_or(0),
781                    by_status: BTreeMap::new(),
782                },
783                issues: issues.iter().map(|i| i.id.clone()).collect(),
784                outcome: String::new(),
785            }],
786            issues,
787            waves: Vec::new(),
788            edges,
789            milestone_edges: Vec::new(),
790            findings,
791        }
792    }
793
794    /// A chain: only the head is ready, and it is the critical blocker for everything behind
795    /// it, weighted by the whole tail.
796    #[test]
797    fn a_dependency_chain_has_one_ready_head_and_one_critical_blocker() {
798        let plan = plan_of(
799            vec![
800                node("I0001", "READY", &[], &[]),
801                node("I0002", "READY", &["I0001"], &[]),
802                node("I0003", "READY", &["I0002"], &[]),
803            ],
804            Vec::new(),
805        );
806        let g = MilestoneGraph::build(&plan, "m1", None);
807        assert_eq!(g.ready, ["I0001"]);
808        assert_eq!(g.blocked, ["I0002", "I0003"]);
809        assert_eq!(g.critical_blockers[0].issue, "I0001");
810        assert_eq!(g.critical_blockers[0].weight, 2);
811        assert_eq!(g.critical_blockers[0].blocks, ["I0002", "I0003"]);
812        assert!(g.is_partitioned());
813    }
814
815    /// Fan-out: one head, three independent followers. The followers are one parallel set
816    /// once the head is done; before that they are all blocked and the head's weight is 3.
817    #[test]
818    fn fan_out_weights_the_head_by_everything_downstream() {
819        let plan = plan_of(
820            vec![
821                node("I0001", "DONE", &[], &[]),
822                node("I0002", "READY", &["I0001"], &["a"]),
823                node("I0003", "READY", &["I0001"], &["b"]),
824                node("I0004", "READY", &["I0001"], &["c"]),
825            ],
826            Vec::new(),
827        );
828        let g = MilestoneGraph::build(&plan, "m1", None);
829        assert_eq!(g.ready, ["I0002", "I0003", "I0004"]);
830        assert_eq!(g.complete, ["I0001"]);
831        // disjoint scopes, one wave, all parallel-safe: one set
832        assert_eq!(g.parallelizable.len(), 1);
833        assert_eq!(g.parallelizable[0].issues, ["I0002", "I0003", "I0004"]);
834    }
835
836    /// Fan-in: three heads and one issue behind all of them. Each head blocks the same one
837    /// thing, so the weights tie and the order falls to the canonical id.
838    #[test]
839    fn fan_in_ties_the_weights_and_orders_by_identity() {
840        let plan = plan_of(
841            vec![
842                node("I0001", "READY", &[], &[]),
843                node("I0002", "READY", &[], &[]),
844                node("I0003", "READY", &[], &[]),
845                node("I0004", "READY", &["I0001", "I0002", "I0003"], &[]),
846            ],
847            Vec::new(),
848        );
849        let g = MilestoneGraph::build(&plan, "m1", None);
850        assert_eq!(g.blocked, ["I0004"]);
851        let names: Vec<&str> = g
852            .critical_blockers
853            .iter()
854            .map(|c| c.issue.as_str())
855            .collect();
856        assert_eq!(names, ["I0001", "I0002", "I0003"]);
857        assert!(g.critical_blockers.iter().all(|c| c.weight == 1));
858    }
859
860    /// A closed dependency does not block: the plan drops it from `blocked_by`, so what was
861    /// behind it is ready.
862    #[test]
863    fn a_closed_dependency_releases_what_waited_on_it() {
864        let plan = plan_of(
865            vec![
866                node("I0001", "DONE", &[], &[]),
867                node("I0002", "READY", &["I0001"], &[]),
868            ],
869            Vec::new(),
870        );
871        let g = MilestoneGraph::build(&plan, "m1", None);
872        assert_eq!(g.ready, ["I0002"]);
873        assert!(g.blocked.is_empty());
874    }
875
876    /// Two ready issues of one wave that share a scope path are serialised into two sets,
877    /// and the conflict names the path — the same containment rule the plan warns with.
878    #[test]
879    fn a_shared_scope_path_serialises_two_otherwise_parallel_issues() {
880        let plan = plan_of(
881            vec![
882                node("I0001", "READY", &[], &["apps/cli/src"]),
883                node("I0002", "READY", &[], &["apps/cli/src/main.rs"]),
884            ],
885            Vec::new(),
886        );
887        let g = MilestoneGraph::build(&plan, "m1", None);
888        assert_eq!(g.parallelizable.len(), 2);
889        assert_eq!(g.parallelizable[0].issues, ["I0001"]);
890        assert_eq!(g.parallelizable[1].issues, ["I0002"]);
891        let conflict = &g.parallelizable[1].serialised[0];
892        assert_eq!(conflict.held, "I0001");
893        assert_eq!(conflict.excluded, "I0002");
894        assert_eq!(conflict.path, "apps/cli/src");
895    }
896
897    /// An issue that declares itself not parallel-safe never joins a set.
898    #[test]
899    fn an_issue_that_is_not_parallel_safe_runs_alone() {
900        let mut alone = node("I0002", "READY", &[], &[]);
901        alone.parallel_safe = false;
902        let plan = plan_of(vec![node("I0001", "READY", &[], &[]), alone], Vec::new());
903        let g = MilestoneGraph::build(&plan, "m1", None);
904        let sets: Vec<Vec<String>> = g.parallelizable.iter().map(|s| s.issues.clone()).collect();
905        assert_eq!(
906            sets,
907            vec![vec!["I0001".to_string()], vec!["I0002".to_string()]]
908        );
909    }
910
911    /// A cycle is reported as its component, and every issue in it loses its wave rather
912    /// than being given a plausible one.
913    #[test]
914    fn a_cycle_is_reported_as_a_component_and_costs_the_wave() {
915        let plan = plan_of(
916            vec![
917                node("I0001", "READY", &["I0002"], &[]),
918                node("I0002", "READY", &["I0001"], &[]),
919                node("I0003", "READY", &[], &[]),
920            ],
921            vec![PlanFinding {
922                level: "FAIL".into(),
923                code: "cycle".into(),
924                subject: "graph".into(),
925                message:
926                    "a dependency cycle prevents these issues from ever becoming ready: I0001 I0002"
927                        .into(),
928            }],
929        );
930        let g = MilestoneGraph::build(&plan, "m1", None);
931        assert_eq!(
932            g.cycles,
933            vec![vec!["I0001".to_string(), "I0002".to_string()]]
934        );
935        for id in ["I0001", "I0002"] {
936            let n = g.nodes.iter().find(|n| n.issue == id).unwrap();
937            assert!(n.wave.is_none(), "{id} was given a wave despite the cycle");
938        }
939        assert!(g
940            .nodes
941            .iter()
942            .find(|n| n.issue == "I0003")
943            .unwrap()
944            .wave
945            .is_some());
946        assert!(g.has_failures());
947    }
948
949    /// A dependency the model does not declare is a diagnostic, and it does not silently
950    /// vanish from the graph: the plan's finding reaches the milestone.
951    #[test]
952    fn a_missing_dependency_is_a_diagnostic_rather_than_a_silent_omission() {
953        let plan = plan_of(
954            vec![node("I0001", "READY", &["I9999"], &[])],
955            vec![PlanFinding {
956                level: "FAIL".into(),
957                code: "unknown_dependency".into(),
958                subject: "I0001".into(),
959                message: "depends on I9999, which is not an issue".into(),
960            }],
961        );
962        let g = MilestoneGraph::build(&plan, "m1", None);
963        assert!(g
964            .diagnostics
965            .iter()
966            .any(|d| d.code == "unknown_dependency" && d.level == "FAIL"));
967        assert!(g.has_failures());
968    }
969
970    /// A milestone the model does not declare is answered with every field unknown, not
971    /// refused and not empty.
972    #[test]
973    fn an_undeclared_milestone_is_answered_with_nothing_claiming_to_be_authored() {
974        let plan = plan_of(vec![node("I0001", "READY", &[], &[])], Vec::new());
975        let g = MilestoneGraph::build(&plan, "nonesuch", None);
976        assert!(!g.declared);
977        assert!(g.nodes.is_empty());
978        assert_eq!(g.status.provenance, FieldProvenance::Unknown);
979        assert_eq!(g.title.provenance, FieldProvenance::Unknown);
980        assert!(g.is_consistent());
981        assert!(g.is_partitioned(), "an empty graph is partitioned");
982    }
983
984    /// The order does not depend on the order the records were read in: the same set of
985    /// issues, shuffled, produces the same graph.
986    #[test]
987    fn the_graph_is_the_same_whatever_order_the_records_arrive_in() {
988        let forward = plan_of(
989            vec![
990                node("I0001", "READY", &[], &["a"]),
991                node("I0002", "READY", &["I0001"], &["b"]),
992                node("I0010", "READY", &[], &["c"]),
993            ],
994            Vec::new(),
995        );
996        let backward = plan_of(
997            vec![
998                node("I0010", "READY", &[], &["c"]),
999                node("I0002", "READY", &["I0001"], &["b"]),
1000                node("I0001", "READY", &[], &["a"]),
1001            ],
1002            Vec::new(),
1003        );
1004        let a = MilestoneGraph::build(&forward, "m1", None);
1005        let b = MilestoneGraph::build(&backward, "m1", None);
1006        assert_eq!(
1007            serde_json::to_string(&a).unwrap(),
1008            serde_json::to_string(&b).unwrap()
1009        );
1010        // and the ids are in canonical order, so I0002 precedes I0010 by digit value
1011        let ids: Vec<&str> = a.nodes.iter().map(|n| n.issue.as_str()).collect();
1012        assert_eq!(ids, ["I0001", "I0002", "I0010"]);
1013    }
1014}