1use std::path::Path;
4
5use speccoder_core::verify::{RedClass, classify_red};
6
7use crate::{sandbox, substrate, tools, verify, worktree};
8
9pub(crate) struct AcceptanceSetup<'a> {
10 pub(crate) rt: &'a tokio::runtime::Runtime,
11 pub(crate) plane: &'a substrate::Plane,
12 pub(crate) project: &'a Path,
13 pub(crate) worktree: &'a Path,
14 pub(crate) tmp: &'a Path,
15 pub(crate) policy: &'a tools::policy::Policy,
16 pub(crate) feature: &'a str,
17 pub(crate) task: &'a str,
18 pub(crate) bundle: serde_json::Value,
19 pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
21 pub(crate) instructions: &'a [crate::instructions::Instruction],
23 pub(crate) runner: &'a sandbox::Runner,
24 pub(crate) layout: Option<&'a crate::layout::TestLayout>,
27 pub(crate) story_files: &'a [String],
30}
31
32#[derive(Clone, serde::Serialize, serde::Deserialize)]
33pub(crate) struct PreparedAcceptance {
34 pub(crate) report: serde_json::Value,
36 pub(crate) argv: Option<Vec<String>>,
38 #[serde(default)]
41 pub(crate) more_argv: Vec<Vec<String>>,
42 pub(crate) red: bool,
45 #[serde(default)]
47 pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
48 #[serde(default)]
51 pub(crate) held_out: Option<String>,
52 #[serde(default)]
55 pub(crate) generation_failed: Option<String>,
56 #[serde(default)]
59 pub(crate) skip_cause: Option<speccoder_core::regression::SkipCause>,
60}
61
62pub(crate) const MANIFESTS: &[&str] = &[
63 "Cargo.toml",
64 "package.json",
65 "pyproject.toml",
66 "go.mod",
67 "pom.xml",
68];
69
70pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
71 use serde_json::json;
72 use speccoder_core::regression::SkipCause;
73 let skipped_by = |why: String, cause: Option<SkipCause>, report: serde_json::Value| {
74 let mut report = report;
75 report["skipped"] = json!(why);
76 PreparedAcceptance {
77 report,
78 argv: None,
79 more_argv: Vec::new(),
80 red: false,
81 red_class: None,
82 held_out: None,
83 generation_failed: None,
84 skip_cause: cause,
85 }
86 };
87 let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
88 generation_failed: Some(why.clone()),
89 ..skipped_by(why, None, report)
90 };
91
92 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
93 .and_then(|c| worktree::files(a.worktree, &c))
94 .unwrap_or_default()
95 .into_iter()
96 .take(400)
97 .collect();
98 let manifest = MANIFESTS
99 .iter()
100 .find_map(|m| {
101 std::fs::read_to_string(a.project.join(m))
102 .ok()
103 .map(|t| format!("{m}:\n{t}"))
104 })
105 .map(|t| t.chars().take(8192).collect::<String>())
106 .unwrap_or_default();
107 let placement = match placement(a) {
111 Ok(p) => p,
112 Err(()) => {
113 return skipped_by(
114 "no place the runner collects".to_owned(),
115 Some(SkipCause::NoPlace),
116 json!({}),
117 );
118 }
119 };
120 let input = substrate::acceptance::input(
121 a.bundle.clone(),
122 a.scenarios,
123 &files,
124 &manifest,
125 a.instructions,
126 placement.as_ref(),
127 );
128 let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
129 let outcome = match outcome {
130 Ok(o) => o,
131 Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
132 };
133 let output = outcome
134 .output
135 .as_ref()
136 .map(|o| o.peek().clone())
137 .unwrap_or_default();
138 let answer = output["answer"].clone();
139 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
140 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
141 let status = format!("{:?}", outcome.status);
142 let why = substrate::provider_refusal(&status).map_or_else(
143 || format!("generation failed: {}", substrate::why_not(&outcome.status)),
144 |r| format!("the model provider refused the request: {r}"),
145 );
146 return failed(why, base);
147 }
148
149 let mut written = Vec::new();
151 let story = placement
152 .as_ref()
153 .and_then(|p| p.existing.as_deref().map(|old| (p.path.as_str(), old)));
154 let (valid, refused) = validate_with(&answer, a.project, a.policy, a.scenarios, story);
155 if let Some(r) = refused.first() {
156 return skipped_by(
157 r.message.clone(),
158 Some(SkipCause::Refused {
159 path: r.path.clone(),
160 why: refusal_words(r.why).to_owned(),
161 }),
162 base,
163 );
164 }
165 let tests: Vec<serde_json::Value> = valid
167 .iter()
168 .map(|f| json!({ "path": f.path, "scenarios": f.cited }))
169 .collect();
170 let planned: Vec<(String, String)> = valid
171 .into_iter()
172 .map(|f| match story.filter(|(p, _)| *p == f.path) {
173 Some((_, old)) => {
174 let content = without_repeated_future(old, &f.content);
175 (f.path, content)
176 }
177 None => (f.path, f.content),
178 })
179 .collect();
180 if planned.is_empty() {
181 return skipped_by(
182 "no acceptance tests were generated".to_owned(),
183 Some(SkipCause::NoFiles),
184 base,
185 );
186 }
187 let argv: Vec<String> = argv_of(&answer["argv"]);
188 let paths: Vec<&str> = planned.iter().map(|(p, _)| p.as_str()).collect();
189 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
190 return skipped_by(
191 format!("`{}` is not an allowed command", argv.join(" ")),
192 Some(SkipCause::CommandNotAllowed {
193 argv: argv.join(" "),
194 }),
195 base,
196 );
197 }
198 for (rel, content) in &planned {
199 let full = a.project.join(rel);
200 if let Some(parent) = full.parent() {
201 let _ = std::fs::create_dir_all(parent);
202 }
203 if let Err(e) = std::fs::write(&full, content) {
204 return skipped_by(
205 format!("`{rel}`: {e}"),
206 Some(SkipCause::WriteFailed { path: rel.clone() }),
207 base,
208 );
209 }
210 written.push(rel.clone());
211 }
212
213 let (first, summary) = execute(a.project, &argv, a.runner);
217 let red_tests = speccoder_core::verify::collected_count(&first, summary.as_ref());
218 let mut class = classify_red(&first, summary.as_ref());
219 if let Some((path, old)) = story.filter(|(p, _)| written.iter().any(|w| w == p)) {
222 let full = a.project.join(path);
223 if let Ok(new) = std::fs::read_to_string(&full)
224 && std::fs::write(&full, old).is_ok()
225 {
226 let (without, wsum) = execute(a.project, &argv, a.runner);
227 class = if std::fs::write(&full, &new).is_err()
229 || std::fs::read_to_string(&full).ok().as_deref() != Some(new.as_str())
230 {
231 RedClass::Unclassified
232 } else {
233 speccoder_core::verify::classify_red_added(
234 (&first, summary.as_ref()),
235 (&without, wsum.as_ref()),
236 )
237 };
238 }
239 }
240 if class.is_red() {
241 let (second, summary) = execute(a.project, &argv, a.runner);
242 if !classify_red(&second, summary.as_ref()).is_red() {
243 class = RedClass::Flaky;
244 }
245 }
246 let red = class.is_red();
247 let broken = matches!(class, RedClass::CollectionError | RedClass::BuildError);
250 let removed = if broken {
251 for rel in &written {
252 let full = a.project.join(rel);
253 match story.filter(|(p, _)| p == rel) {
254 Some((_, old)) => {
255 let _ = std::fs::write(&full, old);
256 }
257 None => {
258 let _ = std::fs::remove_file(&full);
259 }
260 }
261 }
262 std::mem::take(&mut written)
263 } else {
264 Vec::new()
265 };
266 let mut report = base;
267 report["files"] = json!(written);
268 if !removed.is_empty() {
269 report["removed"] = json!(removed);
270 }
271 if let Some(p) = placement.as_ref().filter(|p| p.story.is_some()) {
272 report["story_file"] = json!(p.path);
273 report["appended"] = json!(p.existing.is_some());
274 }
275 report["tests"] = json!(tests);
276 report["argv"] = json!(argv);
277 report["red"] = json!(red);
278 report["red_class"] = json!(class);
279 if let Some(n) = red_tests {
280 report["red_tests"] = json!(n);
281 }
282 if red {
283 report["red_output"] = json!(
285 format!("{}\n{}", first.stdout_tail, first.stderr_tail)
286 .trim()
287 .to_owned()
288 );
289 } else {
290 report["note"] = json!(match class {
291 RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
292 RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
293 other if broken => format!(
294 "the acceptance tests did not fail on an assertion ({}): not demonstrated, and taken out so the project's other tests still run",
295 class_word(other)
296 ),
297 other => format!(
298 "the acceptance tests did not fail on an assertion ({}): not demonstrated",
299 class_word(other)
300 ),
301 });
302 report["excerpt"] = json!(excerpt(&first));
303 }
304 PreparedAcceptance {
305 report,
306 argv: Some(argv),
307 more_argv: Vec::new(),
308 red,
309 red_class: Some(class),
310 held_out: None,
311 generation_failed: None,
312 skip_cause: None,
313 }
314}
315
316fn placement(a: &AcceptanceSetup<'_>) -> Result<Option<substrate::acceptance::Placement>, ()> {
320 let Some(layout) = a.layout.filter(|l| !l.runners.is_empty()) else {
321 return Ok(None);
322 };
323 let task = a.task.to_owned();
324 let story = a.bundle["task"]["story"]
325 .as_str()
326 .filter(|s| !s.is_empty())
327 .map(str::to_owned);
328 let package =
330 speccoder_core::scope::named_paths(a.bundle["task"]["title"].as_str().unwrap_or_default())
331 .into_iter()
332 .find(|p| p.ends_with(".go") || !p.contains('.'))
333 .map(|p| {
334 if p.ends_with(".go") {
335 p.rsplit_once('/')
336 .map_or(String::new(), |(d, _)| d.to_owned())
337 } else {
338 p.trim_end_matches('/').to_owned()
339 }
340 });
341 let per_task =
342 |layout: &crate::layout::TestLayout| layout.story_file(&task, package.as_deref());
343 let (path, story, existing) = match story.as_ref() {
344 Some(st) => {
345 let Some(path) = layout.story_file(st, package.as_deref()) else {
346 return Err(());
347 };
348 let exists = a.project.join(&path).exists();
349 if !exists {
350 (path, story.clone(), None)
351 } else if a.story_files.contains(&path) {
352 let old = std::fs::read_to_string(a.project.join(&path)).unwrap_or_default();
353 (path, story.clone(), Some(old))
354 } else {
355 (per_task(layout).ok_or(())?, None, None)
357 }
358 }
359 None => (per_task(layout).ok_or(())?, None, None),
360 };
361 let comment = if path.ends_with(".py") { "#" } else { "//" }.to_owned();
362 Ok(Some(substrate::acceptance::Placement {
363 command: layout.command_for(&path).unwrap_or_default(),
364 path,
365 existing,
366 task,
367 story,
368 scenarios: a.scenarios.iter().map(|s| s.id.clone()).collect(),
369 comment,
370 layout: layout.text(),
371 }))
372}
373
374fn without_repeated_future(old: &str, new: &str) -> String {
378 let (Some(section), true) = (new.strip_prefix(old), old.contains("from __future__")) else {
379 return new.to_owned();
380 };
381 let future = |l: &str| l.trim_start().starts_with("from __future__ import");
382 let has = |l: &str| old.lines().any(|o| o.trim() == l.trim());
383 if !section.lines().any(future) {
384 return new.to_owned();
385 }
386 let kept: Vec<&str> = section
387 .split_inclusive('\n')
388 .filter(|l| !(future(l) && has(l)))
389 .collect();
390 format!("{old}{}", kept.concat())
391}
392
393pub(crate) fn refusal_words(why: &str) -> &'static str {
395 match why {
396 "exists" => "already exists",
397 "not_collected" => "is not where the runner collects tests",
398 "changes_existing" => "changes existing lines",
399 "skip_marker" => "adds a section that skips a test",
400 _ => "is not a new test file",
401 }
402}
403
404pub(crate) struct ValidFile {
406 pub(crate) path: String,
408 pub(crate) content: String,
409 pub(crate) cited: Vec<String>,
411 pub(crate) argv: Vec<String>,
413}
414
415pub(crate) struct RefusedFile {
417 pub(crate) path: String,
419 pub(crate) why: &'static str,
421 pub(crate) message: String,
423}
424
425#[must_use]
431pub(crate) fn argv_of(v: &serde_json::Value) -> Vec<String> {
432 let argv: Vec<String> = v
433 .as_array()
434 .into_iter()
435 .flatten()
436 .filter_map(|v| v.as_str().map(str::to_owned))
437 .collect();
438 match argv.as_slice() {
439 [line] if line.split_ascii_whitespace().count() > 1 => {
440 let words = crate::acp::govern::argv_of_line(line);
441 if words.len() == 3 && words[0] == "sh" && words[1] == "-c" && words[2] == line.trim() {
443 argv
444 } else {
445 words
446 }
447 }
448 _ => argv,
449 }
450}
451
452pub(crate) fn test_command_allowed(
456 policy: &tools::policy::Policy,
457 layout: Option<&crate::layout::TestLayout>,
458 argv: &[String],
459 files: &[&str],
460) -> bool {
461 layout.is_some_and(|l| l.runs_only(argv, files))
462 || policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
463 == tools::policy::Decision::Allow
464}
465
466pub(crate) fn validate_answer(
470 answer: &serde_json::Value,
471 project: &Path,
472 policy: &tools::policy::Policy,
473 scenarios: &[speccoder_core::spec::ScenarioRef],
474) -> (Vec<ValidFile>, Vec<RefusedFile>) {
475 validate_with(answer, project, policy, scenarios, None)
476}
477
478pub(crate) fn validate_with(
481 answer: &serde_json::Value,
482 project: &Path,
483 policy: &tools::policy::Policy,
484 scenarios: &[speccoder_core::spec::ScenarioRef],
485 story: Option<(&str, &str)>,
486) -> (Vec<ValidFile>, Vec<RefusedFile>) {
487 let protected = policy.protected();
488 let mut valid = Vec::new();
489 let mut refused = Vec::new();
490 for f in answer["files"].as_array().into_iter().flatten() {
491 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
492 continue;
493 };
494 let rel = match tools::paths::normalise(path) {
495 Ok(r) => r,
496 Err(e) => {
497 refused.push(RefusedFile {
498 path: path.to_owned(),
499 why: "not_a_new_test_file",
500 message: format!("`{path}`: {}", e.0),
501 });
502 continue;
503 }
504 };
505 if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
506 refused.push(RefusedFile {
507 message: format!("`{rel}` is not a new test file"),
508 path: rel,
509 why: "not_a_new_test_file",
510 });
511 continue;
512 }
513 match story {
514 Some((path, old)) if path == rel => {
515 match crate::integrity::appended_section(old, content) {
518 None => {
519 refused.push(RefusedFile {
520 message: format!("changes existing lines of `{rel}`"),
521 path: rel,
522 why: "changes_existing",
523 });
524 continue;
525 }
526 Some(added) if crate::integrity::has_skip_marker(added) => {
527 refused.push(RefusedFile {
528 message: format!("`{rel}`: the added section skips a test"),
529 path: rel,
530 why: "skip_marker",
531 });
532 continue;
533 }
534 Some(_) => {}
535 }
536 }
537 _ if project.join(&rel).exists() => {
538 refused.push(RefusedFile {
539 message: format!("`{rel}` already exists"),
540 path: rel,
541 why: "exists",
542 });
543 continue;
544 }
545 _ => {
546 if crate::collection::statically_refused(project, &rel, content).is_some() {
548 refused.push(RefusedFile {
549 message: format!("`{rel}` is not where the runner collects tests"),
550 path: rel,
551 why: "not_collected",
552 });
553 continue;
554 }
555 }
556 }
557 let cited: Vec<String> = f["scenarios"]
558 .as_array()
559 .into_iter()
560 .flatten()
561 .filter_map(|v| v.as_str())
562 .filter(|id| scenarios.iter().any(|s| s.id == *id))
563 .map(str::to_owned)
564 .collect();
565 let argv: Vec<String> = argv_of(&f["argv"]);
566 valid.push(ValidFile {
567 path: rel,
568 content: content.to_owned(),
569 cited,
570 argv,
571 });
572 }
573 (valid, refused)
574}
575
576pub(crate) fn class_word(class: RedClass) -> String {
578 serde_json::to_value(class)
579 .ok()
580 .and_then(|v| v.as_str().map(str::to_owned))
581 .unwrap_or_default()
582}
583
584fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
587 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
588 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
589 let from = lines
590 .iter()
591 .position(|l| {
592 let l = l.to_lowercase();
593 l.contains("error") || l.contains("fail")
594 })
595 .unwrap_or_else(|| lines.len().saturating_sub(20));
596 lines
597 .iter()
598 .skip(from)
599 .take(20)
600 .copied()
601 .collect::<Vec<_>>()
602 .join("\n")
603}
604
605pub(crate) fn acceptance_run(
607 project: &Path,
608 argv: &[String],
609 runner: &sandbox::Runner,
610) -> (
611 speccoder_core::verify::CheckResult,
612 Option<speccoder_core::verify::RunnerSummary>,
613) {
614 execute(project, argv, runner)
615}
616
617pub(crate) fn run_acceptance(
618 project: &Path,
619 argv: &[String],
620 runner: &sandbox::Runner,
621) -> speccoder_core::verify::CheckResult {
622 execute(project, argv, runner).0
623}
624
625fn execute(
628 project: &Path,
629 argv: &[String],
630 runner: &sandbox::Runner,
631) -> (
632 speccoder_core::verify::CheckResult,
633 Option<speccoder_core::verify::RunnerSummary>,
634) {
635 use speccoder_core::verify::{CheckResult, CheckStatus};
636 use verify::process::CommandRunner;
637 let out = runner.run(&verify::process::Invocation {
638 argv: argv.to_vec(),
639 cwd: project.to_path_buf(),
640 env: verify::process::scrubbed_env(std::env::vars()),
641 timeout: std::time::Duration::from_secs(600),
642 output_cap: verify::process::OUTPUT_CAP,
643 });
644 let status = if out.spawn_error.is_some() {
645 CheckStatus::Error
646 } else if out.timed_out {
647 CheckStatus::TimedOut
648 } else if out.exit_code == Some(0) {
649 CheckStatus::Passed
650 } else {
651 CheckStatus::Failed
652 };
653 let summary = verify::summary::parse(
654 &String::from_utf8_lossy(&out.stdout.kept),
655 &String::from_utf8_lossy(&out.stderr.kept),
656 out.exit_code,
657 );
658 let tail = |b: &[u8]| {
659 let start = b.len().saturating_sub(4096);
660 String::from_utf8_lossy(&b[start..]).into_owned()
661 };
662 let result = CheckResult {
663 name: "acceptance".to_owned(),
664 argv: argv.to_vec(),
665 status,
666 exit_code: out.exit_code,
667 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
668 junit: false,
669 tests: None,
670 stdout_tail: tail(&out.stdout.kept),
671 stderr_tail: tail(&out.stderr.kept),
672 truncated: out.stdout.truncated() || out.stderr.truncated(),
673 note: out.spawn_error,
674 flaky: false,
675 };
676 (result, summary)
677}
678
679pub(crate) fn prepare_held_out(
683 a: &AcceptanceSetup<'_>,
684 visible: &PreparedAcceptance,
685 scenarios: &[speccoder_core::spec::ScenarioRef],
686 store: &Path,
687 composition: bool,
688) -> (serde_json::Value, Option<String>) {
689 use serde_json::json;
690 let absent = |reason: &str, mut report: serde_json::Value| {
691 report["state"] = json!("absent");
692 report["reason"] = json!(reason);
693 (report, None)
694 };
695 let visible_files: Vec<(String, String)> = visible.report["files"]
696 .as_array()
697 .into_iter()
698 .flatten()
699 .filter_map(|p| p.as_str())
700 .map(|p| {
701 (
702 p.to_owned(),
703 std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
704 )
705 })
706 .collect();
707 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
708 .and_then(|c| worktree::files(a.worktree, &c))
709 .unwrap_or_default()
710 .into_iter()
711 .take(400)
712 .collect();
713 let manifest_text = 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::held_out_input(
723 a.bundle.clone(),
724 &files,
725 &manifest_text,
726 scenarios,
727 &visible_files,
728 composition,
729 a.instructions,
730 a.layout.map(crate::layout::TestLayout::text).as_deref(),
731 );
732 let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
733 Ok(o) => o,
734 Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
735 };
736 let output = outcome
737 .output
738 .as_ref()
739 .map(|o| o.peek().clone())
740 .unwrap_or_default();
741 let answer = output["answer"].clone();
742 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
743 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
744 return absent("generation_failed", base);
745 }
746
747 let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
750 let protected = a.policy.protected();
751 let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
752 let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
753 let mut discard = |path: &str, reason: &str| {
754 discarded.push(crate::held_out::Discarded {
755 path: path.to_owned(),
756 reason: reason.to_owned(),
757 });
758 };
759 let given = answer["files"].as_array().cloned().unwrap_or_default();
760 if given.is_empty() {
761 return absent("none_generated", base);
762 }
763 for f in &given {
764 let path = f["path"].as_str().unwrap_or_default();
765 let content = f["content"].as_str().unwrap_or_default();
766 let cited: Vec<String> = f["scenarios"]
767 .as_array()
768 .into_iter()
769 .flatten()
770 .filter_map(|s| s.as_str().map(str::to_owned))
771 .collect();
772 let argv: Vec<String> = argv_of(&f["argv"]);
773 let rel = match tools::paths::normalise(path) {
774 Ok(r) => r,
775 Err(_) => {
776 discard(path, "path");
777 continue;
778 }
779 };
780 let taken =
781 visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
782 if !tools::paths::is_test_path(&rel)
783 || protected.write_class(&rel).is_some()
784 || a.project.join(&rel).exists()
785 || taken
786 {
787 discard(&rel, "path");
788 continue;
789 }
790 if crate::collection::statically_refused(a.project, &rel, content).is_some() {
792 discard(&rel, "not_collected");
793 continue;
794 }
795 if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
796 discard(&rel, "unknown_scenario");
797 continue;
798 }
799 let composed = f["composition"].as_bool().unwrap_or(false);
800 if composed && (!composition || cited.len() < 2) {
801 discard(&rel, "composition");
802 continue;
803 }
804 if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
805 discard(&rel, "command");
806 continue;
807 }
808 if kept.len() >= crate::held_out::LIMIT {
809 discard(&rel, "limit");
810 continue;
811 }
812 kept.push((
813 crate::held_out::HeldOutFile {
814 path: rel,
815 sha256: crate::integrity::sha256_hex(content.as_bytes()),
816 scenarios: cited,
817 argv,
818 composition: composed,
819 red_tests: None,
820 },
821 content.to_owned(),
822 ));
823 }
824
825 let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
827 .map(|c| c.0)
828 .unwrap_or_default();
829 match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
832 Ok(runs) => {
833 let mut red = Vec::new();
834 for ((mut file, content), ran) in kept.into_iter().zip(runs) {
835 if ran.result.is_none() {
836 discard(&file.path, "collision");
837 } else if crate::held_out::red_class(&ran).is_red() {
838 file.red_tests = ran
839 .result
840 .as_ref()
841 .and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
842 red.push((file, content));
843 } else {
844 discard(&file.path, "not_red");
845 }
846 }
847 kept = red;
848 }
849 Err(e) => {
850 return absent(
851 "generation_failed",
852 json!({ "error": e, "run_id": base["run_id"] }),
853 );
854 }
855 }
856 let mut report = base;
857 report["discarded"] = json!(discarded);
858 if kept.is_empty() {
859 let only_uncollected =
860 !discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
861 return absent(
862 if only_uncollected {
863 "not_collected"
864 } else {
865 "all_discarded"
866 },
867 report,
868 );
869 }
870 let manifest = crate::held_out::Manifest {
871 version: 1,
872 feature: a.feature.to_owned(),
873 task: a.task.to_owned(),
874 author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
875 checkpoint,
876 visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
877 files: kept.iter().map(|(f, _)| f.clone()).collect(),
878 discarded,
879 };
880 let contents: Vec<(String, String)> = kept
881 .iter()
882 .map(|(f, c)| (f.path.clone(), c.clone()))
883 .collect();
884 match crate::held_out::store(store, &manifest, &contents) {
885 Ok(sha) => {
886 report["state"] = json!("on");
887 report["manifest"] = json!(format!("sha256:{sha}"));
888 report["kept"] = json!(kept.len());
889 (report, Some(sha))
890 }
891 Err(e) => absent("generation_failed", json!({ "error": e })),
892 }
893}
894
895pub(crate) struct HeldOutJudged {
899 pub(crate) held_out: speccoder_core::verify::HeldOut,
901 pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
903}
904
905#[allow(clippy::too_many_arguments)]
906pub(crate) fn judge_held_out(
907 store: &Path,
908 manifest_sha: &str,
909 scenarios: &[speccoder_core::spec::ScenarioRef],
910 known_text: &str,
911 wt_root: &Path,
912 project: &Path,
913 tmp: &Path,
914 start: &worktree::Checkpoint,
915 patch: &str,
916 runner: &sandbox::Runner,
917 policy: &tools::policy::Policy,
918) -> Result<HeldOutJudged, (&'static str, String)> {
919 use speccoder_core::verify::{CheckStatus, HeldOut};
920 let (manifest, contents) =
921 crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
922 let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
923 .files
924 .iter()
925 .cloned()
926 .zip(contents.into_iter().map(|(_, c)| c))
927 .collect();
928 let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
929 if !leaks.is_empty() {
930 return Err((
931 "leak_suspected",
932 format!(
933 "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
934 leaks.len()
935 ),
936 ));
937 }
938 let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
939 .map_err(|e| ("tampered", e))?;
940 let mut failed_ids: Vec<String> = Vec::new();
941 for ((file, _), ran) in files.iter().zip(&runs) {
942 let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
946 files
947 .iter()
948 .find(|(f, _)| f.path == file.path)
949 .map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
950 });
951 let required = file.red_tests.unwrap_or(declared).max(1);
952 let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
953 r.status == CheckStatus::Passed
954 && speccoder_core::verify::collected_count(r, sum.as_ref())
955 .is_some_and(|green| green >= required)
956 });
957 if !passed {
958 failed_ids.extend(file.scenarios.iter().cloned());
959 }
960 }
961 failed_ids.sort();
962 failed_ids.dedup();
963 let held_out = if failed_ids.is_empty() {
964 HeldOut::Passed { files: files.len() }
965 } else {
966 HeldOut::Failed {
967 scenarios: failed_ids.clone(),
968 }
969 };
970 let failed = failed_ids
971 .iter()
972 .map(|id| {
973 scenarios
974 .iter()
975 .find(|s| &s.id == id)
976 .cloned()
977 .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
978 id: id.clone(),
979 text: String::new(),
980 })
981 })
982 .collect();
983 Ok(HeldOutJudged { held_out, failed })
984}
985
986pub(crate) struct Strengthened {
988 pub(crate) files: Vec<String>,
990 pub(crate) argv: Vec<String>,
992 pub(crate) report: serde_json::Value,
994}
995
996pub(crate) fn strengthen(
1001 a: &AcceptanceSetup<'_>,
1002 visible: &[(String, String)],
1003 weak: serde_json::Value,
1004 before: &worktree::Checkpoint,
1005 after: &worktree::Checkpoint,
1006) -> Result<Strengthened, String> {
1007 use serde_json::json;
1008 let files: Vec<String> = worktree::files(a.worktree, after)
1009 .unwrap_or_default()
1010 .into_iter()
1011 .take(400)
1012 .collect();
1013 let manifest = MANIFESTS
1014 .iter()
1015 .find_map(|m| {
1016 std::fs::read_to_string(a.project.join(m))
1017 .ok()
1018 .map(|t| format!("{m}:\n{t}"))
1019 })
1020 .map(|t| t.chars().take(8192).collect::<String>())
1021 .unwrap_or_default();
1022 let input = substrate::acceptance::strengthen_input(
1023 a.bundle.clone(),
1024 a.scenarios,
1025 &files,
1026 &manifest,
1027 visible,
1028 weak,
1029 a.instructions,
1030 );
1031 let outcome =
1032 a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
1033 .map_err(|e| format!("the test author failed: {}", e.0))?;
1034 let output = outcome
1035 .output
1036 .as_ref()
1037 .map(|o| o.peek().clone())
1038 .unwrap_or_default();
1039 let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
1040 let answer = &output["answer"];
1041 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
1042 return Err(format!(
1043 "the test author did not answer ({})",
1044 substrate::why_not(&outcome.status)
1045 ));
1046 }
1047 let protected = a.policy.protected();
1048 let mut planned = Vec::new();
1049 for f in answer["files"].as_array().into_iter().flatten() {
1050 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
1051 continue;
1052 };
1053 let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
1054 if !tools::paths::is_test_path(&rel)
1055 || protected.write_class(&rel).is_some()
1056 || a.project.join(&rel).exists()
1057 {
1058 return Err(format!("`{rel}` is not a new test file"));
1059 }
1060 planned.push((rel, content.to_owned()));
1061 }
1062 if planned.is_empty() {
1063 return Err("the test author added no tests".to_owned());
1064 }
1065 let argv: Vec<String> = argv_of(&answer["argv"]);
1066 let paths: Vec<&str> = planned
1067 .iter()
1068 .map(|(p, _): &(String, String)| p.as_str())
1069 .collect();
1070 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
1071 return Err(format!("`{}` is not an allowed command", argv.join(" ")));
1072 }
1073 let write = || -> Result<(), String> {
1074 for (rel, content) in &planned {
1075 let full = a.project.join(rel);
1076 if let Some(parent) = full.parent() {
1077 let _ = std::fs::create_dir_all(parent);
1078 }
1079 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1080 }
1081 Ok(())
1082 };
1083 let back =
1084 |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
1085 back(before)?;
1087 let red = write().map(|()| {
1088 let (r, summary) = execute(a.project, &argv, a.runner);
1089 classify_red(&r, summary.as_ref())
1090 });
1091 back(after)?;
1092 let red = red?;
1093 if !red.is_red() {
1094 return Err(format!(
1095 "the added tests do not fail without the change ({})",
1096 class_word(red)
1097 ));
1098 }
1099 write()?;
1101 let (green, _) = execute(a.project, &argv, a.runner);
1102 if green.status != speccoder_core::verify::CheckStatus::Passed {
1103 back(after)?;
1104 let cited: Vec<String> = answer["files"]
1105 .as_array()
1106 .into_iter()
1107 .flatten()
1108 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
1109 .filter_map(|v| v.as_str().map(str::to_owned))
1110 .collect();
1111 return Err(format!(
1112 "the added tests fail the implementation{}",
1113 if cited.is_empty() {
1114 String::new()
1115 } else {
1116 format!(" (scenarios {})", cited.join(", "))
1117 }
1118 ));
1119 }
1120 Ok(Strengthened {
1121 files: planned.into_iter().map(|(p, _)| p).collect(),
1122 argv,
1123 report,
1124 })
1125}
1126
1127pub(crate) struct ShareInput<'a> {
1132 pub(crate) repo: &'a Path,
1133 pub(crate) plane: &'a Path,
1134 pub(crate) feature: &'a str,
1135 pub(crate) task: &'a str,
1136 pub(crate) story: Option<&'a str>,
1139 pub(crate) whole_unit: bool,
1140 pub(crate) tasks: Option<&'a speccoder_core::snapshot::ParsedTasks>,
1141 pub(crate) unit_dir: &'a str,
1142 pub(crate) judged: &'a dyn Fn(&str) -> bool,
1143 pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
1144 pub(crate) as_deferred: bool,
1147}
1148
1149pub(crate) fn share_of(i: &ShareInput<'_>) -> Option<speccoder_core::story::Share> {
1150 use speccoder_core::story::{StoryTask, share};
1151 let tasks = i.tasks?;
1152 if i.story.is_none() && !i.whole_unit {
1153 return None;
1154 }
1155 let members: Vec<&speccoder_core::snapshot::Task> = tasks
1156 .tasks
1157 .iter()
1158 .filter(|t| i.whole_unit || t.story.as_deref() == i.story)
1159 .collect();
1160 if !members.iter().any(|t| t.id == i.task) {
1161 return None;
1162 }
1163 let story: Vec<StoryTask<'_>> = members
1164 .iter()
1165 .map(|t| StoryTask {
1166 id: &t.id,
1167 checks_only: checks_only(t, i.unit_dir, i.judged),
1168 done: t.done,
1169 links: &t.scenarios,
1170 })
1171 .collect();
1172 let ids: Vec<String> = i.scenarios.iter().map(|s| s.id.clone()).collect();
1173 let ids_of: Vec<&str> = members.iter().map(|t| t.id.as_str()).collect();
1174 let digests = super::commit::verified_digests(i.repo, "HEAD", i.feature);
1175 let verified: std::collections::BTreeSet<String> = verified_by(&ids_of, i.scenarios, &digests)
1176 .into_keys()
1177 .collect();
1178 let deferred =
1179 i.as_deferred || crate::acceptance_at::deferred(i.plane, i.feature, i.task).is_some();
1180 share(&story, i.task, &ids, &verified, deferred)
1181}
1182
1183fn checks_only(
1186 t: &speccoder_core::snapshot::Task,
1187 unit_dir: &str,
1188 judged: &dyn Fn(&str) -> bool,
1189) -> bool {
1190 t.yours
1191 || speccoder_core::lane::by_paths(&t.title, unit_dir, judged)
1192 == speccoder_core::lane::Lane::Planning
1193}
1194
1195fn verified_by(
1198 tasks: &[&str],
1199 scenarios: &[speccoder_core::spec::ScenarioRef],
1200 digests: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
1201) -> std::collections::BTreeMap<String, String> {
1202 let mut out = std::collections::BTreeMap::new();
1203 for t in tasks {
1204 let Some(d) = digests.get(*t) else { continue };
1205 for s in scenarios {
1206 if d.contains(&super::commit::scenario_digest(&s.id, &s.text)) {
1207 out.entry(s.id.clone()).or_insert_with(|| (*t).to_owned());
1208 }
1209 }
1210 }
1211 out
1212}
1213
1214pub(crate) struct StoryView<'a> {
1216 pub(crate) format: &'a dyn crate::format::Adapter,
1217 pub(crate) snapshot: &'a speccoder_core::snapshot::ProjectSnapshot,
1218 pub(crate) plane: Option<&'a Path>,
1219 pub(crate) feature: &'a str,
1220 pub(crate) unit_dir: &'a str,
1221 pub(crate) tasks: &'a speccoder_core::snapshot::ParsedTasks,
1222 pub(crate) repo: &'a Path,
1225 pub(crate) rev: &'a str,
1226}
1227
1228pub(crate) fn story_view(
1230 v: &StoryView<'_>,
1231) -> (
1232 std::collections::BTreeMap<String, speccoder_core::story::Share>,
1233 Vec<speccoder_core::story::StoryScenarios>,
1234) {
1235 use speccoder_core::story::{ScenarioMark, StoryScenarios, StoryTask, checkpoint, share};
1236 let whole_unit = v.format.kind() == speccoder_core::spec::FormatKind::Openspec;
1237 let judged = v.format.rules().judged;
1238 let digests = super::commit::verified_digests(v.repo, v.rev, v.feature);
1239 let mut labels: Vec<Option<&str>> = Vec::new();
1240 for t in &v.tasks.tasks {
1241 let l = if whole_unit { None } else { t.story.as_deref() };
1242 if (whole_unit || l.is_some()) && !labels.contains(&l) {
1243 labels.push(l);
1244 }
1245 }
1246 let mut shares = std::collections::BTreeMap::new();
1247 let mut stories = Vec::new();
1248 for label in labels {
1249 let members: Vec<&speccoder_core::snapshot::Task> = v
1250 .tasks
1251 .tasks
1252 .iter()
1253 .filter(|t| whole_unit || t.story.as_deref() == label)
1254 .collect();
1255 let Some(first) = members.first() else {
1256 continue;
1257 };
1258 let scenarios = v.format.scenarios(v.snapshot, &first.id);
1259 if scenarios.is_empty() {
1260 continue;
1261 }
1262 let story: Vec<StoryTask<'_>> = members
1263 .iter()
1264 .map(|t| StoryTask {
1265 id: &t.id,
1266 checks_only: checks_only(t, v.unit_dir, &judged),
1267 done: t.done,
1268 links: &t.scenarios,
1269 })
1270 .collect();
1271 let ids_of: Vec<&str> = members.iter().map(|t| t.id.as_str()).collect();
1272 let verified_by = verified_by(&ids_of, &scenarios, &digests);
1273 let verified: std::collections::BTreeSet<String> = verified_by.keys().cloned().collect();
1274 let ids: Vec<String> = scenarios.iter().map(|s| s.id.clone()).collect();
1275 for t in &members {
1276 let deferred = v
1277 .plane
1278 .is_some_and(|p| crate::acceptance_at::deferred(p, v.feature, &t.id).is_some());
1279 if let Some(sh) = share(&story, &t.id, &ids, &verified, deferred) {
1280 shares.insert(t.id.clone(), sh);
1281 }
1282 }
1283 let recorded = ids_of.iter().any(|t| digests.contains_key(*t));
1287 if members.iter().all(|t| t.done) && !(recorded && verified.len() < ids.len()) {
1288 continue;
1289 }
1290 stories.push(StoryScenarios {
1291 story: label.map_or_else(|| v.feature.to_owned(), str::to_owned),
1292 scenarios: ids
1293 .iter()
1294 .map(|id| ScenarioMark {
1295 id: id.clone(),
1296 verified_by: verified_by.get(id).cloned(),
1297 })
1298 .collect(),
1299 checkpoint: checkpoint(&story).map(str::to_owned),
1300 });
1301 }
1302 (shares, stories)
1303}
1304
1305pub(crate) fn story_checkpoint(
1308 tasks: Option<&speccoder_core::snapshot::ParsedTasks>,
1309 task: &str,
1310 scenarios: &[speccoder_core::spec::ScenarioRef],
1311) -> bool {
1312 let Some(tasks) = tasks else { return false };
1313 let Some(label) = tasks
1314 .tasks
1315 .iter()
1316 .find(|t| t.id == task)
1317 .and_then(|t| t.story.as_deref())
1318 else {
1319 return false;
1320 };
1321 let last = tasks
1322 .tasks
1323 .iter()
1324 .rfind(|t| t.story.as_deref() == Some(label))
1325 .map(|t| t.id.as_str());
1326 last == Some(task) && scenarios.len() >= 2
1327}
1328
1329#[cfg(test)]
1330mod argv_tests {
1331 use super::argv_of;
1332 use serde_json::json;
1333
1334 #[test]
1336 fn a_command_given_as_one_string_is_its_words() {
1337 assert_eq!(
1338 argv_of(&json!(["cargo test --test acceptance_us1"])),
1339 ["cargo", "test", "--test", "acceptance_us1"]
1340 );
1341 assert_eq!(
1342 argv_of(&json!(["cargo", "test", "--test", "acceptance_us1"])),
1343 ["cargo", "test", "--test", "acceptance_us1"]
1344 );
1345 assert_eq!(
1347 argv_of(&json!(["cargo test; rm -rf ."])),
1348 ["cargo test; rm -rf ."]
1349 );
1350 assert_eq!(argv_of(&json!(["pytest"])), ["pytest"]);
1351 }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357
1358 #[test]
1359 fn a_repeated_future_import_is_left_out_of_a_python_section() {
1360 let old = "# T008 — US1-1\n\nfrom __future__ import annotations\n\nimport sys\n\ndef test_a() -> None:\n assert True\n";
1361 let new = format!(
1362 "{old}\n# T009 — US1-2\n\nfrom __future__ import annotations\n\nimport sys\n\ndef test_b() -> None:\n assert sys\n"
1363 );
1364 let fixed = without_repeated_future(old, &new);
1365 assert!(fixed.starts_with(old), "{fixed}");
1366 assert_eq!(fixed.matches("from __future__").count(), 1, "{fixed}");
1367 assert!(
1368 fixed.contains("# T009 — US1-2\n\n\nimport sys\n\ndef test_b"),
1369 "{fixed}"
1370 );
1371 let other = "x\nfrom __future__ import annotations\n";
1373 assert_eq!(without_repeated_future("x\n", other), other);
1374 let plain = format!("{old}def test_c():\n pass\n");
1375 assert_eq!(without_repeated_future(old, &plain), plain);
1376 }
1377
1378 #[test]
1379 fn the_last_task_of_a_story_with_two_scenarios_composes() {
1380 let (tasks, _) = speccoder_speckit::tasks::parse(
1381 "tasks.md",
1382 "## 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",
1383 );
1384 let two: Vec<speccoder_core::spec::ScenarioRef> = ["US1-1", "US1-2"]
1385 .iter()
1386 .map(|id| speccoder_core::spec::ScenarioRef {
1387 id: (*id).to_owned(),
1388 text: String::new(),
1389 })
1390 .collect();
1391 assert!(story_checkpoint(Some(&tasks), "T006", &two));
1392 assert!(!story_checkpoint(Some(&tasks), "T005", &two));
1393 assert!(!story_checkpoint(Some(&tasks), "T006", &two[..1]));
1394 assert!(!story_checkpoint(None, "T006", &two));
1395 }
1396
1397 fn record(
1399 producer: speccoder_core::acceptance::Producer,
1400 content: &str,
1401 ) -> speccoder_core::acceptance::PreFrozenAcceptance {
1402 speccoder_core::acceptance::PreFrozenAcceptance {
1403 unit: "001-x".to_owned(),
1404 requirement: "US1".to_owned(),
1405 wording_digest: "sha256:w".to_owned(),
1406 approval_digest: "sha256:a".to_owned(),
1407 visible: vec![speccoder_core::acceptance::VisibleTest {
1408 path: "tests/a.txt".to_owned(),
1409 sha256: format!(
1410 "sha256:{}",
1411 crate::integrity::sha256_hex(content.as_bytes())
1412 ),
1413 cites: vec!["US1-1".to_owned()],
1414 }],
1415 held_out_sealed: None,
1416 producer,
1417 decided: speccoder_core::acceptance::Decided {
1418 by: "Ada".to_owned(),
1419 via: speccoder_core::actions::Via::Terminal,
1420 at: 0,
1421 },
1422 }
1423 }
1424
1425 #[test]
1426 fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
1427 use speccoder_core::acceptance::Producer;
1428 let summary = |passed: u8, failed: u8| {
1429 format!(
1430 "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
1431 if failed > 0 { "FAILED" } else { "ok" },
1432 if failed > 0 { 101 } else { 0 }
1433 )
1434 };
1435 let content = "checks\n";
1436 for (producer, rule) in [
1437 (Producer::Review, RedRule::RunTime),
1438 (Producer::BugConfirmation, RedRule::BugAssertionOnly),
1439 ] {
1440 let rec = record(producer, content);
1441 let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
1442 let run = |script: String| {
1443 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1444 let tests = [ReusedTest {
1445 path: "tests/a.txt".to_owned(),
1446 cites: vec!["US1-1".to_owned()],
1447 argv: vec!["sh".to_owned(), "-c".to_owned(), script],
1448 green: false,
1449 }];
1450 let got = reuse_pre_frozen(
1451 dir.path(),
1452 &rec,
1453 &files,
1454 &tests,
1455 &sandbox::Runner::Plain,
1456 rule,
1457 None,
1458 );
1459 assert!(
1460 dir.path().join("tests/a.txt").is_file(),
1461 "written at the checkpoint"
1462 );
1463 got
1464 };
1465 let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
1467 assert!(red.red);
1468 assert_eq!(red.report["source"], serde_json::json!(producer.source()));
1469 let passing = run(summary(1, 0));
1472 match rule {
1473 RedRule::RunTime => {
1474 let p = passing.unwrap_or_else(|e| panic!("{e}"));
1475 assert!(!p.red);
1476 assert!(
1477 p.report["note"]
1478 .as_str()
1479 .is_some_and(|n| n.contains("already passing"))
1480 );
1481 }
1482 RedRule::BugAssertionOnly => assert!(passing.is_err()),
1483 }
1484 if rule == RedRule::BugAssertionOnly {
1486 assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
1487 }
1488 }
1489 }
1490}
1491
1492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1494pub(crate) enum RedRule {
1495 RunTime,
1499 BugAssertionOnly,
1501}
1502
1503#[derive(Debug)]
1505pub(crate) struct ReusedTest {
1506 pub(crate) path: String,
1508 pub(crate) cites: Vec<String>,
1510 pub(crate) argv: Vec<String>,
1512 pub(crate) green: bool,
1514}
1515
1516pub(crate) struct SealedCheck<'a> {
1519 pub(crate) state: &'a Path,
1521 pub(crate) unit: &'a str,
1523 pub(crate) wt_root: &'a Path,
1525 pub(crate) tmp: &'a Path,
1527 pub(crate) policy: &'a tools::policy::Policy,
1529}
1530
1531pub(crate) fn reuse_pre_frozen(
1537 project: &Path,
1538 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1539 files: &[(String, String)],
1540 tests: &[ReusedTest],
1541 runner: &sandbox::Runner,
1542 rule: RedRule,
1543 sealed: Option<&SealedCheck<'_>>,
1544) -> Result<PreparedAcceptance, String> {
1545 use serde_json::json;
1546 for (rel, content) in files {
1547 let full = project.join(rel);
1548 if let Ok(now) = std::fs::read_to_string(&full) {
1549 if now != *content {
1550 return Err(format!(
1551 "`{rel}` exists and differs from the confirmed test; it is not overwritten"
1552 ));
1553 }
1554 continue;
1555 }
1556 if let Some(parent) = full.parent() {
1557 let _ = std::fs::create_dir_all(parent);
1558 }
1559 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1560 }
1561 let class_of = |r: &speccoder_core::verify::CheckResult,
1564 s: Option<&speccoder_core::verify::RunnerSummary>| match rule
1565 {
1566 RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
1567 RedRule::RunTime => classify_red(r, s),
1568 };
1569 let mut classes = Vec::new();
1570 for t in tests {
1571 let (first, summary) = execute(project, &t.argv, runner);
1572 let one = class_of(&first, summary.as_ref());
1573 let class = if t.green {
1574 one
1575 } else {
1576 match rule {
1577 RedRule::BugAssertionOnly if one == RedClass::Assertion => {
1578 let (second, summary) = execute(project, &t.argv, runner);
1579 speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
1580 }
1581 RedRule::BugAssertionOnly => one,
1582 RedRule::RunTime if one.is_red() => {
1583 let (second, summary) = execute(project, &t.argv, runner);
1584 if classify_red(&second, summary.as_ref()).is_red() {
1585 one
1586 } else {
1587 RedClass::Flaky
1588 }
1589 }
1590 RedRule::RunTime => one,
1591 }
1592 };
1593 let holds = if t.green {
1594 class == RedClass::Passed
1595 } else {
1596 match rule {
1597 RedRule::BugAssertionOnly => class == RedClass::Assertion,
1598 RedRule::RunTime => class.is_red(),
1599 }
1600 };
1601 if !holds && rule == RedRule::RunTime && !t.green {
1602 return Ok(not_red_frozen(record, files, tests, t, class));
1603 }
1604 if !holds {
1605 return Err(if t.green {
1606 format!(
1607 "the confirmed test `{}` ({}) no longer passes on the branch ({})",
1608 t.path,
1609 t.cites.join(", "),
1610 class_word(class)
1611 )
1612 } else {
1613 format!(
1614 "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
1615 t.path,
1616 t.cites.join(", "),
1617 class_word(class)
1618 )
1619 });
1620 }
1621 classes.push(class);
1622 }
1623 let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
1624 let Some(first) = red.first() else {
1625 return Err("the confirmed set has no test that must fail first".to_owned());
1626 };
1627 let more: Vec<Vec<String>> = tests
1628 .iter()
1629 .filter(|t| !std::ptr::eq(*t, *first))
1630 .map(|t| t.argv.clone())
1631 .collect();
1632 let mut held_sha = record
1633 .held_out_sealed
1634 .as_ref()
1635 .map(|h| h.manifest_sha256.clone());
1636 let mut held_out = match &record.held_out_sealed {
1637 Some(h) => {
1638 json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
1639 }
1640 None => json!({ "state": "absent", "reason": "none_kept" }),
1641 };
1642 if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
1645 let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
1646 .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
1647 let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1648 .files
1649 .iter()
1650 .cloned()
1651 .zip(contents.into_iter().map(|(_, c)| c))
1652 .collect();
1653 let runs = crate::held_out::overlay_red(
1654 check.wt_root,
1655 project,
1656 check.tmp,
1657 &set,
1658 runner,
1659 check.policy,
1660 )?;
1661 let all_red = runs
1662 .iter()
1663 .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
1664 if !all_red {
1665 held_sha = None;
1666 held_out = json!({ "state": "absent", "reason": "not_red" });
1667 }
1668 }
1669 let class = classes.first().copied().unwrap_or(RedClass::Assertion);
1670 Ok(PreparedAcceptance {
1671 report: json!({
1672 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1673 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1674 "argv": first.argv,
1675 "red": true,
1676 "red_class": class,
1677 "source": record.producer.source(),
1678 "pre_frozen": {
1679 "manifest": crate::pre_frozen::manifest_digest(record),
1680 "approval": record.approval_digest,
1681 "wording": record.wording_digest,
1682 "by": record.decided.by,
1683 "via": record.decided.via,
1684 },
1685 "held_out": held_out,
1686 }),
1687 argv: Some(first.argv.clone()),
1688 more_argv: more,
1689 red: true,
1690 red_class: Some(class),
1691 held_out: held_sha,
1692 generation_failed: None,
1693 skip_cause: None,
1694 })
1695}
1696
1697fn not_red_frozen(
1700 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1701 files: &[(String, String)],
1702 tests: &[ReusedTest],
1703 failing: &ReusedTest,
1704 class: RedClass,
1705) -> PreparedAcceptance {
1706 use serde_json::json;
1707 let note = match class {
1708 RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
1709 RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
1710 other => format!(
1711 "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
1712 class_word(other)
1713 ),
1714 };
1715 PreparedAcceptance {
1716 report: json!({
1717 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1718 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1719 "argv": failing.argv,
1720 "red": false,
1721 "red_class": class,
1722 "note": note,
1723 "source": record.producer.source(),
1724 "pre_frozen": {
1725 "manifest": crate::pre_frozen::manifest_digest(record),
1726 "approval": record.approval_digest,
1727 "wording": record.wording_digest,
1728 "by": record.decided.by,
1729 "via": record.decided.via,
1730 },
1731 "held_out": { "state": "absent", "reason": "not_red" },
1732 }),
1733 argv: Some(failing.argv.clone()),
1734 more_argv: tests
1735 .iter()
1736 .filter(|t| !std::ptr::eq(*t, failing))
1737 .map(|t| t.argv.clone())
1738 .collect(),
1739 red: false,
1740 red_class: Some(class),
1741 held_out: None,
1742 generation_failed: None,
1743 skip_cause: None,
1744 }
1745}