1use 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
17const FS: char = '\u{1f}';
19const RS: char = '\u{1e}';
20
21#[derive(Debug, Clone)]
23struct Evidence {
24 commit: String,
25 verdict: String,
26 run: String,
27 reason: String,
28 task_line: String,
30 intent: String,
32 strength: String,
34 regression: String,
36 gates: Vec<speccoder_core::hooks::GateMark>,
38}
39
40fn 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
61fn 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 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#[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
140fn 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 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 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#[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#[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 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 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 .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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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
727fn 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 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
773fn 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
812fn 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
845fn 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}