Skip to main content

speccoder_runtime/pipeline/
acceptance.rs

1//! Spec-derived acceptance tests: written before the attempt, red then green.
2
3use 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    /// The task's scenarios, which each test cites.
20    pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
21    /// The repository's instructions (test conventions live there too).
22    pub(crate) instructions: &'a [crate::instructions::Instruction],
23    pub(crate) runner: &'a sandbox::Runner,
24    /// The project's test layout: where acceptance files go (`None`: the
25    /// author places them by the old naming rule).
26    pub(crate) layout: Option<&'a crate::layout::TestLayout>,
27    /// The story files speccoder created on this branch (`Story-File:`
28    /// trailers): the only existing files an author may add a section to.
29    pub(crate) story_files: &'a [String],
30}
31
32#[derive(Clone, serde::Serialize, serde::Deserialize)]
33pub(crate) struct PreparedAcceptance {
34    /// What the run report shows.
35    pub(crate) report: serde_json::Value,
36    /// The command, when the tests were written.
37    pub(crate) argv: Option<Vec<String>>,
38    /// Further commands of the set (a bug's reproductions run one test
39    /// each); every one must pass.
40    #[serde(default)]
41    pub(crate) more_argv: Vec<Vec<String>>,
42    /// They failed before the attempt, on an assertion or for an absent
43    /// symbol.
44    pub(crate) red: bool,
45    /// What the red run showed.
46    #[serde(default)]
47    pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
48    /// The held-out manifest's hash, when a held-out set was kept: a resumed
49    /// attempt reuses it, never re-authors it.
50    #[serde(default)]
51    pub(crate) held_out: Option<String>,
52    /// Why the author call itself failed (the provider refused, the run
53    /// failed) — not "the spec has no examples": the task stops for a person.
54    #[serde(default)]
55    pub(crate) generation_failed: Option<String>,
56    /// Why no acceptance test was kept, typed: what a regression-only
57    /// reason is decided from (`report["skipped"]` is its display).
58    #[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    // Where the tests go: the story's file in the project's layout (one
108    // file per story, grown by sections), a convergence task's own file,
109    // or no place the runner collects.
110    let placement = match placement(a) {
111        Ok(p) => p,
112        Err(()) => {
113            return skipped_by(
114                "no place the runner collects".to_owned(),
115                Some(SkipCause::NoPlace),
116                json!({}),
117            );
118        }
119    };
120    let input = substrate::acceptance::input(
121        a.bundle.clone(),
122        a.scenarios,
123        &files,
124        &manifest,
125        a.instructions,
126        placement.as_ref(),
127    );
128    let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
129    let outcome = match outcome {
130        Ok(o) => o,
131        Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
132    };
133    let output = outcome
134        .output
135        .as_ref()
136        .map(|o| o.peek().clone())
137        .unwrap_or_default();
138    let answer = output["answer"].clone();
139    let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
140    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
141        let status = format!("{:?}", outcome.status);
142        let why = substrate::provider_refusal(&status).map_or_else(
143            || format!("generation did not succeed: {}", outcome.status.as_str()),
144            |r| format!("the model provider refused the request: {r}"),
145        );
146        return failed(why, base);
147    }
148
149    // Validate: new test files only, a command the policy allows.
150    let mut written = Vec::new();
151    let story = placement
152        .as_ref()
153        .and_then(|p| p.existing.as_deref().map(|old| (p.path.as_str(), old)));
154    let (valid, refused) = validate_with(&answer, a.project, a.policy, a.scenarios, story);
155    if let Some(r) = refused.first() {
156        return skipped_by(
157            r.message.clone(),
158            Some(SkipCause::Refused {
159                path: r.path.clone(),
160                why: refusal_words(r.why).to_owned(),
161            }),
162            base,
163        );
164    }
165    // Each test and the scenarios it cites, of those the spec has.
166    let tests: Vec<serde_json::Value> = valid
167        .iter()
168        .map(|f| json!({ "path": f.path, "scenarios": f.cited }))
169        .collect();
170    let planned: Vec<(String, String)> = valid.into_iter().map(|f| (f.path, f.content)).collect();
171    if planned.is_empty() {
172        return skipped_by(
173            "no acceptance tests were generated".to_owned(),
174            Some(SkipCause::NoFiles),
175            base,
176        );
177    }
178    let argv: Vec<String> = argv_of(&answer["argv"]);
179    let paths: Vec<&str> = planned.iter().map(|(p, _)| p.as_str()).collect();
180    if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
181        return skipped_by(
182            format!("`{}` is not an allowed command", argv.join(" ")),
183            Some(SkipCause::CommandNotAllowed {
184                argv: argv.join(" "),
185            }),
186            base,
187        );
188    }
189    for (rel, content) in &planned {
190        let full = a.project.join(rel);
191        if let Some(parent) = full.parent() {
192            let _ = std::fs::create_dir_all(parent);
193        }
194        if let Err(e) = std::fs::write(&full, content) {
195            return skipped_by(
196                format!("`{rel}`: {e}"),
197                Some(SkipCause::WriteFailed { path: rel.clone() }),
198                base,
199            );
200        }
201        written.push(rel.clone());
202    }
203
204    // Red: they must fail before the task is done — on an assertion, or
205    // because what the task introduces does not exist yet; confirmed by a
206    // second run.
207    let (first, summary) = execute(a.project, &argv, a.runner);
208    let red_tests = speccoder_core::verify::collected_count(&first, summary.as_ref());
209    let mut class = classify_red(&first, summary.as_ref());
210    // A section added to the story's file is red only by the tests it adds:
211    // the same command on the file without it.
212    if let Some((path, old)) = story.filter(|(p, _)| written.iter().any(|w| w == p)) {
213        let full = a.project.join(path);
214        if let Ok(new) = std::fs::read_to_string(&full)
215            && std::fs::write(&full, old).is_ok()
216        {
217            let (without, wsum) = execute(a.project, &argv, a.runner);
218            // The section must be back before any evidence describes it.
219            class = if std::fs::write(&full, &new).is_err()
220                || std::fs::read_to_string(&full).ok().as_deref() != Some(new.as_str())
221            {
222                RedClass::Unclassified
223            } else {
224                speccoder_core::verify::classify_red_added(
225                    (&first, summary.as_ref()),
226                    (&without, wsum.as_ref()),
227                )
228            };
229        }
230    }
231    if class.is_red() {
232        let (second, summary) = execute(a.project, &argv, a.runner);
233        if !classify_red(&second, summary.as_ref()).is_red() {
234            class = RedClass::Flaky;
235        }
236    }
237    let red = class.is_red();
238    let mut report = base;
239    report["files"] = json!(written);
240    if let Some(p) = placement.as_ref().filter(|p| p.story.is_some()) {
241        report["story_file"] = json!(p.path);
242        report["appended"] = json!(p.existing.is_some());
243    }
244    report["tests"] = json!(tests);
245    report["argv"] = json!(argv);
246    report["red"] = json!(red);
247    report["red_class"] = json!(class);
248    if let Some(n) = red_tests {
249        report["red_tests"] = json!(n);
250    }
251    if red {
252        // What the attestation's red excerpt is cut from (clean-checked there).
253        report["red_output"] = json!(
254            format!("{}\n{}", first.stdout_tail, first.stderr_tail)
255                .trim()
256                .to_owned()
257        );
258    } else {
259        report["note"] = json!(match class {
260            RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
261            RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
262            other => format!(
263                "the acceptance tests did not fail on an assertion ({}): not demonstrated",
264                class_word(other)
265            ),
266        });
267        report["excerpt"] = json!(excerpt(&first));
268    }
269    PreparedAcceptance {
270        report,
271        argv: Some(argv),
272        more_argv: Vec::new(),
273        red,
274        red_class: Some(class),
275        held_out: None,
276        generation_failed: None,
277        skip_cause: None,
278    }
279}
280
281/// Where the visible author writes, from the layout: `Ok(None)` when no
282/// runner speccoder knows was found (the author names the files), `Err`
283/// when the layout offers no place the runner collects.
284fn placement(a: &AcceptanceSetup<'_>) -> Result<Option<substrate::acceptance::Placement>, ()> {
285    let Some(layout) = a.layout.filter(|l| !l.runners.is_empty()) else {
286        return Ok(None);
287    };
288    let task = a.task.to_owned();
289    let story = a.bundle["task"]["story"]
290        .as_str()
291        .filter(|s| !s.is_empty())
292        .map(str::to_owned);
293    // Go: the package the task names, else the module's root.
294    let package =
295        speccoder_core::scope::named_paths(a.bundle["task"]["title"].as_str().unwrap_or_default())
296            .into_iter()
297            .find(|p| p.ends_with(".go") || !p.contains('.'))
298            .map(|p| {
299                if p.ends_with(".go") {
300                    p.rsplit_once('/')
301                        .map_or(String::new(), |(d, _)| d.to_owned())
302                } else {
303                    p.trim_end_matches('/').to_owned()
304                }
305            });
306    let per_task =
307        |layout: &crate::layout::TestLayout| layout.story_file(&task, package.as_deref());
308    let (path, story, existing) = match story.as_ref() {
309        Some(st) => {
310            let Some(path) = layout.story_file(st, package.as_deref()) else {
311                return Err(());
312            };
313            let exists = a.project.join(&path).exists();
314            if !exists {
315                (path, story.clone(), None)
316            } else if a.story_files.contains(&path) {
317                let old = std::fs::read_to_string(a.project.join(&path)).unwrap_or_default();
318                (path, story.clone(), Some(old))
319            } else {
320                // A file of the person's at that path: this task's own file.
321                (per_task(layout).ok_or(())?, None, None)
322            }
323        }
324        None => (per_task(layout).ok_or(())?, None, None),
325    };
326    let comment = if path.ends_with(".py") { "#" } else { "//" }.to_owned();
327    Ok(Some(substrate::acceptance::Placement {
328        command: layout.command_for(&path).unwrap_or_default(),
329        path,
330        existing,
331        task,
332        story,
333        scenarios: a.scenarios.iter().map(|s| s.id.clone()).collect(),
334        comment,
335        layout: layout.text(),
336    }))
337}
338
339/// speccoder's words for a refusal's kind (a regression-only reason's detail).
340pub(crate) fn refusal_words(why: &str) -> &'static str {
341    match why {
342        "exists" => "already exists",
343        "not_collected" => "is not where the runner collects tests",
344        "changes_existing" => "changes existing lines",
345        "skip_marker" => "adds a section that skips a test",
346        _ => "is not a new test file",
347    }
348}
349
350/// One test file of an author's answer that may be written.
351pub(crate) struct ValidFile {
352    /// Normalised, project-relative.
353    pub(crate) path: String,
354    pub(crate) content: String,
355    /// The scenario ids it cites, of those given (may be empty).
356    pub(crate) cited: Vec<String>,
357    /// The command that runs only it, when the answer gives one.
358    pub(crate) argv: Vec<String>,
359}
360
361/// A file of an answer that may not be written, and why.
362pub(crate) struct RefusedFile {
363    /// Its path as given.
364    pub(crate) path: String,
365    /// `not_a_new_test_file` or `exists`.
366    pub(crate) why: &'static str,
367    /// What a person is told.
368    pub(crate) message: String,
369}
370
371/// The command an author's answer gives: its argv — or, given as one
372/// string (`["cargo test --test acceptance_us1"]`, the line the prompt
373/// shows), that line split into its words when it is a plain command
374/// (anything a shell would read differently stays one word, and is
375/// refused as the command it is not).
376#[must_use]
377pub(crate) fn argv_of(v: &serde_json::Value) -> Vec<String> {
378    let argv: Vec<String> = v
379        .as_array()
380        .into_iter()
381        .flatten()
382        .filter_map(|v| v.as_str().map(str::to_owned))
383        .collect();
384    match argv.as_slice() {
385        [line] if line.split_ascii_whitespace().count() > 1 => {
386            let words = crate::acp::govern::argv_of_line(line);
387            // A line a shell would read: left whole, never guessed at.
388            if words.len() == 3 && words[0] == "sh" && words[1] == "-c" && words[2] == line.trim() {
389                argv
390            } else {
391                words
392            }
393        }
394        _ => argv,
395    }
396}
397
398/// Whether the author's `argv` may run: the policy allows it, or it is
399/// the layout's command that runs only one of `files` (what the prompt
400/// names).
401pub(crate) fn test_command_allowed(
402    policy: &tools::policy::Policy,
403    layout: Option<&crate::layout::TestLayout>,
404    argv: &[String],
405    files: &[&str],
406) -> bool {
407    layout.is_some_and(|l| l.runs_only(argv, files))
408        || policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
409            == tools::policy::Decision::Allow
410}
411
412/// The acceptance author's files, checked (the one validation run time and
413/// review share): new test paths only, nothing protected, nothing that
414/// exists in `project`; each with the scenarios it cites of `scenarios`.
415pub(crate) fn validate_answer(
416    answer: &serde_json::Value,
417    project: &Path,
418    policy: &tools::policy::Policy,
419    scenarios: &[speccoder_core::spec::ScenarioRef],
420) -> (Vec<ValidFile>, Vec<RefusedFile>) {
421    validate_with(answer, project, policy, scenarios, None)
422}
423
424/// [`validate_answer`] with the story's file: the one existing path an
425/// answer may write, and only by adding lines to `old`.
426pub(crate) fn validate_with(
427    answer: &serde_json::Value,
428    project: &Path,
429    policy: &tools::policy::Policy,
430    scenarios: &[speccoder_core::spec::ScenarioRef],
431    story: Option<(&str, &str)>,
432) -> (Vec<ValidFile>, Vec<RefusedFile>) {
433    let protected = policy.protected();
434    let mut valid = Vec::new();
435    let mut refused = Vec::new();
436    for f in answer["files"].as_array().into_iter().flatten() {
437        let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
438            continue;
439        };
440        let rel = match tools::paths::normalise(path) {
441            Ok(r) => r,
442            Err(e) => {
443                refused.push(RefusedFile {
444                    path: path.to_owned(),
445                    why: "not_a_new_test_file",
446                    message: format!("`{path}`: {}", e.0),
447                });
448                continue;
449            }
450        };
451        if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
452            refused.push(RefusedFile {
453                message: format!("`{rel}` is not a new test file"),
454                path: rel,
455                why: "not_a_new_test_file",
456            });
457            continue;
458        }
459        match story {
460            Some((path, old)) if path == rel => {
461                // Only a section at the end: never a line among the earlier
462                // tasks' tests, never a skip of any test.
463                match crate::integrity::appended_section(old, content) {
464                    None => {
465                        refused.push(RefusedFile {
466                            message: format!("changes existing lines of `{rel}`"),
467                            path: rel,
468                            why: "changes_existing",
469                        });
470                        continue;
471                    }
472                    Some(added) if crate::integrity::has_skip_marker(added) => {
473                        refused.push(RefusedFile {
474                            message: format!("`{rel}`: the added section skips a test"),
475                            path: rel,
476                            why: "skip_marker",
477                        });
478                        continue;
479                    }
480                    Some(_) => {}
481                }
482            }
483            _ if project.join(&rel).exists() => {
484                refused.push(RefusedFile {
485                    message: format!("`{rel}` already exists"),
486                    path: rel,
487                    why: "exists",
488                });
489                continue;
490            }
491            _ => {
492                // A new file the runner would never collect is no evidence.
493                if crate::collection::statically_refused(project, &rel, content).is_some() {
494                    refused.push(RefusedFile {
495                        message: format!("`{rel}` is not where the runner collects tests"),
496                        path: rel,
497                        why: "not_collected",
498                    });
499                    continue;
500                }
501            }
502        }
503        let cited: Vec<String> = f["scenarios"]
504            .as_array()
505            .into_iter()
506            .flatten()
507            .filter_map(|v| v.as_str())
508            .filter(|id| scenarios.iter().any(|s| s.id == *id))
509            .map(str::to_owned)
510            .collect();
511        let argv: Vec<String> = argv_of(&f["argv"]);
512        valid.push(ValidFile {
513            path: rel,
514            content: content.to_owned(),
515            cited,
516            argv,
517        });
518    }
519    (valid, refused)
520}
521
522/// The class as written in reports.
523pub(crate) fn class_word(class: RedClass) -> String {
524    serde_json::to_value(class)
525        .ok()
526        .and_then(|v| v.as_str().map(str::to_owned))
527        .unwrap_or_default()
528}
529
530/// Up to 20 lines from the first error line of a run's output (else its
531/// last 20 lines).
532fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
533    let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
534    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
535    let from = lines
536        .iter()
537        .position(|l| {
538            let l = l.to_lowercase();
539            l.contains("error") || l.contains("fail")
540        })
541        .unwrap_or_else(|| lines.len().saturating_sub(20));
542    lines
543        .iter()
544        .skip(from)
545        .take(20)
546        .copied()
547        .collect::<Vec<_>>()
548        .join("\n")
549}
550
551/// One run of an acceptance command, with the runner's summary.
552pub(crate) fn acceptance_run(
553    project: &Path,
554    argv: &[String],
555    runner: &sandbox::Runner,
556) -> (
557    speccoder_core::verify::CheckResult,
558    Option<speccoder_core::verify::RunnerSummary>,
559) {
560    execute(project, argv, runner)
561}
562
563pub(crate) fn run_acceptance(
564    project: &Path,
565    argv: &[String],
566    runner: &sandbox::Runner,
567) -> speccoder_core::verify::CheckResult {
568    execute(project, argv, runner).0
569}
570
571/// Runs the acceptance tests once; the runner's summary is read from the
572/// whole captured output, before it is cut to tails.
573fn execute(
574    project: &Path,
575    argv: &[String],
576    runner: &sandbox::Runner,
577) -> (
578    speccoder_core::verify::CheckResult,
579    Option<speccoder_core::verify::RunnerSummary>,
580) {
581    use speccoder_core::verify::{CheckResult, CheckStatus};
582    use verify::process::CommandRunner;
583    let out = runner.run(&verify::process::Invocation {
584        argv: argv.to_vec(),
585        cwd: project.to_path_buf(),
586        env: verify::process::scrubbed_env(std::env::vars()),
587        timeout: std::time::Duration::from_secs(600),
588        output_cap: verify::process::OUTPUT_CAP,
589    });
590    let status = if out.spawn_error.is_some() {
591        CheckStatus::Error
592    } else if out.timed_out {
593        CheckStatus::TimedOut
594    } else if out.exit_code == Some(0) {
595        CheckStatus::Passed
596    } else {
597        CheckStatus::Failed
598    };
599    let summary = verify::summary::parse(
600        &String::from_utf8_lossy(&out.stdout.kept),
601        &String::from_utf8_lossy(&out.stderr.kept),
602        out.exit_code,
603    );
604    let tail = |b: &[u8]| {
605        let start = b.len().saturating_sub(4096);
606        String::from_utf8_lossy(&b[start..]).into_owned()
607    };
608    let result = CheckResult {
609        name: "acceptance".to_owned(),
610        argv: argv.to_vec(),
611        status,
612        exit_code: out.exit_code,
613        duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
614        junit: false,
615        tests: None,
616        stdout_tail: tail(&out.stdout.kept),
617        stderr_tail: tail(&out.stderr.kept),
618        truncated: out.stdout.truncated() || out.stderr.truncated(),
619        note: out.spawn_error,
620        flaky: false,
621    };
622    (result, summary)
623}
624
625/// The held-out set's author, store and red check: after a visible
626/// set that was red. Returns the report part (`acceptance.held_out`) and
627/// the stored manifest's hash when files were kept.
628pub(crate) fn prepare_held_out(
629    a: &AcceptanceSetup<'_>,
630    visible: &PreparedAcceptance,
631    scenarios: &[speccoder_core::spec::ScenarioRef],
632    store: &Path,
633    composition: bool,
634) -> (serde_json::Value, Option<String>) {
635    use serde_json::json;
636    let absent = |reason: &str, mut report: serde_json::Value| {
637        report["state"] = json!("absent");
638        report["reason"] = json!(reason);
639        (report, None)
640    };
641    let visible_files: Vec<(String, String)> = visible.report["files"]
642        .as_array()
643        .into_iter()
644        .flatten()
645        .filter_map(|p| p.as_str())
646        .map(|p| {
647            (
648                p.to_owned(),
649                std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
650            )
651        })
652        .collect();
653    let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
654        .and_then(|c| worktree::files(a.worktree, &c))
655        .unwrap_or_default()
656        .into_iter()
657        .take(400)
658        .collect();
659    let manifest_text = MANIFESTS
660        .iter()
661        .find_map(|m| {
662            std::fs::read_to_string(a.project.join(m))
663                .ok()
664                .map(|t| format!("{m}:\n{t}"))
665        })
666        .map(|t| t.chars().take(8192).collect::<String>())
667        .unwrap_or_default();
668    let input = substrate::acceptance::held_out_input(
669        a.bundle.clone(),
670        &files,
671        &manifest_text,
672        scenarios,
673        &visible_files,
674        composition,
675        a.instructions,
676        a.layout.map(crate::layout::TestLayout::text).as_deref(),
677    );
678    let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
679        Ok(o) => o,
680        Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
681    };
682    let output = outcome
683        .output
684        .as_ref()
685        .map(|o| o.peek().clone())
686        .unwrap_or_default();
687    let answer = output["answer"].clone();
688    let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
689    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
690        return absent("generation_failed", base);
691    }
692
693    // Validate: new test paths, known scenarios, an allowed
694    // command each, at most `LIMIT`.
695    let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
696    let protected = a.policy.protected();
697    let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
698    let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
699    let mut discard = |path: &str, reason: &str| {
700        discarded.push(crate::held_out::Discarded {
701            path: path.to_owned(),
702            reason: reason.to_owned(),
703        });
704    };
705    let given = answer["files"].as_array().cloned().unwrap_or_default();
706    if given.is_empty() {
707        return absent("none_generated", base);
708    }
709    for f in &given {
710        let path = f["path"].as_str().unwrap_or_default();
711        let content = f["content"].as_str().unwrap_or_default();
712        let cited: Vec<String> = f["scenarios"]
713            .as_array()
714            .into_iter()
715            .flatten()
716            .filter_map(|s| s.as_str().map(str::to_owned))
717            .collect();
718        let argv: Vec<String> = argv_of(&f["argv"]);
719        let rel = match tools::paths::normalise(path) {
720            Ok(r) => r,
721            Err(_) => {
722                discard(path, "path");
723                continue;
724            }
725        };
726        let taken =
727            visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
728        if !tools::paths::is_test_path(&rel)
729            || protected.write_class(&rel).is_some()
730            || a.project.join(&rel).exists()
731            || taken
732        {
733            discard(&rel, "path");
734            continue;
735        }
736        // A held-out test the runner would never collect judges nothing.
737        if crate::collection::statically_refused(a.project, &rel, content).is_some() {
738            discard(&rel, "not_collected");
739            continue;
740        }
741        if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
742            discard(&rel, "unknown_scenario");
743            continue;
744        }
745        let composed = f["composition"].as_bool().unwrap_or(false);
746        if composed && (!composition || cited.len() < 2) {
747            discard(&rel, "composition");
748            continue;
749        }
750        if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
751            discard(&rel, "command");
752            continue;
753        }
754        if kept.len() >= crate::held_out::LIMIT {
755            discard(&rel, "limit");
756            continue;
757        }
758        kept.push((
759            crate::held_out::HeldOutFile {
760                path: rel,
761                sha256: crate::integrity::sha256_hex(content.as_bytes()),
762                scenarios: cited,
763                argv,
764                composition: composed,
765                red_tests: None,
766            },
767            content.to_owned(),
768        ));
769    }
770
771    // Red now, on the tree the attempt starts from.
772    let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
773        .map(|c| c.0)
774        .unwrap_or_default();
775    // Red by what the file adds: a failure its command shows without it is
776    // another test's.
777    match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
778        Ok(runs) => {
779            let mut red = Vec::new();
780            for ((mut file, content), ran) in kept.into_iter().zip(runs) {
781                if ran.result.is_none() {
782                    discard(&file.path, "collision");
783                } else if crate::held_out::red_class(&ran).is_red() {
784                    file.red_tests = ran
785                        .result
786                        .as_ref()
787                        .and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
788                    red.push((file, content));
789                } else {
790                    discard(&file.path, "not_red");
791                }
792            }
793            kept = red;
794        }
795        Err(e) => {
796            return absent(
797                "generation_failed",
798                json!({ "error": e, "run_id": base["run_id"] }),
799            );
800        }
801    }
802    let mut report = base;
803    report["discarded"] = json!(discarded);
804    if kept.is_empty() {
805        let only_uncollected =
806            !discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
807        return absent(
808            if only_uncollected {
809                "not_collected"
810            } else {
811                "all_discarded"
812            },
813            report,
814        );
815    }
816    let manifest = crate::held_out::Manifest {
817        version: 1,
818        feature: a.feature.to_owned(),
819        task: a.task.to_owned(),
820        author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
821        checkpoint,
822        visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
823        files: kept.iter().map(|(f, _)| f.clone()).collect(),
824        discarded,
825    };
826    let contents: Vec<(String, String)> = kept
827        .iter()
828        .map(|(f, c)| (f.path.clone(), c.clone()))
829        .collect();
830    match crate::held_out::store(store, &manifest, &contents) {
831        Ok(sha) => {
832            report["state"] = json!("on");
833            report["manifest"] = json!(format!("sha256:{sha}"));
834            report["kept"] = json!(kept.len());
835            (report, Some(sha))
836        }
837        Err(e) => absent("generation_failed", json!({ "error": e })),
838    }
839}
840
841/// The held-out verdict on the tree an attempt left: `Ok` with what the
842/// tests said, or `Err` with why a person must look (tampering, a leak, a
843/// run that would not leave the tree as it was).
844pub(crate) struct HeldOutJudged {
845    /// The verdict input.
846    pub(crate) held_out: speccoder_core::verify::HeldOut,
847    /// The failed scenarios, with the spec's words, for the retry digest.
848    pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
849}
850
851#[allow(clippy::too_many_arguments)]
852pub(crate) fn judge_held_out(
853    store: &Path,
854    manifest_sha: &str,
855    scenarios: &[speccoder_core::spec::ScenarioRef],
856    known_text: &str,
857    wt_root: &Path,
858    project: &Path,
859    tmp: &Path,
860    start: &worktree::Checkpoint,
861    patch: &str,
862    runner: &sandbox::Runner,
863    policy: &tools::policy::Policy,
864) -> Result<HeldOutJudged, (&'static str, String)> {
865    use speccoder_core::verify::{CheckStatus, HeldOut};
866    let (manifest, contents) =
867        crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
868    let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
869        .files
870        .iter()
871        .cloned()
872        .zip(contents.into_iter().map(|(_, c)| c))
873        .collect();
874    let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
875    if !leaks.is_empty() {
876        return Err((
877            "leak_suspected",
878            format!(
879                "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
880                leaks.len()
881            ),
882        ));
883    }
884    let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
885        .map_err(|e| ("tampered", e))?;
886    let mut failed_ids: Vec<String> = Vec::new();
887    for ((file, _), ran) in files.iter().zip(&runs) {
888        // Green is evidence only when it collected what red did.
889        // Green collects what red did — red's count, else what the file
890        // declares, at least one — and a count that cannot be read fails.
891        let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
892            files
893                .iter()
894                .find(|(f, _)| f.path == file.path)
895                .map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
896        });
897        let required = file.red_tests.unwrap_or(declared).max(1);
898        let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
899            r.status == CheckStatus::Passed
900                && speccoder_core::verify::collected_count(r, sum.as_ref())
901                    .is_some_and(|green| green >= required)
902        });
903        if !passed {
904            failed_ids.extend(file.scenarios.iter().cloned());
905        }
906    }
907    failed_ids.sort();
908    failed_ids.dedup();
909    let held_out = if failed_ids.is_empty() {
910        HeldOut::Passed { files: files.len() }
911    } else {
912        HeldOut::Failed {
913            scenarios: failed_ids.clone(),
914        }
915    };
916    let failed = failed_ids
917        .iter()
918        .map(|id| {
919            scenarios
920                .iter()
921                .find(|s| &s.id == id)
922                .cloned()
923                .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
924                    id: id.clone(),
925                    text: String::new(),
926                })
927        })
928        .collect();
929    Ok(HeldOutJudged { held_out, failed })
930}
931
932/// Added acceptance tests that strengthen a weak set.
933pub(crate) struct Strengthened {
934    /// The new test files, written into the worktree.
935    pub(crate) files: Vec<String>,
936    /// The command that runs them.
937    pub(crate) argv: Vec<String>,
938    /// The author's run and tokens.
939    pub(crate) report: serde_json::Value,
940}
941
942/// Asks the test author once more, telling it what got through (`weak`),
943/// and keeps its new tests only if they fail on the attempt's starting tree
944/// (`before`) and pass on its final tree (`after`), where they are left.
945/// `Err`: why the set could not be strengthened (no worktree change stays).
946pub(crate) fn strengthen(
947    a: &AcceptanceSetup<'_>,
948    visible: &[(String, String)],
949    weak: serde_json::Value,
950    before: &worktree::Checkpoint,
951    after: &worktree::Checkpoint,
952) -> Result<Strengthened, String> {
953    use serde_json::json;
954    let files: Vec<String> = worktree::files(a.worktree, after)
955        .unwrap_or_default()
956        .into_iter()
957        .take(400)
958        .collect();
959    let manifest = MANIFESTS
960        .iter()
961        .find_map(|m| {
962            std::fs::read_to_string(a.project.join(m))
963                .ok()
964                .map(|t| format!("{m}:\n{t}"))
965        })
966        .map(|t| t.chars().take(8192).collect::<String>())
967        .unwrap_or_default();
968    let input = substrate::acceptance::strengthen_input(
969        a.bundle.clone(),
970        a.scenarios,
971        &files,
972        &manifest,
973        visible,
974        weak,
975        a.instructions,
976    );
977    let outcome =
978        a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
979            .map_err(|e| format!("the test author failed: {}", e.0))?;
980    let output = outcome
981        .output
982        .as_ref()
983        .map(|o| o.peek().clone())
984        .unwrap_or_default();
985    let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
986    let answer = &output["answer"];
987    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
988        return Err(format!(
989            "the test author did not answer ({})",
990            outcome.status.as_str()
991        ));
992    }
993    let protected = a.policy.protected();
994    let mut planned = Vec::new();
995    for f in answer["files"].as_array().into_iter().flatten() {
996        let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
997            continue;
998        };
999        let rel = tools::paths::normalise(path).map_err(|e| format!("`{path}`: {}", e.0))?;
1000        if !tools::paths::is_test_path(&rel)
1001            || protected.write_class(&rel).is_some()
1002            || a.project.join(&rel).exists()
1003        {
1004            return Err(format!("`{rel}` is not a new test file"));
1005        }
1006        planned.push((rel, content.to_owned()));
1007    }
1008    if planned.is_empty() {
1009        return Err("the test author added no tests".to_owned());
1010    }
1011    let argv: Vec<String> = argv_of(&answer["argv"]);
1012    let paths: Vec<&str> = planned
1013        .iter()
1014        .map(|(p, _): &(String, String)| p.as_str())
1015        .collect();
1016    if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
1017        return Err(format!("`{}` is not an allowed command", argv.join(" ")));
1018    }
1019    let write = || -> Result<(), String> {
1020        for (rel, content) in &planned {
1021            let full = a.project.join(rel);
1022            if let Some(parent) = full.parent() {
1023                let _ = std::fs::create_dir_all(parent);
1024            }
1025            std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1026        }
1027        Ok(())
1028    };
1029    let back =
1030        |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
1031    // Red on the starting tree: they fail without the task's work.
1032    back(before)?;
1033    let red = write().map(|()| {
1034        let (r, summary) = execute(a.project, &argv, a.runner);
1035        classify_red(&r, summary.as_ref())
1036    });
1037    back(after)?;
1038    let red = red?;
1039    if !red.is_red() {
1040        return Err(format!(
1041            "the added tests do not fail without the change ({})",
1042            class_word(red)
1043        ));
1044    }
1045    // Green on the final tree: the implementation passes them.
1046    write()?;
1047    let (green, _) = execute(a.project, &argv, a.runner);
1048    if green.status != speccoder_core::verify::CheckStatus::Passed {
1049        back(after)?;
1050        let cited: Vec<String> = answer["files"]
1051            .as_array()
1052            .into_iter()
1053            .flatten()
1054            .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
1055            .filter_map(|v| v.as_str().map(str::to_owned))
1056            .collect();
1057        return Err(format!(
1058            "the added tests fail the implementation{}",
1059            if cited.is_empty() {
1060                String::new()
1061            } else {
1062                format!(" (scenarios {})", cited.join(", "))
1063            }
1064        ));
1065    }
1066    Ok(Strengthened {
1067        files: planned.into_iter().map(|(p, _)| p).collect(),
1068        argv,
1069        report,
1070    })
1071}
1072
1073/// Whether `task` completes its story: the last task carrying its story
1074/// label, in a story with at least two scenarios.
1075pub(crate) fn story_checkpoint(
1076    tasks: Option<&speccoder_core::snapshot::ParsedTasks>,
1077    task: &str,
1078    scenarios: &[speccoder_core::spec::ScenarioRef],
1079) -> bool {
1080    let Some(tasks) = tasks else { return false };
1081    let Some(label) = tasks
1082        .tasks
1083        .iter()
1084        .find(|t| t.id == task)
1085        .and_then(|t| t.story.as_deref())
1086    else {
1087        return false;
1088    };
1089    let last = tasks
1090        .tasks
1091        .iter()
1092        .rfind(|t| t.story.as_deref() == Some(label))
1093        .map(|t| t.id.as_str());
1094    last == Some(task) && scenarios.len() >= 2
1095}
1096
1097#[cfg(test)]
1098mod argv_tests {
1099    use super::argv_of;
1100    use serde_json::json;
1101
1102    /// The sample's real answer (2026-10-29): the command as one string.
1103    #[test]
1104    fn a_command_given_as_one_string_is_its_words() {
1105        assert_eq!(
1106            argv_of(&json!(["cargo test --test acceptance_us1"])),
1107            ["cargo", "test", "--test", "acceptance_us1"]
1108        );
1109        assert_eq!(
1110            argv_of(&json!(["cargo", "test", "--test", "acceptance_us1"])),
1111            ["cargo", "test", "--test", "acceptance_us1"]
1112        );
1113        // A shell line stays one word: refused as the command it is not.
1114        assert_eq!(
1115            argv_of(&json!(["cargo test; rm -rf ."])),
1116            ["cargo test; rm -rf ."]
1117        );
1118        assert_eq!(argv_of(&json!(["pytest"])), ["pytest"]);
1119    }
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125
1126    #[test]
1127    fn the_last_task_of_a_story_with_two_scenarios_composes() {
1128        let (tasks, _) = speccoder_speckit::tasks::parse(
1129            "tasks.md",
1130            "## Phase 3: User Story 1 - Login\n\n- [ ] T005 [US1] a\n- [ ] T006 [US1] b\n\n## Phase 4: User Story 2 - X\n\n- [ ] T007 [US2] c\n",
1131        );
1132        let two: Vec<speccoder_core::spec::ScenarioRef> = ["US1-1", "US1-2"]
1133            .iter()
1134            .map(|id| speccoder_core::spec::ScenarioRef {
1135                id: (*id).to_owned(),
1136                text: String::new(),
1137            })
1138            .collect();
1139        assert!(story_checkpoint(Some(&tasks), "T006", &two));
1140        assert!(!story_checkpoint(Some(&tasks), "T005", &two));
1141        assert!(!story_checkpoint(Some(&tasks), "T006", &two[..1]));
1142        assert!(!story_checkpoint(None, "T006", &two));
1143    }
1144
1145    /// One pre-frozen record of `producer` naming `tests/a.txt`.
1146    fn record(
1147        producer: speccoder_core::acceptance::Producer,
1148        content: &str,
1149    ) -> speccoder_core::acceptance::PreFrozenAcceptance {
1150        speccoder_core::acceptance::PreFrozenAcceptance {
1151            unit: "001-x".to_owned(),
1152            requirement: "US1".to_owned(),
1153            wording_digest: "sha256:w".to_owned(),
1154            approval_digest: "sha256:a".to_owned(),
1155            visible: vec![speccoder_core::acceptance::VisibleTest {
1156                path: "tests/a.txt".to_owned(),
1157                sha256: format!(
1158                    "sha256:{}",
1159                    crate::integrity::sha256_hex(content.as_bytes())
1160                ),
1161                cites: vec!["US1-1".to_owned()],
1162            }],
1163            held_out_sealed: None,
1164            producer,
1165            decided: speccoder_core::acceptance::Decided {
1166                by: "Ada".to_owned(),
1167                via: speccoder_core::actions::Via::Terminal,
1168                at: 0,
1169            },
1170        }
1171    }
1172
1173    #[test]
1174    fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
1175        use speccoder_core::acceptance::Producer;
1176        let summary = |passed: u8, failed: u8| {
1177            format!(
1178                "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
1179                if failed > 0 { "FAILED" } else { "ok" },
1180                if failed > 0 { 101 } else { 0 }
1181            )
1182        };
1183        let content = "checks\n";
1184        for (producer, rule) in [
1185            (Producer::Review, RedRule::RunTime),
1186            (Producer::BugConfirmation, RedRule::BugAssertionOnly),
1187        ] {
1188            let rec = record(producer, content);
1189            let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
1190            let run = |script: String| {
1191                let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1192                let tests = [ReusedTest {
1193                    path: "tests/a.txt".to_owned(),
1194                    cites: vec!["US1-1".to_owned()],
1195                    argv: vec!["sh".to_owned(), "-c".to_owned(), script],
1196                    green: false,
1197                }];
1198                let got = reuse_pre_frozen(
1199                    dir.path(),
1200                    &rec,
1201                    &files,
1202                    &tests,
1203                    &sandbox::Runner::Plain,
1204                    rule,
1205                    None,
1206                );
1207                assert!(
1208                    dir.path().join("tests/a.txt").is_file(),
1209                    "written at the checkpoint"
1210                );
1211                got
1212            };
1213            // Red by one assertion, twice: reused, the source the producer's.
1214            let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
1215            assert!(red.red);
1216            assert_eq!(red.report["source"], serde_json::json!(producer.source()));
1217            // Already passing: a review's set is not demonstrated, a bug's
1218            // reproduction no longer reproduces — the person looks.
1219            let passing = run(summary(1, 0));
1220            match rule {
1221                RedRule::RunTime => {
1222                    let p = passing.unwrap_or_else(|e| panic!("{e}"));
1223                    assert!(!p.red);
1224                    assert!(
1225                        p.report["note"]
1226                            .as_str()
1227                            .is_some_and(|n| n.contains("already passing"))
1228                    );
1229                }
1230                RedRule::BugAssertionOnly => assert!(passing.is_err()),
1231            }
1232            // A failure of more than one test is a bug's no reproduction.
1233            if rule == RedRule::BugAssertionOnly {
1234                assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
1235            }
1236        }
1237    }
1238}
1239
1240/// How a reused set must be red at the checkpoint.
1241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1242pub(crate) enum RedRule {
1243    /// A review's oracle: the run-time rule (an assertion, or an absent
1244    /// symbol), twice; a set that is no longer red is reported as at run
1245    /// time — not demonstrated — rather than refused.
1246    RunTime,
1247    /// A bug's reproductions: an assertion only, both runs.
1248    BugAssertionOnly,
1249}
1250
1251/// One test of a reused set: its file, what it cites and how it runs.
1252#[derive(Debug)]
1253pub(crate) struct ReusedTest {
1254    /// Project-relative path.
1255    pub(crate) path: String,
1256    /// The statement or scenario ids.
1257    pub(crate) cites: Vec<String>,
1258    /// The command that runs only it.
1259    pub(crate) argv: Vec<String>,
1260    /// It must pass at the checkpoint instead (an *Unchanged* test).
1261    pub(crate) green: bool,
1262}
1263
1264/// Where a reused set's sealed held-out tests are red-checked: overlaid on
1265/// the checkpoint, run and removed.
1266pub(crate) struct SealedCheck<'a> {
1267    /// The project's state directory.
1268    pub(crate) state: &'a Path,
1269    /// The unit the set is sealed under.
1270    pub(crate) unit: &'a str,
1271    /// The worktree's root.
1272    pub(crate) wt_root: &'a Path,
1273    /// Scratch for checkpoints.
1274    pub(crate) tmp: &'a Path,
1275    /// The task's policy.
1276    pub(crate) policy: &'a tools::policy::Policy,
1277}
1278
1279/// Acceptance tests decided before the run, reused at the checkpoint:
1280/// written into the worktree (the one function every pre-frozen producer
1281/// uses), each red twice by `rule` — or green, for a test that guards what
1282/// must not change — with no acceptance author call. `Err`: why the set
1283/// cannot stand (the person looks).
1284pub(crate) fn reuse_pre_frozen(
1285    project: &Path,
1286    record: &speccoder_core::acceptance::PreFrozenAcceptance,
1287    files: &[(String, String)],
1288    tests: &[ReusedTest],
1289    runner: &sandbox::Runner,
1290    rule: RedRule,
1291    sealed: Option<&SealedCheck<'_>>,
1292) -> Result<PreparedAcceptance, String> {
1293    use serde_json::json;
1294    for (rel, content) in files {
1295        let full = project.join(rel);
1296        if let Ok(now) = std::fs::read_to_string(&full) {
1297            if now != *content {
1298                return Err(format!(
1299                    "`{rel}` exists and differs from the confirmed test; it is not overwritten"
1300                ));
1301            }
1302            continue;
1303        }
1304        if let Some(parent) = full.parent() {
1305            let _ = std::fs::create_dir_all(parent);
1306        }
1307        std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1308    }
1309    // A bug's reproduction is red only by its own test: a run of more than
1310    // one test cannot say whose assertion failed.
1311    let class_of = |r: &speccoder_core::verify::CheckResult,
1312                    s: Option<&speccoder_core::verify::RunnerSummary>| match rule
1313    {
1314        RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
1315        RedRule::RunTime => classify_red(r, s),
1316    };
1317    let mut classes = Vec::new();
1318    for t in tests {
1319        let (first, summary) = execute(project, &t.argv, runner);
1320        let one = class_of(&first, summary.as_ref());
1321        let class = if t.green {
1322            one
1323        } else {
1324            match rule {
1325                RedRule::BugAssertionOnly if one == RedClass::Assertion => {
1326                    let (second, summary) = execute(project, &t.argv, runner);
1327                    speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
1328                }
1329                RedRule::BugAssertionOnly => one,
1330                RedRule::RunTime if one.is_red() => {
1331                    let (second, summary) = execute(project, &t.argv, runner);
1332                    if classify_red(&second, summary.as_ref()).is_red() {
1333                        one
1334                    } else {
1335                        RedClass::Flaky
1336                    }
1337                }
1338                RedRule::RunTime => one,
1339            }
1340        };
1341        let holds = if t.green {
1342            class == RedClass::Passed
1343        } else {
1344            match rule {
1345                RedRule::BugAssertionOnly => class == RedClass::Assertion,
1346                RedRule::RunTime => class.is_red(),
1347            }
1348        };
1349        if !holds && rule == RedRule::RunTime && !t.green {
1350            return Ok(not_red_frozen(record, files, tests, t, class));
1351        }
1352        if !holds {
1353            return Err(if t.green {
1354                format!(
1355                    "the confirmed test `{}` ({}) no longer passes on the branch ({})",
1356                    t.path,
1357                    t.cites.join(", "),
1358                    class_word(class)
1359                )
1360            } else {
1361                format!(
1362                    "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
1363                    t.path,
1364                    t.cites.join(", "),
1365                    class_word(class)
1366                )
1367            });
1368        }
1369        classes.push(class);
1370    }
1371    let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
1372    let Some(first) = red.first() else {
1373        return Err("the confirmed set has no test that must fail first".to_owned());
1374    };
1375    let more: Vec<Vec<String>> = tests
1376        .iter()
1377        .filter(|t| !std::ptr::eq(*t, *first))
1378        .map(|t| t.argv.clone())
1379        .collect();
1380    let mut held_sha = record
1381        .held_out_sealed
1382        .as_ref()
1383        .map(|h| h.manifest_sha256.clone());
1384    let mut held_out = match &record.held_out_sealed {
1385        Some(h) => {
1386            json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
1387        }
1388        None => json!({ "state": "absent", "reason": "none_kept" }),
1389    };
1390    // The sealed held-out set, red on the same checkpoint (overlaid, run,
1391    // removed): a set that is not red decides nothing.
1392    if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
1393        let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
1394            .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
1395        let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1396            .files
1397            .iter()
1398            .cloned()
1399            .zip(contents.into_iter().map(|(_, c)| c))
1400            .collect();
1401        let runs = crate::held_out::overlay_red(
1402            check.wt_root,
1403            project,
1404            check.tmp,
1405            &set,
1406            runner,
1407            check.policy,
1408        )?;
1409        let all_red = runs
1410            .iter()
1411            .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
1412        if !all_red {
1413            held_sha = None;
1414            held_out = json!({ "state": "absent", "reason": "not_red" });
1415        }
1416    }
1417    let class = classes.first().copied().unwrap_or(RedClass::Assertion);
1418    Ok(PreparedAcceptance {
1419        report: json!({
1420            "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1421            "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1422            "argv": first.argv,
1423            "red": true,
1424            "red_class": class,
1425            "source": record.producer.source(),
1426            "pre_frozen": {
1427                "manifest": crate::pre_frozen::manifest_digest(record),
1428                "approval": record.approval_digest,
1429                "wording": record.wording_digest,
1430                "by": record.decided.by,
1431                "via": record.decided.via,
1432            },
1433            "held_out": held_out,
1434        }),
1435        argv: Some(first.argv.clone()),
1436        more_argv: more,
1437        red: true,
1438        red_class: Some(class),
1439        held_out: held_sha,
1440        generation_failed: None,
1441        skip_cause: None,
1442    })
1443}
1444
1445/// A frozen set no longer red at the checkpoint: reported as a run-time
1446/// set that is not red — not demonstrated — with nothing re-authored.
1447fn not_red_frozen(
1448    record: &speccoder_core::acceptance::PreFrozenAcceptance,
1449    files: &[(String, String)],
1450    tests: &[ReusedTest],
1451    failing: &ReusedTest,
1452    class: RedClass,
1453) -> PreparedAcceptance {
1454    use serde_json::json;
1455    let note = match class {
1456        RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
1457        RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
1458        other => format!(
1459            "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
1460            class_word(other)
1461        ),
1462    };
1463    PreparedAcceptance {
1464        report: json!({
1465            "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
1466            "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
1467            "argv": failing.argv,
1468            "red": false,
1469            "red_class": class,
1470            "note": note,
1471            "source": record.producer.source(),
1472            "pre_frozen": {
1473                "manifest": crate::pre_frozen::manifest_digest(record),
1474                "approval": record.approval_digest,
1475                "wording": record.wording_digest,
1476                "by": record.decided.by,
1477                "via": record.decided.via,
1478            },
1479            "held_out": { "state": "absent", "reason": "not_red" },
1480        }),
1481        argv: Some(failing.argv.clone()),
1482        more_argv: tests
1483            .iter()
1484            .filter(|t| !std::ptr::eq(*t, failing))
1485            .map(|t| t.argv.clone())
1486            .collect(),
1487        red: false,
1488        red_class: Some(class),
1489        held_out: None,
1490        generation_failed: None,
1491        skip_cause: None,
1492    }
1493}