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}
25
26#[derive(Clone, serde::Serialize, serde::Deserialize)]
27pub(crate) struct PreparedAcceptance {
28 pub(crate) report: serde_json::Value,
30 pub(crate) argv: Option<Vec<String>>,
32 #[serde(default)]
35 pub(crate) more_argv: Vec<Vec<String>>,
36 pub(crate) red: bool,
39 #[serde(default)]
41 pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
42 #[serde(default)]
45 pub(crate) held_out: Option<String>,
46 #[serde(default)]
49 pub(crate) generation_failed: Option<String>,
50}
51
52pub(crate) const MANIFESTS: &[&str] = &[
53 "Cargo.toml",
54 "package.json",
55 "pyproject.toml",
56 "go.mod",
57 "pom.xml",
58];
59
60pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
61 use serde_json::json;
62 let skipped = |why: String, report: serde_json::Value| {
63 let mut report = report;
64 report["skipped"] = json!(why);
65 PreparedAcceptance {
66 report,
67 argv: None,
68 more_argv: Vec::new(),
69 red: false,
70 red_class: None,
71 held_out: None,
72 generation_failed: None,
73 }
74 };
75 let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
76 generation_failed: Some(why.clone()),
77 ..skipped(why, report)
78 };
79
80 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
81 .and_then(|c| worktree::files(a.worktree, &c))
82 .unwrap_or_default()
83 .into_iter()
84 .take(400)
85 .collect();
86 let manifest = MANIFESTS
87 .iter()
88 .find_map(|m| {
89 std::fs::read_to_string(a.project.join(m))
90 .ok()
91 .map(|t| format!("{m}:\n{t}"))
92 })
93 .map(|t| t.chars().take(8192).collect::<String>())
94 .unwrap_or_default();
95 let input = substrate::acceptance::input(
96 a.bundle.clone(),
97 a.scenarios,
98 &files,
99 &manifest,
100 a.instructions,
101 );
102 let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
103 let outcome = match outcome {
104 Ok(o) => o,
105 Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
106 };
107 let output = outcome
108 .output
109 .as_ref()
110 .map(|o| o.peek().clone())
111 .unwrap_or_default();
112 let answer = output["answer"].clone();
113 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
114 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
115 let status = format!("{:?}", outcome.status);
116 let why = substrate::provider_refusal(&status).map_or_else(
117 || format!("generation did not succeed: {}", outcome.status.as_str()),
118 |r| format!("the model provider refused the request: {r}"),
119 );
120 return failed(why, base);
121 }
122
123 let mut written = Vec::new();
125 let (valid, refused) = validate_answer(&answer, a.project, a.policy, a.scenarios);
126 if let Some(r) = refused.first() {
127 return skipped(r.message.clone(), base);
128 }
129 let tests: Vec<serde_json::Value> = valid
131 .iter()
132 .map(|f| json!({ "path": f.path, "scenarios": f.cited }))
133 .collect();
134 let planned: Vec<(String, String)> = valid.into_iter().map(|f| (f.path, f.content)).collect();
135 if planned.is_empty() {
136 return skipped("no acceptance tests were generated".to_owned(), base);
137 }
138 let argv: Vec<String> = answer["argv"]
139 .as_array()
140 .into_iter()
141 .flatten()
142 .filter_map(|v| v.as_str().map(str::to_owned))
143 .collect();
144 if a.policy
145 .classify("run_command", &json!({ "argv": argv }), None)
146 != tools::policy::Decision::Allow
147 {
148 return skipped(
149 format!("`{}` is not an allowed command", argv.join(" ")),
150 base,
151 );
152 }
153 for (rel, content) in &planned {
154 let full = a.project.join(rel);
155 if let Some(parent) = full.parent() {
156 let _ = std::fs::create_dir_all(parent);
157 }
158 if let Err(e) = std::fs::write(&full, content) {
159 return skipped(format!("`{rel}`: {e}"), base);
160 }
161 written.push(rel.clone());
162 }
163
164 let (first, summary) = execute(a.project, &argv, a.runner);
168 let mut class = classify_red(&first, summary.as_ref());
169 if class.is_red() {
170 let (second, summary) = execute(a.project, &argv, a.runner);
171 if !classify_red(&second, summary.as_ref()).is_red() {
172 class = RedClass::Flaky;
173 }
174 }
175 let red = class.is_red();
176 let mut report = base;
177 report["files"] = json!(written);
178 report["tests"] = json!(tests);
179 report["argv"] = json!(argv);
180 report["red"] = json!(red);
181 report["red_class"] = json!(class);
182 if red {
183 report["red_output"] = json!(
185 format!("{}\n{}", first.stdout_tail, first.stderr_tail)
186 .trim()
187 .to_owned()
188 );
189 } else {
190 report["note"] = json!(match class {
191 RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
192 RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
193 other => format!(
194 "the acceptance tests did not fail on an assertion ({}): not demonstrated",
195 class_word(other)
196 ),
197 });
198 report["excerpt"] = json!(excerpt(&first));
199 }
200 PreparedAcceptance {
201 report,
202 argv: Some(argv),
203 more_argv: Vec::new(),
204 red,
205 red_class: Some(class),
206 held_out: None,
207 generation_failed: None,
208 }
209}
210
211pub(crate) struct ValidFile {
213 pub(crate) path: String,
215 pub(crate) content: String,
216 pub(crate) cited: Vec<String>,
218 pub(crate) argv: Vec<String>,
220}
221
222pub(crate) struct RefusedFile {
224 pub(crate) path: String,
226 pub(crate) why: &'static str,
228 pub(crate) message: String,
230}
231
232pub(crate) fn validate_answer(
236 answer: &serde_json::Value,
237 project: &Path,
238 policy: &tools::policy::Policy,
239 scenarios: &[speccoder_core::spec::ScenarioRef],
240) -> (Vec<ValidFile>, Vec<RefusedFile>) {
241 let protected = policy.protected();
242 let mut valid = Vec::new();
243 let mut refused = Vec::new();
244 for f in answer["files"].as_array().into_iter().flatten() {
245 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
246 continue;
247 };
248 let rel = match tools::paths::normalise(path) {
249 Ok(r) => r,
250 Err(e) => {
251 refused.push(RefusedFile {
252 path: path.to_owned(),
253 why: "not_a_new_test_file",
254 message: format!("`{path}`: {}", e.0),
255 });
256 continue;
257 }
258 };
259 if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
260 refused.push(RefusedFile {
261 message: format!("`{rel}` is not a new test file"),
262 path: rel,
263 why: "not_a_new_test_file",
264 });
265 continue;
266 }
267 if project.join(&rel).exists() {
268 refused.push(RefusedFile {
269 message: format!("`{rel}` already exists"),
270 path: rel,
271 why: "exists",
272 });
273 continue;
274 }
275 let cited: Vec<String> = f["scenarios"]
276 .as_array()
277 .into_iter()
278 .flatten()
279 .filter_map(|v| v.as_str())
280 .filter(|id| scenarios.iter().any(|s| s.id == *id))
281 .map(str::to_owned)
282 .collect();
283 let argv: Vec<String> = f["argv"]
284 .as_array()
285 .into_iter()
286 .flatten()
287 .filter_map(|v| v.as_str().map(str::to_owned))
288 .collect();
289 valid.push(ValidFile {
290 path: rel,
291 content: content.to_owned(),
292 cited,
293 argv,
294 });
295 }
296 (valid, refused)
297}
298
299pub(crate) fn class_word(class: RedClass) -> String {
301 serde_json::to_value(class)
302 .ok()
303 .and_then(|v| v.as_str().map(str::to_owned))
304 .unwrap_or_default()
305}
306
307fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
310 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
311 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
312 let from = lines
313 .iter()
314 .position(|l| {
315 let l = l.to_lowercase();
316 l.contains("error") || l.contains("fail")
317 })
318 .unwrap_or_else(|| lines.len().saturating_sub(20));
319 lines
320 .iter()
321 .skip(from)
322 .take(20)
323 .copied()
324 .collect::<Vec<_>>()
325 .join("\n")
326}
327
328pub(crate) fn acceptance_run(
330 project: &Path,
331 argv: &[String],
332 runner: &sandbox::Runner,
333) -> (
334 speccoder_core::verify::CheckResult,
335 Option<speccoder_core::verify::RunnerSummary>,
336) {
337 execute(project, argv, runner)
338}
339
340pub(crate) fn run_acceptance(
341 project: &Path,
342 argv: &[String],
343 runner: &sandbox::Runner,
344) -> speccoder_core::verify::CheckResult {
345 execute(project, argv, runner).0
346}
347
348fn execute(
351 project: &Path,
352 argv: &[String],
353 runner: &sandbox::Runner,
354) -> (
355 speccoder_core::verify::CheckResult,
356 Option<speccoder_core::verify::RunnerSummary>,
357) {
358 use speccoder_core::verify::{CheckResult, CheckStatus};
359 use verify::process::CommandRunner;
360 let out = runner.run(&verify::process::Invocation {
361 argv: argv.to_vec(),
362 cwd: project.to_path_buf(),
363 env: verify::process::scrubbed_env(std::env::vars()),
364 timeout: std::time::Duration::from_secs(600),
365 output_cap: verify::process::OUTPUT_CAP,
366 });
367 let status = if out.spawn_error.is_some() {
368 CheckStatus::Error
369 } else if out.timed_out {
370 CheckStatus::TimedOut
371 } else if out.exit_code == Some(0) {
372 CheckStatus::Passed
373 } else {
374 CheckStatus::Failed
375 };
376 let summary = verify::summary::parse(
377 &String::from_utf8_lossy(&out.stdout.kept),
378 &String::from_utf8_lossy(&out.stderr.kept),
379 out.exit_code,
380 );
381 let tail = |b: &[u8]| {
382 let start = b.len().saturating_sub(4096);
383 String::from_utf8_lossy(&b[start..]).into_owned()
384 };
385 let result = CheckResult {
386 name: "acceptance".to_owned(),
387 argv: argv.to_vec(),
388 status,
389 exit_code: out.exit_code,
390 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
391 junit: false,
392 tests: None,
393 stdout_tail: tail(&out.stdout.kept),
394 stderr_tail: tail(&out.stderr.kept),
395 truncated: out.stdout.truncated() || out.stderr.truncated(),
396 note: out.spawn_error,
397 flaky: false,
398 };
399 (result, summary)
400}
401
402pub(crate) fn prepare_held_out(
406 a: &AcceptanceSetup<'_>,
407 visible: &PreparedAcceptance,
408 scenarios: &[speccoder_core::spec::ScenarioRef],
409 store: &Path,
410 composition: bool,
411) -> (serde_json::Value, Option<String>) {
412 use serde_json::json;
413 let absent = |reason: &str, mut report: serde_json::Value| {
414 report["state"] = json!("absent");
415 report["reason"] = json!(reason);
416 (report, None)
417 };
418 let visible_files: Vec<(String, String)> = visible.report["files"]
419 .as_array()
420 .into_iter()
421 .flatten()
422 .filter_map(|p| p.as_str())
423 .map(|p| {
424 (
425 p.to_owned(),
426 std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
427 )
428 })
429 .collect();
430 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
431 .and_then(|c| worktree::files(a.worktree, &c))
432 .unwrap_or_default()
433 .into_iter()
434 .take(400)
435 .collect();
436 let manifest_text = MANIFESTS
437 .iter()
438 .find_map(|m| {
439 std::fs::read_to_string(a.project.join(m))
440 .ok()
441 .map(|t| format!("{m}:\n{t}"))
442 })
443 .map(|t| t.chars().take(8192).collect::<String>())
444 .unwrap_or_default();
445 let input = substrate::acceptance::held_out_input(
446 a.bundle.clone(),
447 &files,
448 &manifest_text,
449 scenarios,
450 &visible_files,
451 composition,
452 a.instructions,
453 );
454 let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
455 Ok(o) => o,
456 Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
457 };
458 let output = outcome
459 .output
460 .as_ref()
461 .map(|o| o.peek().clone())
462 .unwrap_or_default();
463 let answer = output["answer"].clone();
464 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
465 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
466 return absent("generation_failed", base);
467 }
468
469 let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
472 let protected = a.policy.protected();
473 let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
474 let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
475 let mut discard = |path: &str, reason: &str| {
476 discarded.push(crate::held_out::Discarded {
477 path: path.to_owned(),
478 reason: reason.to_owned(),
479 });
480 };
481 let given = answer["files"].as_array().cloned().unwrap_or_default();
482 if given.is_empty() {
483 return absent("none_generated", base);
484 }
485 for f in &given {
486 let path = f["path"].as_str().unwrap_or_default();
487 let content = f["content"].as_str().unwrap_or_default();
488 let cited: Vec<String> = f["scenarios"]
489 .as_array()
490 .into_iter()
491 .flatten()
492 .filter_map(|s| s.as_str().map(str::to_owned))
493 .collect();
494 let argv: Vec<String> = f["argv"]
495 .as_array()
496 .into_iter()
497 .flatten()
498 .filter_map(|s| s.as_str().map(str::to_owned))
499 .collect();
500 let rel = match tools::paths::normalise(path) {
501 Ok(r) => r,
502 Err(_) => {
503 discard(path, "path");
504 continue;
505 }
506 };
507 let taken =
508 visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
509 if !tools::paths::is_test_path(&rel)
510 || protected.write_class(&rel).is_some()
511 || a.project.join(&rel).exists()
512 || taken
513 {
514 discard(&rel, "path");
515 continue;
516 }
517 if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
518 discard(&rel, "unknown_scenario");
519 continue;
520 }
521 let composed = f["composition"].as_bool().unwrap_or(false);
522 if composed && (!composition || cited.len() < 2) {
523 discard(&rel, "composition");
524 continue;
525 }
526 if a.policy
527 .classify("run_command", &json!({ "argv": argv }), None)
528 != tools::policy::Decision::Allow
529 {
530 discard(&rel, "command");
531 continue;
532 }
533 if kept.len() >= crate::held_out::LIMIT {
534 discard(&rel, "limit");
535 continue;
536 }
537 kept.push((
538 crate::held_out::HeldOutFile {
539 path: rel,
540 sha256: crate::integrity::sha256_hex(content.as_bytes()),
541 scenarios: cited,
542 argv,
543 composition: composed,
544 },
545 content.to_owned(),
546 ));
547 }
548
549 let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
551 .map(|c| c.0)
552 .unwrap_or_default();
553 match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
556 Ok(runs) => {
557 let mut red = Vec::new();
558 for ((file, content), ran) in kept.into_iter().zip(runs) {
559 if ran.result.is_none() {
560 discard(&file.path, "collision");
561 } else if crate::held_out::red_class(&ran).is_red() {
562 red.push((file, content));
563 } else {
564 discard(&file.path, "not_red");
565 }
566 }
567 kept = red;
568 }
569 Err(e) => {
570 return absent(
571 "generation_failed",
572 json!({ "error": e, "run_id": base["run_id"] }),
573 );
574 }
575 }
576 let mut report = base;
577 report["discarded"] = json!(discarded);
578 if kept.is_empty() {
579 return absent("all_discarded", report);
580 }
581 let manifest = crate::held_out::Manifest {
582 version: 1,
583 feature: a.feature.to_owned(),
584 task: a.task.to_owned(),
585 author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
586 checkpoint,
587 visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
588 files: kept.iter().map(|(f, _)| f.clone()).collect(),
589 discarded,
590 };
591 let contents: Vec<(String, String)> = kept
592 .iter()
593 .map(|(f, c)| (f.path.clone(), c.clone()))
594 .collect();
595 match crate::held_out::store(store, &manifest, &contents) {
596 Ok(sha) => {
597 report["state"] = json!("on");
598 report["manifest"] = json!(format!("sha256:{sha}"));
599 report["kept"] = json!(kept.len());
600 (report, Some(sha))
601 }
602 Err(e) => absent("generation_failed", json!({ "error": e })),
603 }
604}
605
606pub(crate) struct HeldOutJudged {
610 pub(crate) held_out: speccoder_core::verify::HeldOut,
612 pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
614}
615
616#[allow(clippy::too_many_arguments)]
617pub(crate) fn judge_held_out(
618 store: &Path,
619 manifest_sha: &str,
620 scenarios: &[speccoder_core::spec::ScenarioRef],
621 known_text: &str,
622 wt_root: &Path,
623 project: &Path,
624 tmp: &Path,
625 start: &worktree::Checkpoint,
626 patch: &str,
627 runner: &sandbox::Runner,
628 policy: &tools::policy::Policy,
629) -> Result<HeldOutJudged, (&'static str, String)> {
630 use speccoder_core::verify::{CheckStatus, HeldOut};
631 let (manifest, contents) =
632 crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
633 let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
634 .files
635 .iter()
636 .cloned()
637 .zip(contents.into_iter().map(|(_, c)| c))
638 .collect();
639 let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
640 if !leaks.is_empty() {
641 return Err((
642 "leak_suspected",
643 format!(
644 "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
645 leaks.len()
646 ),
647 ));
648 }
649 let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
650 .map_err(|e| ("tampered", e))?;
651 let mut failed_ids: Vec<String> = Vec::new();
652 for ((file, _), ran) in files.iter().zip(&runs) {
653 let passed = ran
654 .result
655 .as_ref()
656 .is_some_and(|(r, _)| r.status == CheckStatus::Passed);
657 if !passed {
658 failed_ids.extend(file.scenarios.iter().cloned());
659 }
660 }
661 failed_ids.sort();
662 failed_ids.dedup();
663 let held_out = if failed_ids.is_empty() {
664 HeldOut::Passed { files: files.len() }
665 } else {
666 HeldOut::Failed {
667 scenarios: failed_ids.clone(),
668 }
669 };
670 let failed = failed_ids
671 .iter()
672 .map(|id| {
673 scenarios
674 .iter()
675 .find(|s| &s.id == id)
676 .cloned()
677 .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
678 id: id.clone(),
679 text: String::new(),
680 })
681 })
682 .collect();
683 Ok(HeldOutJudged { held_out, failed })
684}
685
686pub(crate) struct Strengthened {
688 pub(crate) files: Vec<String>,
690 pub(crate) argv: Vec<String>,
692 pub(crate) report: serde_json::Value,
694}
695
696pub(crate) fn strengthen(
701 a: &AcceptanceSetup<'_>,
702 visible: &[(String, String)],
703 weak: serde_json::Value,
704 before: &worktree::Checkpoint,
705 after: &worktree::Checkpoint,
706) -> Result<Strengthened, String> {
707 use serde_json::json;
708 let files: Vec<String> = worktree::files(a.worktree, after)
709 .unwrap_or_default()
710 .into_iter()
711 .take(400)
712 .collect();
713 let manifest = MANIFESTS
714 .iter()
715 .find_map(|m| {
716 std::fs::read_to_string(a.project.join(m))
717 .ok()
718 .map(|t| format!("{m}:\n{t}"))
719 })
720 .map(|t| t.chars().take(8192).collect::<String>())
721 .unwrap_or_default();
722 let input = substrate::acceptance::strengthen_input(
723 a.bundle.clone(),
724 a.scenarios,
725 &files,
726 &manifest,
727 visible,
728 weak,
729 a.instructions,
730 );
731 let outcome =
732 a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
733 .map_err(|e| format!("the test author failed: {}", e.0))?;
734 let output = outcome
735 .output
736 .as_ref()
737 .map(|o| o.peek().clone())
738 .unwrap_or_default();
739 let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
740 let answer = &output["answer"];
741 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
742 return Err(format!(
743 "the test author did not answer ({})",
744 outcome.status.as_str()
745 ));
746 }
747 let protected = a.policy.protected();
748 let mut planned = Vec::new();
749 for f in answer["files"].as_array().into_iter().flatten() {
750 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
751 continue;
752 };
753 let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
754 if !tools::paths::is_test_path(&rel)
755 || protected.write_class(&rel).is_some()
756 || a.project.join(&rel).exists()
757 {
758 return Err(format!("`{rel}` is not a new test file"));
759 }
760 planned.push((rel, content.to_owned()));
761 }
762 if planned.is_empty() {
763 return Err("the test author added no tests".to_owned());
764 }
765 let argv: Vec<String> = answer["argv"]
766 .as_array()
767 .into_iter()
768 .flatten()
769 .filter_map(|v| v.as_str().map(str::to_owned))
770 .collect();
771 if a.policy
772 .classify("run_command", &json!({ "argv": argv }), None)
773 != tools::policy::Decision::Allow
774 {
775 return Err(format!("`{}` is not an allowed command", argv.join(" ")));
776 }
777 let write = || -> Result<(), String> {
778 for (rel, content) in &planned {
779 let full = a.project.join(rel);
780 if let Some(parent) = full.parent() {
781 let _ = std::fs::create_dir_all(parent);
782 }
783 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
784 }
785 Ok(())
786 };
787 let back =
788 |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
789 back(before)?;
791 let red = write().map(|()| {
792 let (r, summary) = execute(a.project, &argv, a.runner);
793 classify_red(&r, summary.as_ref())
794 });
795 back(after)?;
796 let red = red?;
797 if !red.is_red() {
798 return Err(format!(
799 "the added tests do not fail without the change ({})",
800 class_word(red)
801 ));
802 }
803 write()?;
805 let (green, _) = execute(a.project, &argv, a.runner);
806 if green.status != speccoder_core::verify::CheckStatus::Passed {
807 back(after)?;
808 let cited: Vec<String> = answer["files"]
809 .as_array()
810 .into_iter()
811 .flatten()
812 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
813 .filter_map(|v| v.as_str().map(str::to_owned))
814 .collect();
815 return Err(format!(
816 "the added tests fail the implementation{}",
817 if cited.is_empty() {
818 String::new()
819 } else {
820 format!(" (scenarios {})", cited.join(", "))
821 }
822 ));
823 }
824 Ok(Strengthened {
825 files: planned.into_iter().map(|(p, _)| p).collect(),
826 argv,
827 report,
828 })
829}
830
831pub(crate) fn story_checkpoint(
834 tasks: Option<&speccoder_core::snapshot::ParsedTasks>,
835 task: &str,
836 scenarios: &[speccoder_core::spec::ScenarioRef],
837) -> bool {
838 let Some(tasks) = tasks else { return false };
839 let Some(label) = tasks
840 .tasks
841 .iter()
842 .find(|t| t.id == task)
843 .and_then(|t| t.story.as_deref())
844 else {
845 return false;
846 };
847 let last = tasks
848 .tasks
849 .iter()
850 .rfind(|t| t.story.as_deref() == Some(label))
851 .map(|t| t.id.as_str());
852 last == Some(task) && scenarios.len() >= 2
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858
859 #[test]
860 fn the_last_task_of_a_story_with_two_scenarios_composes() {
861 let (tasks, _) = speccoder_speckit::tasks::parse(
862 "tasks.md",
863 "## Phase 3: User Story 1 - Login\n\n- [ ] T005 [US1] a\n- [ ] T006 [US1] b\n\n## Phase 4: User Story 2 - X\n\n- [ ] T007 [US2] c\n",
864 );
865 let two: Vec<speccoder_core::spec::ScenarioRef> = ["US1-1", "US1-2"]
866 .iter()
867 .map(|id| speccoder_core::spec::ScenarioRef {
868 id: (*id).to_owned(),
869 text: String::new(),
870 })
871 .collect();
872 assert!(story_checkpoint(Some(&tasks), "T006", &two));
873 assert!(!story_checkpoint(Some(&tasks), "T005", &two));
874 assert!(!story_checkpoint(Some(&tasks), "T006", &two[..1]));
875 assert!(!story_checkpoint(None, "T006", &two));
876 }
877
878 fn record(
880 producer: speccoder_core::acceptance::Producer,
881 content: &str,
882 ) -> speccoder_core::acceptance::PreFrozenAcceptance {
883 speccoder_core::acceptance::PreFrozenAcceptance {
884 unit: "001-x".to_owned(),
885 requirement: "US1".to_owned(),
886 wording_digest: "sha256:w".to_owned(),
887 approval_digest: "sha256:a".to_owned(),
888 visible: vec![speccoder_core::acceptance::VisibleTest {
889 path: "tests/a.txt".to_owned(),
890 sha256: format!(
891 "sha256:{}",
892 crate::integrity::sha256_hex(content.as_bytes())
893 ),
894 cites: vec!["US1-1".to_owned()],
895 }],
896 held_out_sealed: None,
897 producer,
898 decided: speccoder_core::acceptance::Decided {
899 by: "Ada".to_owned(),
900 via: speccoder_core::actions::Via::Terminal,
901 at: 0,
902 },
903 }
904 }
905
906 #[test]
907 fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
908 use speccoder_core::acceptance::Producer;
909 let summary = |passed: u8, failed: u8| {
910 format!(
911 "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
912 if failed > 0 { "FAILED" } else { "ok" },
913 if failed > 0 { 101 } else { 0 }
914 )
915 };
916 let content = "checks\n";
917 for (producer, rule) in [
918 (Producer::Review, RedRule::RunTime),
919 (Producer::BugConfirmation, RedRule::BugAssertionOnly),
920 ] {
921 let rec = record(producer, content);
922 let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
923 let run = |script: String| {
924 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
925 let tests = [ReusedTest {
926 path: "tests/a.txt".to_owned(),
927 cites: vec!["US1-1".to_owned()],
928 argv: vec!["sh".to_owned(), "-c".to_owned(), script],
929 green: false,
930 }];
931 let got = reuse_pre_frozen(
932 dir.path(),
933 &rec,
934 &files,
935 &tests,
936 &sandbox::Runner::Plain,
937 rule,
938 None,
939 );
940 assert!(
941 dir.path().join("tests/a.txt").is_file(),
942 "written at the checkpoint"
943 );
944 got
945 };
946 let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
948 assert!(red.red);
949 assert_eq!(red.report["source"], serde_json::json!(producer.source()));
950 let passing = run(summary(1, 0));
953 match rule {
954 RedRule::RunTime => {
955 let p = passing.unwrap_or_else(|e| panic!("{e}"));
956 assert!(!p.red);
957 assert!(
958 p.report["note"]
959 .as_str()
960 .is_some_and(|n| n.contains("already passing"))
961 );
962 }
963 RedRule::BugAssertionOnly => assert!(passing.is_err()),
964 }
965 if rule == RedRule::BugAssertionOnly {
967 assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
968 }
969 }
970 }
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
975pub(crate) enum RedRule {
976 RunTime,
980 BugAssertionOnly,
982}
983
984#[derive(Debug)]
986pub(crate) struct ReusedTest {
987 pub(crate) path: String,
989 pub(crate) cites: Vec<String>,
991 pub(crate) argv: Vec<String>,
993 pub(crate) green: bool,
995}
996
997pub(crate) struct SealedCheck<'a> {
1000 pub(crate) state: &'a Path,
1002 pub(crate) unit: &'a str,
1004 pub(crate) wt_root: &'a Path,
1006 pub(crate) tmp: &'a Path,
1008 pub(crate) policy: &'a tools::policy::Policy,
1010}
1011
1012pub(crate) fn reuse_pre_frozen(
1018 project: &Path,
1019 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1020 files: &[(String, String)],
1021 tests: &[ReusedTest],
1022 runner: &sandbox::Runner,
1023 rule: RedRule,
1024 sealed: Option<&SealedCheck<'_>>,
1025) -> Result<PreparedAcceptance, String> {
1026 use serde_json::json;
1027 for (rel, content) in files {
1028 let full = project.join(rel);
1029 if let Ok(now) = std::fs::read_to_string(&full) {
1030 if now != *content {
1031 return Err(format!(
1032 "`{rel}` exists and differs from the confirmed test; it is not overwritten"
1033 ));
1034 }
1035 continue;
1036 }
1037 if let Some(parent) = full.parent() {
1038 let _ = std::fs::create_dir_all(parent);
1039 }
1040 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1041 }
1042 let class_of = |r: &speccoder_core::verify::CheckResult,
1045 s: Option<&speccoder_core::verify::RunnerSummary>| match rule
1046 {
1047 RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
1048 RedRule::RunTime => classify_red(r, s),
1049 };
1050 let mut classes = Vec::new();
1051 for t in tests {
1052 let (first, summary) = execute(project, &t.argv, runner);
1053 let one = class_of(&first, summary.as_ref());
1054 let class = if t.green {
1055 one
1056 } else {
1057 match rule {
1058 RedRule::BugAssertionOnly if one == RedClass::Assertion => {
1059 let (second, summary) = execute(project, &t.argv, runner);
1060 speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
1061 }
1062 RedRule::BugAssertionOnly => one,
1063 RedRule::RunTime if one.is_red() => {
1064 let (second, summary) = execute(project, &t.argv, runner);
1065 if classify_red(&second, summary.as_ref()).is_red() {
1066 one
1067 } else {
1068 RedClass::Flaky
1069 }
1070 }
1071 RedRule::RunTime => one,
1072 }
1073 };
1074 let holds = if t.green {
1075 class == RedClass::Passed
1076 } else {
1077 match rule {
1078 RedRule::BugAssertionOnly => class == RedClass::Assertion,
1079 RedRule::RunTime => class.is_red(),
1080 }
1081 };
1082 if !holds && rule == RedRule::RunTime && !t.green {
1083 return Ok(not_red_frozen(record, files, tests, t, class));
1084 }
1085 if !holds {
1086 return Err(if t.green {
1087 format!(
1088 "the confirmed test `{}` ({}) no longer passes on the branch ({})",
1089 t.path,
1090 t.cites.join(", "),
1091 class_word(class)
1092 )
1093 } else {
1094 format!(
1095 "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
1096 t.path,
1097 t.cites.join(", "),
1098 class_word(class)
1099 )
1100 });
1101 }
1102 classes.push(class);
1103 }
1104 let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
1105 let Some(first) = red.first() else {
1106 return Err("the confirmed set has no test that must fail first".to_owned());
1107 };
1108 let more: Vec<Vec<String>> = tests
1109 .iter()
1110 .filter(|t| !std::ptr::eq(*t, *first))
1111 .map(|t| t.argv.clone())
1112 .collect();
1113 let mut held_sha = record
1114 .held_out_sealed
1115 .as_ref()
1116 .map(|h| h.manifest_sha256.clone());
1117 let mut held_out = match &record.held_out_sealed {
1118 Some(h) => {
1119 json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
1120 }
1121 None => json!({ "state": "absent", "reason": "none_kept" }),
1122 };
1123 if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
1126 let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
1127 .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
1128 let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1129 .files
1130 .iter()
1131 .cloned()
1132 .zip(contents.into_iter().map(|(_, c)| c))
1133 .collect();
1134 let runs = crate::held_out::overlay_red(
1135 check.wt_root,
1136 project,
1137 check.tmp,
1138 &set,
1139 runner,
1140 check.policy,
1141 )?;
1142 let all_red = runs
1143 .iter()
1144 .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
1145 if !all_red {
1146 held_sha = None;
1147 held_out = json!({ "state": "absent", "reason": "not_red" });
1148 }
1149 }
1150 let class = classes.first().copied().unwrap_or(RedClass::Assertion);
1151 Ok(PreparedAcceptance {
1152 report: json!({
1153 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1154 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1155 "argv": first.argv,
1156 "red": true,
1157 "red_class": class,
1158 "source": record.producer.source(),
1159 "pre_frozen": {
1160 "manifest": crate::pre_frozen::manifest_digest(record),
1161 "approval": record.approval_digest,
1162 "wording": record.wording_digest,
1163 "by": record.decided.by,
1164 "via": record.decided.via,
1165 },
1166 "held_out": held_out,
1167 }),
1168 argv: Some(first.argv.clone()),
1169 more_argv: more,
1170 red: true,
1171 red_class: Some(class),
1172 held_out: held_sha,
1173 generation_failed: None,
1174 })
1175}
1176
1177fn not_red_frozen(
1180 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1181 files: &[(String, String)],
1182 tests: &[ReusedTest],
1183 failing: &ReusedTest,
1184 class: RedClass,
1185) -> PreparedAcceptance {
1186 use serde_json::json;
1187 let note = match class {
1188 RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
1189 RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
1190 other => format!(
1191 "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
1192 class_word(other)
1193 ),
1194 };
1195 PreparedAcceptance {
1196 report: json!({
1197 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1198 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1199 "argv": failing.argv,
1200 "red": false,
1201 "red_class": class,
1202 "note": note,
1203 "source": record.producer.source(),
1204 "pre_frozen": {
1205 "manifest": crate::pre_frozen::manifest_digest(record),
1206 "approval": record.approval_digest,
1207 "wording": record.wording_digest,
1208 "by": record.decided.by,
1209 "via": record.decided.via,
1210 },
1211 "held_out": { "state": "absent", "reason": "not_red" },
1212 }),
1213 argv: Some(failing.argv.clone()),
1214 more_argv: tests
1215 .iter()
1216 .filter(|t| !std::ptr::eq(*t, failing))
1217 .map(|t| t.argv.clone())
1218 .collect(),
1219 red: false,
1220 red_class: Some(class),
1221 held_out: None,
1222 generation_failed: None,
1223 }
1224}