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