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