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 "placeholder" => "is a placeholder that can never pass",
322 _ => "is not a new test file",
323 }
324}
325
326pub(crate) struct ValidFile {
328 pub(crate) path: String,
330 pub(crate) content: String,
331 pub(crate) cited: Vec<String>,
333 pub(crate) argv: Vec<String>,
335}
336
337pub(crate) struct RefusedFile {
339 pub(crate) path: String,
341 pub(crate) why: &'static str,
343 pub(crate) message: String,
345}
346
347#[must_use]
353pub(crate) fn argv_of(v: &serde_json::Value) -> Vec<String> {
354 let argv: Vec<String> = v
355 .as_array()
356 .into_iter()
357 .flatten()
358 .filter_map(|v| v.as_str().map(str::to_owned))
359 .collect();
360 match argv.as_slice() {
361 [line] if line.split_ascii_whitespace().count() > 1 => {
362 let words = crate::acp::govern::argv_of_line(line);
363 if words.len() == 3 && words[0] == "sh" && words[1] == "-c" && words[2] == line.trim() {
365 argv
366 } else {
367 words
368 }
369 }
370 _ => argv,
371 }
372}
373
374pub(crate) fn test_command_allowed(
378 policy: &tools::policy::Policy,
379 layout: Option<&crate::layout::TestLayout>,
380 argv: &[String],
381 files: &[&str],
382) -> bool {
383 layout.is_some_and(|l| l.runs_only(argv, files))
384 || policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
385 == tools::policy::Decision::Allow
386}
387
388pub(crate) fn validate_answer(
392 answer: &serde_json::Value,
393 project: &Path,
394 policy: &tools::policy::Policy,
395 scenarios: &[speccoder_core::spec::ScenarioRef],
396) -> (Vec<ValidFile>, Vec<RefusedFile>) {
397 let protected = policy.protected();
398 let mut valid = Vec::new();
399 let mut refused = Vec::new();
400 for f in answer["files"].as_array().into_iter().flatten() {
401 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
402 continue;
403 };
404 let rel = match tools::paths::normalise(path) {
405 Ok(r) => r,
406 Err(e) => {
407 refused.push(RefusedFile {
408 path: path.to_owned(),
409 why: "not_a_new_test_file",
410 message: format!("`{path}`: {}", e.0),
411 });
412 continue;
413 }
414 };
415 if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
416 refused.push(RefusedFile {
417 message: format!("`{rel}` is not a new test file"),
418 path: rel,
419 why: "not_a_new_test_file",
420 });
421 continue;
422 }
423 if project.join(&rel).exists() {
424 refused.push(RefusedFile {
425 message: format!("`{rel}` already exists"),
426 path: rel,
427 why: "exists",
428 });
429 continue;
430 }
431 if crate::integrity::has_skip_marker(content) {
434 refused.push(RefusedFile {
435 message: format!("`{rel}` skips a test"),
436 path: rel,
437 why: "skip_marker",
438 });
439 continue;
440 }
441 if crate::integrity::has_placeholder(content) {
443 refused.push(RefusedFile {
444 message: format!(
445 "`{rel}` is a placeholder: it raises \"not implemented\" instead of checking the behaviour"
446 ),
447 path: rel,
448 why: "placeholder",
449 });
450 continue;
451 }
452 if crate::collection::statically_refused(project, &rel, content).is_some() {
454 refused.push(RefusedFile {
455 message: format!("`{rel}` is not where the runner collects tests"),
456 path: rel,
457 why: "not_collected",
458 });
459 continue;
460 }
461 let cited: Vec<String> = f["scenarios"]
462 .as_array()
463 .into_iter()
464 .flatten()
465 .filter_map(|v| v.as_str())
466 .filter(|id| scenarios.iter().any(|s| s.id == *id))
467 .map(str::to_owned)
468 .collect();
469 let argv: Vec<String> = argv_of(&f["argv"]);
470 valid.push(ValidFile {
471 path: rel,
472 content: content.to_owned(),
473 cited,
474 argv,
475 });
476 }
477 (valid, refused)
478}
479
480pub(crate) fn class_word(class: RedClass) -> String {
482 serde_json::to_value(class)
483 .ok()
484 .and_then(|v| v.as_str().map(str::to_owned))
485 .unwrap_or_default()
486}
487
488fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
491 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
492 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
493 let from = lines
494 .iter()
495 .position(|l| {
496 let l = l.to_lowercase();
497 l.contains("error") || l.contains("fail")
498 })
499 .unwrap_or_else(|| lines.len().saturating_sub(20));
500 lines
501 .iter()
502 .skip(from)
503 .take(20)
504 .copied()
505 .collect::<Vec<_>>()
506 .join("\n")
507}
508
509pub(crate) fn acceptance_run(
511 project: &Path,
512 argv: &[String],
513 runner: &sandbox::Runner,
514) -> (
515 speccoder_core::verify::CheckResult,
516 Option<speccoder_core::verify::RunnerSummary>,
517) {
518 execute(project, argv, runner)
519}
520
521pub(crate) fn run_acceptance(
522 project: &Path,
523 argv: &[String],
524 runner: &sandbox::Runner,
525) -> speccoder_core::verify::CheckResult {
526 execute(project, argv, runner).0
527}
528
529fn execute(
532 project: &Path,
533 argv: &[String],
534 runner: &sandbox::Runner,
535) -> (
536 speccoder_core::verify::CheckResult,
537 Option<speccoder_core::verify::RunnerSummary>,
538) {
539 use speccoder_core::verify::{CheckResult, CheckStatus};
540 use verify::process::CommandRunner;
541 let out = runner.run(&verify::process::Invocation {
542 argv: argv.to_vec(),
543 cwd: project.to_path_buf(),
544 env: verify::process::scrubbed_env(std::env::vars()),
545 timeout: std::time::Duration::from_secs(600),
546 output_cap: verify::process::OUTPUT_CAP,
547 });
548 let status = if out.spawn_error.is_some() {
549 CheckStatus::Error
550 } else if out.timed_out {
551 CheckStatus::TimedOut
552 } else if out.exit_code == Some(0) {
553 CheckStatus::Passed
554 } else {
555 CheckStatus::Failed
556 };
557 let summary = verify::summary::parse(
558 &String::from_utf8_lossy(&out.stdout.kept),
559 &String::from_utf8_lossy(&out.stderr.kept),
560 out.exit_code,
561 );
562 let tail = |b: &[u8]| {
563 let start = b.len().saturating_sub(4096);
564 String::from_utf8_lossy(&b[start..]).into_owned()
565 };
566 let result = CheckResult {
567 name: "acceptance".to_owned(),
568 argv: argv.to_vec(),
569 status,
570 exit_code: out.exit_code,
571 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
572 junit: false,
573 tests: None,
574 stdout_tail: tail(&out.stdout.kept),
575 stderr_tail: tail(&out.stderr.kept),
576 truncated: out.stdout.truncated() || out.stderr.truncated(),
577 note: out.spawn_error,
578 flaky: false,
579 };
580 (result, summary)
581}
582
583pub(crate) fn prepare_held_out(
587 a: &AcceptanceSetup<'_>,
588 visible: &PreparedAcceptance,
589 scenarios: &[speccoder_core::spec::ScenarioRef],
590 store: &Path,
591 composition: bool,
592) -> (serde_json::Value, Option<String>) {
593 use serde_json::json;
594 let absent = |reason: &str, mut report: serde_json::Value| {
595 report["state"] = json!("absent");
596 report["reason"] = json!(reason);
597 (report, None)
598 };
599 let visible_files: Vec<(String, String)> = visible.report["files"]
600 .as_array()
601 .into_iter()
602 .flatten()
603 .filter_map(|p| p.as_str())
604 .map(|p| {
605 (
606 p.to_owned(),
607 std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
608 )
609 })
610 .collect();
611 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
612 .and_then(|c| worktree::files(a.worktree, &c))
613 .unwrap_or_default()
614 .into_iter()
615 .take(400)
616 .collect();
617 let manifest_text = MANIFESTS
618 .iter()
619 .find_map(|m| {
620 std::fs::read_to_string(a.project.join(m))
621 .ok()
622 .map(|t| format!("{m}:\n{t}"))
623 })
624 .map(|t| t.chars().take(8192).collect::<String>())
625 .unwrap_or_default();
626 let input = substrate::acceptance::held_out_input(
627 a.bundle.clone(),
628 &files,
629 &manifest_text,
630 scenarios,
631 &visible_files,
632 composition,
633 a.instructions,
634 a.layout.map(crate::layout::TestLayout::text).as_deref(),
635 );
636 let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
637 Ok(o) => o,
638 Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
639 };
640 let output = outcome
641 .output
642 .as_ref()
643 .map(|o| o.peek().clone())
644 .unwrap_or_default();
645 let answer = output["answer"].clone();
646 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
647 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
648 return absent("generation_failed", base);
649 }
650
651 let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
654 let protected = a.policy.protected();
655 let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
656 let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
657 let mut discard = |path: &str, reason: &str| {
658 discarded.push(crate::held_out::Discarded {
659 path: path.to_owned(),
660 reason: reason.to_owned(),
661 });
662 };
663 let given = answer["files"].as_array().cloned().unwrap_or_default();
664 if given.is_empty() {
665 return absent("none_generated", base);
666 }
667 for f in &given {
668 let path = f["path"].as_str().unwrap_or_default();
669 let content = f["content"].as_str().unwrap_or_default();
670 let cited: Vec<String> = f["scenarios"]
671 .as_array()
672 .into_iter()
673 .flatten()
674 .filter_map(|s| s.as_str().map(str::to_owned))
675 .collect();
676 let argv: Vec<String> = argv_of(&f["argv"]);
677 let rel = match tools::paths::normalise(path) {
678 Ok(r) => r,
679 Err(_) => {
680 discard(path, "path");
681 continue;
682 }
683 };
684 let taken =
685 visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
686 if !tools::paths::is_test_path(&rel)
687 || protected.write_class(&rel).is_some()
688 || a.project.join(&rel).exists()
689 || taken
690 {
691 discard(&rel, "path");
692 continue;
693 }
694 if crate::collection::statically_refused(a.project, &rel, content).is_some() {
696 discard(&rel, "not_collected");
697 continue;
698 }
699 if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
700 discard(&rel, "unknown_scenario");
701 continue;
702 }
703 let composed = f["composition"].as_bool().unwrap_or(false);
704 if composed && (!composition || cited.len() < 2) {
705 discard(&rel, "composition");
706 continue;
707 }
708 if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
709 discard(&rel, "command");
710 continue;
711 }
712 if kept.len() >= crate::held_out::LIMIT {
713 discard(&rel, "limit");
714 continue;
715 }
716 kept.push((
717 crate::held_out::HeldOutFile {
718 path: rel,
719 sha256: crate::integrity::sha256_hex(content.as_bytes()),
720 scenarios: cited,
721 argv,
722 composition: composed,
723 red_tests: None,
724 },
725 content.to_owned(),
726 ));
727 }
728
729 let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
731 .map(|c| c.0)
732 .unwrap_or_default();
733 match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
736 Ok(runs) => {
737 let mut red = Vec::new();
738 for ((mut file, content), ran) in kept.into_iter().zip(runs) {
739 if ran.result.is_none() {
740 discard(&file.path, "collision");
741 } else if crate::held_out::red_class(&ran).is_red() {
742 file.red_tests = ran
743 .result
744 .as_ref()
745 .and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
746 red.push((file, content));
747 } else {
748 discard(&file.path, "not_red");
749 }
750 }
751 kept = red;
752 }
753 Err(e) => {
754 return absent(
755 "generation_failed",
756 json!({ "error": e, "run_id": base["run_id"] }),
757 );
758 }
759 }
760 let mut report = base;
761 report["discarded"] = json!(discarded);
762 if kept.is_empty() {
763 let only_uncollected =
764 !discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
765 return absent(
766 if only_uncollected {
767 "not_collected"
768 } else {
769 "all_discarded"
770 },
771 report,
772 );
773 }
774 let manifest = crate::held_out::Manifest {
775 version: 1,
776 feature: a.feature.to_owned(),
777 task: a.task.to_owned(),
778 author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
779 checkpoint,
780 visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
781 files: kept.iter().map(|(f, _)| f.clone()).collect(),
782 discarded,
783 };
784 let contents: Vec<(String, String)> = kept
785 .iter()
786 .map(|(f, c)| (f.path.clone(), c.clone()))
787 .collect();
788 match crate::held_out::store(store, &manifest, &contents) {
789 Ok(sha) => {
790 report["state"] = json!("on");
791 report["manifest"] = json!(format!("sha256:{sha}"));
792 report["kept"] = json!(kept.len());
793 (report, Some(sha))
794 }
795 Err(e) => absent("generation_failed", json!({ "error": e })),
796 }
797}
798
799pub(crate) struct HeldOutJudged {
803 pub(crate) held_out: speccoder_core::verify::HeldOut,
805 pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
807}
808
809#[allow(clippy::too_many_arguments)]
810pub(crate) fn judge_held_out(
811 store: &Path,
812 manifest_sha: &str,
813 scenarios: &[speccoder_core::spec::ScenarioRef],
814 known_text: &str,
815 wt_root: &Path,
816 project: &Path,
817 tmp: &Path,
818 start: &worktree::Checkpoint,
819 patch: &str,
820 runner: &sandbox::Runner,
821 policy: &tools::policy::Policy,
822) -> Result<HeldOutJudged, (&'static str, String)> {
823 use speccoder_core::verify::{CheckStatus, HeldOut};
824 let (manifest, contents) =
825 crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
826 let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
827 .files
828 .iter()
829 .cloned()
830 .zip(contents.into_iter().map(|(_, c)| c))
831 .collect();
832 let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
833 if !leaks.is_empty() {
834 return Err((
835 "leak_suspected",
836 format!(
837 "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
838 leaks.len()
839 ),
840 ));
841 }
842 let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
843 .map_err(|e| ("tampered", e))?;
844 let mut failed_ids: Vec<String> = Vec::new();
845 for ((file, _), ran) in files.iter().zip(&runs) {
846 let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
850 files
851 .iter()
852 .find(|(f, _)| f.path == file.path)
853 .map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
854 });
855 let required = file.red_tests.unwrap_or(declared).max(1);
856 let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
857 r.status == CheckStatus::Passed
858 && speccoder_core::verify::collected_count(r, sum.as_ref())
859 .is_some_and(|green| green >= required)
860 });
861 if !passed {
862 failed_ids.extend(file.scenarios.iter().cloned());
863 }
864 }
865 failed_ids.sort();
866 failed_ids.dedup();
867 let held_out = if failed_ids.is_empty() {
868 HeldOut::Passed { files: files.len() }
869 } else {
870 HeldOut::Failed {
871 scenarios: failed_ids.clone(),
872 }
873 };
874 let failed = failed_ids
875 .iter()
876 .map(|id| {
877 scenarios
878 .iter()
879 .find(|s| &s.id == id)
880 .cloned()
881 .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
882 id: id.clone(),
883 text: String::new(),
884 })
885 })
886 .collect();
887 Ok(HeldOutJudged { held_out, failed })
888}
889
890pub(crate) struct Strengthened {
892 pub(crate) files: Vec<String>,
894 pub(crate) argv: Vec<String>,
896 pub(crate) report: serde_json::Value,
898}
899
900pub(crate) fn strengthen(
905 a: &AcceptanceSetup<'_>,
906 visible: &[(String, String)],
907 weak: serde_json::Value,
908 before: &worktree::Checkpoint,
909 after: &worktree::Checkpoint,
910) -> Result<Strengthened, String> {
911 use serde_json::json;
912 let files: Vec<String> = worktree::files(a.worktree, after)
913 .unwrap_or_default()
914 .into_iter()
915 .take(400)
916 .collect();
917 let manifest = MANIFESTS
918 .iter()
919 .find_map(|m| {
920 std::fs::read_to_string(a.project.join(m))
921 .ok()
922 .map(|t| format!("{m}:\n{t}"))
923 })
924 .map(|t| t.chars().take(8192).collect::<String>())
925 .unwrap_or_default();
926 let input = substrate::acceptance::strengthen_input(
927 a.bundle.clone(),
928 a.scenarios,
929 &files,
930 &manifest,
931 visible,
932 weak,
933 a.instructions,
934 );
935 let outcome =
936 a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
937 .map_err(|e| format!("the test author failed: {}", e.0))?;
938 let output = outcome
939 .output
940 .as_ref()
941 .map(|o| o.peek().clone())
942 .unwrap_or_default();
943 let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
944 let answer = &output["answer"];
945 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
946 return Err(format!(
947 "the test author did not answer ({})",
948 substrate::why_not(&outcome.status)
949 ));
950 }
951 let protected = a.policy.protected();
952 let mut planned = Vec::new();
953 for f in answer["files"].as_array().into_iter().flatten() {
954 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
955 continue;
956 };
957 let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
958 if !tools::paths::is_test_path(&rel)
959 || protected.write_class(&rel).is_some()
960 || a.project.join(&rel).exists()
961 {
962 return Err(format!("`{rel}` is not a new test file"));
963 }
964 planned.push((rel, content.to_owned()));
965 }
966 if planned.is_empty() {
967 return Err("the test author added no tests".to_owned());
968 }
969 let argv: Vec<String> = argv_of(&answer["argv"]);
970 let paths: Vec<&str> = planned
971 .iter()
972 .map(|(p, _): &(String, String)| p.as_str())
973 .collect();
974 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
975 return Err(format!("`{}` is not an allowed command", argv.join(" ")));
976 }
977 let write = || -> Result<(), String> {
978 for (rel, content) in &planned {
979 let full = a.project.join(rel);
980 if let Some(parent) = full.parent() {
981 let _ = std::fs::create_dir_all(parent);
982 }
983 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
984 }
985 Ok(())
986 };
987 let back =
988 |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
989 back(before)?;
991 let red = write().map(|()| {
992 let (r, summary) = execute(a.project, &argv, a.runner);
993 classify_red(&r, summary.as_ref())
994 });
995 back(after)?;
996 let red = red?;
997 if !red.is_red() {
998 return Err(format!(
999 "the added tests do not fail without the change ({})",
1000 class_word(red)
1001 ));
1002 }
1003 write()?;
1005 let (green, _) = execute(a.project, &argv, a.runner);
1006 if green.status != speccoder_core::verify::CheckStatus::Passed {
1007 back(after)?;
1008 let cited: Vec<String> = answer["files"]
1009 .as_array()
1010 .into_iter()
1011 .flatten()
1012 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
1013 .filter_map(|v| v.as_str().map(str::to_owned))
1014 .collect();
1015 return Err(format!(
1016 "the added tests fail the implementation{}",
1017 if cited.is_empty() {
1018 String::new()
1019 } else {
1020 format!(" (scenarios {})", cited.join(", "))
1021 }
1022 ));
1023 }
1024 Ok(Strengthened {
1025 files: planned.into_iter().map(|(p, _)| p).collect(),
1026 argv,
1027 report,
1028 })
1029}
1030
1031pub(crate) fn suite(worktree: &Path, title: &str) -> Vec<substrate::acceptance::SuiteTest> {
1035 const FILES: usize = 3;
1036 const BUDGET: usize = 6 * 1024;
1037 let mut names: Vec<String> = title
1038 .split('`')
1039 .skip(1)
1040 .step_by(2)
1041 .flat_map(|span| {
1042 span.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '/' || c == '.'))
1043 .map(str::to_owned)
1044 .collect::<Vec<_>>()
1045 })
1046 .map(|w| {
1047 let file = w.rsplit('/').next().unwrap_or(&w).to_owned();
1049 file.split('.').next().unwrap_or(&file).to_owned()
1050 })
1051 .filter(|w| w.len() >= 4 && w.chars().any(|c| c.is_alphabetic()))
1052 .collect();
1053 names.sort();
1054 names.dedup();
1055 if names.is_empty() {
1056 return Vec::new();
1057 }
1058 let mut args = vec!["grep", "-l", "-w", "-I"];
1059 for n in &names {
1060 args.extend(["-e", n.as_str()]);
1061 }
1062 let Ok(out) = crate::git::Git::at(worktree).line(&args) else {
1063 return Vec::new();
1064 };
1065 let is_test = |p: &str| {
1066 p.split('/').any(|seg| {
1067 let s = seg.to_ascii_lowercase();
1068 s == "tests"
1069 || s == "test"
1070 || s == "spec"
1071 || s == "__tests__"
1072 || s.starts_with("test_")
1073 || s.contains("_test.")
1074 || s.contains(".test.")
1075 || s.contains(".spec.")
1076 })
1077 };
1078 let mut left = BUDGET;
1079 out.lines()
1080 .filter(|p| is_test(p))
1081 .take(FILES)
1082 .filter_map(|p| {
1083 let text = std::fs::read_to_string(worktree.join(p)).ok()?;
1084 let room = left.min(BUDGET / FILES);
1085 let content = if text.len() > room {
1086 let cut = (0..=room)
1087 .rev()
1088 .find(|i| text.is_char_boundary(*i))
1089 .unwrap_or(0);
1090 format!("{}\n… (trimmed)", &text[..cut])
1091 } else {
1092 text
1093 };
1094 left = left.saturating_sub(content.len());
1095 Some(substrate::acceptance::SuiteTest {
1096 path: p.to_owned(),
1097 content,
1098 })
1099 })
1100 .collect()
1101}
1102
1103pub(crate) fn outside_story(
1107 t: &speccoder_core::snapshot::Task,
1108 scenarios: &[speccoder_core::spec::ScenarioRef],
1109 unit_dir: &str,
1110 judged: &dyn Fn(&str) -> bool,
1111) -> Option<speccoder_core::story::Judged> {
1112 if checks_only(t, unit_dir, judged) {
1113 return None;
1114 }
1115 let links: Vec<String> = t
1116 .scenarios
1117 .iter()
1118 .filter(|l| scenarios.iter().any(|s| &s.id == *l))
1119 .cloned()
1120 .collect();
1121 (!links.is_empty()).then_some(speccoder_core::story::Judged::Linked { scenarios: links })
1122}
1123
1124pub(crate) fn checks_only(
1127 t: &speccoder_core::snapshot::Task,
1128 unit_dir: &str,
1129 judged: &dyn Fn(&str) -> bool,
1130) -> bool {
1131 t.yours
1132 || speccoder_core::lane::by_paths(&t.title, unit_dir, judged)
1133 == speccoder_core::lane::Lane::Planning
1134}
1135
1136#[cfg(test)]
1137mod argv_tests {
1138 use super::argv_of;
1139 use serde_json::json;
1140
1141 #[test]
1143 fn a_command_given_as_one_string_is_its_words() {
1144 assert_eq!(
1145 argv_of(&json!(["cargo test --test acceptance_us1"])),
1146 ["cargo", "test", "--test", "acceptance_us1"]
1147 );
1148 assert_eq!(
1149 argv_of(&json!(["cargo", "test", "--test", "acceptance_us1"])),
1150 ["cargo", "test", "--test", "acceptance_us1"]
1151 );
1152 assert_eq!(
1154 argv_of(&json!(["cargo test; rm -rf ."])),
1155 ["cargo test; rm -rf ."]
1156 );
1157 assert_eq!(argv_of(&json!(["pytest"])), ["pytest"]);
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164
1165 fn record(
1167 producer: speccoder_core::acceptance::Producer,
1168 content: &str,
1169 ) -> speccoder_core::acceptance::PreFrozenAcceptance {
1170 speccoder_core::acceptance::PreFrozenAcceptance {
1171 unit: "001-x".to_owned(),
1172 requirement: "US1".to_owned(),
1173 wording_digest: "sha256:w".to_owned(),
1174 approval_digest: "sha256:a".to_owned(),
1175 visible: vec![speccoder_core::acceptance::VisibleTest {
1176 path: "tests/a.txt".to_owned(),
1177 sha256: format!(
1178 "sha256:{}",
1179 crate::integrity::sha256_hex(content.as_bytes())
1180 ),
1181 cites: vec!["US1-1".to_owned()],
1182 }],
1183 held_out_sealed: None,
1184 producer,
1185 decided: speccoder_core::acceptance::Decided {
1186 by: "Ada".to_owned(),
1187 via: speccoder_core::actions::Via::Terminal,
1188 at: 0,
1189 },
1190 }
1191 }
1192
1193 #[test]
1194 fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
1195 use speccoder_core::acceptance::Producer;
1196 let summary = |passed: u8, failed: u8| {
1197 format!(
1198 "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
1199 if failed > 0 { "FAILED" } else { "ok" },
1200 if failed > 0 { 101 } else { 0 }
1201 )
1202 };
1203 let content = "checks\n";
1204 for (producer, rule) in [
1205 (Producer::Review, RedRule::RunTime),
1206 (Producer::BugConfirmation, RedRule::BugAssertionOnly),
1207 ] {
1208 let rec = record(producer, content);
1209 let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
1210 let run = |script: String| {
1211 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1212 let tests = [ReusedTest {
1213 path: "tests/a.txt".to_owned(),
1214 cites: vec!["US1-1".to_owned()],
1215 argv: vec!["sh".to_owned(), "-c".to_owned(), script],
1216 green: false,
1217 }];
1218 let got = reuse_pre_frozen(
1219 dir.path(),
1220 &rec,
1221 &files,
1222 &tests,
1223 &sandbox::Runner::Plain,
1224 rule,
1225 None,
1226 );
1227 assert!(
1228 dir.path().join("tests/a.txt").is_file(),
1229 "written at the checkpoint"
1230 );
1231 got
1232 };
1233 let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
1235 assert!(red.red);
1236 assert_eq!(red.report["source"], serde_json::json!(producer.source()));
1237 let passing = run(summary(1, 0));
1240 match rule {
1241 RedRule::RunTime => {
1242 let p = passing.unwrap_or_else(|e| panic!("{e}"));
1243 assert!(!p.red);
1244 assert!(
1245 p.report["note"]
1246 .as_str()
1247 .is_some_and(|n| n.contains("already passing"))
1248 );
1249 }
1250 RedRule::BugAssertionOnly => assert!(passing.is_err()),
1251 }
1252 if rule == RedRule::BugAssertionOnly {
1254 assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
1255 }
1256 }
1257 }
1258}
1259
1260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1262pub(crate) enum RedRule {
1263 RunTime,
1267 BugAssertionOnly,
1269}
1270
1271#[derive(Debug)]
1273pub(crate) struct ReusedTest {
1274 pub(crate) path: String,
1276 pub(crate) cites: Vec<String>,
1278 pub(crate) argv: Vec<String>,
1280 pub(crate) green: bool,
1282}
1283
1284pub(crate) struct SealedCheck<'a> {
1287 pub(crate) state: &'a Path,
1289 pub(crate) unit: &'a str,
1291 pub(crate) wt_root: &'a Path,
1293 pub(crate) tmp: &'a Path,
1295 pub(crate) policy: &'a tools::policy::Policy,
1297}
1298
1299pub(crate) fn reuse_pre_frozen(
1305 project: &Path,
1306 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1307 files: &[(String, String)],
1308 tests: &[ReusedTest],
1309 runner: &sandbox::Runner,
1310 rule: RedRule,
1311 sealed: Option<&SealedCheck<'_>>,
1312) -> Result<PreparedAcceptance, String> {
1313 use serde_json::json;
1314 for (rel, content) in files {
1315 let full = project.join(rel);
1316 if let Ok(now) = std::fs::read_to_string(&full) {
1317 if now != *content {
1318 return Err(format!(
1319 "`{rel}` exists and differs from the confirmed test; it is not overwritten"
1320 ));
1321 }
1322 continue;
1323 }
1324 if let Some(parent) = full.parent() {
1325 let _ = std::fs::create_dir_all(parent);
1326 }
1327 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1328 }
1329 let class_of = |r: &speccoder_core::verify::CheckResult,
1332 s: Option<&speccoder_core::verify::RunnerSummary>| match rule
1333 {
1334 RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
1335 RedRule::RunTime => classify_red(r, s),
1336 };
1337 let mut classes = Vec::new();
1338 for t in tests {
1339 let (first, summary) = execute(project, &t.argv, runner);
1340 let one = class_of(&first, summary.as_ref());
1341 let class = if t.green {
1342 one
1343 } else {
1344 match rule {
1345 RedRule::BugAssertionOnly if one == RedClass::Assertion => {
1346 let (second, summary) = execute(project, &t.argv, runner);
1347 speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
1348 }
1349 RedRule::BugAssertionOnly => one,
1350 RedRule::RunTime if one.is_red() => {
1351 let (second, summary) = execute(project, &t.argv, runner);
1352 if classify_red(&second, summary.as_ref()).is_red() {
1353 one
1354 } else {
1355 RedClass::Flaky
1356 }
1357 }
1358 RedRule::RunTime => one,
1359 }
1360 };
1361 let holds = if t.green {
1362 class == RedClass::Passed
1363 } else {
1364 match rule {
1365 RedRule::BugAssertionOnly => class == RedClass::Assertion,
1366 RedRule::RunTime => class.is_red(),
1367 }
1368 };
1369 if !holds && rule == RedRule::RunTime && !t.green {
1370 return Ok(not_red_frozen(record, files, tests, t, class));
1371 }
1372 if !holds {
1373 return Err(if t.green {
1374 format!(
1375 "the confirmed test `{}` ({}) no longer passes on the branch ({})",
1376 t.path,
1377 t.cites.join(", "),
1378 class_word(class)
1379 )
1380 } else {
1381 format!(
1382 "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
1383 t.path,
1384 t.cites.join(", "),
1385 class_word(class)
1386 )
1387 });
1388 }
1389 classes.push(class);
1390 }
1391 let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
1392 let Some(first) = red.first() else {
1393 return Err("the confirmed set has no test that must fail first".to_owned());
1394 };
1395 let more: Vec<Vec<String>> = tests
1396 .iter()
1397 .filter(|t| !std::ptr::eq(*t, *first))
1398 .map(|t| t.argv.clone())
1399 .collect();
1400 let mut held_sha = record
1401 .held_out_sealed
1402 .as_ref()
1403 .map(|h| h.manifest_sha256.clone());
1404 let mut held_out = match &record.held_out_sealed {
1405 Some(h) => {
1406 json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
1407 }
1408 None => json!({ "state": "absent", "reason": "none_kept" }),
1409 };
1410 if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
1413 let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
1414 .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
1415 let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1416 .files
1417 .iter()
1418 .cloned()
1419 .zip(contents.into_iter().map(|(_, c)| c))
1420 .collect();
1421 let runs = crate::held_out::overlay_red(
1422 check.wt_root,
1423 project,
1424 check.tmp,
1425 &set,
1426 runner,
1427 check.policy,
1428 )?;
1429 let all_red = runs
1430 .iter()
1431 .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
1432 if !all_red {
1433 held_sha = None;
1434 held_out = json!({ "state": "absent", "reason": "not_red" });
1435 }
1436 }
1437 let class = classes.first().copied().unwrap_or(RedClass::Assertion);
1438 Ok(PreparedAcceptance {
1439 report: json!({
1440 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1441 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1442 "argv": first.argv,
1443 "red": true,
1444 "red_class": class,
1445 "source": record.producer.source(),
1446 "pre_frozen": {
1447 "manifest": crate::pre_frozen::manifest_digest(record),
1448 "approval": record.approval_digest,
1449 "wording": record.wording_digest,
1450 "by": record.decided.by,
1451 "via": record.decided.via,
1452 },
1453 "held_out": held_out,
1454 }),
1455 argv: Some(first.argv.clone()),
1456 more_argv: more,
1457 red: true,
1458 red_class: Some(class),
1459 held_out: held_sha,
1460 generation_failed: None,
1461 skip_cause: None,
1462 })
1463}
1464
1465fn not_red_frozen(
1468 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1469 files: &[(String, String)],
1470 tests: &[ReusedTest],
1471 failing: &ReusedTest,
1472 class: RedClass,
1473) -> PreparedAcceptance {
1474 use serde_json::json;
1475 let note = match class {
1476 RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
1477 RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
1478 other => format!(
1479 "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
1480 class_word(other)
1481 ),
1482 };
1483 PreparedAcceptance {
1484 report: json!({
1485 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1486 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1487 "argv": failing.argv,
1488 "red": false,
1489 "red_class": class,
1490 "note": note,
1491 "source": record.producer.source(),
1492 "pre_frozen": {
1493 "manifest": crate::pre_frozen::manifest_digest(record),
1494 "approval": record.approval_digest,
1495 "wording": record.wording_digest,
1496 "by": record.decided.by,
1497 "via": record.decided.via,
1498 },
1499 "held_out": { "state": "absent", "reason": "not_red" },
1500 }),
1501 argv: Some(failing.argv.clone()),
1502 more_argv: tests
1503 .iter()
1504 .filter(|t| !std::ptr::eq(*t, failing))
1505 .map(|t| t.argv.clone())
1506 .collect(),
1507 red: false,
1508 red_class: Some(class),
1509 held_out: None,
1510 generation_failed: None,
1511 skip_cause: None,
1512 }
1513}