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