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