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