1use std::path::Path;
4
5use speccoder_core::verify::{RedClass, classify_red};
6
7use crate::{sandbox, substrate, tools, verify, worktree};
8
9pub(crate) struct AcceptanceSetup<'a> {
10 pub(crate) rt: &'a tokio::runtime::Runtime,
11 pub(crate) plane: &'a substrate::Plane,
12 pub(crate) project: &'a Path,
13 pub(crate) worktree: &'a Path,
14 pub(crate) tmp: &'a Path,
15 pub(crate) policy: &'a tools::policy::Policy,
16 pub(crate) feature: &'a str,
17 pub(crate) task: &'a str,
18 pub(crate) bundle: serde_json::Value,
19 pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
21 pub(crate) instructions: &'a [crate::instructions::Instruction],
23 pub(crate) runner: &'a sandbox::Runner,
24}
25
26#[derive(Clone, serde::Serialize, serde::Deserialize)]
27pub(crate) struct PreparedAcceptance {
28 pub(crate) report: serde_json::Value,
30 pub(crate) argv: Option<Vec<String>>,
32 pub(crate) red: bool,
35 #[serde(default)]
37 pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
38 #[serde(default)]
41 pub(crate) held_out: Option<String>,
42 #[serde(default)]
45 pub(crate) generation_failed: Option<String>,
46}
47
48pub(crate) const MANIFESTS: &[&str] = &[
49 "Cargo.toml",
50 "package.json",
51 "pyproject.toml",
52 "go.mod",
53 "pom.xml",
54];
55
56pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
57 use serde_json::json;
58 let skipped = |why: String, report: serde_json::Value| {
59 let mut report = report;
60 report["skipped"] = json!(why);
61 PreparedAcceptance {
62 report,
63 argv: None,
64 red: false,
65 red_class: None,
66 held_out: None,
67 generation_failed: None,
68 }
69 };
70 let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
71 generation_failed: Some(why.clone()),
72 ..skipped(why, report)
73 };
74
75 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
76 .and_then(|c| worktree::files(a.worktree, &c))
77 .unwrap_or_default()
78 .into_iter()
79 .take(400)
80 .collect();
81 let manifest = MANIFESTS
82 .iter()
83 .find_map(|m| {
84 std::fs::read_to_string(a.project.join(m))
85 .ok()
86 .map(|t| format!("{m}:\n{t}"))
87 })
88 .map(|t| t.chars().take(8192).collect::<String>())
89 .unwrap_or_default();
90 let input = substrate::acceptance::input(
91 a.bundle.clone(),
92 a.scenarios,
93 &files,
94 &manifest,
95 a.instructions,
96 );
97 let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
98 let outcome = match outcome {
99 Ok(o) => o,
100 Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
101 };
102 let output = outcome
103 .output
104 .as_ref()
105 .map(|o| o.peek().clone())
106 .unwrap_or_default();
107 let answer = output["answer"].clone();
108 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
109 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
110 let status = format!("{:?}", outcome.status);
111 let why = substrate::provider_refusal(&status).map_or_else(
112 || format!("generation did not succeed: {}", outcome.status.as_str()),
113 |r| format!("the model provider refused the request: {r}"),
114 );
115 return failed(why, base);
116 }
117
118 let protected = a.policy.protected();
120 let mut written = Vec::new();
121 let mut planned = Vec::new();
122 let mut tests = Vec::new();
124 for f in answer["files"].as_array().into_iter().flatten() {
125 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
126 continue;
127 };
128 let rel = match tools::paths::normalise(path) {
129 Ok(r) => r,
130 Err(e) => return skipped(format!("`{path}`: {}", e.0), base),
131 };
132 if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
133 return skipped(format!("`{rel}` is not a new test file"), base);
134 }
135 if a.project.join(&rel).exists() {
136 return skipped(format!("`{rel}` already exists"), base);
137 }
138 let cited: Vec<&str> = f["scenarios"]
139 .as_array()
140 .into_iter()
141 .flatten()
142 .filter_map(|v| v.as_str())
143 .filter(|id| a.scenarios.iter().any(|s| s.id == *id))
144 .collect();
145 tests.push(json!({ "path": rel, "scenarios": cited }));
146 planned.push((rel, content.to_owned()));
147 }
148 if planned.is_empty() {
149 return skipped("no acceptance tests were generated".to_owned(), base);
150 }
151 let argv: Vec<String> = answer["argv"]
152 .as_array()
153 .into_iter()
154 .flatten()
155 .filter_map(|v| v.as_str().map(str::to_owned))
156 .collect();
157 if a.policy.classify("run_command", &json!({ "argv": argv })) != tools::policy::Decision::Allow
158 {
159 return skipped(
160 format!("`{}` is not an allowed command", argv.join(" ")),
161 base,
162 );
163 }
164 for (rel, content) in &planned {
165 let full = a.project.join(rel);
166 if let Some(parent) = full.parent() {
167 let _ = std::fs::create_dir_all(parent);
168 }
169 if let Err(e) = std::fs::write(&full, content) {
170 return skipped(format!("`{rel}`: {e}"), base);
171 }
172 written.push(rel.clone());
173 }
174
175 let (first, summary) = execute(a.project, &argv, a.runner);
179 let mut class = classify_red(&first, summary.as_ref());
180 if class.is_red() {
181 let (second, summary) = execute(a.project, &argv, a.runner);
182 if !classify_red(&second, summary.as_ref()).is_red() {
183 class = RedClass::Flaky;
184 }
185 }
186 let red = class.is_red();
187 let mut report = base;
188 report["files"] = json!(written);
189 report["tests"] = json!(tests);
190 report["argv"] = json!(argv);
191 report["red"] = json!(red);
192 report["red_class"] = json!(class);
193 if !red {
194 report["note"] = json!(match class {
195 RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
196 RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
197 other => format!(
198 "the acceptance tests did not fail on an assertion ({}): not demonstrated",
199 class_word(other)
200 ),
201 });
202 report["excerpt"] = json!(excerpt(&first));
203 }
204 PreparedAcceptance {
205 report,
206 argv: Some(argv),
207 red,
208 red_class: Some(class),
209 held_out: None,
210 generation_failed: None,
211 }
212}
213
214pub(crate) fn class_word(class: RedClass) -> String {
216 serde_json::to_value(class)
217 .ok()
218 .and_then(|v| v.as_str().map(str::to_owned))
219 .unwrap_or_default()
220}
221
222fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
225 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
226 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
227 let from = lines
228 .iter()
229 .position(|l| {
230 let l = l.to_lowercase();
231 l.contains("error") || l.contains("fail")
232 })
233 .unwrap_or_else(|| lines.len().saturating_sub(20));
234 lines
235 .iter()
236 .skip(from)
237 .take(20)
238 .copied()
239 .collect::<Vec<_>>()
240 .join("\n")
241}
242
243pub(crate) fn acceptance_run(
245 project: &Path,
246 argv: &[String],
247 runner: &sandbox::Runner,
248) -> (
249 speccoder_core::verify::CheckResult,
250 Option<speccoder_core::verify::RunnerSummary>,
251) {
252 execute(project, argv, runner)
253}
254
255pub(crate) fn run_acceptance(
256 project: &Path,
257 argv: &[String],
258 runner: &sandbox::Runner,
259) -> speccoder_core::verify::CheckResult {
260 execute(project, argv, runner).0
261}
262
263fn execute(
266 project: &Path,
267 argv: &[String],
268 runner: &sandbox::Runner,
269) -> (
270 speccoder_core::verify::CheckResult,
271 Option<speccoder_core::verify::RunnerSummary>,
272) {
273 use speccoder_core::verify::{CheckResult, CheckStatus};
274 use verify::process::CommandRunner;
275 let out = runner.run(&verify::process::Invocation {
276 argv: argv.to_vec(),
277 cwd: project.to_path_buf(),
278 env: verify::process::scrubbed_env(std::env::vars()),
279 timeout: std::time::Duration::from_secs(600),
280 output_cap: verify::process::OUTPUT_CAP,
281 });
282 let status = if out.spawn_error.is_some() {
283 CheckStatus::Error
284 } else if out.timed_out {
285 CheckStatus::TimedOut
286 } else if out.exit_code == Some(0) {
287 CheckStatus::Passed
288 } else {
289 CheckStatus::Failed
290 };
291 let summary = verify::summary::parse(
292 &String::from_utf8_lossy(&out.stdout.kept),
293 &String::from_utf8_lossy(&out.stderr.kept),
294 out.exit_code,
295 );
296 let tail = |b: &[u8]| {
297 let start = b.len().saturating_sub(4096);
298 String::from_utf8_lossy(&b[start..]).into_owned()
299 };
300 let result = CheckResult {
301 name: "acceptance".to_owned(),
302 argv: argv.to_vec(),
303 status,
304 exit_code: out.exit_code,
305 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
306 junit: false,
307 tests: None,
308 stdout_tail: tail(&out.stdout.kept),
309 stderr_tail: tail(&out.stderr.kept),
310 truncated: out.stdout.truncated() || out.stderr.truncated(),
311 note: out.spawn_error,
312 flaky: false,
313 };
314 (result, summary)
315}
316
317pub(crate) fn prepare_held_out(
321 a: &AcceptanceSetup<'_>,
322 visible: &PreparedAcceptance,
323 scenarios: &[speccoder_core::spec::ScenarioRef],
324 store: &Path,
325 composition: bool,
326) -> (serde_json::Value, Option<String>) {
327 use serde_json::json;
328 let absent = |reason: &str, mut report: serde_json::Value| {
329 report["state"] = json!("absent");
330 report["reason"] = json!(reason);
331 (report, None)
332 };
333 let visible_files: Vec<(String, String)> = visible.report["files"]
334 .as_array()
335 .into_iter()
336 .flatten()
337 .filter_map(|p| p.as_str())
338 .map(|p| {
339 (
340 p.to_owned(),
341 std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
342 )
343 })
344 .collect();
345 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
346 .and_then(|c| worktree::files(a.worktree, &c))
347 .unwrap_or_default()
348 .into_iter()
349 .take(400)
350 .collect();
351 let manifest_text = MANIFESTS
352 .iter()
353 .find_map(|m| {
354 std::fs::read_to_string(a.project.join(m))
355 .ok()
356 .map(|t| format!("{m}:\n{t}"))
357 })
358 .map(|t| t.chars().take(8192).collect::<String>())
359 .unwrap_or_default();
360 let input = substrate::acceptance::held_out_input(
361 a.bundle.clone(),
362 &files,
363 &manifest_text,
364 scenarios,
365 &visible_files,
366 composition,
367 a.instructions,
368 );
369 let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
370 Ok(o) => o,
371 Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
372 };
373 let output = outcome
374 .output
375 .as_ref()
376 .map(|o| o.peek().clone())
377 .unwrap_or_default();
378 let answer = output["answer"].clone();
379 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
380 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
381 return absent("generation_failed", base);
382 }
383
384 let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
387 let protected = a.policy.protected();
388 let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
389 let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
390 let mut discard = |path: &str, reason: &str| {
391 discarded.push(crate::held_out::Discarded {
392 path: path.to_owned(),
393 reason: reason.to_owned(),
394 });
395 };
396 let given = answer["files"].as_array().cloned().unwrap_or_default();
397 if given.is_empty() {
398 return absent("none_generated", base);
399 }
400 for f in &given {
401 let path = f["path"].as_str().unwrap_or_default();
402 let content = f["content"].as_str().unwrap_or_default();
403 let cited: Vec<String> = f["scenarios"]
404 .as_array()
405 .into_iter()
406 .flatten()
407 .filter_map(|s| s.as_str().map(str::to_owned))
408 .collect();
409 let argv: Vec<String> = f["argv"]
410 .as_array()
411 .into_iter()
412 .flatten()
413 .filter_map(|s| s.as_str().map(str::to_owned))
414 .collect();
415 let rel = match tools::paths::normalise(path) {
416 Ok(r) => r,
417 Err(_) => {
418 discard(path, "path");
419 continue;
420 }
421 };
422 let taken =
423 visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
424 if !tools::paths::is_test_path(&rel)
425 || protected.write_class(&rel).is_some()
426 || a.project.join(&rel).exists()
427 || taken
428 {
429 discard(&rel, "path");
430 continue;
431 }
432 if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
433 discard(&rel, "unknown_scenario");
434 continue;
435 }
436 let composed = f["composition"].as_bool().unwrap_or(false);
437 if composed && (!composition || cited.len() < 2) {
438 discard(&rel, "composition");
439 continue;
440 }
441 if a.policy.classify("run_command", &json!({ "argv": argv }))
442 != tools::policy::Decision::Allow
443 {
444 discard(&rel, "command");
445 continue;
446 }
447 if kept.len() >= crate::held_out::LIMIT {
448 discard(&rel, "limit");
449 continue;
450 }
451 kept.push((
452 crate::held_out::HeldOutFile {
453 path: rel,
454 sha256: crate::integrity::sha256_hex(content.as_bytes()),
455 scenarios: cited,
456 argv,
457 composition: composed,
458 },
459 content.to_owned(),
460 ));
461 }
462
463 let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
465 .map(|c| c.0)
466 .unwrap_or_default();
467 match crate::held_out::overlay_run(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
468 Ok(runs) => {
469 let mut red = Vec::new();
470 for ((file, content), ran) in kept.into_iter().zip(runs) {
471 match ran.result {
472 None => discard(&file.path, "collision"),
473 Some((result, summary)) if classify_red(&result, summary.as_ref()).is_red() => {
474 red.push((file, content));
475 }
476 Some(_) => discard(&file.path, "not_red"),
477 }
478 }
479 kept = red;
480 }
481 Err(e) => {
482 return absent(
483 "generation_failed",
484 json!({ "error": e, "run_id": base["run_id"] }),
485 );
486 }
487 }
488 let mut report = base;
489 report["discarded"] = json!(discarded);
490 if kept.is_empty() {
491 return absent("all_discarded", report);
492 }
493 let manifest = crate::held_out::Manifest {
494 version: 1,
495 feature: a.feature.to_owned(),
496 task: a.task.to_owned(),
497 author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
498 checkpoint,
499 visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
500 files: kept.iter().map(|(f, _)| f.clone()).collect(),
501 discarded,
502 };
503 let contents: Vec<(String, String)> = kept
504 .iter()
505 .map(|(f, c)| (f.path.clone(), c.clone()))
506 .collect();
507 match crate::held_out::store(store, &manifest, &contents) {
508 Ok(sha) => {
509 report["state"] = json!("on");
510 report["manifest"] = json!(format!("sha256:{sha}"));
511 report["kept"] = json!(kept.len());
512 (report, Some(sha))
513 }
514 Err(e) => absent("generation_failed", json!({ "error": e })),
515 }
516}
517
518pub(crate) struct HeldOutJudged {
522 pub(crate) held_out: speccoder_core::verify::HeldOut,
524 pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
526}
527
528#[allow(clippy::too_many_arguments)]
529pub(crate) fn judge_held_out(
530 store: &Path,
531 manifest_sha: &str,
532 scenarios: &[speccoder_core::spec::ScenarioRef],
533 known_text: &str,
534 wt_root: &Path,
535 project: &Path,
536 tmp: &Path,
537 start: &worktree::Checkpoint,
538 patch: &str,
539 runner: &sandbox::Runner,
540 policy: &tools::policy::Policy,
541) -> Result<HeldOutJudged, (&'static str, String)> {
542 use speccoder_core::verify::{CheckStatus, HeldOut};
543 let (manifest, contents) =
544 crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
545 let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
546 .files
547 .iter()
548 .cloned()
549 .zip(contents.into_iter().map(|(_, c)| c))
550 .collect();
551 let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
552 if !leaks.is_empty() {
553 return Err((
554 "leak_suspected",
555 format!(
556 "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
557 leaks.len()
558 ),
559 ));
560 }
561 let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
562 .map_err(|e| ("tampered", e))?;
563 let mut failed_ids: Vec<String> = Vec::new();
564 for ((file, _), ran) in files.iter().zip(&runs) {
565 let passed = ran
566 .result
567 .as_ref()
568 .is_some_and(|(r, _)| r.status == CheckStatus::Passed);
569 if !passed {
570 failed_ids.extend(file.scenarios.iter().cloned());
571 }
572 }
573 failed_ids.sort();
574 failed_ids.dedup();
575 let held_out = if failed_ids.is_empty() {
576 HeldOut::Passed { files: files.len() }
577 } else {
578 HeldOut::Failed {
579 scenarios: failed_ids.clone(),
580 }
581 };
582 let failed = failed_ids
583 .iter()
584 .map(|id| {
585 scenarios
586 .iter()
587 .find(|s| &s.id == id)
588 .cloned()
589 .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
590 id: id.clone(),
591 text: String::new(),
592 })
593 })
594 .collect();
595 Ok(HeldOutJudged { held_out, failed })
596}
597
598pub(crate) struct Strengthened {
600 pub(crate) files: Vec<String>,
602 pub(crate) argv: Vec<String>,
604 pub(crate) report: serde_json::Value,
606}
607
608pub(crate) fn strengthen(
613 a: &AcceptanceSetup<'_>,
614 visible: &[(String, String)],
615 weak: serde_json::Value,
616 before: &worktree::Checkpoint,
617 after: &worktree::Checkpoint,
618) -> Result<Strengthened, String> {
619 use serde_json::json;
620 let files: Vec<String> = worktree::files(a.worktree, after)
621 .unwrap_or_default()
622 .into_iter()
623 .take(400)
624 .collect();
625 let manifest = MANIFESTS
626 .iter()
627 .find_map(|m| {
628 std::fs::read_to_string(a.project.join(m))
629 .ok()
630 .map(|t| format!("{m}:\n{t}"))
631 })
632 .map(|t| t.chars().take(8192).collect::<String>())
633 .unwrap_or_default();
634 let input = substrate::acceptance::strengthen_input(
635 a.bundle.clone(),
636 a.scenarios,
637 &files,
638 &manifest,
639 visible,
640 weak,
641 a.instructions,
642 );
643 let outcome =
644 a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
645 .map_err(|e| format!("the test author failed: {}", e.0))?;
646 let output = outcome
647 .output
648 .as_ref()
649 .map(|o| o.peek().clone())
650 .unwrap_or_default();
651 let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
652 let answer = &output["answer"];
653 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
654 return Err(format!(
655 "the test author did not answer ({})",
656 outcome.status.as_str()
657 ));
658 }
659 let protected = a.policy.protected();
660 let mut planned = Vec::new();
661 for f in answer["files"].as_array().into_iter().flatten() {
662 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
663 continue;
664 };
665 let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
666 if !tools::paths::is_test_path(&rel)
667 || protected.write_class(&rel).is_some()
668 || a.project.join(&rel).exists()
669 {
670 return Err(format!("`{rel}` is not a new test file"));
671 }
672 planned.push((rel, content.to_owned()));
673 }
674 if planned.is_empty() {
675 return Err("the test author added no tests".to_owned());
676 }
677 let argv: Vec<String> = answer["argv"]
678 .as_array()
679 .into_iter()
680 .flatten()
681 .filter_map(|v| v.as_str().map(str::to_owned))
682 .collect();
683 if a.policy.classify("run_command", &json!({ "argv": argv })) != tools::policy::Decision::Allow
684 {
685 return Err(format!("`{}` is not an allowed command", argv.join(" ")));
686 }
687 let write = || -> Result<(), String> {
688 for (rel, content) in &planned {
689 let full = a.project.join(rel);
690 if let Some(parent) = full.parent() {
691 let _ = std::fs::create_dir_all(parent);
692 }
693 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
694 }
695 Ok(())
696 };
697 let back =
698 |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
699 back(before)?;
701 let red = write().map(|()| {
702 let (r, summary) = execute(a.project, &argv, a.runner);
703 classify_red(&r, summary.as_ref())
704 });
705 back(after)?;
706 let red = red?;
707 if !red.is_red() {
708 return Err(format!(
709 "the added tests do not fail without the change ({})",
710 class_word(red)
711 ));
712 }
713 write()?;
715 let (green, _) = execute(a.project, &argv, a.runner);
716 if green.status != speccoder_core::verify::CheckStatus::Passed {
717 back(after)?;
718 let cited: Vec<String> = answer["files"]
719 .as_array()
720 .into_iter()
721 .flatten()
722 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
723 .filter_map(|v| v.as_str().map(str::to_owned))
724 .collect();
725 return Err(format!(
726 "the added tests fail the implementation{}",
727 if cited.is_empty() {
728 String::new()
729 } else {
730 format!(" (scenarios {})", cited.join(", "))
731 }
732 ));
733 }
734 Ok(Strengthened {
735 files: planned.into_iter().map(|(p, _)| p).collect(),
736 argv,
737 report,
738 })
739}
740
741pub(crate) fn story_checkpoint(
744 tasks: Option<&speccoder_core::snapshot::ParsedTasks>,
745 task: &str,
746 scenarios: &[speccoder_core::spec::ScenarioRef],
747) -> bool {
748 let Some(tasks) = tasks else { return false };
749 let Some(label) = tasks
750 .tasks
751 .iter()
752 .find(|t| t.id == task)
753 .and_then(|t| t.story.as_deref())
754 else {
755 return false;
756 };
757 let last = tasks
758 .tasks
759 .iter()
760 .rfind(|t| t.story.as_deref() == Some(label))
761 .map(|t| t.id.as_str());
762 last == Some(task) && scenarios.len() >= 2
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn the_last_task_of_a_story_with_two_scenarios_composes() {
771 let (tasks, _) = speccoder_speckit::tasks::parse(
772 "tasks.md",
773 "## Phase 3: User Story 1 - Login\n\n- [ ] T005 [US1] a\n- [ ] T006 [US1] b\n\n## Phase 4: User Story 2 - X\n\n- [ ] T007 [US2] c\n",
774 );
775 let two: Vec<speccoder_core::spec::ScenarioRef> = ["US1-1", "US1-2"]
776 .iter()
777 .map(|id| speccoder_core::spec::ScenarioRef {
778 id: (*id).to_owned(),
779 text: String::new(),
780 })
781 .collect();
782 assert!(story_checkpoint(Some(&tasks), "T006", &two));
783 assert!(!story_checkpoint(Some(&tasks), "T005", &two));
784 assert!(!story_checkpoint(Some(&tasks), "T006", &two[..1]));
785 assert!(!story_checkpoint(None, "T006", &two));
786 }
787}