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