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