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 pub(crate) related: &'a [String],
33 pub(crate) oracle: bool,
36}
37
38#[derive(Clone, serde::Serialize, serde::Deserialize)]
39pub(crate) struct PreparedAcceptance {
40 pub(crate) report: serde_json::Value,
42 pub(crate) argv: Option<Vec<String>>,
44 #[serde(default)]
47 pub(crate) more_argv: Vec<Vec<String>>,
48 pub(crate) red: bool,
51 #[serde(default)]
53 pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
54 #[serde(default)]
57 pub(crate) held_out: Option<String>,
58 #[serde(default)]
61 pub(crate) generation_failed: Option<String>,
62 #[serde(default)]
65 pub(crate) skip_cause: Option<speccoder_core::regression::SkipCause>,
66}
67
68pub(crate) const MANIFESTS: &[&str] = &[
69 "Cargo.toml",
70 "package.json",
71 "pyproject.toml",
72 "go.mod",
73 "pom.xml",
74];
75
76pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
77 use serde_json::json;
78 use speccoder_core::regression::SkipCause;
79 let skipped_by = |why: String, cause: Option<SkipCause>, report: serde_json::Value| {
80 let mut report = report;
81 report["skipped"] = json!(why);
82 PreparedAcceptance {
83 report,
84 argv: None,
85 more_argv: Vec::new(),
86 red: false,
87 red_class: None,
88 held_out: None,
89 generation_failed: None,
90 skip_cause: cause,
91 }
92 };
93 let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
94 generation_failed: Some(why.clone()),
95 ..skipped_by(why, None, report)
96 };
97
98 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
99 .and_then(|c| worktree::files(a.worktree, &c))
100 .unwrap_or_default()
101 .into_iter()
102 .take(400)
103 .collect();
104 let manifest = MANIFESTS
105 .iter()
106 .find_map(|m| {
107 std::fs::read_to_string(a.project.join(m))
108 .ok()
109 .map(|t| format!("{m}:\n{t}"))
110 })
111 .map(|t| t.chars().take(8192).collect::<String>())
112 .unwrap_or_default();
113 let placement = match placement(a) {
116 Ok(p) => p,
117 Err(()) => {
118 return skipped_by(
119 "no place the runner collects".to_owned(),
120 Some(SkipCause::NoPlace),
121 json!({}),
122 );
123 }
124 };
125 let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
126 let suite = suite(a.worktree, title, a.related);
127 let interface = interface(a.worktree, title);
128 let commands = task_commands(a);
129 let shown: Vec<String> = commands.iter().map(|c| c.shown.clone()).collect();
130 let mut refusals: Vec<String> = Vec::new();
133 let mut spent: Option<serde_json::Value> = None;
135 let (answer, base, planned, argv, ran) = loop {
136 let input = substrate::acceptance::input(
137 a.bundle.clone(),
138 a.scenarios,
139 &files,
140 &manifest,
141 a.instructions,
142 placement.as_ref(),
143 &substrate::acceptance::Known {
144 told: a.told,
145 refused: &refusals,
146 suite: &suite,
147 interface: &interface,
148 commands: &shown,
149 },
150 );
151 let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
152 let outcome = match outcome {
153 Ok(o) => o,
154 Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
155 };
156 let output = outcome
157 .output
158 .as_ref()
159 .map(|o| o.peek().clone())
160 .unwrap_or_default();
161 let answer = output["answer"].clone();
162 spent = super::commit::add_usage(spent.as_ref(), output.get("usage"));
163 let mut base = json!({ "run_id": outcome.run_id.to_string(), "usage": spent, "notes": answer["notes"] });
164 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
165 {
166 let status = format!("{:?}", outcome.status);
167 let why = substrate::provider_refusal(&status).map_or_else(
168 || format!("generation failed: {}", substrate::why_not(&outcome.status)),
169 |r| format!("the model provider refused the request: {r}"),
170 );
171 return failed(why, base);
172 }
173 if !refusals.is_empty() {
174 base["refused_first"] = json!(refusals);
175 }
176
177 let (valid, mut refused) =
180 validate_answer(&answer, a.project, a.policy, a.scenarios, Some(title));
181 for f in valid
183 .iter()
184 .filter(|f| !commands.iter().any(|c| c.run_by(&f.content)))
185 {
186 if let Some(c) = commands.first() {
187 refused.push(RefusedFile {
188 path: f.path.clone(),
189 why: "no_command",
190 message: format!(
191 "`{}` never runs the project's command: run `{}` as a person would, in a subprocess",
192 f.path, c.shown
193 ),
194 });
195 }
196 }
197 let planned: Vec<(String, String)> = valid
200 .iter()
201 .map(|f| (f.path.clone(), f.content.clone()))
202 .collect();
203 let argv: Vec<String> = argv_of(&answer["argv"]);
204 let paths: Vec<&str> = planned.iter().map(|(p, _)| p.as_str()).collect();
205 let mut ran = None;
206 if refused.is_empty() && !planned.is_empty() {
207 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
208 if !refusals.is_empty() {
209 return skipped_by(
210 format!("`{}` is not an allowed command", argv.join(" ")),
211 Some(SkipCause::CommandNotAllowed {
212 argv: argv.join(" "),
213 }),
214 base,
215 );
216 }
217 refused.push(RefusedFile {
218 path: paths[0].to_owned(),
219 why: "command_not_allowed",
220 message: format!(
221 "`{}` is not an allowed command: give the command that runs only your files",
222 argv.join(" ")
223 ),
224 });
225 } else {
226 for (rel, content) in &planned {
227 let full = a.project.join(rel);
228 if let Some(parent) = full.parent() {
229 let _ = std::fs::create_dir_all(parent);
230 }
231 if let Err(e) = std::fs::write(&full, content) {
232 return skipped_by(
233 format!("`{rel}`: {e}"),
234 Some(SkipCause::WriteFailed { path: rel.clone() }),
235 base,
236 );
237 }
238 }
239 let (first, summary) = execute(a.project, &argv, a.runner);
240 let class = classify_red(&first, summary.as_ref());
241 let named = named(title);
245 let theirs = |n: &str| {
246 let last = n.rsplit(['.', '/']).next().unwrap_or(n);
247 named.iter().any(|w| w == last) || title.contains(n)
248 };
249 let not_there = speccoder_core::verify::missing_names(&first)
250 .filter(|names| !names.is_empty() && !names.iter().any(|n| theirs(n)));
251 let escape = crate::integrity::invalid_escape(
252 &format!("{}\n{}", first.stdout_tail, first.stderr_tail),
253 &paths,
254 );
255 let wrong = match class {
256 _ if escape.is_some() => escape.map(|w| {
257 format!("hold a string other than written (an invalid escape: {w})")
258 }),
259 RedClass::CollectionError | RedClass::BuildError => {
260 Some("do not build or collect".to_owned())
261 }
262 _ => not_there.map(|names| {
263 format!(
264 "fail only on what the code does not have ({}), never on an assertion",
265 names.join(", ")
266 )
267 }),
268 };
269 match wrong {
270 Some(how) if refusals.is_empty() => {
271 for rel in &paths {
272 let _ = std::fs::remove_file(a.project.join(rel));
273 }
274 refused.push(RefusedFile {
275 path: paths[0].to_owned(),
276 why: "errored",
277 message: format!(
278 "`{}` {how}: {}",
279 paths.join("`, `"),
280 first_failure(&first)
281 ),
282 });
283 }
284 _ => ran = Some((first, summary)),
285 }
286 }
287 }
288 if refused.is_empty() {
289 break (answer, base, planned, argv, ran);
290 }
291 if refusals.is_empty() {
292 refusals.extend(refused.iter().map(|r| r.message.clone()));
293 continue;
294 }
295 let r = &refused[0];
296 return skipped_by(
297 r.message.clone(),
298 Some(SkipCause::Refused {
299 path: r.path.clone(),
300 why: refusal_words(r.why).to_owned(),
301 }),
302 base,
303 );
304 };
305 let tests: Vec<serde_json::Value> = planned
306 .iter()
307 .map(|(p, _)| {
308 let cited = valid_cites(&answer, p, a.scenarios);
309 json!({ "path": p, "scenarios": cited })
310 })
311 .collect();
312 let Some((first, summary)) = ran else {
313 return skipped_by(
314 "no acceptance tests were generated".to_owned(),
315 Some(SkipCause::NoFiles),
316 base,
317 );
318 };
319 let mut written: Vec<String> = planned.iter().map(|(p, _)| p.clone()).collect();
320
321 let red_tests = speccoder_core::verify::collected_count(&first, summary.as_ref());
325 let mut class = classify_red(&first, summary.as_ref());
326 if class.is_red() {
327 let (second, summary) = execute(a.project, &argv, a.runner);
328 if !classify_red(&second, summary.as_ref()).is_red() {
329 class = RedClass::Flaky;
330 }
331 }
332 let red = class.is_red();
333 let broken = matches!(class, RedClass::CollectionError | RedClass::BuildError);
336 let removed = if broken {
337 for rel in &written {
338 let _ = std::fs::remove_file(a.project.join(rel));
339 }
340 std::mem::take(&mut written)
341 } else {
342 Vec::new()
343 };
344 let mut report = base;
345 report["files"] = json!(written);
346 if !removed.is_empty() {
347 report["removed"] = json!(removed);
348 }
349 report["tests"] = json!(tests);
350 report["argv"] = json!(argv);
351 report["red"] = json!(red);
352 report["red_class"] = json!(class);
353 if let Some(n) = red_tests {
354 report["red_tests"] = json!(n);
355 }
356 if red {
357 report["red_output"] = json!(
359 format!("{}\n{}", first.stdout_tail, first.stderr_tail)
360 .trim()
361 .to_owned()
362 );
363 } else {
364 report["note"] = json!(match class {
365 RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
366 RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
367 other if broken => format!(
368 "the acceptance tests did not fail on an assertion ({}): not demonstrated, and taken out so the project's other tests still run",
369 class_word(other)
370 ),
371 other => format!(
372 "the acceptance tests did not fail on an assertion ({}): not demonstrated",
373 class_word(other)
374 ),
375 });
376 report["excerpt"] = json!(excerpt(&first));
377 }
378 let kept = red || class == RedClass::Passed;
383 PreparedAcceptance {
384 report,
385 argv: kept.then_some(argv),
386 more_argv: Vec::new(),
387 red,
388 red_class: Some(class),
389 held_out: None,
390 generation_failed: None,
391 skip_cause: None,
392 }
393}
394
395fn placement(a: &AcceptanceSetup<'_>) -> Result<Option<substrate::acceptance::Placement>, ()> {
399 let Some(layout) = a.layout.filter(|l| !l.runners.is_empty()) else {
400 return Ok(None);
401 };
402 let task = a.task.to_owned();
403 let package =
405 speccoder_core::scope::named_paths(a.bundle["task"]["title"].as_str().unwrap_or_default())
406 .into_iter()
407 .find(|p| p.ends_with(".go") || !p.contains('.'))
408 .map(|p| {
409 if p.ends_with(".go") {
410 p.rsplit_once('/')
411 .map_or(String::new(), |(d, _)| d.to_owned())
412 } else {
413 p.trim_end_matches('/').to_owned()
414 }
415 });
416 let number: String = a.feature.chars().take_while(char::is_ascii_digit).collect();
419 let scoped = if number.is_empty() {
420 task.clone()
421 } else {
422 format!("{number}_{task}")
423 };
424 let path = (1..)
425 .map(|n| {
426 let name = if n == 1 {
427 scoped.clone()
428 } else {
429 format!("{scoped}_{n}")
430 };
431 layout.acceptance_file(&name, package.as_deref())
432 })
433 .take(9)
434 .find(|p| p.as_ref().is_none_or(|p| !a.project.join(p).exists()))
435 .flatten()
436 .ok_or(())?;
437 let comment = if path.ends_with(".py") { "#" } else { "//" }.to_owned();
438 Ok(Some(substrate::acceptance::Placement {
439 command: layout.command_for(&path).unwrap_or_default(),
440 path,
441 task,
442 scenarios: a.scenarios.iter().map(|s| s.id.clone()).collect(),
443 comment,
444 layout: layout.text(),
445 }))
446}
447
448fn valid_cites(
450 answer: &serde_json::Value,
451 path: &str,
452 scenarios: &[speccoder_core::spec::ScenarioRef],
453) -> Vec<String> {
454 answer["files"]
455 .as_array()
456 .into_iter()
457 .flatten()
458 .filter(|f| {
459 f["path"]
460 .as_str()
461 .and_then(|p| tools::paths::normalise(p).ok())
462 .is_some_and(|p| p == path)
463 })
464 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
465 .filter_map(|v| v.as_str().map(str::to_owned))
466 .filter(|id| scenarios.iter().any(|s| s.id == *id))
467 .collect()
468}
469
470pub(crate) fn first_failure(r: &speccoder_core::verify::CheckResult) -> String {
472 let text = format!("{}\n{}", r.stdout_tail, r.stderr_tail);
473 let line = text
474 .lines()
475 .map(str::trim)
476 .find(|l| {
477 l.starts_with("FAILED ")
478 || l.starts_with("ERROR ")
479 || l.starts_with("E ")
480 || l.starts_with("error")
481 || l.contains("Error:")
482 })
483 .or_else(|| text.lines().map(str::trim).find(|l| !l.is_empty()))
484 .unwrap_or("no output");
485 line.chars().take(240).collect()
486}
487
488pub(crate) fn refusal_words(why: &str) -> &'static str {
490 match why {
491 "exists" => "already exists",
492 "not_collected" => "is not where the runner collects tests",
493 "skip_marker" => "skips a test",
494 "placeholder" => "is a placeholder that can never pass",
495 "invalid_escape" => "holds a string other than written (an invalid escape sequence)",
496 "crlf_as_text" => "asserts `\\r\\n` in output it reads as text, which can never pass",
497 "imagined" => "calls code that is not there",
498 "errored" => "fails only on an error of its own, or does not build",
499 "command_not_allowed" => "gives a command the policy does not allow",
500 "no_command" => "never runs the project's command",
501 _ => "is not a new test file",
502 }
503}
504
505pub(crate) struct ValidFile {
507 pub(crate) path: String,
509 pub(crate) content: String,
510 pub(crate) cited: Vec<String>,
512 pub(crate) argv: Vec<String>,
514}
515
516pub(crate) struct RefusedFile {
518 pub(crate) path: String,
520 pub(crate) why: &'static str,
522 pub(crate) message: String,
524}
525
526#[must_use]
532pub(crate) fn argv_of(v: &serde_json::Value) -> Vec<String> {
533 let argv: Vec<String> = v
534 .as_array()
535 .into_iter()
536 .flatten()
537 .filter_map(|v| v.as_str().map(str::to_owned))
538 .collect();
539 match argv.as_slice() {
540 [line] if line.split_ascii_whitespace().count() > 1 => {
541 let words = crate::acp::govern::argv_of_line(line);
542 if words.len() == 3 && words[0] == "sh" && words[1] == "-c" && words[2] == line.trim() {
544 argv
545 } else {
546 words
547 }
548 }
549 _ => argv,
550 }
551}
552
553pub(crate) fn test_command_allowed(
557 policy: &tools::policy::Policy,
558 layout: Option<&crate::layout::TestLayout>,
559 argv: &[String],
560 files: &[&str],
561) -> bool {
562 layout.is_some_and(|l| l.runs_only(argv, files))
563 || policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
564 == tools::policy::Decision::Allow
565}
566
567pub(crate) fn validate_answer(
574 answer: &serde_json::Value,
575 project: &Path,
576 policy: &tools::policy::Policy,
577 scenarios: &[speccoder_core::spec::ScenarioRef],
578 task: Option<&str>,
579) -> (Vec<ValidFile>, Vec<RefusedFile>) {
580 let protected = policy.protected();
581 let mut valid = Vec::new();
582 let mut refused = Vec::new();
583 for f in answer["files"].as_array().into_iter().flatten() {
584 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
585 continue;
586 };
587 let rel = match tools::paths::normalise(path) {
588 Ok(r) => r,
589 Err(e) => {
590 refused.push(RefusedFile {
591 path: path.to_owned(),
592 why: "not_a_new_test_file",
593 message: format!("`{path}`: {}", e.0),
594 });
595 continue;
596 }
597 };
598 if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
599 refused.push(RefusedFile {
600 message: format!("`{rel}` is not a new test file"),
601 path: rel,
602 why: "not_a_new_test_file",
603 });
604 continue;
605 }
606 if project.join(&rel).exists() {
607 refused.push(RefusedFile {
608 message: format!("`{rel}` already exists"),
609 path: rel,
610 why: "exists",
611 });
612 continue;
613 }
614 if crate::integrity::has_skip_marker(content) {
617 refused.push(RefusedFile {
618 message: format!("`{rel}` skips a test"),
619 path: rel,
620 why: "skip_marker",
621 });
622 continue;
623 }
624 if crate::integrity::asserts_crlf_as_text(&rel, content) {
625 refused.push(RefusedFile {
626 message: format!(
627 "`{rel}` asserts `\\r\\n` in output read with `text=True`, which turns it into `\\n`: it can never pass — compare `.splitlines()`, or read bytes"
628 ),
629 path: rel,
630 why: "crlf_as_text",
631 });
632 continue;
633 }
634 if crate::integrity::has_placeholder(content) {
636 refused.push(RefusedFile {
637 message: format!(
638 "`{rel}` is a placeholder: it raises \"not implemented\" instead of checking the behaviour"
639 ),
640 path: rel,
641 why: "placeholder",
642 });
643 continue;
644 }
645 if crate::collection::statically_refused(project, &rel, content).is_some() {
647 refused.push(RefusedFile {
648 message: format!("`{rel}` is not where the runner collects tests"),
649 path: rel,
650 why: "not_collected",
651 });
652 continue;
653 }
654 if let Some(message) = task.and_then(|t| imagined(project, &rel, content, t)) {
656 refused.push(RefusedFile {
657 message,
658 path: rel,
659 why: "imagined",
660 });
661 continue;
662 }
663 let cited: Vec<String> = f["scenarios"]
664 .as_array()
665 .into_iter()
666 .flatten()
667 .filter_map(|v| v.as_str())
668 .filter(|id| scenarios.iter().any(|s| s.id == *id))
669 .map(str::to_owned)
670 .collect();
671 let argv: Vec<String> = argv_of(&f["argv"]);
672 valid.push(ValidFile {
673 path: rel,
674 content: content.to_owned(),
675 cited,
676 argv,
677 });
678 }
679 (valid, refused)
680}
681
682pub(crate) fn class_word(class: RedClass) -> String {
684 serde_json::to_value(class)
685 .ok()
686 .and_then(|v| v.as_str().map(str::to_owned))
687 .unwrap_or_default()
688}
689
690fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
693 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
694 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
695 let from = lines
696 .iter()
697 .position(|l| {
698 let l = l.to_lowercase();
699 l.contains("error") || l.contains("fail")
700 })
701 .unwrap_or_else(|| lines.len().saturating_sub(20));
702 lines
703 .iter()
704 .skip(from)
705 .take(20)
706 .copied()
707 .collect::<Vec<_>>()
708 .join("\n")
709}
710
711pub(crate) fn acceptance_run(
713 project: &Path,
714 argv: &[String],
715 runner: &sandbox::Runner,
716) -> (
717 speccoder_core::verify::CheckResult,
718 Option<speccoder_core::verify::RunnerSummary>,
719) {
720 execute(project, argv, runner)
721}
722
723pub(crate) fn run_acceptance(
724 project: &Path,
725 argv: &[String],
726 runner: &sandbox::Runner,
727) -> speccoder_core::verify::CheckResult {
728 execute(project, argv, runner).0
729}
730
731fn execute(
734 project: &Path,
735 argv: &[String],
736 runner: &sandbox::Runner,
737) -> (
738 speccoder_core::verify::CheckResult,
739 Option<speccoder_core::verify::RunnerSummary>,
740) {
741 use speccoder_core::verify::{CheckResult, CheckStatus};
742 use verify::process::CommandRunner;
743 let out = runner.run(&verify::process::Invocation {
744 argv: argv.to_vec(),
745 cwd: project.to_path_buf(),
746 env: verify::process::scrubbed_env(std::env::vars()),
747 timeout: std::time::Duration::from_secs(600),
748 output_cap: verify::process::OUTPUT_CAP,
749 });
750 let status = if out.spawn_error.is_some() {
751 CheckStatus::Error
752 } else if out.timed_out {
753 CheckStatus::TimedOut
754 } else if out.exit_code == Some(0) {
755 CheckStatus::Passed
756 } else {
757 CheckStatus::Failed
758 };
759 let summary = verify::summary::parse(
760 &String::from_utf8_lossy(&out.stdout.kept),
761 &String::from_utf8_lossy(&out.stderr.kept),
762 out.exit_code,
763 );
764 let tail = |b: &[u8]| {
765 let start = b.len().saturating_sub(4096);
766 String::from_utf8_lossy(&b[start..]).into_owned()
767 };
768 let result = CheckResult {
769 name: "acceptance".to_owned(),
770 argv: argv.to_vec(),
771 status,
772 exit_code: out.exit_code,
773 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
774 junit: false,
775 tests: None,
776 stdout_tail: tail(&out.stdout.kept),
777 stderr_tail: tail(&out.stderr.kept),
778 truncated: out.stdout.truncated() || out.stderr.truncated(),
779 note: out.spawn_error,
780 flaky: false,
781 sandbox: out.sandbox,
782 };
783 (result, summary)
784}
785
786pub(crate) fn prepare_held_out(
790 a: &AcceptanceSetup<'_>,
791 visible: &PreparedAcceptance,
792 scenarios: &[speccoder_core::spec::ScenarioRef],
793 store: &Path,
794 composition: bool,
795) -> (serde_json::Value, Option<String>) {
796 use serde_json::json;
797 let absent = |reason: &str, mut report: serde_json::Value| {
798 report["state"] = json!("absent");
799 report["reason"] = json!(reason);
800 (report, None)
801 };
802 let visible_files: Vec<(String, String)> = visible.report["files"]
803 .as_array()
804 .into_iter()
805 .flatten()
806 .filter_map(|p| p.as_str())
807 .map(|p| {
808 (
809 p.to_owned(),
810 std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
811 )
812 })
813 .collect();
814 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
815 .and_then(|c| worktree::files(a.worktree, &c))
816 .unwrap_or_default()
817 .into_iter()
818 .take(400)
819 .collect();
820 let manifest_text = MANIFESTS
821 .iter()
822 .find_map(|m| {
823 std::fs::read_to_string(a.project.join(m))
824 .ok()
825 .map(|t| format!("{m}:\n{t}"))
826 })
827 .map(|t| t.chars().take(8192).collect::<String>())
828 .unwrap_or_default();
829 let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
830 let input = substrate::acceptance::held_out_input(
831 a.bundle.clone(),
832 &files,
833 &manifest_text,
834 scenarios,
835 &visible_files,
836 composition,
837 a.instructions,
838 a.layout.map(crate::layout::TestLayout::text).as_deref(),
839 &substrate::acceptance::Known {
840 told: a.told,
841 refused: &[],
842 commands: &task_commands(a)
843 .into_iter()
844 .map(|c| c.shown)
845 .collect::<Vec<_>>(),
846 suite: &suite(a.worktree, title, a.related),
847 interface: &if a.oracle {
848 Vec::new()
849 } else {
850 interface(a.worktree, title)
851 },
852 },
853 );
854 let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
855 Ok(o) => o,
856 Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
857 };
858 let output = outcome
859 .output
860 .as_ref()
861 .map(|o| o.peek().clone())
862 .unwrap_or_default();
863 let answer = output["answer"].clone();
864 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
865 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
866 return absent("generation_failed", base);
867 }
868
869 let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
872 let protected = a.policy.protected();
873 let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
874 let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
875 let mut discard = |path: &str, reason: &str| {
876 discarded.push(crate::held_out::Discarded {
877 path: path.to_owned(),
878 reason: reason.to_owned(),
879 });
880 };
881 let given = answer["files"].as_array().cloned().unwrap_or_default();
882 if given.is_empty() {
883 return absent("none_generated", base);
884 }
885 for f in &given {
886 let path = f["path"].as_str().unwrap_or_default();
887 let content = f["content"].as_str().unwrap_or_default();
888 let cited: Vec<String> = f["scenarios"]
889 .as_array()
890 .into_iter()
891 .flatten()
892 .filter_map(|s| s.as_str().map(str::to_owned))
893 .collect();
894 let argv: Vec<String> = argv_of(&f["argv"]);
895 let rel = match tools::paths::normalise(path) {
896 Ok(r) => r,
897 Err(_) => {
898 discard(path, "path");
899 continue;
900 }
901 };
902 let taken =
903 visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
904 if !tools::paths::is_test_path(&rel)
905 || protected.write_class(&rel).is_some()
906 || a.project.join(&rel).exists()
907 || taken
908 {
909 discard(&rel, "path");
910 continue;
911 }
912 if crate::collection::statically_refused(a.project, &rel, content).is_some() {
914 discard(&rel, "not_collected");
915 continue;
916 }
917 if crate::integrity::has_skip_marker(content) {
920 discard(&rel, "skip_marker");
921 continue;
922 }
923 if crate::integrity::asserts_crlf_as_text(&rel, content) {
924 discard(&rel, "crlf_as_text");
925 continue;
926 }
927 if crate::integrity::has_placeholder(content) {
928 discard(&rel, "placeholder");
929 continue;
930 }
931 if !a.oracle && imagined(a.project, &rel, content, title).is_some() {
932 discard(&rel, "imagined");
933 continue;
934 }
935 if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
936 discard(&rel, "unknown_scenario");
937 continue;
938 }
939 let composed = f["composition"].as_bool().unwrap_or(false);
940 if composed && (!composition || cited.len() < 2) {
941 discard(&rel, "composition");
942 continue;
943 }
944 if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
945 discard(&rel, "command");
946 continue;
947 }
948 if kept.len() >= crate::held_out::LIMIT {
949 discard(&rel, "limit");
950 continue;
951 }
952 kept.push((
953 crate::held_out::HeldOutFile {
954 path: rel,
955 sha256: crate::integrity::sha256_hex(content.as_bytes()),
956 scenarios: cited,
957 argv,
958 composition: composed,
959 red_tests: None,
960 },
961 content.to_owned(),
962 ));
963 }
964
965 let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
967 .map(|c| c.0)
968 .unwrap_or_default();
969 match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
972 Ok(runs) => {
973 let mut red = Vec::new();
974 for ((mut file, content), ran) in kept.into_iter().zip(runs) {
975 if ran.result.is_none() {
976 discard(&file.path, "collision");
977 } else if crate::held_out::red_class(&ran).is_red() {
978 file.red_tests = ran
979 .result
980 .as_ref()
981 .and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
982 red.push((file, content));
983 } else {
984 discard(&file.path, "not_red");
985 }
986 }
987 kept = red;
988 }
989 Err(e) => {
990 return absent(
991 "generation_failed",
992 json!({ "error": e, "run_id": base["run_id"] }),
993 );
994 }
995 }
996 let mut report = base;
997 report["discarded"] = json!(discarded);
998 if kept.is_empty() {
999 let only_uncollected =
1000 !discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
1001 return absent(
1002 if only_uncollected {
1003 "not_collected"
1004 } else {
1005 "all_discarded"
1006 },
1007 report,
1008 );
1009 }
1010 let manifest = crate::held_out::Manifest {
1011 version: 1,
1012 feature: a.feature.to_owned(),
1013 task: a.task.to_owned(),
1014 author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
1015 checkpoint,
1016 visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
1017 files: kept.iter().map(|(f, _)| f.clone()).collect(),
1018 discarded,
1019 };
1020 let contents: Vec<(String, String)> = kept
1021 .iter()
1022 .map(|(f, c)| (f.path.clone(), c.clone()))
1023 .collect();
1024 match crate::held_out::store(store, &manifest, &contents) {
1025 Ok(sha) => {
1026 report["state"] = json!("on");
1027 report["manifest"] = json!(format!("sha256:{sha}"));
1028 report["kept"] = json!(kept.len());
1029 (report, Some(sha))
1030 }
1031 Err(e) => absent("generation_failed", json!({ "error": e })),
1032 }
1033}
1034
1035pub(crate) struct HeldOutJudged {
1039 pub(crate) held_out: speccoder_core::verify::HeldOut,
1041 pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
1043}
1044
1045#[allow(clippy::too_many_arguments)]
1046pub(crate) fn judge_held_out(
1047 store: &Path,
1048 manifest_sha: &str,
1049 scenarios: &[speccoder_core::spec::ScenarioRef],
1050 known_text: &str,
1051 wt_root: &Path,
1052 project: &Path,
1053 tmp: &Path,
1054 start: &worktree::Checkpoint,
1055 patch: &str,
1056 runner: &sandbox::Runner,
1057 policy: &tools::policy::Policy,
1058) -> Result<HeldOutJudged, (&'static str, String)> {
1059 use speccoder_core::verify::{CheckStatus, HeldOut};
1060 let (manifest, contents) =
1061 crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
1062 let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1063 .files
1064 .iter()
1065 .cloned()
1066 .zip(contents.into_iter().map(|(_, c)| c))
1067 .collect();
1068 let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
1069 if !leaks.is_empty() {
1070 return Err((
1071 "leak_suspected",
1072 format!(
1073 "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
1074 leaks.len()
1075 ),
1076 ));
1077 }
1078 let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
1079 .map_err(|e| ("tampered", e))?;
1080 let mut failed_ids: Vec<String> = Vec::new();
1081 for ((file, _), ran) in files.iter().zip(&runs) {
1082 let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
1086 files
1087 .iter()
1088 .find(|(f, _)| f.path == file.path)
1089 .map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
1090 });
1091 let required = file.red_tests.unwrap_or(declared).max(1);
1092 let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
1093 r.status == CheckStatus::Passed
1094 && speccoder_core::verify::collected_count(r, sum.as_ref())
1095 .is_some_and(|green| green >= required)
1096 });
1097 if !passed {
1098 failed_ids.extend(file.scenarios.iter().cloned());
1099 }
1100 }
1101 failed_ids.sort();
1102 failed_ids.dedup();
1103 let held_out = if failed_ids.is_empty() {
1104 HeldOut::Passed { files: files.len() }
1105 } else {
1106 HeldOut::Failed {
1107 scenarios: failed_ids.clone(),
1108 }
1109 };
1110 let failed = failed_ids
1111 .iter()
1112 .map(|id| {
1113 scenarios
1114 .iter()
1115 .find(|s| &s.id == id)
1116 .cloned()
1117 .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
1118 id: id.clone(),
1119 text: String::new(),
1120 })
1121 })
1122 .collect();
1123 Ok(HeldOutJudged { held_out, failed })
1124}
1125
1126pub(crate) struct Strengthened {
1128 pub(crate) files: Vec<String>,
1130 pub(crate) argv: Vec<String>,
1132 pub(crate) report: serde_json::Value,
1134}
1135
1136pub(crate) fn strengthen(
1141 a: &AcceptanceSetup<'_>,
1142 visible: &[(String, String)],
1143 weak: serde_json::Value,
1144 before: &worktree::Checkpoint,
1145 after: &worktree::Checkpoint,
1146) -> Result<Strengthened, String> {
1147 use serde_json::json;
1148 let files: Vec<String> = worktree::files(a.worktree, after)
1149 .unwrap_or_default()
1150 .into_iter()
1151 .take(400)
1152 .collect();
1153 let manifest = MANIFESTS
1154 .iter()
1155 .find_map(|m| {
1156 std::fs::read_to_string(a.project.join(m))
1157 .ok()
1158 .map(|t| format!("{m}:\n{t}"))
1159 })
1160 .map(|t| t.chars().take(8192).collect::<String>())
1161 .unwrap_or_default();
1162 let input = substrate::acceptance::strengthen_input(
1163 a.bundle.clone(),
1164 a.scenarios,
1165 &files,
1166 &manifest,
1167 visible,
1168 weak,
1169 a.instructions,
1170 );
1171 let outcome =
1172 a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
1173 .map_err(|e| format!("the test author failed: {}", e.0))?;
1174 let output = outcome
1175 .output
1176 .as_ref()
1177 .map(|o| o.peek().clone())
1178 .unwrap_or_default();
1179 let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
1180 let answer = &output["answer"];
1181 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
1182 return Err(format!(
1183 "the test author did not answer ({})",
1184 substrate::why_not(&outcome.status)
1185 ));
1186 }
1187 let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
1189 let (valid, refused) = validate_answer(
1190 answer,
1191 a.project,
1192 a.policy,
1193 a.scenarios,
1194 (!a.oracle).then_some(title),
1195 );
1196 if let Some(r) = refused.first() {
1197 return Err(r.message.clone());
1198 }
1199 let planned: Vec<(String, String)> = valid.into_iter().map(|f| (f.path, f.content)).collect();
1200 if planned.is_empty() {
1201 return Err("the test author added no tests".to_owned());
1202 }
1203 let argv: Vec<String> = argv_of(&answer["argv"]);
1204 let paths: Vec<&str> = planned
1205 .iter()
1206 .map(|(p, _): &(String, String)| p.as_str())
1207 .collect();
1208 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
1209 return Err(format!("`{}` is not an allowed command", argv.join(" ")));
1210 }
1211 let write = || -> Result<(), String> {
1212 for (rel, content) in &planned {
1213 let full = a.project.join(rel);
1214 if let Some(parent) = full.parent() {
1215 let _ = std::fs::create_dir_all(parent);
1216 }
1217 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1218 }
1219 Ok(())
1220 };
1221 let back =
1222 |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
1223 back(before)?;
1225 let red = write().map(|()| {
1226 let (r, summary) = execute(a.project, &argv, a.runner);
1227 classify_red(&r, summary.as_ref())
1228 });
1229 back(after)?;
1230 let red = red?;
1231 if !red.is_red() {
1232 return Err(format!(
1233 "the added tests do not fail without the change ({})",
1234 class_word(red)
1235 ));
1236 }
1237 write()?;
1239 let (green, _) = execute(a.project, &argv, a.runner);
1240 if green.status != speccoder_core::verify::CheckStatus::Passed {
1241 back(after)?;
1242 let cited: Vec<String> = answer["files"]
1243 .as_array()
1244 .into_iter()
1245 .flatten()
1246 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
1247 .filter_map(|v| v.as_str().map(str::to_owned))
1248 .collect();
1249 return Err(format!(
1250 "the added tests fail the implementation{}",
1251 if cited.is_empty() {
1252 String::new()
1253 } else {
1254 format!(" (scenarios {})", cited.join(", "))
1255 }
1256 ));
1257 }
1258 Ok(Strengthened {
1259 files: planned.into_iter().map(|(p, _)| p).collect(),
1260 argv,
1261 report,
1262 })
1263}
1264
1265fn named(title: &str) -> Vec<String> {
1268 let mut names: Vec<String> = title
1269 .split('`')
1270 .skip(1)
1271 .step_by(2)
1272 .flat_map(|span| {
1273 span.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '/' || c == '.'))
1274 .map(str::to_owned)
1275 .collect::<Vec<_>>()
1276 })
1277 .map(|w| {
1278 let file = w.rsplit('/').next().unwrap_or(&w).to_owned();
1280 file.split('.').next().unwrap_or(&file).to_owned()
1281 })
1282 .filter(|w| w.len() >= 4 && w.chars().any(|c| c.is_alphabetic()))
1283 .collect();
1284 names.sort();
1285 names.dedup();
1286 names
1287}
1288
1289fn is_test(p: &str) -> bool {
1291 p.split('/').any(|seg| {
1292 let s = seg.to_ascii_lowercase();
1293 s == "tests"
1294 || s == "test"
1295 || s == "spec"
1296 || s == "__tests__"
1297 || s.starts_with("test_")
1298 || s.contains("_test.")
1299 || s.contains(".test.")
1300 || s.contains(".spec.")
1301 })
1302}
1303
1304pub(crate) fn related(
1308 this: &speccoder_core::snapshot::Task,
1309 all: &[speccoder_core::snapshot::Task],
1310) -> Vec<String> {
1311 let cites = |title: &str| -> Vec<String> {
1312 title
1313 .split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '/'))
1314 .map(|w| w.trim_matches('-').to_ascii_uppercase())
1315 .filter(|w| {
1316 let (head, num) = w.split_once('-').unwrap_or_default();
1317 (matches!(head, "FR" | "SC")
1318 && !num.is_empty()
1319 && num.chars().all(|c| c.is_ascii_digit()))
1320 || w.split_once("/AC").is_some_and(|(us, ac)| {
1321 us.strip_prefix("US")
1322 .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
1323 && !ac.is_empty()
1324 && ac.chars().all(|c| c.is_ascii_digit())
1325 })
1326 })
1327 .collect()
1328 };
1329 let mine = cites(&this.title);
1330 let named = |id: &str| {
1331 this.title
1332 .split(|c: char| !c.is_alphanumeric())
1333 .any(|w| w == id)
1334 };
1335 let others: Vec<&speccoder_core::snapshot::Task> =
1337 all.iter().filter(|t| t.id != this.id).collect();
1338 others
1339 .iter()
1340 .filter(|t| named(&t.id))
1341 .chain(
1342 others
1343 .iter()
1344 .filter(|t| !named(&t.id) && cites(&t.title).iter().any(|c| mine.contains(c))),
1345 )
1346 .map(|t| t.id.clone())
1347 .collect()
1348}
1349
1350pub(crate) fn suite(
1356 worktree: &Path,
1357 title: &str,
1358 related: &[String],
1359) -> Vec<substrate::acceptance::SuiteTest> {
1360 const FILES: usize = 3;
1361 const BUDGET: usize = 6 * 1024;
1362 let git = crate::git::Git::at(worktree);
1363 let ids: Vec<String> = related.iter().map(|t| t.to_ascii_lowercase()).collect();
1364 let mut paths: Vec<String> = if ids.is_empty() {
1365 Vec::new()
1366 } else {
1367 git.line(&["ls-files", "--cached", "--others", "--exclude-standard"])
1368 .unwrap_or_default()
1369 .lines()
1370 .filter(|p| is_test(p))
1371 .filter_map(|p| {
1372 let file = p.rsplit('/').next().unwrap_or(p).to_ascii_lowercase();
1373 let at = file
1374 .split(|c: char| !c.is_alphanumeric())
1375 .filter_map(|w| ids.iter().position(|id| id == w))
1376 .min()?;
1377 Some((at, p.to_owned()))
1378 })
1379 .collect::<std::collections::BTreeSet<_>>()
1380 .into_iter()
1381 .map(|(_, p)| p)
1382 .collect()
1383 };
1384 let names = named(title);
1385 if !names.is_empty() {
1386 let mut args = vec!["grep", "-l", "-w", "-I"];
1387 for n in &names {
1388 args.extend(["-e", n.as_str()]);
1389 }
1390 if let Ok(out) = git.line(&args) {
1391 paths.extend(out.lines().filter(|p| is_test(p)).map(str::to_owned));
1392 }
1393 }
1394 let mut seen = std::collections::BTreeSet::new();
1395 paths.retain(|p| seen.insert(p.clone()));
1396 let mut left = BUDGET;
1397 paths
1398 .iter()
1399 .take(FILES)
1400 .filter_map(|p| {
1401 let text = std::fs::read_to_string(worktree.join(p)).ok()?;
1402 let room = left.min(BUDGET / FILES);
1403 let content = if text.len() > room {
1404 let cut = (0..=room)
1405 .rev()
1406 .find(|i| text.is_char_boundary(*i))
1407 .unwrap_or(0);
1408 format!("{}\n… (trimmed)", &text[..cut])
1409 } else {
1410 text
1411 };
1412 left = left.saturating_sub(content.len());
1413 Some(substrate::acceptance::SuiteTest {
1414 path: p.clone(),
1415 content,
1416 })
1417 })
1418 .collect()
1419}
1420
1421fn task_commands(a: &AcceptanceSetup<'_>) -> Vec<crate::layout::Command> {
1428 let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
1429 if crate::layout::task_via_command(title, a.scenarios.iter().map(|s| s.text.as_str())) {
1430 crate::layout::commands(a.project)
1431 } else {
1432 Vec::new()
1433 }
1434}
1435
1436pub(crate) fn interface(worktree: &Path, title: &str) -> Vec<substrate::acceptance::SuiteTest> {
1437 const BUDGET: usize = 8 * 1024;
1438 const SOURCE: &[&str] = &[
1439 "py", "rs", "ts", "tsx", "js", "mjs", "go", "java", "kt", "rb", "cs", "swift",
1440 ];
1441 let Ok(out) = crate::git::Git::at(worktree).line(&[
1442 "ls-files",
1443 "--cached",
1444 "--others",
1445 "--exclude-standard",
1446 ]) else {
1447 return Vec::new();
1448 };
1449 let names = named(title);
1450 let stem = |p: &str| {
1451 let f = p.rsplit('/').next().unwrap_or(p);
1452 f.split('.').next().unwrap_or(f).to_owned()
1453 };
1454 let elsewhere = |p: &str| {
1456 p.split('/').any(|seg| {
1457 matches!(
1458 seg,
1459 "docs"
1460 | "doc"
1461 | "site"
1462 | "examples"
1463 | "vendor"
1464 | "node_modules"
1465 | "dist"
1466 | "build"
1467 | "target"
1468 | "scripts"
1469 ) || seg.starts_with('.')
1470 })
1471 };
1472 let mut files: Vec<&str> = out
1473 .lines()
1474 .filter(|p| {
1475 !is_test(p)
1476 && !elsewhere(p)
1477 && p.rsplit_once('.')
1478 .is_some_and(|(_, ext)| SOURCE.contains(&ext))
1479 })
1480 .collect();
1481 files.sort_by_key(|p| (!names.contains(&stem(p)), !p.starts_with("src/")));
1483 let mut left = BUDGET;
1484 let mut shown = Vec::new();
1485 for p in files {
1486 let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
1487 continue;
1488 };
1489 let content = declarations(&text, p.ends_with("__init__.py"));
1490 if content.is_empty() {
1491 continue;
1492 }
1493 if content.len() > left {
1495 continue;
1496 }
1497 left -= content.len();
1498 shown.push(substrate::acceptance::SuiteTest {
1499 path: p.to_owned(),
1500 content,
1501 });
1502 }
1503 shown
1504}
1505
1506fn declarations(text: &str, init: bool) -> String {
1510 const STARTS: &[&str] = &[
1511 "def ",
1512 "async def ",
1513 "class ",
1514 "pub ",
1515 "pub(",
1516 "export ",
1517 "func ",
1518 "type ",
1519 "public ",
1520 "fun ",
1521 "interface ",
1522 "struct ",
1523 "enum ",
1524 "trait ",
1525 ];
1526 let mut out = String::new();
1527 let mut in_class = false;
1528 let mut lines = text.lines();
1529 while let Some(line) = lines.next() {
1530 let t = line.trim_start();
1531 let top = t.len() == line.len();
1532 if top && !t.is_empty() {
1533 in_class = t.starts_with("class ");
1534 }
1535 let private = ["def _", "async def _", "class _"]
1536 .iter()
1537 .any(|p| t.starts_with(p) && !t.starts_with("def __init__"));
1538 let field = in_class
1541 && !top
1542 && line.len() - t.len() == 4
1543 && t.split_once(':').is_some_and(|(n, rest)| {
1544 !rest.trim().is_empty()
1545 && !n.is_empty()
1546 && n.chars().all(|c| c.is_alphanumeric() || c == '_')
1547 });
1548 let export = init && top && t.starts_with("from .");
1549 let decl = STARTS.iter().any(|s| t.starts_with(s)) || field || export;
1550 if !decl || private {
1551 continue;
1552 }
1553 let mut sig = line.trim_end().to_owned();
1554 let open = |s: &str| {
1555 s.chars().filter(|c| matches!(c, '(' | '[')).count() as isize
1556 - s.chars().filter(|c| matches!(c, ')' | ']')).count() as isize
1557 };
1558 let mut depth = open(&sig);
1559 while depth > 0 {
1560 let Some(next) = lines.next() else { break };
1561 sig.push(' ');
1562 sig.push_str(next.trim());
1563 depth += open(next);
1564 }
1565 let sig: String = sig.chars().take(200).collect();
1566 out.push_str(sig.trim_end_matches(['{', ' ']));
1567 out.push('\n');
1568 }
1569 out
1570}
1571
1572pub(crate) fn imagined(project: &Path, path: &str, content: &str, title: &str) -> Option<String> {
1579 let named = named(title);
1580 let adds = |module_path: &str| {
1581 let stem = module_path
1582 .rsplit('/')
1583 .next()
1584 .unwrap_or(module_path)
1585 .split('.')
1586 .next()
1587 .unwrap_or_default()
1588 .to_owned();
1589 title.contains(module_path) || named.contains(&stem)
1590 };
1591 let missing = |shown: &str| {
1592 format!(
1593 "`{path}` imports `{shown}`, which is not in the code and which this task does not add — call the code by the modules it has (under \"The code as it is\")"
1594 )
1595 };
1596 let ident = |c: char| c.is_alphanumeric() || c == '_';
1597 if path.ends_with(".py") {
1598 for (module, names) in python_imports(content) {
1599 let first = module.split('.').next().unwrap_or_default();
1600 let Some(base) = ["", "src/"].into_iter().find(|b| {
1602 project.join(format!("{b}{first}")).exists()
1603 || project.join(format!("{b}{first}.py")).exists()
1604 }) else {
1605 continue;
1606 };
1607 let rel = format!("{base}{}", module.replace('.', "/"));
1608 let file = [
1609 format!("{rel}.py"),
1610 format!("{rel}.pyi"),
1611 format!("{rel}/__init__.py"),
1612 format!("{rel}/__init__.pyi"),
1613 ]
1614 .into_iter()
1615 .find(|f| project.join(f).is_file());
1616 let Some(file) = file else {
1617 if project.join(&rel).is_dir() || adds(&format!("{rel}.py")) {
1619 continue;
1620 }
1621 return Some(missing(&module));
1622 };
1623 let text = std::fs::read_to_string(project.join(&file)).unwrap_or_default();
1624 if text.contains("import *") || text.contains("__getattr__") {
1626 continue;
1627 }
1628 let declared: Vec<String> = declarations(&text, true)
1629 .lines()
1630 .flat_map(|l| {
1631 l.split(|c: char| !ident(c))
1632 .filter(|w| !w.is_empty())
1633 .map(str::to_owned)
1634 .collect::<Vec<_>>()
1635 })
1636 .collect();
1637 for n in names {
1638 let there = text.split(|c: char| !ident(c)).any(|t| t == n);
1639 if there || named.contains(&n) || project.join(format!("{rel}/{n}.py")).is_file() {
1640 continue;
1641 }
1642 if let Some(real) = declared.iter().find(|t| t.eq_ignore_ascii_case(&n)) {
1643 return Some(format!(
1644 "`{path}` imports `{n}` from `{module}`, which has `{real}` — call the code by the names it has"
1645 ));
1646 }
1647 }
1648 }
1649 return None;
1650 }
1651 if [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
1652 .iter()
1653 .any(|e| path.ends_with(e))
1654 {
1655 let dir = Path::new(path).parent().unwrap_or(Path::new(""));
1656 for spec in relative_imports(content) {
1657 let joined = dir.join(&spec);
1658 let rel = crate::tools::paths::normalise(&joined.to_string_lossy()).unwrap_or_default();
1659 let bare = [".js", ".mjs", ".cjs", ".jsx"]
1661 .iter()
1662 .find_map(|e| rel.strip_suffix(e))
1663 .unwrap_or(&rel);
1664 let found = [rel.as_str(), bare].iter().any(|r| {
1665 [
1666 "",
1667 ".ts",
1668 ".tsx",
1669 ".js",
1670 ".jsx",
1671 ".mjs",
1672 ".svelte",
1673 ".vue",
1674 "/index.ts",
1675 "/index.tsx",
1676 "/index.js",
1677 ]
1678 .iter()
1679 .any(|e| project.join(format!("{r}{e}")).is_file())
1680 });
1681 if !found && !adds(&rel) {
1682 return Some(missing(&spec));
1683 }
1684 }
1685 }
1686 None
1687}
1688
1689fn python_imports(content: &str) -> Vec<(String, Vec<String>)> {
1692 let mut out = Vec::new();
1693 let mut lines = content.lines();
1694 while let Some(line) = lines.next() {
1695 let t = line.trim();
1696 if let Some(rest) = t.strip_prefix("from ") {
1697 let Some((module, names)) = rest.split_once(" import ") else {
1698 continue;
1699 };
1700 if module.starts_with('.') {
1701 continue;
1702 }
1703 let mut names = names.to_owned();
1704 if names.contains('(') && !names.contains(')') {
1705 for next in lines.by_ref() {
1706 names.push_str(next);
1707 if next.contains(')') {
1708 break;
1709 }
1710 }
1711 }
1712 let names = names
1713 .split([',', '(', ')'])
1714 .filter_map(|n| n.split_whitespace().next())
1715 .filter(|n| *n != "*" && !n.starts_with('#'))
1716 .map(str::to_owned)
1717 .collect();
1718 out.push((module.trim().to_owned(), names));
1719 } else if let Some(rest) = t.strip_prefix("import ") {
1720 for m in rest.split(',') {
1721 if let Some(m) = m.split_whitespace().next() {
1722 out.push((m.to_owned(), Vec::new()));
1723 }
1724 }
1725 }
1726 }
1727 out
1728}
1729
1730fn relative_imports(content: &str) -> Vec<String> {
1732 let mut out = Vec::new();
1733 for quote in ['\'', '"'] {
1734 for (i, _) in content.match_indices(quote) {
1735 let rest = &content[i + 1..];
1736 if !(rest.starts_with("./") || rest.starts_with("../")) {
1737 continue;
1738 }
1739 let before = content[..i].trim_end();
1740 if !(before.ends_with("from")
1741 || before.ends_with("import")
1742 || before.ends_with("require(")
1743 || before.ends_with("import("))
1744 {
1745 continue;
1746 }
1747 if let Some(end) = rest.find(quote) {
1748 out.push(rest[..end].to_owned());
1749 }
1750 }
1751 }
1752 out
1753}
1754
1755pub(crate) fn outside_story(
1759 t: &speccoder_core::snapshot::Task,
1760 scenarios: &[speccoder_core::spec::ScenarioRef],
1761 unit_dir: &str,
1762 judged: &dyn Fn(&str) -> bool,
1763) -> Option<speccoder_core::story::Judged> {
1764 if checks_only(t, unit_dir, judged) {
1765 return None;
1766 }
1767 let links: Vec<String> = t
1768 .scenarios
1769 .iter()
1770 .filter(|l| scenarios.iter().any(|s| &s.id == *l))
1771 .cloned()
1772 .collect();
1773 (!links.is_empty()).then_some(speccoder_core::story::Judged::Linked { scenarios: links })
1774}
1775
1776pub(crate) fn linked_story(
1780 t: &speccoder_core::snapshot::Task,
1781 scenarios: &[speccoder_core::spec::ScenarioRef],
1782 unit_dir: &str,
1783 judged: &dyn Fn(&str) -> bool,
1784 reqs: &[crate::format::OracleRequirement],
1785) -> Option<(crate::format::OracleRequirement, Vec<String>)> {
1786 let Some(speccoder_core::story::Judged::Linked { scenarios: links }) =
1787 outside_story(t, scenarios, unit_dir, judged)
1788 else {
1789 return None;
1790 };
1791 let owner = |id: &String| {
1792 reqs.iter()
1793 .position(|r| r.scenarios.iter().any(|s| &s.id == id))
1794 };
1795 let owners: std::collections::BTreeSet<usize> =
1796 links.iter().map(owner).collect::<Option<_>>()?;
1797 match owners.into_iter().collect::<Vec<_>>()[..] {
1798 [one] => Some((reqs[one].clone(), links)),
1799 _ => None,
1800 }
1801}
1802
1803pub(crate) fn checks_only(
1806 t: &speccoder_core::snapshot::Task,
1807 unit_dir: &str,
1808 judged: &dyn Fn(&str) -> bool,
1809) -> bool {
1810 t.yours
1811 || speccoder_core::lane::by_paths(&t.title, unit_dir, judged)
1812 == speccoder_core::lane::Lane::Planning
1813}
1814
1815#[cfg(test)]
1816mod interface_tests {
1817 use super::{declarations, imagined, related};
1818
1819 #[test]
1822 fn tasks_on_the_same_requirements_are_related() {
1823 let task = |id: &str, title: &str| speccoder_core::snapshot::Task {
1824 id: id.to_owned(),
1825 title: title.to_owned(),
1826 ..Default::default()
1827 };
1828 let all = [
1829 task(
1830 "T026",
1831 "Route both tables in `src/lib.rs` per US1/AC3 (partial)",
1832 ),
1833 task(
1834 "T029",
1835 "Add header-presence validation per FR-004, FR-007 (partial)",
1836 ),
1837 task("T030", "Propagate the source path per FR-010"),
1838 task("T031", "Escape pipes per FR-0041 and us1/ac3"),
1839 task(
1840 "T032",
1841 "Add explicit header-presence validation per FR-004, FR-007, T029 (partial)",
1842 ),
1843 ];
1844 assert_eq!(related(&all[4], &all), ["T029"]);
1845 assert_eq!(related(&all[0], &all), ["T031"]);
1846 let named = task("T033", "Finish what T030 began");
1847 assert_eq!(related(&named, &all), ["T030"]);
1848 }
1849
1850 #[test]
1853 fn a_test_s_imports_are_checked_against_the_code() {
1854 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1855 let w = dir.path();
1856 for (p, t) in [
1857 ("src/__init__.py", ""),
1858 (
1859 "src/models/__init__.py",
1860 "from .csv_output import CsvOutput\nfrom .conversion_error import ConversionError\n",
1861 ),
1862 ("src/models/csv_output.py", "class CsvOutput:\n pass\n"),
1863 (
1864 "src/services/table_parser.py",
1865 "def parse_markdown_table(content):\n return ()\n",
1866 ),
1867 ("web/src/lib/csv.ts", "export function toCsv() {}\n"),
1868 ] {
1869 std::fs::create_dir_all(w.join(p).parent().unwrap_or(w))
1870 .unwrap_or_else(|e| panic!("{e}"));
1871 std::fs::write(w.join(p), t).unwrap_or_else(|e| panic!("{e}"));
1872 }
1873 let task = "Add header-presence validation in the conversion flow per FR-004";
1874 let check = |content: &str| imagined(w, "tests/test_t032.py", content, task);
1875 let got = check(
1877 "def test():\n from src.lib.convert_table_to_csv import convert_table_to_csv\n from src.models.conversion_error import ConversionError\n",
1878 );
1879 assert!(
1880 got.as_deref()
1881 .is_some_and(|m| m.contains("`src.lib.convert_table_to_csv`")),
1882 "{got:?}"
1883 );
1884 assert_eq!(
1886 imagined(
1887 w,
1888 "tests/test_t032.py",
1889 "from src.lib.convert_table_to_csv import convert\n",
1890 "Add `src/lib/convert_table_to_csv.py`"
1891 ),
1892 None
1893 );
1894 let got = check("from src.models import CSVOutput\n");
1896 assert!(
1897 got.as_deref()
1898 .is_some_and(|m| m.contains("which has `CsvOutput`")),
1899 "{got:?}"
1900 );
1901 assert_eq!(
1903 check(
1904 "import pytest\nfrom dataclasses import dataclass\nfrom src.models import (\n CsvOutput,\n ConversionError,\n)\nfrom src.services.table_parser import parse_markdown_table, validate_header\n"
1905 ),
1906 None
1907 );
1908 let ts = |c: &str| imagined(w, "web/src/lib/csv.test.ts", c, task);
1910 assert_eq!(
1911 ts("import { toCsv } from \"./csv\";\nimport { it } from \"vitest\";\n"),
1912 None
1913 );
1914 assert!(ts("import { toCsv } from './convert';\n").is_some());
1915 assert_eq!(ts("import { toCsv } from './csv.js';\n"), None, "NodeNext");
1916 for (p, t) in [
1918 ("pkg/__init__.py", "from .parser import *\n"),
1919 ("pkg/parser.py", "class Parser:\n pass\n"),
1920 ("ns/inner.py", "def f():\n pass\n"),
1921 ] {
1922 std::fs::create_dir_all(w.join(p).parent().unwrap_or(w))
1923 .unwrap_or_else(|e| panic!("{e}"));
1924 std::fs::write(w.join(p), t).unwrap_or_else(|e| panic!("{e}"));
1925 }
1926 assert_eq!(check("from pkg import Parser\nimport ns\n"), None);
1927 }
1928
1929 #[test]
1932 fn only_a_test_framework_s_skip_is_a_skip() {
1933 assert!(!crate::integrity::has_skip_marker(
1934 "let rows = lines.iter().skip(1);"
1935 ));
1936 assert!(crate::integrity::has_skip_marker(
1937 "it.skip('later', () => {})"
1938 ));
1939 assert!(crate::integrity::has_skip_marker(
1940 " pytest.skip(\"later\")"
1941 ));
1942 }
1943
1944 #[test]
1947 fn a_file_s_declarations_are_its_names_and_shapes() {
1948 let py = "from dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass MarkdownTable:\n header: tuple[str, ...]\n rows: tuple[tuple[str, ...], ...]\n\n def width(self) -> int:\n return len(self.header)\n\n def _check(self) -> None:\n pass\n\n\ndef parse_markdown_table(\n content: str,\n) -> tuple[MarkdownTable, ...]:\n x: int = 1\n return ()\n\n\ndef _split_row(line: str) -> tuple[str, ...]:\n return ()\n";
1949 assert_eq!(
1950 declarations(py, false),
1951 "class MarkdownTable:\n header: tuple[str, ...]\n rows: tuple[tuple[str, ...], ...]\n def width(self) -> int:\ndef parse_markdown_table( content: str, ) -> tuple[MarkdownTable, ...]:\n"
1952 );
1953 assert_eq!(
1954 declarations("from .csv_output import CsvOutput\nimport os\n", true),
1955 "from .csv_output import CsvOutput\n"
1956 );
1957 assert_eq!(
1958 declarations(
1959 "pub struct Table {\n pub header: Vec<String>,\n rows: usize,\n}\nfn private() {}\npub fn parse(s: &str) -> Vec<Table> {\n",
1960 false
1961 ),
1962 "pub struct Table\n pub header: Vec<String>,\npub fn parse(s: &str) -> Vec<Table>\n"
1963 );
1964 assert_eq!(
1965 declarations(
1966 "export function toCsv(t: Table): string {\nconst x = 1;\n",
1967 false
1968 ),
1969 "export function toCsv(t: Table): string\n"
1970 );
1971 }
1972}
1973
1974#[cfg(test)]
1975mod argv_tests {
1976 use super::argv_of;
1977 use serde_json::json;
1978
1979 #[test]
1981 fn a_command_given_as_one_string_is_its_words() {
1982 assert_eq!(
1983 argv_of(&json!(["cargo test --test acceptance_us1"])),
1984 ["cargo", "test", "--test", "acceptance_us1"]
1985 );
1986 assert_eq!(
1987 argv_of(&json!(["cargo", "test", "--test", "acceptance_us1"])),
1988 ["cargo", "test", "--test", "acceptance_us1"]
1989 );
1990 assert_eq!(
1992 argv_of(&json!(["cargo test; rm -rf ."])),
1993 ["cargo test; rm -rf ."]
1994 );
1995 assert_eq!(argv_of(&json!(["pytest"])), ["pytest"]);
1996 }
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001 use super::*;
2002
2003 fn record(
2005 producer: speccoder_core::acceptance::Producer,
2006 content: &str,
2007 ) -> speccoder_core::acceptance::PreFrozenAcceptance {
2008 speccoder_core::acceptance::PreFrozenAcceptance {
2009 unit: "001-x".to_owned(),
2010 requirement: "US1".to_owned(),
2011 wording_digest: "sha256:w".to_owned(),
2012 approval_digest: "sha256:a".to_owned(),
2013 visible: vec![speccoder_core::acceptance::VisibleTest {
2014 path: "tests/a.txt".to_owned(),
2015 sha256: format!(
2016 "sha256:{}",
2017 crate::integrity::sha256_hex(content.as_bytes())
2018 ),
2019 cites: vec!["US1-1".to_owned()],
2020 }],
2021 held_out_sealed: None,
2022 producer,
2023 decided: speccoder_core::acceptance::Decided {
2024 by: "Ada".to_owned(),
2025 via: speccoder_core::actions::Via::Terminal,
2026 at: 0,
2027 },
2028 }
2029 }
2030
2031 #[test]
2032 fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
2033 use speccoder_core::acceptance::Producer;
2034 let summary = |passed: u8, failed: u8| {
2035 format!(
2036 "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
2037 if failed > 0 { "FAILED" } else { "ok" },
2038 if failed > 0 { 101 } else { 0 }
2039 )
2040 };
2041 let content = "checks\n";
2042 for (producer, rule) in [
2043 (Producer::Review, RedRule::RunTime),
2044 (Producer::BugConfirmation, RedRule::BugAssertionOnly),
2045 ] {
2046 let rec = record(producer, content);
2047 let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
2048 let run = |script: String| {
2049 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2050 let tests = [ReusedTest {
2051 path: "tests/a.txt".to_owned(),
2052 cites: vec!["US1-1".to_owned()],
2053 argv: vec!["sh".to_owned(), "-c".to_owned(), script],
2054 green: false,
2055 }];
2056 let got = reuse_pre_frozen(
2057 dir.path(),
2058 &rec,
2059 &files,
2060 &tests,
2061 &sandbox::Runner::Plain,
2062 rule,
2063 None,
2064 );
2065 assert!(
2066 dir.path().join("tests/a.txt").is_file(),
2067 "written at the checkpoint"
2068 );
2069 got
2070 };
2071 let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
2073 assert!(red.red);
2074 assert_eq!(red.report["source"], serde_json::json!(producer.source()));
2075 let passing = run(summary(1, 0));
2078 match rule {
2079 RedRule::RunTime => {
2080 let p = passing.unwrap_or_else(|e| panic!("{e}"));
2081 assert!(!p.red);
2082 assert!(
2083 p.report["note"]
2084 .as_str()
2085 .is_some_and(|n| n.contains("already passing"))
2086 );
2087 }
2088 RedRule::BugAssertionOnly => assert!(passing.is_err()),
2089 }
2090 if rule == RedRule::BugAssertionOnly {
2092 assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
2093 }
2094 }
2095 }
2096}
2097
2098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2100pub(crate) enum RedRule {
2101 RunTime,
2105 BugAssertionOnly,
2107}
2108
2109#[derive(Debug)]
2111pub(crate) struct ReusedTest {
2112 pub(crate) path: String,
2114 pub(crate) cites: Vec<String>,
2116 pub(crate) argv: Vec<String>,
2118 pub(crate) green: bool,
2120}
2121
2122pub(crate) struct SealedCheck<'a> {
2125 pub(crate) state: &'a Path,
2127 pub(crate) unit: &'a str,
2129 pub(crate) wt_root: &'a Path,
2131 pub(crate) tmp: &'a Path,
2133 pub(crate) policy: &'a tools::policy::Policy,
2135}
2136
2137pub(crate) fn reuse_pre_frozen(
2143 project: &Path,
2144 record: &speccoder_core::acceptance::PreFrozenAcceptance,
2145 files: &[(String, String)],
2146 tests: &[ReusedTest],
2147 runner: &sandbox::Runner,
2148 rule: RedRule,
2149 sealed: Option<&SealedCheck<'_>>,
2150) -> Result<PreparedAcceptance, String> {
2151 use serde_json::json;
2152 for (rel, content) in files {
2153 let full = project.join(rel);
2154 if let Ok(now) = std::fs::read_to_string(&full) {
2155 if now != *content {
2156 return Err(format!(
2157 "`{rel}` exists and differs from the confirmed test; it is not overwritten"
2158 ));
2159 }
2160 continue;
2161 }
2162 if let Some(parent) = full.parent() {
2163 let _ = std::fs::create_dir_all(parent);
2164 }
2165 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
2166 }
2167 let class_of = |r: &speccoder_core::verify::CheckResult,
2170 s: Option<&speccoder_core::verify::RunnerSummary>| match rule
2171 {
2172 RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
2173 RedRule::RunTime => classify_red(r, s),
2174 };
2175 let mut classes = Vec::new();
2176 for t in tests {
2177 let (first, summary) = execute(project, &t.argv, runner);
2178 let one = class_of(&first, summary.as_ref());
2179 let class = if t.green {
2180 one
2181 } else {
2182 match rule {
2183 RedRule::BugAssertionOnly if one == RedClass::Assertion => {
2184 let (second, summary) = execute(project, &t.argv, runner);
2185 speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
2186 }
2187 RedRule::BugAssertionOnly => one,
2188 RedRule::RunTime if one.is_red() => {
2189 let (second, summary) = execute(project, &t.argv, runner);
2190 if classify_red(&second, summary.as_ref()).is_red() {
2191 one
2192 } else {
2193 RedClass::Flaky
2194 }
2195 }
2196 RedRule::RunTime => one,
2197 }
2198 };
2199 let holds = if t.green {
2200 class == RedClass::Passed
2201 } else {
2202 match rule {
2203 RedRule::BugAssertionOnly => class == RedClass::Assertion,
2204 RedRule::RunTime => class.is_red(),
2205 }
2206 };
2207 if !holds && rule == RedRule::RunTime && !t.green {
2208 return Ok(not_red_frozen(record, files, tests, t, class));
2209 }
2210 if !holds {
2211 return Err(if t.green {
2212 format!(
2213 "the confirmed test `{}` ({}) no longer passes on the branch ({})",
2214 t.path,
2215 t.cites.join(", "),
2216 class_word(class)
2217 )
2218 } else {
2219 format!(
2220 "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
2221 t.path,
2222 t.cites.join(", "),
2223 class_word(class)
2224 )
2225 });
2226 }
2227 classes.push(class);
2228 }
2229 let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
2230 let Some(first) = red.first() else {
2231 return Err("the confirmed set has no test that must fail first".to_owned());
2232 };
2233 let more: Vec<Vec<String>> = tests
2234 .iter()
2235 .filter(|t| !std::ptr::eq(*t, *first))
2236 .map(|t| t.argv.clone())
2237 .collect();
2238 let mut held_sha = record
2239 .held_out_sealed
2240 .as_ref()
2241 .map(|h| h.manifest_sha256.clone());
2242 let mut held_out = match &record.held_out_sealed {
2243 Some(h) => {
2244 json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
2245 }
2246 None => json!({ "state": "absent", "reason": "none_kept" }),
2247 };
2248 if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
2251 let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
2252 .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
2253 let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
2254 .files
2255 .iter()
2256 .cloned()
2257 .zip(contents.into_iter().map(|(_, c)| c))
2258 .collect();
2259 let runs = crate::held_out::overlay_red(
2260 check.wt_root,
2261 project,
2262 check.tmp,
2263 &set,
2264 runner,
2265 check.policy,
2266 )?;
2267 let all_red = runs
2268 .iter()
2269 .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
2270 if !all_red {
2271 held_sha = None;
2272 held_out = json!({ "state": "absent", "reason": "not_red" });
2273 }
2274 }
2275 let class = classes.first().copied().unwrap_or(RedClass::Assertion);
2276 Ok(PreparedAcceptance {
2277 report: json!({
2278 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
2279 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
2280 "argv": first.argv,
2281 "red": true,
2282 "red_class": class,
2283 "source": record.producer.source(),
2284 "pre_frozen": {
2285 "manifest": crate::pre_frozen::manifest_digest(record),
2286 "approval": record.approval_digest,
2287 "wording": record.wording_digest,
2288 "by": record.decided.by,
2289 "via": record.decided.via,
2290 },
2291 "held_out": held_out,
2292 }),
2293 argv: Some(first.argv.clone()),
2294 more_argv: more,
2295 red: true,
2296 red_class: Some(class),
2297 held_out: held_sha,
2298 generation_failed: None,
2299 skip_cause: None,
2300 })
2301}
2302
2303fn not_red_frozen(
2306 record: &speccoder_core::acceptance::PreFrozenAcceptance,
2307 files: &[(String, String)],
2308 tests: &[ReusedTest],
2309 failing: &ReusedTest,
2310 class: RedClass,
2311) -> PreparedAcceptance {
2312 use serde_json::json;
2313 let note = match class {
2314 RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
2315 RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
2316 other => format!(
2317 "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
2318 class_word(other)
2319 ),
2320 };
2321 PreparedAcceptance {
2322 report: json!({
2323 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
2324 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
2325 "argv": failing.argv,
2326 "red": false,
2327 "red_class": class,
2328 "note": note,
2329 "source": record.producer.source(),
2330 "pre_frozen": {
2331 "manifest": crate::pre_frozen::manifest_digest(record),
2332 "approval": record.approval_digest,
2333 "wording": record.wording_digest,
2334 "by": record.decided.by,
2335 "via": record.decided.via,
2336 },
2337 "held_out": { "state": "absent", "reason": "not_red" },
2338 }),
2339 argv: Some(failing.argv.clone()),
2340 more_argv: tests
2341 .iter()
2342 .filter(|t| !std::ptr::eq(*t, failing))
2343 .map(|t| t.argv.clone())
2344 .collect(),
2345 red: false,
2346 red_class: Some(class),
2347 held_out: None,
2348 generation_failed: None,
2349 skip_cause: None,
2350 }
2351}