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