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