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