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 pub(crate) layout: Option<&'a crate::layout::TestLayout>,
27 pub(crate) story_files: &'a [String],
30}
31
32#[derive(Clone, serde::Serialize, serde::Deserialize)]
33pub(crate) struct PreparedAcceptance {
34 pub(crate) report: serde_json::Value,
36 pub(crate) argv: Option<Vec<String>>,
38 #[serde(default)]
41 pub(crate) more_argv: Vec<Vec<String>>,
42 pub(crate) red: bool,
45 #[serde(default)]
47 pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
48 #[serde(default)]
51 pub(crate) held_out: Option<String>,
52 #[serde(default)]
55 pub(crate) generation_failed: Option<String>,
56 #[serde(default)]
59 pub(crate) skip_cause: Option<speccoder_core::regression::SkipCause>,
60}
61
62pub(crate) const MANIFESTS: &[&str] = &[
63 "Cargo.toml",
64 "package.json",
65 "pyproject.toml",
66 "go.mod",
67 "pom.xml",
68];
69
70pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
71 use serde_json::json;
72 use speccoder_core::regression::SkipCause;
73 let skipped_by = |why: String, cause: Option<SkipCause>, report: serde_json::Value| {
74 let mut report = report;
75 report["skipped"] = json!(why);
76 PreparedAcceptance {
77 report,
78 argv: None,
79 more_argv: Vec::new(),
80 red: false,
81 red_class: None,
82 held_out: None,
83 generation_failed: None,
84 skip_cause: cause,
85 }
86 };
87 let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
88 generation_failed: Some(why.clone()),
89 ..skipped_by(why, None, report)
90 };
91
92 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
93 .and_then(|c| worktree::files(a.worktree, &c))
94 .unwrap_or_default()
95 .into_iter()
96 .take(400)
97 .collect();
98 let manifest = MANIFESTS
99 .iter()
100 .find_map(|m| {
101 std::fs::read_to_string(a.project.join(m))
102 .ok()
103 .map(|t| format!("{m}:\n{t}"))
104 })
105 .map(|t| t.chars().take(8192).collect::<String>())
106 .unwrap_or_default();
107 let placement = match placement(a) {
111 Ok(p) => p,
112 Err(()) => {
113 return skipped_by(
114 "no place the runner collects".to_owned(),
115 Some(SkipCause::NoPlace),
116 json!({}),
117 );
118 }
119 };
120 let input = substrate::acceptance::input(
121 a.bundle.clone(),
122 a.scenarios,
123 &files,
124 &manifest,
125 a.instructions,
126 placement.as_ref(),
127 );
128 let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
129 let outcome = match outcome {
130 Ok(o) => o,
131 Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
132 };
133 let output = outcome
134 .output
135 .as_ref()
136 .map(|o| o.peek().clone())
137 .unwrap_or_default();
138 let answer = output["answer"].clone();
139 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
140 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
141 let status = format!("{:?}", outcome.status);
142 let why = substrate::provider_refusal(&status).map_or_else(
143 || format!("generation did not succeed: {}", outcome.status.as_str()),
144 |r| format!("the model provider refused the request: {r}"),
145 );
146 return failed(why, base);
147 }
148
149 let mut written = Vec::new();
151 let story = placement
152 .as_ref()
153 .and_then(|p| p.existing.as_deref().map(|old| (p.path.as_str(), old)));
154 let (valid, refused) = validate_with(&answer, a.project, a.policy, a.scenarios, story);
155 if let Some(r) = refused.first() {
156 return skipped_by(
157 r.message.clone(),
158 Some(SkipCause::Refused {
159 path: r.path.clone(),
160 why: refusal_words(r.why).to_owned(),
161 }),
162 base,
163 );
164 }
165 let tests: Vec<serde_json::Value> = valid
167 .iter()
168 .map(|f| json!({ "path": f.path, "scenarios": f.cited }))
169 .collect();
170 let planned: Vec<(String, String)> = valid.into_iter().map(|f| (f.path, f.content)).collect();
171 if planned.is_empty() {
172 return skipped_by(
173 "no acceptance tests were generated".to_owned(),
174 Some(SkipCause::NoFiles),
175 base,
176 );
177 }
178 let argv: Vec<String> = answer["argv"]
179 .as_array()
180 .into_iter()
181 .flatten()
182 .filter_map(|v| v.as_str().map(str::to_owned))
183 .collect();
184 let paths: Vec<&str> = planned.iter().map(|(p, _)| p.as_str()).collect();
185 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
186 return skipped_by(
187 format!("`{}` is not an allowed command", argv.join(" ")),
188 Some(SkipCause::CommandNotAllowed {
189 argv: argv.join(" "),
190 }),
191 base,
192 );
193 }
194 for (rel, content) in &planned {
195 let full = a.project.join(rel);
196 if let Some(parent) = full.parent() {
197 let _ = std::fs::create_dir_all(parent);
198 }
199 if let Err(e) = std::fs::write(&full, content) {
200 return skipped_by(
201 format!("`{rel}`: {e}"),
202 Some(SkipCause::WriteFailed { path: rel.clone() }),
203 base,
204 );
205 }
206 written.push(rel.clone());
207 }
208
209 let (first, summary) = execute(a.project, &argv, a.runner);
213 let red_tests = speccoder_core::verify::collected_count(&first, summary.as_ref());
214 let mut class = classify_red(&first, summary.as_ref());
215 if let Some((path, old)) = story.filter(|(p, _)| written.iter().any(|w| w == p)) {
218 let full = a.project.join(path);
219 if let Ok(new) = std::fs::read_to_string(&full)
220 && std::fs::write(&full, old).is_ok()
221 {
222 let (without, wsum) = execute(a.project, &argv, a.runner);
223 class = if std::fs::write(&full, &new).is_err()
225 || std::fs::read_to_string(&full).ok().as_deref() != Some(new.as_str())
226 {
227 RedClass::Unclassified
228 } else {
229 speccoder_core::verify::classify_red_added(
230 (&first, summary.as_ref()),
231 (&without, wsum.as_ref()),
232 )
233 };
234 }
235 }
236 if class.is_red() {
237 let (second, summary) = execute(a.project, &argv, a.runner);
238 if !classify_red(&second, summary.as_ref()).is_red() {
239 class = RedClass::Flaky;
240 }
241 }
242 let red = class.is_red();
243 let mut report = base;
244 report["files"] = json!(written);
245 if let Some(p) = placement.as_ref().filter(|p| p.story.is_some()) {
246 report["story_file"] = json!(p.path);
247 report["appended"] = json!(p.existing.is_some());
248 }
249 report["tests"] = json!(tests);
250 report["argv"] = json!(argv);
251 report["red"] = json!(red);
252 report["red_class"] = json!(class);
253 if let Some(n) = red_tests {
254 report["red_tests"] = json!(n);
255 }
256 if red {
257 report["red_output"] = json!(
259 format!("{}\n{}", first.stdout_tail, first.stderr_tail)
260 .trim()
261 .to_owned()
262 );
263 } else {
264 report["note"] = json!(match class {
265 RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
266 RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
267 other => format!(
268 "the acceptance tests did not fail on an assertion ({}): not demonstrated",
269 class_word(other)
270 ),
271 });
272 report["excerpt"] = json!(excerpt(&first));
273 }
274 PreparedAcceptance {
275 report,
276 argv: Some(argv),
277 more_argv: Vec::new(),
278 red,
279 red_class: Some(class),
280 held_out: None,
281 generation_failed: None,
282 skip_cause: None,
283 }
284}
285
286fn placement(a: &AcceptanceSetup<'_>) -> Result<Option<substrate::acceptance::Placement>, ()> {
290 let Some(layout) = a.layout.filter(|l| !l.runners.is_empty()) else {
291 return Ok(None);
292 };
293 let task = a.task.to_owned();
294 let story = a.bundle["task"]["story"]
295 .as_str()
296 .filter(|s| !s.is_empty())
297 .map(str::to_owned);
298 let package =
300 speccoder_core::scope::named_paths(a.bundle["task"]["title"].as_str().unwrap_or_default())
301 .into_iter()
302 .find(|p| p.ends_with(".go") || !p.contains('.'))
303 .map(|p| {
304 if p.ends_with(".go") {
305 p.rsplit_once('/')
306 .map_or(String::new(), |(d, _)| d.to_owned())
307 } else {
308 p.trim_end_matches('/').to_owned()
309 }
310 });
311 let per_task =
312 |layout: &crate::layout::TestLayout| layout.story_file(&task, package.as_deref());
313 let (path, story, existing) = match story.as_ref() {
314 Some(st) => {
315 let Some(path) = layout.story_file(st, package.as_deref()) else {
316 return Err(());
317 };
318 let exists = a.project.join(&path).exists();
319 if !exists {
320 (path, story.clone(), None)
321 } else if a.story_files.contains(&path) {
322 let old = std::fs::read_to_string(a.project.join(&path)).unwrap_or_default();
323 (path, story.clone(), Some(old))
324 } else {
325 (per_task(layout).ok_or(())?, None, None)
327 }
328 }
329 None => (per_task(layout).ok_or(())?, None, None),
330 };
331 let comment = if path.ends_with(".py") { "#" } else { "//" }.to_owned();
332 Ok(Some(substrate::acceptance::Placement {
333 command: layout.command_for(&path).unwrap_or_default(),
334 path,
335 existing,
336 task,
337 story,
338 scenarios: a.scenarios.iter().map(|s| s.id.clone()).collect(),
339 comment,
340 layout: layout.text(),
341 }))
342}
343
344pub(crate) fn refusal_words(why: &str) -> &'static str {
346 match why {
347 "exists" => "already exists",
348 "not_collected" => "is not where the runner collects tests",
349 "changes_existing" => "changes existing lines",
350 "skip_marker" => "adds a section that skips a test",
351 _ => "is not a new test file",
352 }
353}
354
355pub(crate) struct ValidFile {
357 pub(crate) path: String,
359 pub(crate) content: String,
360 pub(crate) cited: Vec<String>,
362 pub(crate) argv: Vec<String>,
364}
365
366pub(crate) struct RefusedFile {
368 pub(crate) path: String,
370 pub(crate) why: &'static str,
372 pub(crate) message: String,
374}
375
376pub(crate) fn test_command_allowed(
380 policy: &tools::policy::Policy,
381 layout: Option<&crate::layout::TestLayout>,
382 argv: &[String],
383 files: &[&str],
384) -> bool {
385 layout.is_some_and(|l| l.runs_only(argv, files))
386 || policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
387 == tools::policy::Decision::Allow
388}
389
390pub(crate) fn validate_answer(
394 answer: &serde_json::Value,
395 project: &Path,
396 policy: &tools::policy::Policy,
397 scenarios: &[speccoder_core::spec::ScenarioRef],
398) -> (Vec<ValidFile>, Vec<RefusedFile>) {
399 validate_with(answer, project, policy, scenarios, None)
400}
401
402pub(crate) fn validate_with(
405 answer: &serde_json::Value,
406 project: &Path,
407 policy: &tools::policy::Policy,
408 scenarios: &[speccoder_core::spec::ScenarioRef],
409 story: Option<(&str, &str)>,
410) -> (Vec<ValidFile>, Vec<RefusedFile>) {
411 let protected = policy.protected();
412 let mut valid = Vec::new();
413 let mut refused = Vec::new();
414 for f in answer["files"].as_array().into_iter().flatten() {
415 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
416 continue;
417 };
418 let rel = match tools::paths::normalise(path) {
419 Ok(r) => r,
420 Err(e) => {
421 refused.push(RefusedFile {
422 path: path.to_owned(),
423 why: "not_a_new_test_file",
424 message: format!("`{path}`: {}", e.0),
425 });
426 continue;
427 }
428 };
429 if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
430 refused.push(RefusedFile {
431 message: format!("`{rel}` is not a new test file"),
432 path: rel,
433 why: "not_a_new_test_file",
434 });
435 continue;
436 }
437 match story {
438 Some((path, old)) if path == rel => {
439 match crate::integrity::appended_section(old, content) {
442 None => {
443 refused.push(RefusedFile {
444 message: format!("changes existing lines of `{rel}`"),
445 path: rel,
446 why: "changes_existing",
447 });
448 continue;
449 }
450 Some(added) if crate::integrity::has_skip_marker(added) => {
451 refused.push(RefusedFile {
452 message: format!("`{rel}`: the added section skips a test"),
453 path: rel,
454 why: "skip_marker",
455 });
456 continue;
457 }
458 Some(_) => {}
459 }
460 }
461 _ if project.join(&rel).exists() => {
462 refused.push(RefusedFile {
463 message: format!("`{rel}` already exists"),
464 path: rel,
465 why: "exists",
466 });
467 continue;
468 }
469 _ => {
470 if crate::collection::statically_refused(project, &rel, content).is_some() {
472 refused.push(RefusedFile {
473 message: format!("`{rel}` is not where the runner collects tests"),
474 path: rel,
475 why: "not_collected",
476 });
477 continue;
478 }
479 }
480 }
481 let cited: Vec<String> = f["scenarios"]
482 .as_array()
483 .into_iter()
484 .flatten()
485 .filter_map(|v| v.as_str())
486 .filter(|id| scenarios.iter().any(|s| s.id == *id))
487 .map(str::to_owned)
488 .collect();
489 let argv: Vec<String> = f["argv"]
490 .as_array()
491 .into_iter()
492 .flatten()
493 .filter_map(|v| v.as_str().map(str::to_owned))
494 .collect();
495 valid.push(ValidFile {
496 path: rel,
497 content: content.to_owned(),
498 cited,
499 argv,
500 });
501 }
502 (valid, refused)
503}
504
505pub(crate) fn class_word(class: RedClass) -> String {
507 serde_json::to_value(class)
508 .ok()
509 .and_then(|v| v.as_str().map(str::to_owned))
510 .unwrap_or_default()
511}
512
513fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
516 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
517 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
518 let from = lines
519 .iter()
520 .position(|l| {
521 let l = l.to_lowercase();
522 l.contains("error") || l.contains("fail")
523 })
524 .unwrap_or_else(|| lines.len().saturating_sub(20));
525 lines
526 .iter()
527 .skip(from)
528 .take(20)
529 .copied()
530 .collect::<Vec<_>>()
531 .join("\n")
532}
533
534pub(crate) fn acceptance_run(
536 project: &Path,
537 argv: &[String],
538 runner: &sandbox::Runner,
539) -> (
540 speccoder_core::verify::CheckResult,
541 Option<speccoder_core::verify::RunnerSummary>,
542) {
543 execute(project, argv, runner)
544}
545
546pub(crate) fn run_acceptance(
547 project: &Path,
548 argv: &[String],
549 runner: &sandbox::Runner,
550) -> speccoder_core::verify::CheckResult {
551 execute(project, argv, runner).0
552}
553
554fn execute(
557 project: &Path,
558 argv: &[String],
559 runner: &sandbox::Runner,
560) -> (
561 speccoder_core::verify::CheckResult,
562 Option<speccoder_core::verify::RunnerSummary>,
563) {
564 use speccoder_core::verify::{CheckResult, CheckStatus};
565 use verify::process::CommandRunner;
566 let out = runner.run(&verify::process::Invocation {
567 argv: argv.to_vec(),
568 cwd: project.to_path_buf(),
569 env: verify::process::scrubbed_env(std::env::vars()),
570 timeout: std::time::Duration::from_secs(600),
571 output_cap: verify::process::OUTPUT_CAP,
572 });
573 let status = if out.spawn_error.is_some() {
574 CheckStatus::Error
575 } else if out.timed_out {
576 CheckStatus::TimedOut
577 } else if out.exit_code == Some(0) {
578 CheckStatus::Passed
579 } else {
580 CheckStatus::Failed
581 };
582 let summary = verify::summary::parse(
583 &String::from_utf8_lossy(&out.stdout.kept),
584 &String::from_utf8_lossy(&out.stderr.kept),
585 out.exit_code,
586 );
587 let tail = |b: &[u8]| {
588 let start = b.len().saturating_sub(4096);
589 String::from_utf8_lossy(&b[start..]).into_owned()
590 };
591 let result = CheckResult {
592 name: "acceptance".to_owned(),
593 argv: argv.to_vec(),
594 status,
595 exit_code: out.exit_code,
596 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
597 junit: false,
598 tests: None,
599 stdout_tail: tail(&out.stdout.kept),
600 stderr_tail: tail(&out.stderr.kept),
601 truncated: out.stdout.truncated() || out.stderr.truncated(),
602 note: out.spawn_error,
603 flaky: false,
604 };
605 (result, summary)
606}
607
608pub(crate) fn prepare_held_out(
612 a: &AcceptanceSetup<'_>,
613 visible: &PreparedAcceptance,
614 scenarios: &[speccoder_core::spec::ScenarioRef],
615 store: &Path,
616 composition: bool,
617) -> (serde_json::Value, Option<String>) {
618 use serde_json::json;
619 let absent = |reason: &str, mut report: serde_json::Value| {
620 report["state"] = json!("absent");
621 report["reason"] = json!(reason);
622 (report, None)
623 };
624 let visible_files: Vec<(String, String)> = visible.report["files"]
625 .as_array()
626 .into_iter()
627 .flatten()
628 .filter_map(|p| p.as_str())
629 .map(|p| {
630 (
631 p.to_owned(),
632 std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
633 )
634 })
635 .collect();
636 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
637 .and_then(|c| worktree::files(a.worktree, &c))
638 .unwrap_or_default()
639 .into_iter()
640 .take(400)
641 .collect();
642 let manifest_text = MANIFESTS
643 .iter()
644 .find_map(|m| {
645 std::fs::read_to_string(a.project.join(m))
646 .ok()
647 .map(|t| format!("{m}:\n{t}"))
648 })
649 .map(|t| t.chars().take(8192).collect::<String>())
650 .unwrap_or_default();
651 let input = substrate::acceptance::held_out_input(
652 a.bundle.clone(),
653 &files,
654 &manifest_text,
655 scenarios,
656 &visible_files,
657 composition,
658 a.instructions,
659 a.layout.map(crate::layout::TestLayout::text).as_deref(),
660 );
661 let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
662 Ok(o) => o,
663 Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
664 };
665 let output = outcome
666 .output
667 .as_ref()
668 .map(|o| o.peek().clone())
669 .unwrap_or_default();
670 let answer = output["answer"].clone();
671 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
672 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
673 return absent("generation_failed", base);
674 }
675
676 let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
679 let protected = a.policy.protected();
680 let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
681 let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
682 let mut discard = |path: &str, reason: &str| {
683 discarded.push(crate::held_out::Discarded {
684 path: path.to_owned(),
685 reason: reason.to_owned(),
686 });
687 };
688 let given = answer["files"].as_array().cloned().unwrap_or_default();
689 if given.is_empty() {
690 return absent("none_generated", base);
691 }
692 for f in &given {
693 let path = f["path"].as_str().unwrap_or_default();
694 let content = f["content"].as_str().unwrap_or_default();
695 let cited: Vec<String> = f["scenarios"]
696 .as_array()
697 .into_iter()
698 .flatten()
699 .filter_map(|s| s.as_str().map(str::to_owned))
700 .collect();
701 let argv: Vec<String> = f["argv"]
702 .as_array()
703 .into_iter()
704 .flatten()
705 .filter_map(|s| s.as_str().map(str::to_owned))
706 .collect();
707 let rel = match tools::paths::normalise(path) {
708 Ok(r) => r,
709 Err(_) => {
710 discard(path, "path");
711 continue;
712 }
713 };
714 let taken =
715 visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
716 if !tools::paths::is_test_path(&rel)
717 || protected.write_class(&rel).is_some()
718 || a.project.join(&rel).exists()
719 || taken
720 {
721 discard(&rel, "path");
722 continue;
723 }
724 if crate::collection::statically_refused(a.project, &rel, content).is_some() {
726 discard(&rel, "not_collected");
727 continue;
728 }
729 if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
730 discard(&rel, "unknown_scenario");
731 continue;
732 }
733 let composed = f["composition"].as_bool().unwrap_or(false);
734 if composed && (!composition || cited.len() < 2) {
735 discard(&rel, "composition");
736 continue;
737 }
738 if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
739 discard(&rel, "command");
740 continue;
741 }
742 if kept.len() >= crate::held_out::LIMIT {
743 discard(&rel, "limit");
744 continue;
745 }
746 kept.push((
747 crate::held_out::HeldOutFile {
748 path: rel,
749 sha256: crate::integrity::sha256_hex(content.as_bytes()),
750 scenarios: cited,
751 argv,
752 composition: composed,
753 red_tests: None,
754 },
755 content.to_owned(),
756 ));
757 }
758
759 let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
761 .map(|c| c.0)
762 .unwrap_or_default();
763 match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
766 Ok(runs) => {
767 let mut red = Vec::new();
768 for ((mut file, content), ran) in kept.into_iter().zip(runs) {
769 if ran.result.is_none() {
770 discard(&file.path, "collision");
771 } else if crate::held_out::red_class(&ran).is_red() {
772 file.red_tests = ran
773 .result
774 .as_ref()
775 .and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
776 red.push((file, content));
777 } else {
778 discard(&file.path, "not_red");
779 }
780 }
781 kept = red;
782 }
783 Err(e) => {
784 return absent(
785 "generation_failed",
786 json!({ "error": e, "run_id": base["run_id"] }),
787 );
788 }
789 }
790 let mut report = base;
791 report["discarded"] = json!(discarded);
792 if kept.is_empty() {
793 let only_uncollected =
794 !discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
795 return absent(
796 if only_uncollected {
797 "not_collected"
798 } else {
799 "all_discarded"
800 },
801 report,
802 );
803 }
804 let manifest = crate::held_out::Manifest {
805 version: 1,
806 feature: a.feature.to_owned(),
807 task: a.task.to_owned(),
808 author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
809 checkpoint,
810 visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
811 files: kept.iter().map(|(f, _)| f.clone()).collect(),
812 discarded,
813 };
814 let contents: Vec<(String, String)> = kept
815 .iter()
816 .map(|(f, c)| (f.path.clone(), c.clone()))
817 .collect();
818 match crate::held_out::store(store, &manifest, &contents) {
819 Ok(sha) => {
820 report["state"] = json!("on");
821 report["manifest"] = json!(format!("sha256:{sha}"));
822 report["kept"] = json!(kept.len());
823 (report, Some(sha))
824 }
825 Err(e) => absent("generation_failed", json!({ "error": e })),
826 }
827}
828
829pub(crate) struct HeldOutJudged {
833 pub(crate) held_out: speccoder_core::verify::HeldOut,
835 pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
837}
838
839#[allow(clippy::too_many_arguments)]
840pub(crate) fn judge_held_out(
841 store: &Path,
842 manifest_sha: &str,
843 scenarios: &[speccoder_core::spec::ScenarioRef],
844 known_text: &str,
845 wt_root: &Path,
846 project: &Path,
847 tmp: &Path,
848 start: &worktree::Checkpoint,
849 patch: &str,
850 runner: &sandbox::Runner,
851 policy: &tools::policy::Policy,
852) -> Result<HeldOutJudged, (&'static str, String)> {
853 use speccoder_core::verify::{CheckStatus, HeldOut};
854 let (manifest, contents) =
855 crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
856 let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
857 .files
858 .iter()
859 .cloned()
860 .zip(contents.into_iter().map(|(_, c)| c))
861 .collect();
862 let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
863 if !leaks.is_empty() {
864 return Err((
865 "leak_suspected",
866 format!(
867 "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
868 leaks.len()
869 ),
870 ));
871 }
872 let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
873 .map_err(|e| ("tampered", e))?;
874 let mut failed_ids: Vec<String> = Vec::new();
875 for ((file, _), ran) in files.iter().zip(&runs) {
876 let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
880 files
881 .iter()
882 .find(|(f, _)| f.path == file.path)
883 .map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
884 });
885 let required = file.red_tests.unwrap_or(declared).max(1);
886 let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
887 r.status == CheckStatus::Passed
888 && speccoder_core::verify::collected_count(r, sum.as_ref())
889 .is_some_and(|green| green >= required)
890 });
891 if !passed {
892 failed_ids.extend(file.scenarios.iter().cloned());
893 }
894 }
895 failed_ids.sort();
896 failed_ids.dedup();
897 let held_out = if failed_ids.is_empty() {
898 HeldOut::Passed { files: files.len() }
899 } else {
900 HeldOut::Failed {
901 scenarios: failed_ids.clone(),
902 }
903 };
904 let failed = failed_ids
905 .iter()
906 .map(|id| {
907 scenarios
908 .iter()
909 .find(|s| &s.id == id)
910 .cloned()
911 .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
912 id: id.clone(),
913 text: String::new(),
914 })
915 })
916 .collect();
917 Ok(HeldOutJudged { held_out, failed })
918}
919
920pub(crate) struct Strengthened {
922 pub(crate) files: Vec<String>,
924 pub(crate) argv: Vec<String>,
926 pub(crate) report: serde_json::Value,
928}
929
930pub(crate) fn strengthen(
935 a: &AcceptanceSetup<'_>,
936 visible: &[(String, String)],
937 weak: serde_json::Value,
938 before: &worktree::Checkpoint,
939 after: &worktree::Checkpoint,
940) -> Result<Strengthened, String> {
941 use serde_json::json;
942 let files: Vec<String> = worktree::files(a.worktree, after)
943 .unwrap_or_default()
944 .into_iter()
945 .take(400)
946 .collect();
947 let manifest = MANIFESTS
948 .iter()
949 .find_map(|m| {
950 std::fs::read_to_string(a.project.join(m))
951 .ok()
952 .map(|t| format!("{m}:\n{t}"))
953 })
954 .map(|t| t.chars().take(8192).collect::<String>())
955 .unwrap_or_default();
956 let input = substrate::acceptance::strengthen_input(
957 a.bundle.clone(),
958 a.scenarios,
959 &files,
960 &manifest,
961 visible,
962 weak,
963 a.instructions,
964 );
965 let outcome =
966 a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
967 .map_err(|e| format!("the test author failed: {}", e.0))?;
968 let output = outcome
969 .output
970 .as_ref()
971 .map(|o| o.peek().clone())
972 .unwrap_or_default();
973 let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
974 let answer = &output["answer"];
975 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
976 return Err(format!(
977 "the test author did not answer ({})",
978 outcome.status.as_str()
979 ));
980 }
981 let protected = a.policy.protected();
982 let mut planned = Vec::new();
983 for f in answer["files"].as_array().into_iter().flatten() {
984 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
985 continue;
986 };
987 let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
988 if !tools::paths::is_test_path(&rel)
989 || protected.write_class(&rel).is_some()
990 || a.project.join(&rel).exists()
991 {
992 return Err(format!("`{rel}` is not a new test file"));
993 }
994 planned.push((rel, content.to_owned()));
995 }
996 if planned.is_empty() {
997 return Err("the test author added no tests".to_owned());
998 }
999 let argv: Vec<String> = answer["argv"]
1000 .as_array()
1001 .into_iter()
1002 .flatten()
1003 .filter_map(|v| v.as_str().map(str::to_owned))
1004 .collect();
1005 let paths: Vec<&str> = planned
1006 .iter()
1007 .map(|(p, _): &(String, String)| p.as_str())
1008 .collect();
1009 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
1010 return Err(format!("`{}` is not an allowed command", argv.join(" ")));
1011 }
1012 let write = || -> Result<(), String> {
1013 for (rel, content) in &planned {
1014 let full = a.project.join(rel);
1015 if let Some(parent) = full.parent() {
1016 let _ = std::fs::create_dir_all(parent);
1017 }
1018 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1019 }
1020 Ok(())
1021 };
1022 let back =
1023 |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
1024 back(before)?;
1026 let red = write().map(|()| {
1027 let (r, summary) = execute(a.project, &argv, a.runner);
1028 classify_red(&r, summary.as_ref())
1029 });
1030 back(after)?;
1031 let red = red?;
1032 if !red.is_red() {
1033 return Err(format!(
1034 "the added tests do not fail without the change ({})",
1035 class_word(red)
1036 ));
1037 }
1038 write()?;
1040 let (green, _) = execute(a.project, &argv, a.runner);
1041 if green.status != speccoder_core::verify::CheckStatus::Passed {
1042 back(after)?;
1043 let cited: Vec<String> = answer["files"]
1044 .as_array()
1045 .into_iter()
1046 .flatten()
1047 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
1048 .filter_map(|v| v.as_str().map(str::to_owned))
1049 .collect();
1050 return Err(format!(
1051 "the added tests fail the implementation{}",
1052 if cited.is_empty() {
1053 String::new()
1054 } else {
1055 format!(" (scenarios {})", cited.join(", "))
1056 }
1057 ));
1058 }
1059 Ok(Strengthened {
1060 files: planned.into_iter().map(|(p, _)| p).collect(),
1061 argv,
1062 report,
1063 })
1064}
1065
1066pub(crate) fn story_checkpoint(
1069 tasks: Option<&speccoder_core::snapshot::ParsedTasks>,
1070 task: &str,
1071 scenarios: &[speccoder_core::spec::ScenarioRef],
1072) -> bool {
1073 let Some(tasks) = tasks else { return false };
1074 let Some(label) = tasks
1075 .tasks
1076 .iter()
1077 .find(|t| t.id == task)
1078 .and_then(|t| t.story.as_deref())
1079 else {
1080 return false;
1081 };
1082 let last = tasks
1083 .tasks
1084 .iter()
1085 .rfind(|t| t.story.as_deref() == Some(label))
1086 .map(|t| t.id.as_str());
1087 last == Some(task) && scenarios.len() >= 2
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093
1094 #[test]
1095 fn the_last_task_of_a_story_with_two_scenarios_composes() {
1096 let (tasks, _) = speccoder_speckit::tasks::parse(
1097 "tasks.md",
1098 "## 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",
1099 );
1100 let two: Vec<speccoder_core::spec::ScenarioRef> = ["US1-1", "US1-2"]
1101 .iter()
1102 .map(|id| speccoder_core::spec::ScenarioRef {
1103 id: (*id).to_owned(),
1104 text: String::new(),
1105 })
1106 .collect();
1107 assert!(story_checkpoint(Some(&tasks), "T006", &two));
1108 assert!(!story_checkpoint(Some(&tasks), "T005", &two));
1109 assert!(!story_checkpoint(Some(&tasks), "T006", &two[..1]));
1110 assert!(!story_checkpoint(None, "T006", &two));
1111 }
1112
1113 fn record(
1115 producer: speccoder_core::acceptance::Producer,
1116 content: &str,
1117 ) -> speccoder_core::acceptance::PreFrozenAcceptance {
1118 speccoder_core::acceptance::PreFrozenAcceptance {
1119 unit: "001-x".to_owned(),
1120 requirement: "US1".to_owned(),
1121 wording_digest: "sha256:w".to_owned(),
1122 approval_digest: "sha256:a".to_owned(),
1123 visible: vec![speccoder_core::acceptance::VisibleTest {
1124 path: "tests/a.txt".to_owned(),
1125 sha256: format!(
1126 "sha256:{}",
1127 crate::integrity::sha256_hex(content.as_bytes())
1128 ),
1129 cites: vec!["US1-1".to_owned()],
1130 }],
1131 held_out_sealed: None,
1132 producer,
1133 decided: speccoder_core::acceptance::Decided {
1134 by: "Ada".to_owned(),
1135 via: speccoder_core::actions::Via::Terminal,
1136 at: 0,
1137 },
1138 }
1139 }
1140
1141 #[test]
1142 fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
1143 use speccoder_core::acceptance::Producer;
1144 let summary = |passed: u8, failed: u8| {
1145 format!(
1146 "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
1147 if failed > 0 { "FAILED" } else { "ok" },
1148 if failed > 0 { 101 } else { 0 }
1149 )
1150 };
1151 let content = "checks\n";
1152 for (producer, rule) in [
1153 (Producer::Review, RedRule::RunTime),
1154 (Producer::BugConfirmation, RedRule::BugAssertionOnly),
1155 ] {
1156 let rec = record(producer, content);
1157 let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
1158 let run = |script: String| {
1159 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1160 let tests = [ReusedTest {
1161 path: "tests/a.txt".to_owned(),
1162 cites: vec!["US1-1".to_owned()],
1163 argv: vec!["sh".to_owned(), "-c".to_owned(), script],
1164 green: false,
1165 }];
1166 let got = reuse_pre_frozen(
1167 dir.path(),
1168 &rec,
1169 &files,
1170 &tests,
1171 &sandbox::Runner::Plain,
1172 rule,
1173 None,
1174 );
1175 assert!(
1176 dir.path().join("tests/a.txt").is_file(),
1177 "written at the checkpoint"
1178 );
1179 got
1180 };
1181 let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
1183 assert!(red.red);
1184 assert_eq!(red.report["source"], serde_json::json!(producer.source()));
1185 let passing = run(summary(1, 0));
1188 match rule {
1189 RedRule::RunTime => {
1190 let p = passing.unwrap_or_else(|e| panic!("{e}"));
1191 assert!(!p.red);
1192 assert!(
1193 p.report["note"]
1194 .as_str()
1195 .is_some_and(|n| n.contains("already passing"))
1196 );
1197 }
1198 RedRule::BugAssertionOnly => assert!(passing.is_err()),
1199 }
1200 if rule == RedRule::BugAssertionOnly {
1202 assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
1203 }
1204 }
1205 }
1206}
1207
1208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1210pub(crate) enum RedRule {
1211 RunTime,
1215 BugAssertionOnly,
1217}
1218
1219#[derive(Debug)]
1221pub(crate) struct ReusedTest {
1222 pub(crate) path: String,
1224 pub(crate) cites: Vec<String>,
1226 pub(crate) argv: Vec<String>,
1228 pub(crate) green: bool,
1230}
1231
1232pub(crate) struct SealedCheck<'a> {
1235 pub(crate) state: &'a Path,
1237 pub(crate) unit: &'a str,
1239 pub(crate) wt_root: &'a Path,
1241 pub(crate) tmp: &'a Path,
1243 pub(crate) policy: &'a tools::policy::Policy,
1245}
1246
1247pub(crate) fn reuse_pre_frozen(
1253 project: &Path,
1254 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1255 files: &[(String, String)],
1256 tests: &[ReusedTest],
1257 runner: &sandbox::Runner,
1258 rule: RedRule,
1259 sealed: Option<&SealedCheck<'_>>,
1260) -> Result<PreparedAcceptance, String> {
1261 use serde_json::json;
1262 for (rel, content) in files {
1263 let full = project.join(rel);
1264 if let Ok(now) = std::fs::read_to_string(&full) {
1265 if now != *content {
1266 return Err(format!(
1267 "`{rel}` exists and differs from the confirmed test; it is not overwritten"
1268 ));
1269 }
1270 continue;
1271 }
1272 if let Some(parent) = full.parent() {
1273 let _ = std::fs::create_dir_all(parent);
1274 }
1275 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1276 }
1277 let class_of = |r: &speccoder_core::verify::CheckResult,
1280 s: Option<&speccoder_core::verify::RunnerSummary>| match rule
1281 {
1282 RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
1283 RedRule::RunTime => classify_red(r, s),
1284 };
1285 let mut classes = Vec::new();
1286 for t in tests {
1287 let (first, summary) = execute(project, &t.argv, runner);
1288 let one = class_of(&first, summary.as_ref());
1289 let class = if t.green {
1290 one
1291 } else {
1292 match rule {
1293 RedRule::BugAssertionOnly if one == RedClass::Assertion => {
1294 let (second, summary) = execute(project, &t.argv, runner);
1295 speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
1296 }
1297 RedRule::BugAssertionOnly => one,
1298 RedRule::RunTime if one.is_red() => {
1299 let (second, summary) = execute(project, &t.argv, runner);
1300 if classify_red(&second, summary.as_ref()).is_red() {
1301 one
1302 } else {
1303 RedClass::Flaky
1304 }
1305 }
1306 RedRule::RunTime => one,
1307 }
1308 };
1309 let holds = if t.green {
1310 class == RedClass::Passed
1311 } else {
1312 match rule {
1313 RedRule::BugAssertionOnly => class == RedClass::Assertion,
1314 RedRule::RunTime => class.is_red(),
1315 }
1316 };
1317 if !holds && rule == RedRule::RunTime && !t.green {
1318 return Ok(not_red_frozen(record, files, tests, t, class));
1319 }
1320 if !holds {
1321 return Err(if t.green {
1322 format!(
1323 "the confirmed test `{}` ({}) no longer passes on the branch ({})",
1324 t.path,
1325 t.cites.join(", "),
1326 class_word(class)
1327 )
1328 } else {
1329 format!(
1330 "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
1331 t.path,
1332 t.cites.join(", "),
1333 class_word(class)
1334 )
1335 });
1336 }
1337 classes.push(class);
1338 }
1339 let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
1340 let Some(first) = red.first() else {
1341 return Err("the confirmed set has no test that must fail first".to_owned());
1342 };
1343 let more: Vec<Vec<String>> = tests
1344 .iter()
1345 .filter(|t| !std::ptr::eq(*t, *first))
1346 .map(|t| t.argv.clone())
1347 .collect();
1348 let mut held_sha = record
1349 .held_out_sealed
1350 .as_ref()
1351 .map(|h| h.manifest_sha256.clone());
1352 let mut held_out = match &record.held_out_sealed {
1353 Some(h) => {
1354 json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
1355 }
1356 None => json!({ "state": "absent", "reason": "none_kept" }),
1357 };
1358 if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
1361 let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
1362 .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
1363 let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1364 .files
1365 .iter()
1366 .cloned()
1367 .zip(contents.into_iter().map(|(_, c)| c))
1368 .collect();
1369 let runs = crate::held_out::overlay_red(
1370 check.wt_root,
1371 project,
1372 check.tmp,
1373 &set,
1374 runner,
1375 check.policy,
1376 )?;
1377 let all_red = runs
1378 .iter()
1379 .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
1380 if !all_red {
1381 held_sha = None;
1382 held_out = json!({ "state": "absent", "reason": "not_red" });
1383 }
1384 }
1385 let class = classes.first().copied().unwrap_or(RedClass::Assertion);
1386 Ok(PreparedAcceptance {
1387 report: json!({
1388 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1389 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1390 "argv": first.argv,
1391 "red": true,
1392 "red_class": class,
1393 "source": record.producer.source(),
1394 "pre_frozen": {
1395 "manifest": crate::pre_frozen::manifest_digest(record),
1396 "approval": record.approval_digest,
1397 "wording": record.wording_digest,
1398 "by": record.decided.by,
1399 "via": record.decided.via,
1400 },
1401 "held_out": held_out,
1402 }),
1403 argv: Some(first.argv.clone()),
1404 more_argv: more,
1405 red: true,
1406 red_class: Some(class),
1407 held_out: held_sha,
1408 generation_failed: None,
1409 skip_cause: None,
1410 })
1411}
1412
1413fn not_red_frozen(
1416 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1417 files: &[(String, String)],
1418 tests: &[ReusedTest],
1419 failing: &ReusedTest,
1420 class: RedClass,
1421) -> PreparedAcceptance {
1422 use serde_json::json;
1423 let note = match class {
1424 RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
1425 RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
1426 other => format!(
1427 "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
1428 class_word(other)
1429 ),
1430 };
1431 PreparedAcceptance {
1432 report: json!({
1433 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1434 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1435 "argv": failing.argv,
1436 "red": false,
1437 "red_class": class,
1438 "note": note,
1439 "source": record.producer.source(),
1440 "pre_frozen": {
1441 "manifest": crate::pre_frozen::manifest_digest(record),
1442 "approval": record.approval_digest,
1443 "wording": record.wording_digest,
1444 "by": record.decided.by,
1445 "via": record.decided.via,
1446 },
1447 "held_out": { "state": "absent", "reason": "not_red" },
1448 }),
1449 argv: Some(failing.argv.clone()),
1450 more_argv: tests
1451 .iter()
1452 .filter(|t| !std::ptr::eq(*t, failing))
1453 .map(|t| t.argv.clone())
1454 .collect(),
1455 red: false,
1456 red_class: Some(class),
1457 held_out: None,
1458 generation_failed: None,
1459 skip_cause: None,
1460 }
1461}