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}
37
38fn 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#[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
104fn 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 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 id: t.id.clone(),
141 title: t.title.clone(),
142 phase: t.phase.clone().unwrap_or_default(),
143 blocked_by: if t.done {
144 Vec::new()
145 } else {
146 scheduled.map(|s| s.blocked_by.clone()).unwrap_or_default()
147 },
148 commit: found
149 .filter(|_| t.done || untick.is_some())
150 .map(|e| e.commit.clone()),
151 attestation: None,
152 strength: found
153 .filter(|e| t.done && !e.strength.is_empty())
154 .map(|e| e.strength.clone()),
155 drift: None,
156 run_id: found
157 .filter(|e| t.done && !e.run.is_empty())
158 .map(|e| e.run.clone()),
159 last_attempt: saved
161 .iter()
162 .find(|a| !t.done && a.task == t.id && !crate::controls::is_set_aside(a))
163 .map(|a| speccoder_core::LastAttempt {
164 outcome: a.outcome.clone(),
165 reason: a.reason.clone(),
166 run_id: a.run_id.clone(),
167 reference: a.reference.clone(),
168 }),
169 set_aside: None,
170 bug: None,
171 regression: found
172 .filter(|e| t.done && e.verdict == "regression_only")
173 .map(|e| regression_of(&e.regression)),
174 note: if t.done {
175 found.map(|e| e.reason.clone()).filter(|r| !r.is_empty())
176 } else {
177 skipped.get(&t.id).cloned()
178 },
179 state,
180 }
181 })
182 .collect()
183}
184
185fn totals(tasks: &[TaskStatus]) -> StatusTotals {
186 let n = |s: &str| tasks.iter().filter(|t| t.state == s).count();
187 StatusTotals {
188 verified: n("verified"),
189 regression_only: n("regression_only"),
190 ticked_without_evidence: n("ticked_without_evidence"),
191 evidence_without_tick: n("evidence_without_tick"),
192 without_attestation: tasks
193 .iter()
194 .filter(|t| t.attestation.as_deref() == Some("missing"))
195 .count(),
196 attested: n("attested"),
197 skipped: n("skipped"),
198 ready: n("ready"),
199 blocked: n("blocked"),
200 stale: tasks
201 .iter()
202 .filter(|t| t.drift.as_ref().is_some_and(speccoder_core::Drift::stale))
203 .count(),
204 }
205}
206
207#[must_use]
209pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
210 let inspected = crate::inspect(path, feature);
211 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
212 return CommandResult::carried("report", &inspected);
213 };
214 let format = crate::format::of(snapshot);
215 let Some(f) = snapshot.feature.as_ref() else {
216 if snapshot.features.is_empty()
218 && !snapshot
219 .diagnostics
220 .iter()
221 .any(|d| d.code == "select.not-found")
222 {
223 let unit = format.messages().unit;
224 let create = (format.planning().creates.first())
225 .or((format.planning().toward_tasks)(false).first())
226 .map_or_else(
227 || "speccoder spec".to_owned(),
228 |s| format!("speccoder spec {s} \"…\""),
229 );
230 return CommandResult::new(
231 "report",
232 CommandStatus::Ok,
233 &format!("no {unit}s yet — `{create}` writes the first one"),
234 )
235 .with_next(vec![speccoder_core::next::NextStep {
236 command: create,
237 why: format!("describe the {unit}; its spec is written for you to review"),
238 }]);
239 }
240 let unit = format.messages().unit;
243 let names: Vec<&str> = snapshot.features.iter().map(|f| f.name.as_str()).collect();
244 if let Some(d) = snapshot
245 .diagnostics
246 .iter()
247 .find(|d| d.code == "select.not-found")
248 {
249 return CommandResult::new(
250 "report",
251 CommandStatus::SelectionNeeded,
252 &match &d.hint {
253 Some(h) => format!("{}; {h}", d.message),
254 None => d.message.clone(),
255 },
256 )
257 .with_code(if feature.is_some() {
259 "FEATURE-UNKNOWN"
260 } else {
261 "FEATURE-GONE"
262 });
263 }
264 return CommandResult::new(
265 "report",
266 CommandStatus::SelectionNeeded,
267 &format!(
268 "{} {unit}s and none is current ({})",
269 names.len(),
270 names.join(", ")
271 ),
272 );
273 };
274 let artifacts: Vec<String> = [
275 ("spec", f.artifacts.spec),
276 ("plan", f.artifacts.plan),
277 ("tasks", f.artifacts.tasks),
278 ]
279 .into_iter()
280 .filter(|(_, on)| *on)
281 .map(|(n, _)| n.to_owned())
282 .collect();
283 let uncommitted = crate::hosting::uncommitted(path, feature);
284 let Some(checkout_tasks) = f.tasks.as_ref() else {
287 let planning: Vec<String> = (format.planning().toward_tasks)(f.artifacts.plan)
288 .iter()
289 .map(|s| (*s).to_owned())
290 .collect();
291 return CommandResult::new(
292 "report",
293 CommandStatus::Ok,
294 &format!(
295 "{} is being planned: {} written, no {} yet",
296 f.feature.name,
297 if artifacts.is_empty() {
298 "nothing".to_owned()
299 } else {
300 speccoder_core::words::listed(&artifacts)
301 },
302 format.messages().tasks_file
303 ),
304 )
305 .with_data(CommandData::Report(Box::new(StatusReport {
306 review: crate::review::totals_of(
307 Path::new(&snapshot.root),
308 format,
309 &f.feature.name,
310 &f.feature.dir,
311 ),
312 issues: issues_of(path, Some(&f.feature.name)),
313 feature: f.feature.name.clone(),
314 branch: None,
315 source: "checkout".to_owned(),
316 selected_by: crate::current::reason_text(snapshot.selection.reason, format),
317 journey: journey_of(&artifacts, &uncommitted, 0, 0),
318 artifacts,
319 planning,
320 uncommitted,
321 unmerged_commits: 0,
322 tasks: Vec::new(),
323 convergence: Vec::new(),
324 trace: Vec::new(),
325 totals: speccoder_core::StatusTotals::default(),
326 })));
327 };
328 let name = f.feature.name.clone();
329 let branch = format!("speccoder/{name}");
330 let root = Path::new(&snapshot.root);
331 let git = Git::at(root);
332 let has_branch = git
333 .succeeds(&[
334 "rev-parse",
335 "--verify",
336 "-q",
337 &format!("refs/heads/{branch}"),
338 ])
339 .unwrap_or(false);
340
341 let mut prefix = String::new();
342 let (parsed, source, log, unmerged) = if has_branch {
343 let top = git.toplevel().ok().flatten();
345 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
346 let rel = top
347 .as_deref()
348 .and_then(|t| {
349 canonical(root)
350 .strip_prefix(canonical(t))
351 .ok()
352 .map(Path::to_path_buf)
353 })
354 .unwrap_or_default();
355 prefix = rel.to_string_lossy().replace('\\', "/");
356 if !prefix.is_empty() {
357 prefix.push('/');
358 }
359 let repo_path = rel
360 .join(&checkout_tasks.path)
361 .to_string_lossy()
362 .replace('\\', "/");
363 let text = git
364 .run(&["show", &format!("{branch}:{repo_path}")])
365 .map(|b| String::from_utf8_lossy(&b).into_owned());
366 let local = text.is_err()
369 && !git
370 .succeeds(&["ls-files", "--error-unmatch", "--", &repo_path])
371 .unwrap_or(false);
372 let parsed = match text {
373 Ok(text) => format.parse_tasks(&checkout_tasks.path, &text).0,
374 Err(_) if local => checkout_tasks.clone(),
375 Err(_) => {
376 return CommandResult::new(
377 "report",
378 CommandStatus::GitPrecondition,
379 &format!("{branch} has no {repo_path}"),
380 );
381 }
382 };
383 let format = format!(
384 "--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}"
385 );
386 let log = git
387 .run(&["log", &format, &branch])
388 .map(|b| String::from_utf8_lossy(&b).into_owned())
389 .unwrap_or_default();
390 let unmerged = git
391 .line(&["rev-list", "--count", &format!("HEAD..{branch}")])
392 .ok()
393 .and_then(|n| n.trim().parse().ok())
394 .unwrap_or(0);
395 (
396 parsed,
397 if local { "checkout" } else { "branch" },
398 log,
399 unmerged,
400 )
401 } else {
402 (checkout_tasks.clone(), "checkout", String::new(), 0)
403 };
404 let local_ticks = has_branch && source == "checkout";
405 let lines = if local_ticks {
406 open_lines(&root.join(&checkout_tasks.path), &parsed)
407 } else {
408 BTreeMap::new()
409 };
410 let (evidence, convergence) = read_log(&log, &name);
411 let saved = crate::attempts::list(root, &name);
412 let skipped = crate::controls::skipped(root, &name);
413 let mut tasks = statuses(&parsed, &evidence, &lines, &saved, &skipped);
414 for a in saved.iter().filter(|a| crate::controls::is_set_aside(a)) {
417 if let Some(t) = tasks
418 .iter_mut()
419 .find(|t| t.id == a.task && t.set_aside.is_none())
420 {
421 t.set_aside = Some(speccoder_core::SetAside {
422 reference: a.reference.clone(),
423 files: crate::controls::changed_files(root, &a.commit),
424 });
425 }
426 }
427 for t in tasks.iter_mut() {
429 if let Some(commit) = t.commit.as_deref() {
430 t.attestation = Some(crate::attest::note_state(root, commit));
431 }
432 }
433 if !crate::drift::mark(root, format, &f.feature.dir, &prefix, &mut tasks) && local_ticks {
434 let now = crate::in_place::intent_digest(
437 root,
438 &crate::tools::policy::Policy::new(&[], &[]).rules(format.rules()),
439 format.messages().tasks_file,
440 );
441 for t in &mut tasks {
442 let judged = evidence.get(&t.id).map(|e| e.intent.as_str());
443 if t.commit.is_some() && judged.is_some_and(|i| !i.is_empty() && i != now) {
444 t.drift = Some(speccoder_core::Drift {
445 requirements: Vec::new(),
446 files: Vec::new(),
447 });
448 }
449 }
450 }
451 let trace = trace(&format.requirement_ids(root, &f.feature.dir), &tasks);
452 let totals = totals(&tasks);
453 let finished = totals.verified
454 + totals.regression_only
455 + totals.attested
456 + totals.ticked_without_evidence
457 + totals.skipped;
458 let done =
459 totals.verified + totals.regression_only + totals.attested + totals.ticked_without_evidence;
460 let parts: Vec<String> = [
463 (totals.verified, "verified"),
464 (totals.regression_only, "regression only"),
465 (totals.attested, "attested"),
466 (totals.ticked_without_evidence, "without evidence"),
467 ]
468 .iter()
469 .filter(|(n, _)| *n > 0)
470 .map(|(n, w)| format!("{n} {w}"))
471 .collect();
472 let open: Vec<String> = [
473 (totals.ready, "ready"),
474 (totals.blocked, "blocked"),
475 (totals.skipped, "skipped"),
476 ]
477 .iter()
478 .filter(|(n, _)| *n > 0)
479 .map(|(n, w)| format!("{n} {w}"))
480 .collect();
481 let mut message = if tasks.is_empty() {
483 format!(
484 "no tasks yet: {} lists none; {}",
485 format.messages().tasks_file,
486 format.messages().make_tasks
487 )
488 } else {
489 format!("{done}/{} done", tasks.len())
490 };
491 let planning: Vec<String> = if tasks.is_empty() {
492 (format.planning().toward_tasks)(f.artifacts.plan)
493 .iter()
494 .map(|s| (*s).to_owned())
495 .collect()
496 } else {
497 Vec::new()
498 };
499 if !parts.is_empty() {
500 message.push_str(&format!(": {}", parts.join(", ")));
501 }
502 if !open.is_empty() {
503 message.push_str(&format!(
504 "{}{}",
505 if parts.is_empty() { ": " } else { "; " },
506 open.join(", ")
507 ));
508 }
509 if !convergence.is_empty() {
510 message.push_str(&format!("; {} convergence round(s)", convergence.len()));
511 }
512 let uncited = trace.iter().filter(|r| r.task.is_none()).count();
513 if uncited > 0 {
514 message.push_str(&format!("; {uncited} requirement(s) no task cites"));
515 }
516 if totals.without_attestation > 0 {
517 message.push_str(&format!(
518 "; {} tick(s) without an attestation",
519 totals.without_attestation
520 ));
521 }
522 if totals.evidence_without_tick > 0 {
523 message.push_str(&format!(
524 "; {} committed without their local tick (the next `run --in-place` ticks them)",
525 totals.evidence_without_tick
526 ));
527 }
528 if totals.stale > 0 {
529 message.push_str(&format!(
530 "; {} tick(s) proven against an earlier wording of their requirements",
531 totals.stale
532 ));
533 }
534 let drifted = tasks.iter().filter(|t| t.drift.is_some()).count() - totals.stale;
535 if drifted > 0 {
536 message.push_str(&format!(
537 "; the spec changed since {drifted}{} tick(s), not in what they cite",
538 if totals.stale > 0 { " other" } else { "" }
539 ));
540 }
541 if local_ticks {
542 message.push_str(&format!(
543 "; ticks are local to this checkout ({} is not in git)",
544 format.messages().tasks_file
545 ));
546 }
547 if has_branch {
548 if unmerged > 0 {
549 message.push_str(&format!("; {unmerged} commit(s) on {branch} not merged"));
550 }
551 } else {
552 message.push_str("; speccoder has not worked on this feature yet");
553 }
554 if let Some(state) = crate::worktree::state_dir() {
556 let plane = crate::worktree::project_state_dir(&state, root);
557 for t in &mut tasks {
558 t.bug = crate::bug::state::load(&plane, &name, &t.id)
559 .map(|b| b.outcome.as_str().to_owned());
560 }
561 }
562 CommandResult::new("report", CommandStatus::Ok, &message).with_data(CommandData::Report(
563 Box::new(StatusReport {
564 review: crate::review::totals_of(root, format, &name, &f.feature.dir),
565 issues: issues_of(root, Some(&name)),
566 feature: name,
567 branch: has_branch.then_some(branch),
568 source: source.to_owned(),
569 selected_by: crate::current::reason_text(snapshot.selection.reason, format),
570 journey: journey_of(&artifacts, &uncommitted, tasks.len(), finished),
571 artifacts,
572 planning,
573 uncommitted,
574 unmerged_commits: unmerged,
575 tasks,
576 convergence,
577 trace,
578 totals,
579 }),
580 ))
581}
582
583#[must_use]
585pub fn show(path: &Path, feature: Option<&str>, task: &str) -> CommandResult {
586 let full = report(path, feature);
587 let Some(CommandData::Report(r)) = full.data() else {
588 return CommandResult::carried("task show", &full);
589 };
590 let Some(t) = r.tasks.iter().find(|t| t.id == task) else {
591 return CommandResult::new(
592 "task show",
593 CommandStatus::NotFound,
594 &format!("{} has no task {task}", r.feature),
595 )
596 .with_code("NOT-FOUND")
597 .with_fix(vec![format!("speccoder report --feature {}", r.feature)]);
598 };
599 let mut one = (**r).clone();
600 one.tasks = vec![t.clone()];
601 one.trace.retain(|row| row.task.as_deref() == Some(task));
602 one.convergence.clear();
603 one.totals = totals(&one.tasks);
604 let message = format!("{task}: {}", t.state.replace('_', " "));
605 CommandResult::ok("task show", &message).with_data(CommandData::Report(Box::new(one)))
606}
607
608fn trace(requirements: &[(String, String)], tasks: &[TaskStatus]) -> Vec<TraceRow> {
611 requirements
612 .iter()
613 .flat_map(|(id, _)| {
614 let rows: Vec<TraceRow> = tasks
615 .iter()
616 .filter(|t| crate::attest::cites(&t.title, id))
617 .map(|t| TraceRow {
618 requirement: id.clone(),
619 scenario: None,
620 test: None,
621 task: Some(t.id.clone()),
622 verdict: Some(t.state.clone()),
623 changed: t
624 .drift
625 .as_ref()
626 .is_some_and(|d| d.requirements.iter().any(|r| r.id == *id)),
627 })
628 .collect();
629 if rows.is_empty() {
630 vec![TraceRow {
631 requirement: id.clone(),
632 scenario: None,
633 test: None,
634 task: None,
635 verdict: None,
636 changed: false,
637 }]
638 } else {
639 rows
640 }
641 })
642 .collect()
643}
644
645fn open_lines(file: &Path, parsed: &ParsedTasks) -> BTreeMap<String, String> {
648 let text = std::fs::read_to_string(file).unwrap_or_default();
649 let lines: Vec<&str> = text.lines().collect();
650 parsed
651 .tasks
652 .iter()
653 .filter_map(|t| {
654 let line = lines.get(usize::try_from(t.line).ok()?.checked_sub(1)?)?;
655 let open = crate::in_place::untick(line.as_bytes());
656 Some((t.id.clone(), crate::integrity::sha256_hex(&open)))
657 })
658 .collect()
659}
660
661fn journey_of(
662 artifacts: &[String],
663 uncommitted: &[String],
664 total: usize,
665 finished: usize,
666) -> Vec<speccoder_core::actions::JourneyStep> {
667 let has = |a: &str| artifacts.iter().any(|x| x == a);
668 speccoder_core::actions::journey(speccoder_core::actions::JourneyFacts {
669 spec: has("spec"),
670 plan: has("plan"),
671 tasks: has("tasks"),
672 uncommitted: !uncommitted.is_empty(),
673 total,
674 finished,
675 })
676}
677
678fn issues_of(root: &Path, feature: Option<&str>) -> Vec<String> {
680 let mut out: Vec<String> = Vec::new();
681 for (_, _, text) in crate::documents::sources(root, feature) {
682 for url in crate::github::issue_urls(&text) {
683 if !out.contains(&url) {
684 out.push(url);
685 }
686 }
687 }
688 out
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 #[test]
696 fn a_requirement_no_task_cites_is_a_row_of_its_own() {
697 let task = |id: &str, title: &str, state: &str| TaskStatus {
698 id: id.to_owned(),
699 title: title.to_owned(),
700 phase: "Phase 3".to_owned(),
701 state: state.to_owned(),
702 drift: None,
703 blocked_by: Vec::new(),
704 commit: None,
705 run_id: None,
706 last_attempt: None,
707 set_aside: None,
708 bug: None,
709 regression: None,
710 note: None,
711 attestation: None,
712 strength: None,
713 };
714 let ids = |v: &[&str]| {
715 v.iter()
716 .map(|i| ((*i).to_owned(), String::new()))
717 .collect::<Vec<_>>()
718 };
719 let tasks = [
720 task("T001", "Parse the config (FR-001, FR-002)", "verified"),
721 task("T002", "Render it per FR-002", "ready"),
722 task("T003", "Unrelated FR-0010 work", "ready"),
723 ];
724 let rows = trace(&ids(&["FR-001", "FR-002", "FR-003"]), &tasks);
725 let shown: Vec<_> = rows
726 .iter()
727 .map(|r| {
728 (
729 r.requirement.as_str(),
730 r.task.as_deref(),
731 r.verdict.as_deref(),
732 )
733 })
734 .collect();
735 assert_eq!(
736 shown,
737 [
738 ("FR-001", Some("T001"), Some("verified")),
739 ("FR-002", Some("T001"), Some("verified")),
740 ("FR-002", Some("T002"), Some("ready")),
741 ("FR-003", None, None),
742 ]
743 );
744 }
745
746 #[test]
747 fn trailers_and_rounds_are_read_newest_first() {
748 let rec = |c: &str, s: &str, t: &str, v: &str, r: &str, b: &str| {
749 format!("{c}{FS}{s}{FS}{t}\n{FS}{v}\n{FS}{r}\n{FS}{FS}{FS}{FS}{FS}{FS}{b}{RS}\n")
750 };
751 let log = [
752 rec(
753 "c4",
754 "speccoder: T003 again",
755 "f/T003",
756 "verified",
757 "run_b",
758 "",
759 ),
760 rec(
761 "c3",
762 "speccoder: converge round 1 (1 task)",
763 "",
764 "",
765 "",
766 "T003 Fix it per FR-1 (missing)\n\nSpec-Feature: f\nRun: run_c\n",
767 ),
768 rec(
769 "c2",
770 "speccoder: T003 first",
771 "f/T003",
772 "regression_only",
773 "run_a",
774 "",
775 ),
776 rec("c1", "init", "", "", "", ""),
777 rec("c0", "other feature", "g/T001", "verified", "x", ""),
778 ]
779 .concat();
780 let (ev, rounds) = read_log(&log, "f");
781 assert_eq!(ev.len(), 1);
782 assert_eq!(
783 (ev["T003"].commit.as_str(), ev["T003"].verdict.as_str()),
784 ("c4", "verified")
785 );
786 assert_eq!(rounds.len(), 1);
787 assert_eq!(rounds[0].tasks, ["T003 Fix it per FR-1 (missing)"]);
788 }
789}