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}
31
32#[derive(Clone, serde::Serialize, serde::Deserialize)]
33pub(crate) struct PreparedAcceptance {
34 pub(crate) report: serde_json::Value,
36 pub(crate) argv: Option<Vec<String>>,
38 #[serde(default)]
41 pub(crate) more_argv: Vec<Vec<String>>,
42 pub(crate) red: bool,
45 #[serde(default)]
47 pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
48 #[serde(default)]
51 pub(crate) held_out: Option<String>,
52 #[serde(default)]
55 pub(crate) generation_failed: Option<String>,
56 #[serde(default)]
59 pub(crate) skip_cause: Option<speccoder_core::regression::SkipCause>,
60}
61
62pub(crate) const MANIFESTS: &[&str] = &[
63 "Cargo.toml",
64 "package.json",
65 "pyproject.toml",
66 "go.mod",
67 "pom.xml",
68];
69
70pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
71 use serde_json::json;
72 use speccoder_core::regression::SkipCause;
73 let skipped_by = |why: String, cause: Option<SkipCause>, report: serde_json::Value| {
74 let mut report = report;
75 report["skipped"] = json!(why);
76 PreparedAcceptance {
77 report,
78 argv: None,
79 more_argv: Vec::new(),
80 red: false,
81 red_class: None,
82 held_out: None,
83 generation_failed: None,
84 skip_cause: cause,
85 }
86 };
87 let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
88 generation_failed: Some(why.clone()),
89 ..skipped_by(why, None, report)
90 };
91
92 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
93 .and_then(|c| worktree::files(a.worktree, &c))
94 .unwrap_or_default()
95 .into_iter()
96 .take(400)
97 .collect();
98 let manifest = MANIFESTS
99 .iter()
100 .find_map(|m| {
101 std::fs::read_to_string(a.project.join(m))
102 .ok()
103 .map(|t| format!("{m}:\n{t}"))
104 })
105 .map(|t| t.chars().take(8192).collect::<String>())
106 .unwrap_or_default();
107 let placement = match placement(a) {
110 Ok(p) => p,
111 Err(()) => {
112 return skipped_by(
113 "no place the runner collects".to_owned(),
114 Some(SkipCause::NoPlace),
115 json!({}),
116 );
117 }
118 };
119 let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
120 let suite = suite(a.worktree, title);
121 let interface = interface(a.worktree, title);
122 let mut refusals: Vec<String> = Vec::new();
125 let (answer, base, valid) = loop {
126 let input = substrate::acceptance::input(
127 a.bundle.clone(),
128 a.scenarios,
129 &files,
130 &manifest,
131 a.instructions,
132 placement.as_ref(),
133 &substrate::acceptance::Known {
134 told: a.told,
135 refused: &refusals,
136 suite: &suite,
137 interface: &interface,
138 },
139 );
140 let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
141 let outcome = match outcome {
142 Ok(o) => o,
143 Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
144 };
145 let output = outcome
146 .output
147 .as_ref()
148 .map(|o| o.peek().clone())
149 .unwrap_or_default();
150 let answer = output["answer"].clone();
151 let mut base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
152 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
153 {
154 let status = format!("{:?}", outcome.status);
155 let why = substrate::provider_refusal(&status).map_or_else(
156 || format!("generation failed: {}", substrate::why_not(&outcome.status)),
157 |r| format!("the model provider refused the request: {r}"),
158 );
159 return failed(why, base);
160 }
161 if !refusals.is_empty() {
162 base["refused_first"] = json!(refusals);
163 }
164
165 let (valid, mut refused) = validate_answer(&answer, a.project, a.policy, a.scenarios);
168 refused.extend(valid.iter().filter_map(|f| {
169 imagined(a.worktree, &f.path, &f.content, title).map(|message| RefusedFile {
170 message,
171 path: f.path.clone(),
172 why: "imagined",
173 })
174 }));
175 match refused.first() {
176 None => break (answer, base, valid),
177 Some(r) if refusals.is_empty() => refusals.push(r.message.clone()),
178 Some(r) => {
179 return skipped_by(
180 r.message.clone(),
181 Some(SkipCause::Refused {
182 path: r.path.clone(),
183 why: refusal_words(r.why).to_owned(),
184 }),
185 base,
186 );
187 }
188 }
189 };
190 let mut written = Vec::new();
191 let tests: Vec<serde_json::Value> = valid
193 .iter()
194 .map(|f| json!({ "path": f.path, "scenarios": f.cited }))
195 .collect();
196 let planned: Vec<(String, String)> = valid.into_iter().map(|f| (f.path, f.content)).collect();
197 if planned.is_empty() {
198 return skipped_by(
199 "no acceptance tests were generated".to_owned(),
200 Some(SkipCause::NoFiles),
201 base,
202 );
203 }
204 let argv: Vec<String> = argv_of(&answer["argv"]);
205 let paths: Vec<&str> = planned.iter().map(|(p, _)| p.as_str()).collect();
206 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
207 return skipped_by(
208 format!("`{}` is not an allowed command", argv.join(" ")),
209 Some(SkipCause::CommandNotAllowed {
210 argv: argv.join(" "),
211 }),
212 base,
213 );
214 }
215 for (rel, content) in &planned {
216 let full = a.project.join(rel);
217 if let Some(parent) = full.parent() {
218 let _ = std::fs::create_dir_all(parent);
219 }
220 if let Err(e) = std::fs::write(&full, content) {
221 return skipped_by(
222 format!("`{rel}`: {e}"),
223 Some(SkipCause::WriteFailed { path: rel.clone() }),
224 base,
225 );
226 }
227 written.push(rel.clone());
228 }
229
230 let (first, summary) = execute(a.project, &argv, a.runner);
234 let red_tests = speccoder_core::verify::collected_count(&first, summary.as_ref());
235 let mut class = classify_red(&first, summary.as_ref());
236 if class.is_red() {
237 let (second, summary) = execute(a.project, &argv, a.runner);
238 if !classify_red(&second, summary.as_ref()).is_red() {
239 class = RedClass::Flaky;
240 }
241 }
242 let red = class.is_red();
243 let broken = matches!(class, RedClass::CollectionError | RedClass::BuildError);
246 let removed = if broken {
247 for rel in &written {
248 let _ = std::fs::remove_file(a.project.join(rel));
249 }
250 std::mem::take(&mut written)
251 } else {
252 Vec::new()
253 };
254 let mut report = base;
255 report["files"] = json!(written);
256 if !removed.is_empty() {
257 report["removed"] = json!(removed);
258 }
259 report["tests"] = json!(tests);
260 report["argv"] = json!(argv);
261 report["red"] = json!(red);
262 report["red_class"] = json!(class);
263 if let Some(n) = red_tests {
264 report["red_tests"] = json!(n);
265 }
266 if red {
267 report["red_output"] = json!(
269 format!("{}\n{}", first.stdout_tail, first.stderr_tail)
270 .trim()
271 .to_owned()
272 );
273 } else {
274 report["note"] = json!(match class {
275 RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
276 RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
277 other if broken => format!(
278 "the acceptance tests did not fail on an assertion ({}): not demonstrated, and taken out so the project's other tests still run",
279 class_word(other)
280 ),
281 other => format!(
282 "the acceptance tests did not fail on an assertion ({}): not demonstrated",
283 class_word(other)
284 ),
285 });
286 report["excerpt"] = json!(excerpt(&first));
287 }
288 let kept = red || class == RedClass::Passed;
293 PreparedAcceptance {
294 report,
295 argv: kept.then_some(argv),
296 more_argv: Vec::new(),
297 red,
298 red_class: Some(class),
299 held_out: None,
300 generation_failed: None,
301 skip_cause: None,
302 }
303}
304
305fn placement(a: &AcceptanceSetup<'_>) -> Result<Option<substrate::acceptance::Placement>, ()> {
309 let Some(layout) = a.layout.filter(|l| !l.runners.is_empty()) else {
310 return Ok(None);
311 };
312 let task = a.task.to_owned();
313 let package =
315 speccoder_core::scope::named_paths(a.bundle["task"]["title"].as_str().unwrap_or_default())
316 .into_iter()
317 .find(|p| p.ends_with(".go") || !p.contains('.'))
318 .map(|p| {
319 if p.ends_with(".go") {
320 p.rsplit_once('/')
321 .map_or(String::new(), |(d, _)| d.to_owned())
322 } else {
323 p.trim_end_matches('/').to_owned()
324 }
325 });
326 let path = layout
327 .acceptance_file(&task, package.as_deref())
328 .ok_or(())?;
329 let comment = if path.ends_with(".py") { "#" } else { "//" }.to_owned();
330 Ok(Some(substrate::acceptance::Placement {
331 command: layout.command_for(&path).unwrap_or_default(),
332 path,
333 task,
334 scenarios: a.scenarios.iter().map(|s| s.id.clone()).collect(),
335 comment,
336 layout: layout.text(),
337 }))
338}
339
340pub(crate) fn refusal_words(why: &str) -> &'static str {
342 match why {
343 "exists" => "already exists",
344 "not_collected" => "is not where the runner collects tests",
345 "skip_marker" => "skips a test",
346 "placeholder" => "is a placeholder that can never pass",
347 "imagined" => "calls code that is not there",
348 _ => "is not a new test file",
349 }
350}
351
352pub(crate) struct ValidFile {
354 pub(crate) path: String,
356 pub(crate) content: String,
357 pub(crate) cited: Vec<String>,
359 pub(crate) argv: Vec<String>,
361}
362
363pub(crate) struct RefusedFile {
365 pub(crate) path: String,
367 pub(crate) why: &'static str,
369 pub(crate) message: String,
371}
372
373#[must_use]
379pub(crate) fn argv_of(v: &serde_json::Value) -> Vec<String> {
380 let argv: Vec<String> = v
381 .as_array()
382 .into_iter()
383 .flatten()
384 .filter_map(|v| v.as_str().map(str::to_owned))
385 .collect();
386 match argv.as_slice() {
387 [line] if line.split_ascii_whitespace().count() > 1 => {
388 let words = crate::acp::govern::argv_of_line(line);
389 if words.len() == 3 && words[0] == "sh" && words[1] == "-c" && words[2] == line.trim() {
391 argv
392 } else {
393 words
394 }
395 }
396 _ => argv,
397 }
398}
399
400pub(crate) fn test_command_allowed(
404 policy: &tools::policy::Policy,
405 layout: Option<&crate::layout::TestLayout>,
406 argv: &[String],
407 files: &[&str],
408) -> bool {
409 layout.is_some_and(|l| l.runs_only(argv, files))
410 || policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
411 == tools::policy::Decision::Allow
412}
413
414pub(crate) fn validate_answer(
418 answer: &serde_json::Value,
419 project: &Path,
420 policy: &tools::policy::Policy,
421 scenarios: &[speccoder_core::spec::ScenarioRef],
422) -> (Vec<ValidFile>, Vec<RefusedFile>) {
423 let protected = policy.protected();
424 let mut valid = Vec::new();
425 let mut refused = Vec::new();
426 for f in answer["files"].as_array().into_iter().flatten() {
427 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
428 continue;
429 };
430 let rel = match tools::paths::normalise(path) {
431 Ok(r) => r,
432 Err(e) => {
433 refused.push(RefusedFile {
434 path: path.to_owned(),
435 why: "not_a_new_test_file",
436 message: format!("`{path}`: {}", e.0),
437 });
438 continue;
439 }
440 };
441 if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
442 refused.push(RefusedFile {
443 message: format!("`{rel}` is not a new test file"),
444 path: rel,
445 why: "not_a_new_test_file",
446 });
447 continue;
448 }
449 if project.join(&rel).exists() {
450 refused.push(RefusedFile {
451 message: format!("`{rel}` already exists"),
452 path: rel,
453 why: "exists",
454 });
455 continue;
456 }
457 if crate::integrity::has_skip_marker(content) {
460 refused.push(RefusedFile {
461 message: format!("`{rel}` skips a test"),
462 path: rel,
463 why: "skip_marker",
464 });
465 continue;
466 }
467 if crate::integrity::has_placeholder(content) {
469 refused.push(RefusedFile {
470 message: format!(
471 "`{rel}` is a placeholder: it raises \"not implemented\" instead of checking the behaviour"
472 ),
473 path: rel,
474 why: "placeholder",
475 });
476 continue;
477 }
478 if crate::collection::statically_refused(project, &rel, content).is_some() {
480 refused.push(RefusedFile {
481 message: format!("`{rel}` is not where the runner collects tests"),
482 path: rel,
483 why: "not_collected",
484 });
485 continue;
486 }
487 let cited: Vec<String> = f["scenarios"]
488 .as_array()
489 .into_iter()
490 .flatten()
491 .filter_map(|v| v.as_str())
492 .filter(|id| scenarios.iter().any(|s| s.id == *id))
493 .map(str::to_owned)
494 .collect();
495 let argv: Vec<String> = argv_of(&f["argv"]);
496 valid.push(ValidFile {
497 path: rel,
498 content: content.to_owned(),
499 cited,
500 argv,
501 });
502 }
503 (valid, refused)
504}
505
506pub(crate) fn class_word(class: RedClass) -> String {
508 serde_json::to_value(class)
509 .ok()
510 .and_then(|v| v.as_str().map(str::to_owned))
511 .unwrap_or_default()
512}
513
514fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
517 let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
518 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
519 let from = lines
520 .iter()
521 .position(|l| {
522 let l = l.to_lowercase();
523 l.contains("error") || l.contains("fail")
524 })
525 .unwrap_or_else(|| lines.len().saturating_sub(20));
526 lines
527 .iter()
528 .skip(from)
529 .take(20)
530 .copied()
531 .collect::<Vec<_>>()
532 .join("\n")
533}
534
535pub(crate) fn acceptance_run(
537 project: &Path,
538 argv: &[String],
539 runner: &sandbox::Runner,
540) -> (
541 speccoder_core::verify::CheckResult,
542 Option<speccoder_core::verify::RunnerSummary>,
543) {
544 execute(project, argv, runner)
545}
546
547pub(crate) fn run_acceptance(
548 project: &Path,
549 argv: &[String],
550 runner: &sandbox::Runner,
551) -> speccoder_core::verify::CheckResult {
552 execute(project, argv, runner).0
553}
554
555fn execute(
558 project: &Path,
559 argv: &[String],
560 runner: &sandbox::Runner,
561) -> (
562 speccoder_core::verify::CheckResult,
563 Option<speccoder_core::verify::RunnerSummary>,
564) {
565 use speccoder_core::verify::{CheckResult, CheckStatus};
566 use verify::process::CommandRunner;
567 let out = runner.run(&verify::process::Invocation {
568 argv: argv.to_vec(),
569 cwd: project.to_path_buf(),
570 env: verify::process::scrubbed_env(std::env::vars()),
571 timeout: std::time::Duration::from_secs(600),
572 output_cap: verify::process::OUTPUT_CAP,
573 });
574 let status = if out.spawn_error.is_some() {
575 CheckStatus::Error
576 } else if out.timed_out {
577 CheckStatus::TimedOut
578 } else if out.exit_code == Some(0) {
579 CheckStatus::Passed
580 } else {
581 CheckStatus::Failed
582 };
583 let summary = verify::summary::parse(
584 &String::from_utf8_lossy(&out.stdout.kept),
585 &String::from_utf8_lossy(&out.stderr.kept),
586 out.exit_code,
587 );
588 let tail = |b: &[u8]| {
589 let start = b.len().saturating_sub(4096);
590 String::from_utf8_lossy(&b[start..]).into_owned()
591 };
592 let result = CheckResult {
593 name: "acceptance".to_owned(),
594 argv: argv.to_vec(),
595 status,
596 exit_code: out.exit_code,
597 duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
598 junit: false,
599 tests: None,
600 stdout_tail: tail(&out.stdout.kept),
601 stderr_tail: tail(&out.stderr.kept),
602 truncated: out.stdout.truncated() || out.stderr.truncated(),
603 note: out.spawn_error,
604 flaky: false,
605 };
606 (result, summary)
607}
608
609pub(crate) fn prepare_held_out(
613 a: &AcceptanceSetup<'_>,
614 visible: &PreparedAcceptance,
615 scenarios: &[speccoder_core::spec::ScenarioRef],
616 store: &Path,
617 composition: bool,
618) -> (serde_json::Value, Option<String>) {
619 use serde_json::json;
620 let absent = |reason: &str, mut report: serde_json::Value| {
621 report["state"] = json!("absent");
622 report["reason"] = json!(reason);
623 (report, None)
624 };
625 let visible_files: Vec<(String, String)> = visible.report["files"]
626 .as_array()
627 .into_iter()
628 .flatten()
629 .filter_map(|p| p.as_str())
630 .map(|p| {
631 (
632 p.to_owned(),
633 std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
634 )
635 })
636 .collect();
637 let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
638 .and_then(|c| worktree::files(a.worktree, &c))
639 .unwrap_or_default()
640 .into_iter()
641 .take(400)
642 .collect();
643 let manifest_text = MANIFESTS
644 .iter()
645 .find_map(|m| {
646 std::fs::read_to_string(a.project.join(m))
647 .ok()
648 .map(|t| format!("{m}:\n{t}"))
649 })
650 .map(|t| t.chars().take(8192).collect::<String>())
651 .unwrap_or_default();
652 let input = substrate::acceptance::held_out_input(
653 a.bundle.clone(),
654 &files,
655 &manifest_text,
656 scenarios,
657 &visible_files,
658 composition,
659 a.instructions,
660 a.layout.map(crate::layout::TestLayout::text).as_deref(),
661 );
662 let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
663 Ok(o) => o,
664 Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
665 };
666 let output = outcome
667 .output
668 .as_ref()
669 .map(|o| o.peek().clone())
670 .unwrap_or_default();
671 let answer = output["answer"].clone();
672 let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
673 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
674 return absent("generation_failed", base);
675 }
676
677 let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
680 let protected = a.policy.protected();
681 let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
682 let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
683 let mut discard = |path: &str, reason: &str| {
684 discarded.push(crate::held_out::Discarded {
685 path: path.to_owned(),
686 reason: reason.to_owned(),
687 });
688 };
689 let given = answer["files"].as_array().cloned().unwrap_or_default();
690 if given.is_empty() {
691 return absent("none_generated", base);
692 }
693 for f in &given {
694 let path = f["path"].as_str().unwrap_or_default();
695 let content = f["content"].as_str().unwrap_or_default();
696 let cited: Vec<String> = f["scenarios"]
697 .as_array()
698 .into_iter()
699 .flatten()
700 .filter_map(|s| s.as_str().map(str::to_owned))
701 .collect();
702 let argv: Vec<String> = argv_of(&f["argv"]);
703 let rel = match tools::paths::normalise(path) {
704 Ok(r) => r,
705 Err(_) => {
706 discard(path, "path");
707 continue;
708 }
709 };
710 let taken =
711 visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
712 if !tools::paths::is_test_path(&rel)
713 || protected.write_class(&rel).is_some()
714 || a.project.join(&rel).exists()
715 || taken
716 {
717 discard(&rel, "path");
718 continue;
719 }
720 if crate::collection::statically_refused(a.project, &rel, content).is_some() {
722 discard(&rel, "not_collected");
723 continue;
724 }
725 if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
726 discard(&rel, "unknown_scenario");
727 continue;
728 }
729 let composed = f["composition"].as_bool().unwrap_or(false);
730 if composed && (!composition || cited.len() < 2) {
731 discard(&rel, "composition");
732 continue;
733 }
734 if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
735 discard(&rel, "command");
736 continue;
737 }
738 if kept.len() >= crate::held_out::LIMIT {
739 discard(&rel, "limit");
740 continue;
741 }
742 kept.push((
743 crate::held_out::HeldOutFile {
744 path: rel,
745 sha256: crate::integrity::sha256_hex(content.as_bytes()),
746 scenarios: cited,
747 argv,
748 composition: composed,
749 red_tests: None,
750 },
751 content.to_owned(),
752 ));
753 }
754
755 let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
757 .map(|c| c.0)
758 .unwrap_or_default();
759 match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
762 Ok(runs) => {
763 let mut red = Vec::new();
764 for ((mut file, content), ran) in kept.into_iter().zip(runs) {
765 if ran.result.is_none() {
766 discard(&file.path, "collision");
767 } else if crate::held_out::red_class(&ran).is_red() {
768 file.red_tests = ran
769 .result
770 .as_ref()
771 .and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
772 red.push((file, content));
773 } else {
774 discard(&file.path, "not_red");
775 }
776 }
777 kept = red;
778 }
779 Err(e) => {
780 return absent(
781 "generation_failed",
782 json!({ "error": e, "run_id": base["run_id"] }),
783 );
784 }
785 }
786 let mut report = base;
787 report["discarded"] = json!(discarded);
788 if kept.is_empty() {
789 let only_uncollected =
790 !discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
791 return absent(
792 if only_uncollected {
793 "not_collected"
794 } else {
795 "all_discarded"
796 },
797 report,
798 );
799 }
800 let manifest = crate::held_out::Manifest {
801 version: 1,
802 feature: a.feature.to_owned(),
803 task: a.task.to_owned(),
804 author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
805 checkpoint,
806 visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
807 files: kept.iter().map(|(f, _)| f.clone()).collect(),
808 discarded,
809 };
810 let contents: Vec<(String, String)> = kept
811 .iter()
812 .map(|(f, c)| (f.path.clone(), c.clone()))
813 .collect();
814 match crate::held_out::store(store, &manifest, &contents) {
815 Ok(sha) => {
816 report["state"] = json!("on");
817 report["manifest"] = json!(format!("sha256:{sha}"));
818 report["kept"] = json!(kept.len());
819 (report, Some(sha))
820 }
821 Err(e) => absent("generation_failed", json!({ "error": e })),
822 }
823}
824
825pub(crate) struct HeldOutJudged {
829 pub(crate) held_out: speccoder_core::verify::HeldOut,
831 pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
833}
834
835#[allow(clippy::too_many_arguments)]
836pub(crate) fn judge_held_out(
837 store: &Path,
838 manifest_sha: &str,
839 scenarios: &[speccoder_core::spec::ScenarioRef],
840 known_text: &str,
841 wt_root: &Path,
842 project: &Path,
843 tmp: &Path,
844 start: &worktree::Checkpoint,
845 patch: &str,
846 runner: &sandbox::Runner,
847 policy: &tools::policy::Policy,
848) -> Result<HeldOutJudged, (&'static str, String)> {
849 use speccoder_core::verify::{CheckStatus, HeldOut};
850 let (manifest, contents) =
851 crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
852 let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
853 .files
854 .iter()
855 .cloned()
856 .zip(contents.into_iter().map(|(_, c)| c))
857 .collect();
858 let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
859 if !leaks.is_empty() {
860 return Err((
861 "leak_suspected",
862 format!(
863 "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
864 leaks.len()
865 ),
866 ));
867 }
868 let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
869 .map_err(|e| ("tampered", e))?;
870 let mut failed_ids: Vec<String> = Vec::new();
871 for ((file, _), ran) in files.iter().zip(&runs) {
872 let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
876 files
877 .iter()
878 .find(|(f, _)| f.path == file.path)
879 .map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
880 });
881 let required = file.red_tests.unwrap_or(declared).max(1);
882 let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
883 r.status == CheckStatus::Passed
884 && speccoder_core::verify::collected_count(r, sum.as_ref())
885 .is_some_and(|green| green >= required)
886 });
887 if !passed {
888 failed_ids.extend(file.scenarios.iter().cloned());
889 }
890 }
891 failed_ids.sort();
892 failed_ids.dedup();
893 let held_out = if failed_ids.is_empty() {
894 HeldOut::Passed { files: files.len() }
895 } else {
896 HeldOut::Failed {
897 scenarios: failed_ids.clone(),
898 }
899 };
900 let failed = failed_ids
901 .iter()
902 .map(|id| {
903 scenarios
904 .iter()
905 .find(|s| &s.id == id)
906 .cloned()
907 .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
908 id: id.clone(),
909 text: String::new(),
910 })
911 })
912 .collect();
913 Ok(HeldOutJudged { held_out, failed })
914}
915
916pub(crate) struct Strengthened {
918 pub(crate) files: Vec<String>,
920 pub(crate) argv: Vec<String>,
922 pub(crate) report: serde_json::Value,
924}
925
926pub(crate) fn strengthen(
931 a: &AcceptanceSetup<'_>,
932 visible: &[(String, String)],
933 weak: serde_json::Value,
934 before: &worktree::Checkpoint,
935 after: &worktree::Checkpoint,
936) -> Result<Strengthened, String> {
937 use serde_json::json;
938 let files: Vec<String> = worktree::files(a.worktree, after)
939 .unwrap_or_default()
940 .into_iter()
941 .take(400)
942 .collect();
943 let manifest = MANIFESTS
944 .iter()
945 .find_map(|m| {
946 std::fs::read_to_string(a.project.join(m))
947 .ok()
948 .map(|t| format!("{m}:\n{t}"))
949 })
950 .map(|t| t.chars().take(8192).collect::<String>())
951 .unwrap_or_default();
952 let input = substrate::acceptance::strengthen_input(
953 a.bundle.clone(),
954 a.scenarios,
955 &files,
956 &manifest,
957 visible,
958 weak,
959 a.instructions,
960 );
961 let outcome =
962 a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
963 .map_err(|e| format!("the test author failed: {}", e.0))?;
964 let output = outcome
965 .output
966 .as_ref()
967 .map(|o| o.peek().clone())
968 .unwrap_or_default();
969 let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
970 let answer = &output["answer"];
971 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
972 return Err(format!(
973 "the test author did not answer ({})",
974 substrate::why_not(&outcome.status)
975 ));
976 }
977 let protected = a.policy.protected();
978 let mut planned = Vec::new();
979 for f in answer["files"].as_array().into_iter().flatten() {
980 let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
981 continue;
982 };
983 let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
984 if !tools::paths::is_test_path(&rel)
985 || protected.write_class(&rel).is_some()
986 || a.project.join(&rel).exists()
987 {
988 return Err(format!("`{rel}` is not a new test file"));
989 }
990 planned.push((rel, content.to_owned()));
991 }
992 if planned.is_empty() {
993 return Err("the test author added no tests".to_owned());
994 }
995 let argv: Vec<String> = argv_of(&answer["argv"]);
996 let paths: Vec<&str> = planned
997 .iter()
998 .map(|(p, _): &(String, String)| p.as_str())
999 .collect();
1000 if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
1001 return Err(format!("`{}` is not an allowed command", argv.join(" ")));
1002 }
1003 let write = || -> Result<(), String> {
1004 for (rel, content) in &planned {
1005 let full = a.project.join(rel);
1006 if let Some(parent) = full.parent() {
1007 let _ = std::fs::create_dir_all(parent);
1008 }
1009 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1010 }
1011 Ok(())
1012 };
1013 let back =
1014 |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
1015 back(before)?;
1017 let red = write().map(|()| {
1018 let (r, summary) = execute(a.project, &argv, a.runner);
1019 classify_red(&r, summary.as_ref())
1020 });
1021 back(after)?;
1022 let red = red?;
1023 if !red.is_red() {
1024 return Err(format!(
1025 "the added tests do not fail without the change ({})",
1026 class_word(red)
1027 ));
1028 }
1029 write()?;
1031 let (green, _) = execute(a.project, &argv, a.runner);
1032 if green.status != speccoder_core::verify::CheckStatus::Passed {
1033 back(after)?;
1034 let cited: Vec<String> = answer["files"]
1035 .as_array()
1036 .into_iter()
1037 .flatten()
1038 .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
1039 .filter_map(|v| v.as_str().map(str::to_owned))
1040 .collect();
1041 return Err(format!(
1042 "the added tests fail the implementation{}",
1043 if cited.is_empty() {
1044 String::new()
1045 } else {
1046 format!(" (scenarios {})", cited.join(", "))
1047 }
1048 ));
1049 }
1050 Ok(Strengthened {
1051 files: planned.into_iter().map(|(p, _)| p).collect(),
1052 argv,
1053 report,
1054 })
1055}
1056
1057fn named(title: &str) -> Vec<String> {
1060 let mut names: Vec<String> = title
1061 .split('`')
1062 .skip(1)
1063 .step_by(2)
1064 .flat_map(|span| {
1065 span.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '/' || c == '.'))
1066 .map(str::to_owned)
1067 .collect::<Vec<_>>()
1068 })
1069 .map(|w| {
1070 let file = w.rsplit('/').next().unwrap_or(&w).to_owned();
1072 file.split('.').next().unwrap_or(&file).to_owned()
1073 })
1074 .filter(|w| w.len() >= 4 && w.chars().any(|c| c.is_alphabetic()))
1075 .collect();
1076 names.sort();
1077 names.dedup();
1078 names
1079}
1080
1081fn is_test(p: &str) -> bool {
1083 p.split('/').any(|seg| {
1084 let s = seg.to_ascii_lowercase();
1085 s == "tests"
1086 || s == "test"
1087 || s == "spec"
1088 || s == "__tests__"
1089 || s.starts_with("test_")
1090 || s.contains("_test.")
1091 || s.contains(".test.")
1092 || s.contains(".spec.")
1093 })
1094}
1095
1096pub(crate) fn suite(worktree: &Path, title: &str) -> Vec<substrate::acceptance::SuiteTest> {
1100 const FILES: usize = 3;
1101 const BUDGET: usize = 6 * 1024;
1102 let names = named(title);
1103 if names.is_empty() {
1104 return Vec::new();
1105 }
1106 let mut args = vec!["grep", "-l", "-w", "-I"];
1107 for n in &names {
1108 args.extend(["-e", n.as_str()]);
1109 }
1110 let Ok(out) = crate::git::Git::at(worktree).line(&args) else {
1111 return Vec::new();
1112 };
1113 let mut left = BUDGET;
1114 out.lines()
1115 .filter(|p| is_test(p))
1116 .take(FILES)
1117 .filter_map(|p| {
1118 let text = std::fs::read_to_string(worktree.join(p)).ok()?;
1119 let room = left.min(BUDGET / FILES);
1120 let content = if text.len() > room {
1121 let cut = (0..=room)
1122 .rev()
1123 .find(|i| text.is_char_boundary(*i))
1124 .unwrap_or(0);
1125 format!("{}\n… (trimmed)", &text[..cut])
1126 } else {
1127 text
1128 };
1129 left = left.saturating_sub(content.len());
1130 Some(substrate::acceptance::SuiteTest {
1131 path: p.to_owned(),
1132 content,
1133 })
1134 })
1135 .collect()
1136}
1137
1138pub(crate) fn interface(worktree: &Path, title: &str) -> Vec<substrate::acceptance::SuiteTest> {
1143 const BUDGET: usize = 8 * 1024;
1144 const SOURCE: &[&str] = &[
1145 "py", "rs", "ts", "tsx", "js", "mjs", "go", "java", "kt", "rb", "cs", "swift",
1146 ];
1147 let Ok(out) = crate::git::Git::at(worktree).line(&[
1148 "ls-files",
1149 "--cached",
1150 "--others",
1151 "--exclude-standard",
1152 ]) else {
1153 return Vec::new();
1154 };
1155 let names = named(title);
1156 let stem = |p: &str| {
1157 let f = p.rsplit('/').next().unwrap_or(p);
1158 f.split('.').next().unwrap_or(f).to_owned()
1159 };
1160 let mut files: Vec<&str> = out
1161 .lines()
1162 .filter(|p| {
1163 !is_test(p)
1164 && p.rsplit_once('.')
1165 .is_some_and(|(_, ext)| SOURCE.contains(&ext))
1166 })
1167 .collect();
1168 files.sort_by_key(|p| !names.contains(&stem(p)));
1169 let mut left = BUDGET;
1170 let mut shown = Vec::new();
1171 for p in files {
1172 let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
1173 continue;
1174 };
1175 let content = declarations(&text, p.ends_with("__init__.py"));
1176 if content.is_empty() {
1177 continue;
1178 }
1179 if content.len() > left {
1180 break;
1181 }
1182 left -= content.len();
1183 shown.push(substrate::acceptance::SuiteTest {
1184 path: p.to_owned(),
1185 content,
1186 });
1187 }
1188 shown
1189}
1190
1191fn declarations(text: &str, init: bool) -> String {
1195 const STARTS: &[&str] = &[
1196 "def ",
1197 "async def ",
1198 "class ",
1199 "pub ",
1200 "pub(",
1201 "export ",
1202 "func ",
1203 "type ",
1204 "public ",
1205 "fun ",
1206 "interface ",
1207 "struct ",
1208 "enum ",
1209 "trait ",
1210 ];
1211 let mut out = String::new();
1212 let mut in_class = false;
1213 let mut lines = text.lines();
1214 while let Some(line) = lines.next() {
1215 let t = line.trim_start();
1216 let top = t.len() == line.len();
1217 if top && !t.is_empty() {
1218 in_class = t.starts_with("class ");
1219 }
1220 let private = ["def _", "async def _", "class _"]
1221 .iter()
1222 .any(|p| t.starts_with(p) && !t.starts_with("def __init__"));
1223 let field = in_class
1226 && !top
1227 && line.len() - t.len() == 4
1228 && t.split_once(':').is_some_and(|(n, rest)| {
1229 !rest.trim().is_empty()
1230 && !n.is_empty()
1231 && n.chars().all(|c| c.is_alphanumeric() || c == '_')
1232 });
1233 let export = init && top && t.starts_with("from .");
1234 let decl = STARTS.iter().any(|s| t.starts_with(s)) || field || export;
1235 if !decl || private {
1236 continue;
1237 }
1238 let mut sig = line.trim_end().to_owned();
1239 let open = |s: &str| {
1240 s.chars().filter(|c| matches!(c, '(' | '[')).count() as isize
1241 - s.chars().filter(|c| matches!(c, ')' | ']')).count() as isize
1242 };
1243 let mut depth = open(&sig);
1244 while depth > 0 {
1245 let Some(next) = lines.next() else { break };
1246 sig.push(' ');
1247 sig.push_str(next.trim());
1248 depth += open(next);
1249 }
1250 let sig: String = sig.chars().take(200).collect();
1251 out.push_str(sig.trim_end_matches(['{', ' ']));
1252 out.push('\n');
1253 }
1254 out
1255}
1256
1257pub(crate) fn imagined(worktree: &Path, path: &str, content: &str, title: &str) -> Option<String> {
1264 let named = named(title);
1265 let adds = |module_path: &str| {
1266 let stem = module_path
1267 .rsplit('/')
1268 .next()
1269 .unwrap_or(module_path)
1270 .split('.')
1271 .next()
1272 .unwrap_or_default()
1273 .to_owned();
1274 title.contains(module_path) || named.contains(&stem)
1275 };
1276 let missing = |shown: &str| {
1277 format!(
1278 "`{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\")"
1279 )
1280 };
1281 if path.ends_with(".py") {
1282 for (module, names) in python_imports(content) {
1283 let first = module.split('.').next().unwrap_or_default();
1284 let Some(base) = ["", "src/"].into_iter().find(|b| {
1286 worktree.join(format!("{b}{first}")).exists()
1287 || worktree.join(format!("{b}{first}.py")).exists()
1288 }) else {
1289 continue;
1290 };
1291 let rel = format!("{base}{}", module.replace('.', "/"));
1292 let file = [format!("{rel}.py"), format!("{rel}/__init__.py")]
1293 .into_iter()
1294 .find(|f| worktree.join(f).is_file());
1295 let Some(file) = file else {
1296 if adds(&format!("{rel}.py")) {
1297 continue;
1298 }
1299 return Some(missing(&module));
1300 };
1301 let text = std::fs::read_to_string(worktree.join(&file)).unwrap_or_default();
1302 for n in names {
1303 let word = |w: &str| {
1304 text.split(|c: char| !(c.is_alphanumeric() || c == '_'))
1305 .any(|t| t == w)
1306 };
1307 if word(&n) || worktree.join(format!("{rel}/{n}.py")).is_file() {
1308 continue;
1309 }
1310 if let Some(real) = text
1311 .split(|c: char| !(c.is_alphanumeric() || c == '_'))
1312 .find(|t| t.eq_ignore_ascii_case(&n))
1313 {
1314 return Some(format!(
1315 "`{path}` imports `{n}` from `{module}`, which has `{real}` — call the code by the names it has"
1316 ));
1317 }
1318 }
1319 }
1320 return None;
1321 }
1322 if [".ts", ".tsx", ".js", ".mjs", ".cjs"]
1323 .iter()
1324 .any(|e| path.ends_with(e))
1325 {
1326 let dir = Path::new(path).parent().unwrap_or(Path::new(""));
1327 for spec in relative_imports(content) {
1328 let joined = dir.join(&spec);
1329 let rel = crate::tools::paths::normalise(&joined.to_string_lossy()).unwrap_or_default();
1330 let found = [
1331 "",
1332 ".ts",
1333 ".tsx",
1334 ".js",
1335 ".mjs",
1336 ".svelte",
1337 "/index.ts",
1338 "/index.js",
1339 ]
1340 .iter()
1341 .any(|e| worktree.join(format!("{rel}{e}")).is_file());
1342 if !found && !adds(&rel) {
1343 return Some(missing(&spec));
1344 }
1345 }
1346 }
1347 None
1348}
1349
1350fn python_imports(content: &str) -> Vec<(String, Vec<String>)> {
1353 let mut out = Vec::new();
1354 let mut lines = content.lines();
1355 while let Some(line) = lines.next() {
1356 let t = line.trim();
1357 if let Some(rest) = t.strip_prefix("from ") {
1358 let Some((module, names)) = rest.split_once(" import ") else {
1359 continue;
1360 };
1361 if module.starts_with('.') {
1362 continue;
1363 }
1364 let mut names = names.to_owned();
1365 if names.contains('(') && !names.contains(')') {
1366 for next in lines.by_ref() {
1367 names.push_str(next);
1368 if next.contains(')') {
1369 break;
1370 }
1371 }
1372 }
1373 let names = names
1374 .split([',', '(', ')'])
1375 .filter_map(|n| n.split_whitespace().next())
1376 .filter(|n| *n != "*" && !n.starts_with('#'))
1377 .map(str::to_owned)
1378 .collect();
1379 out.push((module.trim().to_owned(), names));
1380 } else if let Some(rest) = t.strip_prefix("import ") {
1381 for m in rest.split(',') {
1382 if let Some(m) = m.split_whitespace().next() {
1383 out.push((m.to_owned(), Vec::new()));
1384 }
1385 }
1386 }
1387 }
1388 out
1389}
1390
1391fn relative_imports(content: &str) -> Vec<String> {
1393 let mut out = Vec::new();
1394 for quote in ['\'', '"'] {
1395 for (i, _) in content.match_indices(quote) {
1396 let rest = &content[i + 1..];
1397 if !(rest.starts_with("./") || rest.starts_with("../")) {
1398 continue;
1399 }
1400 let before = content[..i].trim_end();
1401 if !(before.ends_with("from")
1402 || before.ends_with("import")
1403 || before.ends_with("require(")
1404 || before.ends_with("import("))
1405 {
1406 continue;
1407 }
1408 if let Some(end) = rest.find(quote) {
1409 out.push(rest[..end].to_owned());
1410 }
1411 }
1412 }
1413 out
1414}
1415
1416pub(crate) fn outside_story(
1420 t: &speccoder_core::snapshot::Task,
1421 scenarios: &[speccoder_core::spec::ScenarioRef],
1422 unit_dir: &str,
1423 judged: &dyn Fn(&str) -> bool,
1424) -> Option<speccoder_core::story::Judged> {
1425 if checks_only(t, unit_dir, judged) {
1426 return None;
1427 }
1428 let links: Vec<String> = t
1429 .scenarios
1430 .iter()
1431 .filter(|l| scenarios.iter().any(|s| &s.id == *l))
1432 .cloned()
1433 .collect();
1434 (!links.is_empty()).then_some(speccoder_core::story::Judged::Linked { scenarios: links })
1435}
1436
1437pub(crate) fn checks_only(
1440 t: &speccoder_core::snapshot::Task,
1441 unit_dir: &str,
1442 judged: &dyn Fn(&str) -> bool,
1443) -> bool {
1444 t.yours
1445 || speccoder_core::lane::by_paths(&t.title, unit_dir, judged)
1446 == speccoder_core::lane::Lane::Planning
1447}
1448
1449#[cfg(test)]
1450mod interface_tests {
1451 use super::{declarations, imagined};
1452
1453 #[test]
1456 fn a_test_s_imports_are_checked_against_the_code() {
1457 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1458 let w = dir.path();
1459 for (p, t) in [
1460 ("src/__init__.py", ""),
1461 (
1462 "src/models/__init__.py",
1463 "from .csv_output import CsvOutput\nfrom .conversion_error import ConversionError\n",
1464 ),
1465 ("src/models/csv_output.py", "class CsvOutput:\n pass\n"),
1466 (
1467 "src/services/table_parser.py",
1468 "def parse_markdown_table(content):\n return ()\n",
1469 ),
1470 ("web/src/lib/csv.ts", "export function toCsv() {}\n"),
1471 ] {
1472 std::fs::create_dir_all(w.join(p).parent().unwrap_or(w))
1473 .unwrap_or_else(|e| panic!("{e}"));
1474 std::fs::write(w.join(p), t).unwrap_or_else(|e| panic!("{e}"));
1475 }
1476 let task = "Add header-presence validation in the conversion flow per FR-004";
1477 let check = |content: &str| imagined(w, "tests/test_t032.py", content, task);
1478 let got = check(
1480 "def test():\n from src.lib.convert_table_to_csv import convert_table_to_csv\n from src.models.conversion_error import ConversionError\n",
1481 );
1482 assert!(
1483 got.as_deref()
1484 .is_some_and(|m| m.contains("`src.lib.convert_table_to_csv`")),
1485 "{got:?}"
1486 );
1487 assert_eq!(
1489 imagined(
1490 w,
1491 "tests/test_t032.py",
1492 "from src.lib.convert_table_to_csv import convert\n",
1493 "Add `src/lib/convert_table_to_csv.py`"
1494 ),
1495 None
1496 );
1497 let got = check("from src.models import CSVOutput\n");
1499 assert!(
1500 got.as_deref()
1501 .is_some_and(|m| m.contains("which has `CsvOutput`")),
1502 "{got:?}"
1503 );
1504 assert_eq!(
1506 check(
1507 "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"
1508 ),
1509 None
1510 );
1511 let ts = |c: &str| imagined(w, "web/src/lib/csv.test.ts", c, task);
1513 assert_eq!(
1514 ts("import { toCsv } from \"./csv\";\nimport { it } from \"vitest\";\n"),
1515 None
1516 );
1517 assert!(ts("import { toCsv } from './convert';\n").is_some());
1518 }
1519
1520 #[test]
1523 fn a_file_s_declarations_are_its_names_and_shapes() {
1524 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";
1525 assert_eq!(
1526 declarations(py, false),
1527 "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"
1528 );
1529 assert_eq!(
1530 declarations("from .csv_output import CsvOutput\nimport os\n", true),
1531 "from .csv_output import CsvOutput\n"
1532 );
1533 assert_eq!(
1534 declarations(
1535 "pub struct Table {\n pub header: Vec<String>,\n rows: usize,\n}\nfn private() {}\npub fn parse(s: &str) -> Vec<Table> {\n",
1536 false
1537 ),
1538 "pub struct Table\n pub header: Vec<String>,\npub fn parse(s: &str) -> Vec<Table>\n"
1539 );
1540 assert_eq!(
1541 declarations(
1542 "export function toCsv(t: Table): string {\nconst x = 1;\n",
1543 false
1544 ),
1545 "export function toCsv(t: Table): string\n"
1546 );
1547 }
1548}
1549
1550#[cfg(test)]
1551mod argv_tests {
1552 use super::argv_of;
1553 use serde_json::json;
1554
1555 #[test]
1557 fn a_command_given_as_one_string_is_its_words() {
1558 assert_eq!(
1559 argv_of(&json!(["cargo test --test acceptance_us1"])),
1560 ["cargo", "test", "--test", "acceptance_us1"]
1561 );
1562 assert_eq!(
1563 argv_of(&json!(["cargo", "test", "--test", "acceptance_us1"])),
1564 ["cargo", "test", "--test", "acceptance_us1"]
1565 );
1566 assert_eq!(
1568 argv_of(&json!(["cargo test; rm -rf ."])),
1569 ["cargo test; rm -rf ."]
1570 );
1571 assert_eq!(argv_of(&json!(["pytest"])), ["pytest"]);
1572 }
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578
1579 fn record(
1581 producer: speccoder_core::acceptance::Producer,
1582 content: &str,
1583 ) -> speccoder_core::acceptance::PreFrozenAcceptance {
1584 speccoder_core::acceptance::PreFrozenAcceptance {
1585 unit: "001-x".to_owned(),
1586 requirement: "US1".to_owned(),
1587 wording_digest: "sha256:w".to_owned(),
1588 approval_digest: "sha256:a".to_owned(),
1589 visible: vec![speccoder_core::acceptance::VisibleTest {
1590 path: "tests/a.txt".to_owned(),
1591 sha256: format!(
1592 "sha256:{}",
1593 crate::integrity::sha256_hex(content.as_bytes())
1594 ),
1595 cites: vec!["US1-1".to_owned()],
1596 }],
1597 held_out_sealed: None,
1598 producer,
1599 decided: speccoder_core::acceptance::Decided {
1600 by: "Ada".to_owned(),
1601 via: speccoder_core::actions::Via::Terminal,
1602 at: 0,
1603 },
1604 }
1605 }
1606
1607 #[test]
1608 fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
1609 use speccoder_core::acceptance::Producer;
1610 let summary = |passed: u8, failed: u8| {
1611 format!(
1612 "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
1613 if failed > 0 { "FAILED" } else { "ok" },
1614 if failed > 0 { 101 } else { 0 }
1615 )
1616 };
1617 let content = "checks\n";
1618 for (producer, rule) in [
1619 (Producer::Review, RedRule::RunTime),
1620 (Producer::BugConfirmation, RedRule::BugAssertionOnly),
1621 ] {
1622 let rec = record(producer, content);
1623 let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
1624 let run = |script: String| {
1625 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1626 let tests = [ReusedTest {
1627 path: "tests/a.txt".to_owned(),
1628 cites: vec!["US1-1".to_owned()],
1629 argv: vec!["sh".to_owned(), "-c".to_owned(), script],
1630 green: false,
1631 }];
1632 let got = reuse_pre_frozen(
1633 dir.path(),
1634 &rec,
1635 &files,
1636 &tests,
1637 &sandbox::Runner::Plain,
1638 rule,
1639 None,
1640 );
1641 assert!(
1642 dir.path().join("tests/a.txt").is_file(),
1643 "written at the checkpoint"
1644 );
1645 got
1646 };
1647 let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
1649 assert!(red.red);
1650 assert_eq!(red.report["source"], serde_json::json!(producer.source()));
1651 let passing = run(summary(1, 0));
1654 match rule {
1655 RedRule::RunTime => {
1656 let p = passing.unwrap_or_else(|e| panic!("{e}"));
1657 assert!(!p.red);
1658 assert!(
1659 p.report["note"]
1660 .as_str()
1661 .is_some_and(|n| n.contains("already passing"))
1662 );
1663 }
1664 RedRule::BugAssertionOnly => assert!(passing.is_err()),
1665 }
1666 if rule == RedRule::BugAssertionOnly {
1668 assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
1669 }
1670 }
1671 }
1672}
1673
1674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1676pub(crate) enum RedRule {
1677 RunTime,
1681 BugAssertionOnly,
1683}
1684
1685#[derive(Debug)]
1687pub(crate) struct ReusedTest {
1688 pub(crate) path: String,
1690 pub(crate) cites: Vec<String>,
1692 pub(crate) argv: Vec<String>,
1694 pub(crate) green: bool,
1696}
1697
1698pub(crate) struct SealedCheck<'a> {
1701 pub(crate) state: &'a Path,
1703 pub(crate) unit: &'a str,
1705 pub(crate) wt_root: &'a Path,
1707 pub(crate) tmp: &'a Path,
1709 pub(crate) policy: &'a tools::policy::Policy,
1711}
1712
1713pub(crate) fn reuse_pre_frozen(
1719 project: &Path,
1720 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1721 files: &[(String, String)],
1722 tests: &[ReusedTest],
1723 runner: &sandbox::Runner,
1724 rule: RedRule,
1725 sealed: Option<&SealedCheck<'_>>,
1726) -> Result<PreparedAcceptance, String> {
1727 use serde_json::json;
1728 for (rel, content) in files {
1729 let full = project.join(rel);
1730 if let Ok(now) = std::fs::read_to_string(&full) {
1731 if now != *content {
1732 return Err(format!(
1733 "`{rel}` exists and differs from the confirmed test; it is not overwritten"
1734 ));
1735 }
1736 continue;
1737 }
1738 if let Some(parent) = full.parent() {
1739 let _ = std::fs::create_dir_all(parent);
1740 }
1741 std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1742 }
1743 let class_of = |r: &speccoder_core::verify::CheckResult,
1746 s: Option<&speccoder_core::verify::RunnerSummary>| match rule
1747 {
1748 RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
1749 RedRule::RunTime => classify_red(r, s),
1750 };
1751 let mut classes = Vec::new();
1752 for t in tests {
1753 let (first, summary) = execute(project, &t.argv, runner);
1754 let one = class_of(&first, summary.as_ref());
1755 let class = if t.green {
1756 one
1757 } else {
1758 match rule {
1759 RedRule::BugAssertionOnly if one == RedClass::Assertion => {
1760 let (second, summary) = execute(project, &t.argv, runner);
1761 speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
1762 }
1763 RedRule::BugAssertionOnly => one,
1764 RedRule::RunTime if one.is_red() => {
1765 let (second, summary) = execute(project, &t.argv, runner);
1766 if classify_red(&second, summary.as_ref()).is_red() {
1767 one
1768 } else {
1769 RedClass::Flaky
1770 }
1771 }
1772 RedRule::RunTime => one,
1773 }
1774 };
1775 let holds = if t.green {
1776 class == RedClass::Passed
1777 } else {
1778 match rule {
1779 RedRule::BugAssertionOnly => class == RedClass::Assertion,
1780 RedRule::RunTime => class.is_red(),
1781 }
1782 };
1783 if !holds && rule == RedRule::RunTime && !t.green {
1784 return Ok(not_red_frozen(record, files, tests, t, class));
1785 }
1786 if !holds {
1787 return Err(if t.green {
1788 format!(
1789 "the confirmed test `{}` ({}) no longer passes on the branch ({})",
1790 t.path,
1791 t.cites.join(", "),
1792 class_word(class)
1793 )
1794 } else {
1795 format!(
1796 "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
1797 t.path,
1798 t.cites.join(", "),
1799 class_word(class)
1800 )
1801 });
1802 }
1803 classes.push(class);
1804 }
1805 let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
1806 let Some(first) = red.first() else {
1807 return Err("the confirmed set has no test that must fail first".to_owned());
1808 };
1809 let more: Vec<Vec<String>> = tests
1810 .iter()
1811 .filter(|t| !std::ptr::eq(*t, *first))
1812 .map(|t| t.argv.clone())
1813 .collect();
1814 let mut held_sha = record
1815 .held_out_sealed
1816 .as_ref()
1817 .map(|h| h.manifest_sha256.clone());
1818 let mut held_out = match &record.held_out_sealed {
1819 Some(h) => {
1820 json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
1821 }
1822 None => json!({ "state": "absent", "reason": "none_kept" }),
1823 };
1824 if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
1827 let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
1828 .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
1829 let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1830 .files
1831 .iter()
1832 .cloned()
1833 .zip(contents.into_iter().map(|(_, c)| c))
1834 .collect();
1835 let runs = crate::held_out::overlay_red(
1836 check.wt_root,
1837 project,
1838 check.tmp,
1839 &set,
1840 runner,
1841 check.policy,
1842 )?;
1843 let all_red = runs
1844 .iter()
1845 .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
1846 if !all_red {
1847 held_sha = None;
1848 held_out = json!({ "state": "absent", "reason": "not_red" });
1849 }
1850 }
1851 let class = classes.first().copied().unwrap_or(RedClass::Assertion);
1852 Ok(PreparedAcceptance {
1853 report: json!({
1854 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1855 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1856 "argv": first.argv,
1857 "red": true,
1858 "red_class": class,
1859 "source": record.producer.source(),
1860 "pre_frozen": {
1861 "manifest": crate::pre_frozen::manifest_digest(record),
1862 "approval": record.approval_digest,
1863 "wording": record.wording_digest,
1864 "by": record.decided.by,
1865 "via": record.decided.via,
1866 },
1867 "held_out": held_out,
1868 }),
1869 argv: Some(first.argv.clone()),
1870 more_argv: more,
1871 red: true,
1872 red_class: Some(class),
1873 held_out: held_sha,
1874 generation_failed: None,
1875 skip_cause: None,
1876 })
1877}
1878
1879fn not_red_frozen(
1882 record: &speccoder_core::acceptance::PreFrozenAcceptance,
1883 files: &[(String, String)],
1884 tests: &[ReusedTest],
1885 failing: &ReusedTest,
1886 class: RedClass,
1887) -> PreparedAcceptance {
1888 use serde_json::json;
1889 let note = match class {
1890 RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
1891 RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
1892 other => format!(
1893 "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
1894 class_word(other)
1895 ),
1896 };
1897 PreparedAcceptance {
1898 report: json!({
1899 "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1900 "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1901 "argv": failing.argv,
1902 "red": false,
1903 "red_class": class,
1904 "note": note,
1905 "source": record.producer.source(),
1906 "pre_frozen": {
1907 "manifest": crate::pre_frozen::manifest_digest(record),
1908 "approval": record.approval_digest,
1909 "wording": record.wording_digest,
1910 "by": record.decided.by,
1911 "via": record.decided.via,
1912 },
1913 "held_out": { "state": "absent", "reason": "not_red" },
1914 }),
1915 argv: Some(failing.argv.clone()),
1916 more_argv: tests
1917 .iter()
1918 .filter(|t| !std::ptr::eq(*t, failing))
1919 .map(|t| t.argv.clone())
1920 .collect(),
1921 red: false,
1922 red_class: Some(class),
1923 held_out: None,
1924 generation_failed: None,
1925 skip_cause: None,
1926 }
1927}