Skip to main content

speccoder_runtime/pipeline/
acceptance.rs

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