Skip to main content

speccoder_runtime/
status.rs

1//! `speccoder report`: a feature's tasks and the evidence behind
2//! each tick, read from what is durable and reviewable — `tasks.md` at the
3//! tip of `speccoder/<feature>` and the trailers of its commits. Read-only:
4//! no model, no worktree, no state directory.
5
6use std::collections::BTreeMap;
7use std::path::Path;
8
9use speccoder_core::snapshot::{ParsedTasks, TaskState};
10use speccoder_core::{
11    CommandData, CommandResult, CommandStatus, ConvergenceRound, StatusReport, StatusTotals,
12    TaskStatus, TraceRow,
13};
14
15use crate::git::Git;
16
17/// Field and record separators for `git log` output.
18const FS: char = '\u{1f}';
19const RS: char = '\u{1e}';
20
21/// What a speccoder commit says about a task.
22#[derive(Debug, Clone)]
23struct Evidence {
24    commit: String,
25    verdict: String,
26    run: String,
27    reason: String,
28    /// The task's line, unticked, when the commit was made in place.
29    task_line: String,
30    /// The intent it was judged against, when made in place.
31    intent: String,
32    /// `Acceptance-Strength:`.
33    strength: String,
34    /// `Regression-Only:`.
35    regression: String,
36    /// `Gate: <name> <outcome>` lines.
37    gates: Vec<speccoder_core::hooks::GateMark>,
38}
39
40/// The `Gate:` trailers of a tick's message.
41fn gate_marks(body: &str) -> Vec<speccoder_core::hooks::GateMark> {
42    use speccoder_core::hooks::{GateMark, GateState};
43    body.lines()
44        .filter_map(|l| l.strip_prefix("Gate: "))
45        .filter_map(|v| {
46            let (name, outcome) = v.trim().rsplit_once(' ')?;
47            let outcome = match outcome {
48                "passed" => GateState::Passed,
49                "blocked" => GateState::Blocked,
50                "not_run" => GateState::NotRun,
51                _ => return None,
52            };
53            Some(GateMark {
54                name: name.to_owned(),
55                outcome,
56            })
57        })
58        .collect()
59}
60
61/// Reads the trailers speccoder writes (`Spec-Task`, `Verdict`, `Run`) and
62/// its convergence commits from `log` (newest first): the latest commit per
63/// task wins; rounds come back oldest first.
64fn read_log(log: &str, feature: &str) -> (BTreeMap<String, Evidence>, Vec<ConvergenceRound>) {
65    let mut evidence: BTreeMap<String, Evidence> = BTreeMap::new();
66    let mut rounds = Vec::new();
67    for record in log.split(RS) {
68        let fields: Vec<&str> = record.trim_start_matches('\n').split(FS).collect();
69        let [
70            commit,
71            subject,
72            task,
73            verdict,
74            run,
75            reason,
76            task_line,
77            intent,
78            strength,
79            regression,
80            body,
81        ] = fields[..]
82        else {
83            continue;
84        };
85        if let Some(task) = task.trim().strip_prefix(&format!("{feature}/")) {
86            let e = Evidence {
87                commit: commit.to_owned(),
88                verdict: verdict.trim().to_owned(),
89                run: run.trim().to_owned(),
90                reason: reason.trim().to_owned(),
91                task_line: task_line.trim().to_owned(),
92                intent: intent.trim().trim_start_matches("sha256:").to_owned(),
93                strength: strength.trim().to_owned(),
94                regression: regression.trim().to_owned(),
95                gates: gate_marks(body),
96            };
97            // A fixed bug's verdict is the evidence of each task it closed.
98            for closed in body
99                .lines()
100                .filter_map(|l| l.strip_prefix("Closes-Task:"))
101                .filter_map(|v| v.trim().strip_prefix(&format!("{feature}/")))
102            {
103                evidence.entry(closed.to_owned()).or_insert(Evidence {
104                    task_line: String::new(),
105                    ..e.clone()
106                });
107            }
108            evidence.entry(task.to_owned()).or_insert(e);
109        } else if let Some(n) = subject
110            .strip_prefix("speccoder: converge round ")
111            .and_then(|r| r.split(' ').next())
112            .and_then(|n| n.parse::<u32>().ok())
113        {
114            rounds.push(ConvergenceRound {
115                round: n,
116                commit: commit.to_owned(),
117                tasks: body
118                    .lines()
119                    .filter(|l| {
120                        l.strip_prefix('T')
121                            .is_some_and(|r| r.starts_with(|c: char| c.is_ascii_digit()))
122                    })
123                    .map(str::to_owned)
124                    .collect(),
125            });
126        }
127    }
128    rounds.reverse();
129    (evidence, rounds)
130}
131
132/// A regression-only tick's reason from its trailer; a tick without one
133/// (without a recorded reason) reads `unrecorded`, never a guess.
134#[must_use]
135pub fn regression_of(trailer: &str) -> speccoder_core::regression::Regression {
136    use speccoder_core::regression::{Regression, RegressionReason, parse_trailer};
137    Regression::of(parse_trailer(trailer.trim()).unwrap_or(RegressionReason::Unrecorded))
138}
139
140/// Each task's state: evidence for ticked tasks, the schedule for open ones.
141/// `lines` (the open-line hash per task, for local ticks) decides whether a
142/// commit naming a `Task-Line` still matches the task as written.
143fn statuses(
144    parsed: &ParsedTasks,
145    evidence: &BTreeMap<String, Evidence>,
146    lines: &BTreeMap<String, String>,
147    saved: &[crate::attempts::Saved],
148    skipped: &BTreeMap<String, String>,
149) -> Vec<TaskStatus> {
150    let (schedule, _) = speccoder_core::graph::schedule(parsed);
151    parsed
152        .tasks
153        .iter()
154        .map(|t| {
155            let scheduled = schedule.tasks.iter().find(|s| s.id == t.id);
156            let found = evidence.get(&t.id).filter(|e| {
157                e.task_line.is_empty() || lines.get(&t.id).is_none_or(|h| *h == e.task_line)
158            });
159            // In place: a commit for exactly this line, the tick not written.
160            let untick = found.filter(|e| !t.done && !e.task_line.is_empty() && !lines.is_empty());
161            let state = if untick.is_some() {
162                "evidence_without_tick".to_owned()
163            } else if t.done {
164                match found.map(|e| e.verdict.as_str()) {
165                    Some(v @ ("verified" | "regression_only" | "attested")) => v.to_owned(),
166                    _ => "ticked_without_evidence".to_owned(),
167                }
168            } else if skipped.contains_key(&t.id) {
169                "skipped".to_owned()
170            } else if scheduled.is_some_and(|s| s.state == TaskState::Ready) {
171                "ready".to_owned()
172            } else {
173                "blocked".to_owned()
174            };
175            TaskStatus {
176                lane: speccoder_core::lane::Lane::default(),
177                id: t.id.clone(),
178                title: t.title.clone(),
179                phase: t.phase.clone().unwrap_or_default(),
180                blocked_by: if t.done {
181                    Vec::new()
182                } else {
183                    scheduled.map(|s| s.blocked_by.clone()).unwrap_or_default()
184                },
185                commit: found
186                    .filter(|_| t.done || untick.is_some())
187                    .map(|e| e.commit.clone()),
188                attestation: None,
189                strength: found
190                    .filter(|e| t.done && !e.strength.is_empty())
191                    .map(|e| e.strength.clone()),
192                drift: None,
193                run_id: found
194                    .filter(|e| t.done && !e.run.is_empty())
195                    .map(|e| e.run.clone()),
196                // Newest first: the first saved attempt of an open task.
197                last_attempt: saved
198                    .iter()
199                    .find(|a| !t.done && a.task == t.id && !crate::controls::is_set_aside(a))
200                    .map(|a| speccoder_core::LastAttempt {
201                        outcome: a.outcome.clone(),
202                        reason: a.reason.clone(),
203                        run_id: a.run_id.clone(),
204                        reference: a.reference.clone(),
205                        story_open: a.story_open.clone(),
206                        own_test: a.own_test.clone(),
207                    }),
208                judged: None,
209                gates: found
210                    .filter(|_| t.done)
211                    .map(|e| e.gates.clone())
212                    .unwrap_or_default(),
213                set_aside: None,
214                bug: None,
215                regression: found
216                    .filter(|e| t.done && e.verdict == "regression_only")
217                    .map(|e| regression_of(&e.regression)),
218                note: if t.done {
219                    found.map(|e| e.reason.clone()).filter(|r| !r.is_empty())
220                } else {
221                    skipped.get(&t.id).cloned()
222                },
223                state,
224            }
225        })
226        .collect()
227}
228
229fn totals(tasks: &[TaskStatus]) -> StatusTotals {
230    let n = |s: &str| tasks.iter().filter(|t| t.state == s).count();
231    StatusTotals {
232        verified: n("verified"),
233        regression_only: n("regression_only"),
234        ticked_without_evidence: n("ticked_without_evidence"),
235        evidence_without_tick: n("evidence_without_tick"),
236        without_attestation: tasks
237            .iter()
238            .filter(|t| t.attestation.as_deref() == Some("missing"))
239            .count(),
240        attested: n("attested"),
241        skipped: n("skipped"),
242        ready: n("ready"),
243        blocked: n("blocked"),
244        stale: tasks
245            .iter()
246            .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
247            .count(),
248    }
249}
250
251/// The open tasks by lane, for *Before you run*: `None` when every one
252/// is code (nothing worth saying) or there is no task to run.
253#[must_use]
254pub fn lanes(path: &Path, feature: Option<&str>) -> Option<speccoder_core::lane::Lanes> {
255    let r = report(path, feature);
256    let Some(CommandData::Report(r)) = r.data() else {
257        return None;
258    };
259    let lanes = speccoder_core::lane::Lanes::of(
260        r.tasks
261            .iter()
262            .filter(|t| matches!(t.state.as_str(), "ready" | "blocked"))
263            .map(|t| (t.id.as_str(), t.lane)),
264    );
265    lanes.notable().then_some(lanes)
266}
267
268/// `speccoder report [--feature]`.
269#[must_use]
270pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
271    let inspected = crate::inspect(path, feature);
272    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
273        return CommandResult::carried("report", &inspected);
274    };
275    let format = crate::format::of(snapshot);
276    let Some(f) = snapshot.feature.as_ref() else {
277        // A project with nothing planned yet is a beginning, not an error.
278        if snapshot.features.is_empty()
279            && !snapshot
280                .diagnostics
281                .iter()
282                .any(|d| d.code == "select.not-found")
283        {
284            let unit = format.messages().unit;
285            let create = (format.planning().creates.first())
286                .or((format.planning().toward_tasks)(false).first())
287                .map_or_else(
288                    || "speccoder spec".to_owned(),
289                    |s| format!("speccoder spec {s} \"…\""),
290                );
291            return CommandResult::new(
292                "report",
293                CommandStatus::Ok,
294                &format!("no {unit}s yet — `{create}` writes the first one"),
295            )
296            .with_next(vec![speccoder_core::next::NextStep {
297                command: create,
298                why: format!("describe the {unit}; its spec is written for you to review"),
299            }]);
300        }
301        // Several and none current, or a pointer to one that is gone: say
302        // which exist; the code's fixes say how to pick.
303        let unit = format.messages().unit;
304        let names: Vec<&str> = snapshot.features.iter().map(|f| f.name.as_str()).collect();
305        if let Some(d) = snapshot
306            .diagnostics
307            .iter()
308            .find(|d| d.code == "select.not-found")
309        {
310            return CommandResult::new(
311                "report",
312                CommandStatus::SelectionNeeded,
313                &match &d.hint {
314                    Some(h) => format!("{}; {h}", d.message),
315                    None => d.message.clone(),
316                },
317            )
318            // Named here, or remembered by the format's pointer.
319            .with_code(if feature.is_some() {
320                "FEATURE-UNKNOWN"
321            } else {
322                "FEATURE-GONE"
323            });
324        }
325        return CommandResult::new(
326            "report",
327            CommandStatus::SelectionNeeded,
328            &format!(
329                "{} {unit}s and none is current ({})",
330                names.len(),
331                names.join(", ")
332            ),
333        );
334    };
335    let artifacts: Vec<String> = [
336        ("spec", f.artifacts.spec),
337        ("plan", f.artifacts.plan),
338        ("tasks", f.artifacts.tasks),
339    ]
340    .into_iter()
341    .filter(|(_, on)| *on)
342    .map(|(n, _)| n.to_owned())
343    .collect();
344    let uncommitted = crate::hosting::uncommitted(path, feature);
345    // No task list yet: the feature is being planned — a stage, not an
346    // error. The report says what exists and which planning skills are next.
347    let Some(checkout_tasks) = f.tasks.as_ref() else {
348        let planning: Vec<String> = (format.planning().toward_tasks)(f.artifacts.plan)
349            .iter()
350            .map(|s| (*s).to_owned())
351            .collect();
352        return CommandResult::new(
353            "report",
354            CommandStatus::Ok,
355            &format!(
356                "{} is being planned: {} written, no {} yet",
357                f.feature.name,
358                if artifacts.is_empty() {
359                    "nothing".to_owned()
360                } else {
361                    speccoder_core::words::listed(&artifacts)
362                },
363                format.messages().tasks_file
364            ),
365        )
366        .with_data(CommandData::Report(Box::new(StatusReport {
367            bug: crate::bug::of_unit(snapshot),
368            stories: Vec::new(),
369            review: crate::review::totals_of(
370                Path::new(&snapshot.root),
371                format,
372                &f.feature.name,
373                &f.feature.dir,
374            ),
375            issues: issues_of(path, Some(&f.feature.name)),
376            feature: f.feature.name.clone(),
377            branch: None,
378            source: "checkout".to_owned(),
379            selected_by: crate::current::reason_text(snapshot.selection.reason, format),
380            journey: journey_of(&artifacts, &uncommitted, 0, 0),
381            artifacts,
382            planning,
383            uncommitted,
384            unmerged_commits: 0,
385            tasks: Vec::new(),
386            convergence: Vec::new(),
387            trace: Vec::new(),
388            totals: speccoder_core::StatusTotals::default(),
389        })));
390    };
391    let name = f.feature.name.clone();
392    let branch = format!("speccoder/{name}");
393    let root = Path::new(&snapshot.root);
394    let git = Git::at(root);
395    let has_branch = git
396        .succeeds(&[
397            "rev-parse",
398            "--verify",
399            "-q",
400            &format!("refs/heads/{branch}"),
401        ])
402        .unwrap_or(false);
403
404    let mut prefix = String::new();
405    let (parsed, source, log, unmerged) = if has_branch {
406        // tasks.md as committed at the branch tip, by its repository path.
407        let top = git.toplevel().ok().flatten();
408        let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
409        let rel = top
410            .as_deref()
411            .and_then(|t| {
412                canonical(root)
413                    .strip_prefix(canonical(t))
414                    .ok()
415                    .map(Path::to_path_buf)
416            })
417            .unwrap_or_default();
418        prefix = rel.to_string_lossy().replace('\\', "/");
419        if !prefix.is_empty() {
420            prefix.push('/');
421        }
422        let repo_path = rel
423            .join(&checkout_tasks.path)
424            .to_string_lossy()
425            .replace('\\', "/");
426        let text = git
427            .run(&["show", &format!("{branch}:{repo_path}")])
428            .map(|b| String::from_utf8_lossy(&b).into_owned());
429        // In place, an ignored task list is never committed: its ticks are
430        // the checkout's, checked against the commits' `Task-Line`.
431        let local = text.is_err()
432            && !git
433                .succeeds(&["ls-files", "--error-unmatch", "--", &repo_path])
434                .unwrap_or(false);
435        let parsed = match text {
436            Ok(text) => format.parse_tasks(&checkout_tasks.path, &text).0,
437            Err(_) if local => checkout_tasks.clone(),
438            Err(_) => {
439                return CommandResult::new(
440                    "report",
441                    CommandStatus::GitPrecondition,
442                    &format!("{branch} has no {repo_path}"),
443                );
444            }
445        };
446        let format = format!(
447            "--format=%H{FS}%s{FS}%(trailers:key=Spec-Task,valueonly){FS}%(trailers:key=Verdict,valueonly){FS}%(trailers:key=Run,valueonly){FS}%(trailers:key=Reason,valueonly){FS}%(trailers:key=Task-Line,valueonly){FS}%(trailers:key=Intent-Digest,valueonly){FS}%(trailers:key=Acceptance-Strength,valueonly){FS}%(trailers:key=Regression-Only,valueonly){FS}%b{RS}"
448        );
449        let log = git
450            .run(&["log", &format, &branch])
451            .map(|b| String::from_utf8_lossy(&b).into_owned())
452            .unwrap_or_default();
453        let unmerged = git
454            .line(&["rev-list", "--count", &format!("HEAD..{branch}")])
455            .ok()
456            .and_then(|n| n.trim().parse().ok())
457            .unwrap_or(0);
458        (
459            parsed,
460            if local { "checkout" } else { "branch" },
461            log,
462            unmerged,
463        )
464    } else {
465        (checkout_tasks.clone(), "checkout", String::new(), 0)
466    };
467    let local_ticks = has_branch && source == "checkout";
468    let lines = if local_ticks {
469        open_lines(&root.join(&checkout_tasks.path), &parsed)
470    } else {
471        BTreeMap::new()
472    };
473    let (evidence, convergence) = read_log(&log, &name);
474    let saved = crate::attempts::list(root, &name);
475    let skipped = crate::controls::skipped(root, &name);
476    let mut tasks = statuses(&parsed, &evidence, &lines, &saved, &skipped);
477    // Who does each task: derived from its line, its unit and its stop.
478    for (t, p) in tasks.iter_mut().zip(&parsed.tasks) {
479        let open = !matches!(
480            t.state.as_str(),
481            "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
482        );
483        t.lane = speccoder_core::lane::classify(
484            &p.title,
485            open && p.yours,
486            open && t
487                .last_attempt
488                .as_ref()
489                .is_some_and(|a| a.outcome == "blocked"),
490            &f.feature.dir,
491            &format.rules().judged,
492        );
493        // A planning attempt that changed code made the task code.
494        if t.lane == speccoder_core::lane::Lane::Planning
495            && saved
496                .iter()
497                .any(|a| a.task == t.id && a.outcome == crate::pipeline::CHANGED_CODE)
498        {
499            t.lane = speccoder_core::lane::Lane::Code;
500        }
501    }
502    // Changes a run set aside, newest first: the person decides on them,
503    // whatever became of the task.
504    for a in saved.iter().filter(|a| crate::controls::is_set_aside(a)) {
505        if let Some(t) = tasks
506            .iter_mut()
507            .find(|t| t.id == a.task && t.set_aside.is_none())
508        {
509            t.set_aside = Some(speccoder_core::SetAside {
510                reference: a.reference.clone(),
511                files: crate::controls::changed_files(root, &a.commit),
512            });
513        }
514    }
515    // What each committed tick's note says (signatures are `attest verify`'s).
516    for t in tasks.iter_mut() {
517        if let Some(commit) = t.commit.as_deref() {
518            t.attestation = Some(crate::attest::note_state(root, commit));
519        }
520    }
521    if !crate::drift::mark(root, format, &f.feature.dir, &prefix, &mut tasks) && local_ticks {
522        // The intent is not in git (in place, ignored): the commits'
523        // `Intent-Digest` says whether it changed, not where.
524        let now = crate::in_place::intent_digest(
525            root,
526            &crate::tools::policy::Policy::new(&[], &[]).rules(format.rules()),
527            format.messages().tasks_file,
528        );
529        for t in &mut tasks {
530            let judged = evidence.get(&t.id).map(|e| e.intent.as_str());
531            if t.commit.is_some() && judged.is_some_and(|i| !i.is_empty() && i != now) {
532                t.drift = Some(speccoder_core::Drift {
533                    requirements: Vec::new(),
534                    files: Vec::new(),
535                });
536            }
537        }
538    }
539    let mut trace = trace(&format.requirement_ids(root, &f.feature.dir), &tasks);
540    trace.extend(inherited(root, &name));
541    let totals = totals(&tasks);
542    let finished = totals.verified
543        + totals.regression_only
544        + totals.attested
545        + totals.ticked_without_evidence
546        + totals.skipped;
547    let done =
548        totals.verified + totals.regression_only + totals.attested + totals.ticked_without_evidence;
549    // Only what there is: a first report says "0/11 done: 3 ready, 8
550    // blocked", not four kinds of evidence nothing has yet.
551    let parts: Vec<String> = [
552        (totals.verified, "verified"),
553        (totals.regression_only, "regression only"),
554        (totals.attested, "attested"),
555        (totals.ticked_without_evidence, "without evidence"),
556    ]
557    .iter()
558    .filter(|(n, _)| *n > 0)
559    .map(|(n, w)| format!("{n} {w}"))
560    .collect();
561    // A bug's tasks close together with its fix: none is ready on its own.
562    let bug = crate::bug::of_unit(snapshot);
563    let open: Vec<String> = if bug.is_some() {
564        Vec::new()
565    } else {
566        [
567            (totals.ready, "ready"),
568            (totals.blocked, "blocked"),
569            (totals.skipped, "skipped"),
570        ]
571        .iter()
572        .filter(|(n, _)| *n > 0)
573        .map(|(n, w)| format!("{n} {w}"))
574        .collect()
575    };
576    // A task list without a task: nothing is done, the tasks are to write.
577    let mut message = if tasks.is_empty() {
578        format!(
579            "no tasks yet: {} lists none; {}",
580            format.messages().tasks_file,
581            format.messages().make_tasks
582        )
583    } else {
584        format!("{done}/{} done", tasks.len())
585    };
586    let planning: Vec<String> = if tasks.is_empty() {
587        (format.planning().toward_tasks)(f.artifacts.plan)
588            .iter()
589            .map(|s| (*s).to_owned())
590            .collect()
591    } else {
592        Vec::new()
593    };
594    if !parts.is_empty() {
595        message.push_str(&format!(": {}", parts.join(", ")));
596    }
597    if !open.is_empty() {
598        message.push_str(&format!(
599            "{}{}",
600            if parts.is_empty() { ": " } else { "; " },
601            open.join(", ")
602        ));
603    }
604    if !convergence.is_empty() {
605        message.push_str(&format!("; {} convergence round(s)", convergence.len()));
606    }
607    let uncited = trace.iter().filter(|r| r.task.is_none()).count();
608    if uncited > 0 {
609        message.push_str(&format!("; {uncited} requirement(s) no task cites"));
610    }
611    if totals.without_attestation > 0 {
612        message.push_str(&format!(
613            "; {} tick(s) without an attestation",
614            totals.without_attestation
615        ));
616    }
617    if totals.evidence_without_tick > 0 {
618        message.push_str(&format!(
619            "; {} committed without their local tick (the next `run --in-place` ticks them)",
620            totals.evidence_without_tick
621        ));
622    }
623    if totals.stale > 0 {
624        message.push_str(&format!(
625            "; {} tick(s) proven against an earlier wording of their requirements",
626            totals.stale
627        ));
628    }
629    let drifted = tasks.iter().filter(|t| t.drift.is_some()).count() - totals.stale;
630    if drifted > 0 {
631        message.push_str(&format!(
632            "; the spec changed since {drifted}{} tick(s), not in what they cite",
633            if totals.stale > 0 { " other" } else { "" }
634        ));
635    }
636    if local_ticks {
637        message.push_str(&format!(
638            "; ticks are local to this checkout ({} is not in git)",
639            format.messages().tasks_file
640        ));
641    }
642    if has_branch {
643        if unmerged > 0 {
644            message.push_str(&format!("; {unmerged} commit(s) on {branch} not merged"));
645        }
646    } else {
647        message.push_str("; speccoder has not worked on this feature yet");
648    }
649    // A bug task's outcome, from what speccoder keeps of the bug.
650    if let Some(state) = crate::worktree::state_dir() {
651        let plane = crate::worktree::project_state_dir(&state, root);
652        for t in &mut tasks {
653            t.bug = crate::bug::state::load(&plane, &name, &t.id)
654                .map(|b| b.outcome.as_str().to_owned());
655        }
656    }
657    // What judges each open task, and each story's scenarios: from the
658    // ticks' `Scenarios:` on the unit's branch (first-parent, as a run reads them).
659    let (shares, stories) =
660        crate::pipeline::story::story_view(&crate::pipeline::story::StoryView {
661            format,
662            snapshot,
663            feature: &name,
664            unit_dir: &f.feature.dir,
665            tasks: &parsed,
666            repo: root,
667            rev: if has_branch { &branch } else { "HEAD" },
668        });
669    for t in &mut tasks {
670        let open = !matches!(
671            t.state.as_str(),
672            "verified" | "regression_only" | "attested" | "ticked_without_evidence" | "skipped"
673        );
674        // A bug's tasks are judged by the bug's verdict, not one by one.
675        if open && bug.is_none() {
676            t.judged = shares.get(&t.id).cloned();
677        }
678    }
679    CommandResult::new("report", CommandStatus::Ok, &message).with_data(CommandData::Report(
680        Box::new(StatusReport {
681            stories: if bug.is_some() { Vec::new() } else { stories },
682            bug,
683            review: crate::review::totals_of(root, format, &name, &f.feature.dir),
684            issues: issues_of(root, Some(&name)),
685            feature: name,
686            branch: has_branch.then_some(branch),
687            source: source.to_owned(),
688            selected_by: crate::current::reason_text(snapshot.selection.reason, format),
689            journey: journey_of(&artifacts, &uncommitted, tasks.len(), finished),
690            artifacts,
691            planning,
692            uncommitted,
693            unmerged_commits: unmerged,
694            tasks,
695            convergence,
696            trace,
697            totals,
698        }),
699    ))
700}
701
702/// `speccoder task show TASK`: the report narrowed to one task.
703#[must_use]
704pub fn show(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
705    let full = report(path, feature);
706    let Some(CommandData::Report(r)) = full.data() else {
707        return CommandResult::carried("task show", &full);
708    };
709    let Some(t) = r.tasks.iter().find(|t| t.id == task) else {
710        return CommandResult::new(
711            "task show",
712            CommandStatus::NotFound,
713            &format!("{} has no task {task}", r.feature),
714        )
715        .with_code("NOT-FOUND")
716        .with_fix(vec![format!("speccoder report --feature {}", r.feature)]);
717    };
718    let mut one = (**r).clone();
719    one.tasks = vec![t.clone()];
720    one.trace.retain(|row| row.task.as_deref() == Some(task));
721    one.convergence.clear();
722    one.totals = totals(&one.tasks);
723    let message = format!("{task}: {}", t.state.replace('_', " "));
724    CommandResult::ok("task show", &message).with_data(CommandData::Report(Box::new(one)))
725}
726
727/// A promoted unit's rows from where it came from: the lower unit's ticks
728/// of the carried requirement, proven against its earlier wording.
729fn inherited(root: &Path, unit: &str) -> Vec<TraceRow> {
730    let Some(state) = crate::worktree::state_dir() else {
731        return Vec::new();
732    };
733    let plane = crate::worktree::project_state_dir(&state, root);
734    let Some((from_unit, requirement, task)) = crate::ladder::of(&plane, unit)
735        .into_iter()
736        .find_map(|o| match o {
737            speccoder_core::ladder::Origin::PromotedFrom {
738                from_unit,
739                requirement,
740                task,
741                ..
742            } => Some((from_unit, requirement, task)),
743            _ => None,
744        })
745    else {
746        return Vec::new();
747    };
748    let lower = report(root, Some(&from_unit));
749    let Some(CommandData::Report(r)) = lower.data() else {
750        return Vec::new();
751    };
752    r.tasks
753        .iter()
754        .filter(|t| t.id == task && t.commit.is_some())
755        .map(|t| TraceRow {
756            // The lower unit's requirement, never one of this unit's.
757            requirement: format!("{from_unit}/{requirement}"),
758            scenario: None,
759            test: None,
760            task: Some(format!("{from_unit}/{}", t.id)),
761            verdict: Some(t.state.clone()),
762            changed: true,
763            inherited: Some(speccoder_core::Inherited {
764                unit: from_unit.clone(),
765                requirement: requirement.clone(),
766                task: t.id.clone(),
767                commit: t.commit.clone(),
768            }),
769        })
770        .collect()
771}
772
773/// One row per requirement and task citing it (by its identifier as a word
774/// in the title); a requirement no task cites is a row of its own.
775fn trace(requirements: &[(String, String)], tasks: &[TaskStatus]) -> Vec<TraceRow> {
776    requirements
777        .iter()
778        .flat_map(|(id, _)| {
779            let rows: Vec<TraceRow> = tasks
780                .iter()
781                .filter(|t| crate::attest::cites(&t.title, id))
782                .map(|t| TraceRow {
783                    requirement: id.clone(),
784                    scenario: None,
785                    test: None,
786                    task: Some(t.id.clone()),
787                    verdict: Some(t.state.clone()),
788                    changed: t
789                        .drift
790                        .as_ref()
791                        .is_some_and(|d| d.requirements.iter().any(|r| r.id == *id)),
792                    inherited: None,
793                })
794                .collect();
795            if rows.is_empty() {
796                vec![TraceRow {
797                    requirement: id.clone(),
798                    scenario: None,
799                    test: None,
800                    task: None,
801                    verdict: None,
802                    changed: false,
803                    inherited: None,
804                }]
805            } else {
806                rows
807            }
808        })
809        .collect()
810}
811
812/// Each task's line with its box read as open, hashed: what an in-place
813/// commit's `Task-Line` names.
814fn open_lines(file: &Path, parsed: &ParsedTasks) -> BTreeMap<String, String> {
815    let text = std::fs::read_to_string(file).unwrap_or_default();
816    let lines: Vec<&str> = text.lines().collect();
817    parsed
818        .tasks
819        .iter()
820        .filter_map(|t| {
821            let line = lines.get(usize::try_from(t.line).ok()?.checked_sub(1)?)?;
822            let open = crate::in_place::untick(line.as_bytes());
823            Some((t.id.clone(), crate::integrity::sha256_hex(&open)))
824        })
825        .collect()
826}
827
828fn journey_of(
829    artifacts: &[String],
830    uncommitted: &[String],
831    total: usize,
832    finished: usize,
833) -> Vec<speccoder_core::actions::JourneyStep> {
834    let has = |a: &str| artifacts.iter().any(|x| x == a);
835    speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts {
836        spec: has("spec"),
837        plan: has("plan"),
838        tasks: has("tasks"),
839        uncommitted: !uncommitted.is_empty(),
840        total,
841        finished,
842    })
843}
844
845/// The GitHub issue addresses the feature's documents name.
846fn issues_of(root: &Path, feature: Option<&str>) -> Vec<String> {
847    let mut out: Vec<String> = Vec::new();
848    for (_, _, text) in crate::documents::sources(root, feature) {
849        for url in crate::github::issue_urls(&text) {
850            if !out.contains(&url) {
851                out.push(url);
852            }
853        }
854    }
855    out
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    #[test]
863    fn a_requirement_no_task_cites_is_a_row_of_its_own() {
864        let task = |id: &str, title: &str, state: &str| TaskStatus {
865            lane: speccoder_core::lane::Lane::default(),
866            id: id.to_owned(),
867            title: title.to_owned(),
868            phase: "Phase 3".to_owned(),
869            state: state.to_owned(),
870            drift: None,
871            blocked_by: Vec::new(),
872            commit: None,
873            run_id: None,
874            last_attempt: None,
875            judged: None,
876            gates: Vec::new(),
877            set_aside: None,
878            bug: None,
879            regression: None,
880            note: None,
881            attestation: None,
882            strength: None,
883        };
884        let ids = |v: &[&str]| {
885            v.iter()
886                .map(|i| ((*i).to_owned(), String::new()))
887                .collect::<Vec<_>>()
888        };
889        let tasks = [
890            task("T001", "Parse the config (FR-001, FR-002)", "verified"),
891            task("T002", "Render it per FR-002", "ready"),
892            task("T003", "Unrelated FR-0010 work", "ready"),
893        ];
894        let rows = trace(&ids(&["FR-001", "FR-002", "FR-003"]), &tasks);
895        let shown: Vec<_> = rows
896            .iter()
897            .map(|r| {
898                (
899                    r.requirement.as_str(),
900                    r.task.as_deref(),
901                    r.verdict.as_deref(),
902                )
903            })
904            .collect();
905        assert_eq!(
906            shown,
907            [
908                ("FR-001", Some("T001"), Some("verified")),
909                ("FR-002", Some("T001"), Some("verified")),
910                ("FR-002", Some("T002"), Some("ready")),
911                ("FR-003", None, None),
912            ]
913        );
914    }
915
916    #[test]
917    fn trailers_and_rounds_are_read_newest_first() {
918        let rec = |c: &str, s: &str, t: &str, v: &str, r: &str, b: &str| {
919            format!("{c}{FS}{s}{FS}{t}\n{FS}{v}\n{FS}{r}\n{FS}{FS}{FS}{FS}{FS}{FS}{b}{RS}\n")
920        };
921        let log = [
922            rec(
923                "c4",
924                "speccoder: T003 again",
925                "f/T003",
926                "verified",
927                "run_b",
928                "",
929            ),
930            rec(
931                "c3",
932                "speccoder: converge round 1 (1 task)",
933                "",
934                "",
935                "",
936                "T003 Fix it per FR-1 (missing)\n\nSpec-Feature: f\nRun: run_c\n",
937            ),
938            rec(
939                "c2",
940                "speccoder: T003 first",
941                "f/T003",
942                "regression_only",
943                "run_a",
944                "",
945            ),
946            rec("c1", "init", "", "", "", ""),
947            rec("c0", "other feature", "g/T001", "verified", "x", ""),
948        ]
949        .concat();
950        let (ev, rounds) = read_log(&log, "f");
951        assert_eq!(ev.len(), 1);
952        assert_eq!(
953            (ev["T003"].commit.as_str(), ev["T003"].verdict.as_str()),
954            ("c4", "verified")
955        );
956        assert_eq!(rounds.len(), 1);
957        assert_eq!(rounds[0].tasks, ["T003 Fix it per FR-1 (missing)"]);
958    }
959}