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