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