Skip to main content

speccoder_runtime/pipeline/
acceptance.rs

1//! Spec-derived acceptance tests: written before the attempt, red then green.
2
3use std::path::Path;
4
5use speccoder_core::verify::{RedClass, classify_red};
6
7use crate::{sandbox, substrate, tools, verify, worktree};
8
9pub(crate) struct AcceptanceSetup<'a> {
10    pub(crate) rt: &'a tokio::runtime::Runtime,
11    pub(crate) plane: &'a substrate::Plane,
12    pub(crate) project: &'a Path,
13    pub(crate) worktree: &'a Path,
14    pub(crate) tmp: &'a Path,
15    pub(crate) policy: &'a tools::policy::Policy,
16    pub(crate) feature: &'a str,
17    pub(crate) task: &'a str,
18    pub(crate) bundle: serde_json::Value,
19    /// The task's scenarios, which each test cites.
20    pub(crate) scenarios: &'a [speccoder_core::spec::ScenarioRef],
21    /// The repository's instructions (test conventions live there too).
22    pub(crate) instructions: &'a [crate::instructions::Instruction],
23    pub(crate) runner: &'a sandbox::Runner,
24    /// The project's test layout: where acceptance files go (`None`: the
25    /// author places them by the old naming rule).
26    pub(crate) layout: Option<&'a crate::layout::TestLayout>,
27    /// A person's rulings that the task's earlier tests were wrong: their
28    /// words, told to the author verbatim.
29    pub(crate) told: &'a [String],
30    /// The tasks on the same requirements (or named by this one's line):
31    /// their tests are shown, never contradicted.
32    pub(crate) related: &'a [String],
33    /// A story's oracle, written before its code: imports of what the
34    /// story adds are expected, never checked against the code.
35    pub(crate) oracle: bool,
36}
37
38#[derive(Clone, serde::Serialize, serde::Deserialize)]
39pub(crate) struct PreparedAcceptance {
40    /// What the run report shows.
41    pub(crate) report: serde_json::Value,
42    /// The command, when the tests were written.
43    pub(crate) argv: Option<Vec<String>>,
44    /// Further commands of the set (a bug's reproductions run one test
45    /// each); every one must pass.
46    #[serde(default)]
47    pub(crate) more_argv: Vec<Vec<String>>,
48    /// They failed before the attempt, on an assertion or for an absent
49    /// symbol.
50    pub(crate) red: bool,
51    /// What the red run showed.
52    #[serde(default)]
53    pub(crate) red_class: Option<speccoder_core::verify::RedClass>,
54    /// The held-out manifest's hash, when a held-out set was kept: a resumed
55    /// attempt reuses it, never re-authors it.
56    #[serde(default)]
57    pub(crate) held_out: Option<String>,
58    /// Why the author call itself failed (the provider refused, the run
59    /// failed) — not "the spec has no examples": the task stops for a person.
60    #[serde(default)]
61    pub(crate) generation_failed: Option<String>,
62    /// Why no acceptance test was kept, typed: what a regression-only
63    /// reason is decided from (`report["skipped"]` is its display).
64    #[serde(default)]
65    pub(crate) skip_cause: Option<speccoder_core::regression::SkipCause>,
66}
67
68pub(crate) const MANIFESTS: &[&str] = &[
69    "Cargo.toml",
70    "package.json",
71    "pyproject.toml",
72    "go.mod",
73    "pom.xml",
74];
75
76pub(crate) fn prepare_acceptance(a: &AcceptanceSetup<'_>) -> PreparedAcceptance {
77    use serde_json::json;
78    use speccoder_core::regression::SkipCause;
79    let skipped_by = |why: String, cause: Option<SkipCause>, report: serde_json::Value| {
80        let mut report = report;
81        report["skipped"] = json!(why);
82        PreparedAcceptance {
83            report,
84            argv: None,
85            more_argv: Vec::new(),
86            red: false,
87            red_class: None,
88            held_out: None,
89            generation_failed: None,
90            skip_cause: cause,
91        }
92    };
93    let failed = |why: String, report: serde_json::Value| PreparedAcceptance {
94        generation_failed: Some(why.clone()),
95        ..skipped_by(why, None, report)
96    };
97
98    let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
99        .and_then(|c| worktree::files(a.worktree, &c))
100        .unwrap_or_default()
101        .into_iter()
102        .take(400)
103        .collect();
104    let manifest = MANIFESTS
105        .iter()
106        .find_map(|m| {
107            std::fs::read_to_string(a.project.join(m))
108                .ok()
109                .map(|t| format!("{m}:\n{t}"))
110        })
111        .map(|t| t.chars().take(8192).collect::<String>())
112        .unwrap_or_default();
113    // Where the tests go: the task's own file in the project's layout, or
114    // no place the runner collects.
115    let placement = match placement(a) {
116        Ok(p) => p,
117        Err(()) => {
118            return skipped_by(
119                "no place the runner collects".to_owned(),
120                Some(SkipCause::NoPlace),
121                json!({}),
122            );
123        }
124    };
125    let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
126    let suite = suite(a.worktree, title, a.related);
127    let interface = interface(a.worktree, title);
128    // A refused answer is asked for once more, told exactly why: what
129    // speccoder refuses is checked, never left to the author's reading.
130    let mut refusals: Vec<String> = Vec::new();
131    // Both calls' tokens, when it is asked again.
132    let mut spent: Option<serde_json::Value> = None;
133    let (answer, base, planned, argv, ran) = loop {
134        let input = substrate::acceptance::input(
135            a.bundle.clone(),
136            a.scenarios,
137            &files,
138            &manifest,
139            a.instructions,
140            placement.as_ref(),
141            &substrate::acceptance::Known {
142                told: a.told,
143                refused: &refusals,
144                suite: &suite,
145                interface: &interface,
146            },
147        );
148        let outcome = a.rt.block_on(a.plane.acceptance(a.feature, a.task, input));
149        let outcome = match outcome {
150            Ok(o) => o,
151            Err(e) => return failed(format!("generation failed: {}", e.0), json!({})),
152        };
153        let output = outcome
154            .output
155            .as_ref()
156            .map(|o| o.peek().clone())
157            .unwrap_or_default();
158        let answer = output["answer"].clone();
159        spent = super::commit::add_usage(spent.as_ref(), output.get("usage"));
160        let mut base = json!({ "run_id": outcome.run_id.to_string(), "usage": spent, "notes": answer["notes"] });
161        if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
162        {
163            let status = format!("{:?}", outcome.status);
164            let why = substrate::provider_refusal(&status).map_or_else(
165                || format!("generation failed: {}", substrate::why_not(&outcome.status)),
166                |r| format!("the model provider refused the request: {r}"),
167            );
168            return failed(why, base);
169        }
170        if !refusals.is_empty() {
171            base["refused_first"] = json!(refusals);
172        }
173
174        // Validate: new test files only, a command the policy allows, and
175        // calls to code that is there (or that the task adds).
176        let (valid, refused) =
177            validate_answer(&answer, a.project, a.policy, a.scenarios, Some(title));
178        // Valid: written and run red here, so a test red only by its own
179        // error, or one that does not build or collect, is refused too.
180        let mut refused = refused;
181        let planned: Vec<(String, String)> = valid
182            .iter()
183            .map(|f| (f.path.clone(), f.content.clone()))
184            .collect();
185        let argv: Vec<String> = argv_of(&answer["argv"]);
186        let paths: Vec<&str> = planned.iter().map(|(p, _)| p.as_str()).collect();
187        let mut ran = None;
188        if refused.is_empty() && !planned.is_empty() {
189            if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
190                if !refusals.is_empty() {
191                    return skipped_by(
192                        format!("`{}` is not an allowed command", argv.join(" ")),
193                        Some(SkipCause::CommandNotAllowed {
194                            argv: argv.join(" "),
195                        }),
196                        base,
197                    );
198                }
199                refused.push(RefusedFile {
200                    path: paths[0].to_owned(),
201                    why: "command_not_allowed",
202                    message: format!(
203                        "`{}` is not an allowed command: give the command that runs only your files",
204                        argv.join(" ")
205                    ),
206                });
207            } else {
208                for (rel, content) in &planned {
209                    let full = a.project.join(rel);
210                    if let Some(parent) = full.parent() {
211                        let _ = std::fs::create_dir_all(parent);
212                    }
213                    if let Err(e) = std::fs::write(&full, content) {
214                        return skipped_by(
215                            format!("`{rel}`: {e}"),
216                            Some(SkipCause::WriteFailed { path: rel.clone() }),
217                            base,
218                        );
219                    }
220                }
221                let (first, summary) = execute(a.project, &argv, a.runner);
222                let class = classify_red(&first, summary.as_ref());
223                // Red only on names the code does not have and the task does
224                // not add (a module, a class, an attribute of the wrong shape)
225                // is the test's own mistake, never the task's gap.
226                let named = named(title);
227                let theirs = |n: &str| {
228                    let last = n.rsplit(['.', '/']).next().unwrap_or(n);
229                    named.iter().any(|w| w == last) || title.contains(n)
230                };
231                let not_there = speccoder_core::verify::missing_names(&first)
232                    .filter(|names| !names.is_empty() && !names.iter().any(|n| theirs(n)));
233                let wrong = match class {
234                    RedClass::CollectionError | RedClass::BuildError => {
235                        Some("do not build or collect".to_owned())
236                    }
237                    _ => not_there.map(|names| {
238                        format!(
239                            "fail only on what the code does not have ({}), never on an assertion",
240                            names.join(", ")
241                        )
242                    }),
243                };
244                match wrong {
245                    Some(how) if refusals.is_empty() => {
246                        for rel in &paths {
247                            let _ = std::fs::remove_file(a.project.join(rel));
248                        }
249                        refused.push(RefusedFile {
250                            path: paths[0].to_owned(),
251                            why: "errored",
252                            message: format!(
253                                "`{}` {how}: {}",
254                                paths.join("`, `"),
255                                first_failure(&first)
256                            ),
257                        });
258                    }
259                    _ => ran = Some((first, summary)),
260                }
261            }
262        }
263        if refused.is_empty() {
264            break (answer, base, planned, argv, ran);
265        }
266        if refusals.is_empty() {
267            refusals.extend(refused.iter().map(|r| r.message.clone()));
268            continue;
269        }
270        let r = &refused[0];
271        return skipped_by(
272            r.message.clone(),
273            Some(SkipCause::Refused {
274                path: r.path.clone(),
275                why: refusal_words(r.why).to_owned(),
276            }),
277            base,
278        );
279    };
280    let tests: Vec<serde_json::Value> = planned
281        .iter()
282        .map(|(p, _)| {
283            let cited = valid_cites(&answer, p, a.scenarios);
284            json!({ "path": p, "scenarios": cited })
285        })
286        .collect();
287    let Some((first, summary)) = ran else {
288        return skipped_by(
289            "no acceptance tests were generated".to_owned(),
290            Some(SkipCause::NoFiles),
291            base,
292        );
293    };
294    let mut written: Vec<String> = planned.iter().map(|(p, _)| p.clone()).collect();
295
296    // Red: they must fail before the task is done — on an assertion, or
297    // because what the task introduces does not exist yet; confirmed by a
298    // second run.
299    let red_tests = speccoder_core::verify::collected_count(&first, summary.as_ref());
300    let mut class = classify_red(&first, summary.as_ref());
301    if class.is_red() {
302        let (second, summary) = execute(a.project, &argv, a.runner);
303        if !classify_red(&second, summary.as_ref()).is_red() {
304            class = RedClass::Flaky;
305        }
306    }
307    let red = class.is_red();
308    // Tests that do not even build or collect would break every other test
309    // of the project for the attempt: they are taken out again.
310    let broken = matches!(class, RedClass::CollectionError | RedClass::BuildError);
311    let removed = if broken {
312        for rel in &written {
313            let _ = std::fs::remove_file(a.project.join(rel));
314        }
315        std::mem::take(&mut written)
316    } else {
317        Vec::new()
318    };
319    let mut report = base;
320    report["files"] = json!(written);
321    if !removed.is_empty() {
322        report["removed"] = json!(removed);
323    }
324    report["tests"] = json!(tests);
325    report["argv"] = json!(argv);
326    report["red"] = json!(red);
327    report["red_class"] = json!(class);
328    if let Some(n) = red_tests {
329        report["red_tests"] = json!(n);
330    }
331    if red {
332        // What the attestation's red excerpt is cut from (clean-checked there).
333        report["red_output"] = json!(
334            format!("{}\n{}", first.stdout_tail, first.stderr_tail)
335                .trim()
336                .to_owned()
337        );
338    } else {
339        report["note"] = json!(match class {
340            RedClass::Passed => "the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
341            RedClass::Flaky => "the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
342            other if broken => format!(
343                "the acceptance tests did not fail on an assertion ({}): not demonstrated, and taken out so the project's other tests still run",
344                class_word(other)
345            ),
346            other => format!(
347                "the acceptance tests did not fail on an assertion ({}): not demonstrated",
348                class_word(other)
349            ),
350        });
351        report["excerpt"] = json!(excerpt(&first));
352    }
353    // Green runs what went red, and what already passed (a regression
354    // guard); tests that never failed on an assertion — taken out, or that
355    // do not collect or time out — would fail every attempt, whatever it
356    // does.
357    let kept = red || class == RedClass::Passed;
358    PreparedAcceptance {
359        report,
360        argv: kept.then_some(argv),
361        more_argv: Vec::new(),
362        red,
363        red_class: Some(class),
364        held_out: None,
365        generation_failed: None,
366        skip_cause: None,
367    }
368}
369
370/// Where the visible author writes, from the layout: `Ok(None)` when no
371/// runner speccoder knows was found (the author names the files), `Err`
372/// when the layout offers no place the runner collects.
373fn placement(a: &AcceptanceSetup<'_>) -> Result<Option<substrate::acceptance::Placement>, ()> {
374    let Some(layout) = a.layout.filter(|l| !l.runners.is_empty()) else {
375        return Ok(None);
376    };
377    let task = a.task.to_owned();
378    // Go: the package the task names, else the module's root.
379    let package =
380        speccoder_core::scope::named_paths(a.bundle["task"]["title"].as_str().unwrap_or_default())
381            .into_iter()
382            .find(|p| p.ends_with(".go") || !p.contains('.'))
383            .map(|p| {
384                if p.ends_with(".go") {
385                    p.rsplit_once('/')
386                        .map_or(String::new(), |(d, _)| d.to_owned())
387                } else {
388                    p.trim_end_matches('/').to_owned()
389                }
390            });
391    // Named for the feature too (Spec Kit restarts at T001 in every
392    // feature), and free: an old file there is never the task's.
393    let number: String = a.feature.chars().take_while(char::is_ascii_digit).collect();
394    let scoped = if number.is_empty() {
395        task.clone()
396    } else {
397        format!("{number}_{task}")
398    };
399    let path = (1..)
400        .map(|n| {
401            let name = if n == 1 {
402                scoped.clone()
403            } else {
404                format!("{scoped}_{n}")
405            };
406            layout.acceptance_file(&name, package.as_deref())
407        })
408        .take(9)
409        .find(|p| p.as_ref().is_none_or(|p| !a.project.join(p).exists()))
410        .flatten()
411        .ok_or(())?;
412    let comment = if path.ends_with(".py") { "#" } else { "//" }.to_owned();
413    Ok(Some(substrate::acceptance::Placement {
414        command: layout.command_for(&path).unwrap_or_default(),
415        path,
416        task,
417        scenarios: a.scenarios.iter().map(|s| s.id.clone()).collect(),
418        comment,
419        layout: layout.text(),
420    }))
421}
422
423/// The scenario ids a planned file of `answer` cites, of those given.
424fn valid_cites(
425    answer: &serde_json::Value,
426    path: &str,
427    scenarios: &[speccoder_core::spec::ScenarioRef],
428) -> Vec<String> {
429    answer["files"]
430        .as_array()
431        .into_iter()
432        .flatten()
433        .filter(|f| {
434            f["path"]
435                .as_str()
436                .and_then(|p| tools::paths::normalise(p).ok())
437                .is_some_and(|p| p == path)
438        })
439        .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
440        .filter_map(|v| v.as_str().map(str::to_owned))
441        .filter(|id| scenarios.iter().any(|s| s.id == *id))
442        .collect()
443}
444
445/// A failed run's first failure, as the runner wrote it (one line).
446pub(crate) fn first_failure(r: &speccoder_core::verify::CheckResult) -> String {
447    let text = format!("{}\n{}", r.stdout_tail, r.stderr_tail);
448    let line = text
449        .lines()
450        .map(str::trim)
451        .find(|l| {
452            l.starts_with("FAILED ")
453                || l.starts_with("ERROR ")
454                || l.starts_with("E ")
455                || l.starts_with("error")
456                || l.contains("Error:")
457        })
458        .or_else(|| text.lines().map(str::trim).find(|l| !l.is_empty()))
459        .unwrap_or("no output");
460    line.chars().take(240).collect()
461}
462
463/// speccoder's words for a refusal's kind (a regression-only reason's detail).
464pub(crate) fn refusal_words(why: &str) -> &'static str {
465    match why {
466        "exists" => "already exists",
467        "not_collected" => "is not where the runner collects tests",
468        "skip_marker" => "skips a test",
469        "placeholder" => "is a placeholder that can never pass",
470        "imagined" => "calls code that is not there",
471        "errored" => "fails only on an error of its own, or does not build",
472        "command_not_allowed" => "gives a command the policy does not allow",
473        _ => "is not a new test file",
474    }
475}
476
477/// One test file of an author's answer that may be written.
478pub(crate) struct ValidFile {
479    /// Normalised, project-relative.
480    pub(crate) path: String,
481    pub(crate) content: String,
482    /// The scenario ids it cites, of those given (may be empty).
483    pub(crate) cited: Vec<String>,
484    /// The command that runs only it, when the answer gives one.
485    pub(crate) argv: Vec<String>,
486}
487
488/// A file of an answer that may not be written, and why.
489pub(crate) struct RefusedFile {
490    /// Its path as given.
491    pub(crate) path: String,
492    /// `not_a_new_test_file` or `exists`.
493    pub(crate) why: &'static str,
494    /// What a person is told.
495    pub(crate) message: String,
496}
497
498/// The command an author's answer gives: its argv — or, given as one
499/// string (`["cargo test --test acceptance_us1"]`, the line the prompt
500/// shows), that line split into its words when it is a plain command
501/// (anything a shell would read differently stays one word, and is
502/// refused as the command it is not).
503#[must_use]
504pub(crate) fn argv_of(v: &serde_json::Value) -> Vec<String> {
505    let argv: Vec<String> = v
506        .as_array()
507        .into_iter()
508        .flatten()
509        .filter_map(|v| v.as_str().map(str::to_owned))
510        .collect();
511    match argv.as_slice() {
512        [line] if line.split_ascii_whitespace().count() > 1 => {
513            let words = crate::acp::govern::argv_of_line(line);
514            // A line a shell would read: left whole, never guessed at.
515            if words.len() == 3 && words[0] == "sh" && words[1] == "-c" && words[2] == line.trim() {
516                argv
517            } else {
518                words
519            }
520        }
521        _ => argv,
522    }
523}
524
525/// Whether the author's `argv` may run: the policy allows it, or it is
526/// the layout's command that runs only one of `files` (what the prompt
527/// names).
528pub(crate) fn test_command_allowed(
529    policy: &tools::policy::Policy,
530    layout: Option<&crate::layout::TestLayout>,
531    argv: &[String],
532    files: &[&str],
533) -> bool {
534    layout.is_some_and(|l| l.runs_only(argv, files))
535        || policy.classify("run_command", &serde_json::json!({ "argv": argv }), None)
536            == tools::policy::Decision::Allow
537}
538
539/// The acceptance author's files, checked (the one validation run time and
540/// review share): new test paths only, nothing protected, nothing that
541/// exists in `project`; each with the scenarios it cites of `scenarios`.
542/// `task`: the line of a task whose tests call code that is there (a gap,
543/// held-out, strengthening): their imports must resolve. `None` for a
544/// story's oracle, written before its code.
545pub(crate) fn validate_answer(
546    answer: &serde_json::Value,
547    project: &Path,
548    policy: &tools::policy::Policy,
549    scenarios: &[speccoder_core::spec::ScenarioRef],
550    task: Option<&str>,
551) -> (Vec<ValidFile>, Vec<RefusedFile>) {
552    let protected = policy.protected();
553    let mut valid = Vec::new();
554    let mut refused = Vec::new();
555    for f in answer["files"].as_array().into_iter().flatten() {
556        let (Some(path), Some(content)) = (f["path"].as_str(), f["content"].as_str()) else {
557            continue;
558        };
559        let rel = match tools::paths::normalise(path) {
560            Ok(r) => r,
561            Err(e) => {
562                refused.push(RefusedFile {
563                    path: path.to_owned(),
564                    why: "not_a_new_test_file",
565                    message: format!("`{path}`: {}", e.0),
566                });
567                continue;
568            }
569        };
570        if !tools::paths::is_test_path(&rel) || protected.write_class(&rel).is_some() {
571            refused.push(RefusedFile {
572                message: format!("`{rel}` is not a new test file"),
573                path: rel,
574                why: "not_a_new_test_file",
575            });
576            continue;
577        }
578        if project.join(&rel).exists() {
579            refused.push(RefusedFile {
580                message: format!("`{rel}` already exists"),
581                path: rel,
582                why: "exists",
583            });
584            continue;
585        }
586        // A new file that skips a test (or expects it to fail) proves
587        // nothing it skips.
588        if crate::integrity::has_skip_marker(content) {
589            refused.push(RefusedFile {
590                message: format!("`{rel}` skips a test"),
591                path: rel,
592                why: "skip_marker",
593            });
594            continue;
595        }
596        // A placeholder fails whatever the code does: no evidence.
597        if crate::integrity::has_placeholder(content) {
598            refused.push(RefusedFile {
599                message: format!(
600                    "`{rel}` is a placeholder: it raises \"not implemented\" instead of checking the behaviour"
601                ),
602                path: rel,
603                why: "placeholder",
604            });
605            continue;
606        }
607        // A new file the runner would never collect is no evidence.
608        if crate::collection::statically_refused(project, &rel, content).is_some() {
609            refused.push(RefusedFile {
610                message: format!("`{rel}` is not where the runner collects tests"),
611                path: rel,
612                why: "not_collected",
613            });
614            continue;
615        }
616        // A test of code that is there calls it as it is.
617        if let Some(message) = task.and_then(|t| imagined(project, &rel, content, t)) {
618            refused.push(RefusedFile {
619                message,
620                path: rel,
621                why: "imagined",
622            });
623            continue;
624        }
625        let cited: Vec<String> = f["scenarios"]
626            .as_array()
627            .into_iter()
628            .flatten()
629            .filter_map(|v| v.as_str())
630            .filter(|id| scenarios.iter().any(|s| s.id == *id))
631            .map(str::to_owned)
632            .collect();
633        let argv: Vec<String> = argv_of(&f["argv"]);
634        valid.push(ValidFile {
635            path: rel,
636            content: content.to_owned(),
637            cited,
638            argv,
639        });
640    }
641    (valid, refused)
642}
643
644/// The class as written in reports.
645pub(crate) fn class_word(class: RedClass) -> String {
646    serde_json::to_value(class)
647        .ok()
648        .and_then(|v| v.as_str().map(str::to_owned))
649        .unwrap_or_default()
650}
651
652/// Up to 20 lines from the first error line of a run's output (else its
653/// last 20 lines).
654fn excerpt(result: &speccoder_core::verify::CheckResult) -> String {
655    let text = format!("{}\n{}", result.stdout_tail, result.stderr_tail);
656    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
657    let from = lines
658        .iter()
659        .position(|l| {
660            let l = l.to_lowercase();
661            l.contains("error") || l.contains("fail")
662        })
663        .unwrap_or_else(|| lines.len().saturating_sub(20));
664    lines
665        .iter()
666        .skip(from)
667        .take(20)
668        .copied()
669        .collect::<Vec<_>>()
670        .join("\n")
671}
672
673/// One run of an acceptance command, with the runner's summary.
674pub(crate) fn acceptance_run(
675    project: &Path,
676    argv: &[String],
677    runner: &sandbox::Runner,
678) -> (
679    speccoder_core::verify::CheckResult,
680    Option<speccoder_core::verify::RunnerSummary>,
681) {
682    execute(project, argv, runner)
683}
684
685pub(crate) fn run_acceptance(
686    project: &Path,
687    argv: &[String],
688    runner: &sandbox::Runner,
689) -> speccoder_core::verify::CheckResult {
690    execute(project, argv, runner).0
691}
692
693/// Runs the acceptance tests once; the runner's summary is read from the
694/// whole captured output, before it is cut to tails.
695fn execute(
696    project: &Path,
697    argv: &[String],
698    runner: &sandbox::Runner,
699) -> (
700    speccoder_core::verify::CheckResult,
701    Option<speccoder_core::verify::RunnerSummary>,
702) {
703    use speccoder_core::verify::{CheckResult, CheckStatus};
704    use verify::process::CommandRunner;
705    let out = runner.run(&verify::process::Invocation {
706        argv: argv.to_vec(),
707        cwd: project.to_path_buf(),
708        env: verify::process::scrubbed_env(std::env::vars()),
709        timeout: std::time::Duration::from_secs(600),
710        output_cap: verify::process::OUTPUT_CAP,
711    });
712    let status = if out.spawn_error.is_some() {
713        CheckStatus::Error
714    } else if out.timed_out {
715        CheckStatus::TimedOut
716    } else if out.exit_code == Some(0) {
717        CheckStatus::Passed
718    } else {
719        CheckStatus::Failed
720    };
721    let summary = verify::summary::parse(
722        &String::from_utf8_lossy(&out.stdout.kept),
723        &String::from_utf8_lossy(&out.stderr.kept),
724        out.exit_code,
725    );
726    let tail = |b: &[u8]| {
727        let start = b.len().saturating_sub(4096);
728        String::from_utf8_lossy(&b[start..]).into_owned()
729    };
730    let result = CheckResult {
731        name: "acceptance".to_owned(),
732        argv: argv.to_vec(),
733        status,
734        exit_code: out.exit_code,
735        duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
736        junit: false,
737        tests: None,
738        stdout_tail: tail(&out.stdout.kept),
739        stderr_tail: tail(&out.stderr.kept),
740        truncated: out.stdout.truncated() || out.stderr.truncated(),
741        note: out.spawn_error,
742        flaky: false,
743        sandbox: out.sandbox,
744    };
745    (result, summary)
746}
747
748/// The held-out set's author, store and red check: after a visible
749/// set that was red. Returns the report part (`acceptance.held_out`) and
750/// the stored manifest's hash when files were kept.
751pub(crate) fn prepare_held_out(
752    a: &AcceptanceSetup<'_>,
753    visible: &PreparedAcceptance,
754    scenarios: &[speccoder_core::spec::ScenarioRef],
755    store: &Path,
756    composition: bool,
757) -> (serde_json::Value, Option<String>) {
758    use serde_json::json;
759    let absent = |reason: &str, mut report: serde_json::Value| {
760        report["state"] = json!("absent");
761        report["reason"] = json!(reason);
762        (report, None)
763    };
764    let visible_files: Vec<(String, String)> = visible.report["files"]
765        .as_array()
766        .into_iter()
767        .flatten()
768        .filter_map(|p| p.as_str())
769        .map(|p| {
770            (
771                p.to_owned(),
772                std::fs::read_to_string(a.project.join(p)).unwrap_or_default(),
773            )
774        })
775        .collect();
776    let files: Vec<String> = worktree::checkpoint(a.worktree, a.tmp)
777        .and_then(|c| worktree::files(a.worktree, &c))
778        .unwrap_or_default()
779        .into_iter()
780        .take(400)
781        .collect();
782    let manifest_text = MANIFESTS
783        .iter()
784        .find_map(|m| {
785            std::fs::read_to_string(a.project.join(m))
786                .ok()
787                .map(|t| format!("{m}:\n{t}"))
788        })
789        .map(|t| t.chars().take(8192).collect::<String>())
790        .unwrap_or_default();
791    let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
792    let input = substrate::acceptance::held_out_input(
793        a.bundle.clone(),
794        &files,
795        &manifest_text,
796        scenarios,
797        &visible_files,
798        composition,
799        a.instructions,
800        a.layout.map(crate::layout::TestLayout::text).as_deref(),
801        &substrate::acceptance::Known {
802            told: a.told,
803            refused: &[],
804            suite: &suite(a.worktree, title, a.related),
805            interface: &if a.oracle {
806                Vec::new()
807            } else {
808                interface(a.worktree, title)
809            },
810        },
811    );
812    let outcome = match a.rt.block_on(a.plane.acceptance(a.feature, a.task, input)) {
813        Ok(o) => o,
814        Err(e) => return absent("generation_failed", json!({ "error": e.0 })),
815    };
816    let output = outcome
817        .output
818        .as_ref()
819        .map(|o| o.peek().clone())
820        .unwrap_or_default();
821    let answer = output["answer"].clone();
822    let base = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"], "notes": answer["notes"] });
823    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
824        return absent("generation_failed", base);
825    }
826
827    // Validate: new test paths, known scenarios, an allowed
828    // command each, at most `LIMIT`.
829    let known: std::collections::BTreeSet<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
830    let protected = a.policy.protected();
831    let mut kept: Vec<(crate::held_out::HeldOutFile, String)> = Vec::new();
832    let mut discarded: Vec<crate::held_out::Discarded> = Vec::new();
833    let mut discard = |path: &str, reason: &str| {
834        discarded.push(crate::held_out::Discarded {
835            path: path.to_owned(),
836            reason: reason.to_owned(),
837        });
838    };
839    let given = answer["files"].as_array().cloned().unwrap_or_default();
840    if given.is_empty() {
841        return absent("none_generated", base);
842    }
843    for f in &given {
844        let path = f["path"].as_str().unwrap_or_default();
845        let content = f["content"].as_str().unwrap_or_default();
846        let cited: Vec<String> = f["scenarios"]
847            .as_array()
848            .into_iter()
849            .flatten()
850            .filter_map(|s| s.as_str().map(str::to_owned))
851            .collect();
852        let argv: Vec<String> = argv_of(&f["argv"]);
853        let rel = match tools::paths::normalise(path) {
854            Ok(r) => r,
855            Err(_) => {
856                discard(path, "path");
857                continue;
858            }
859        };
860        let taken =
861            visible_files.iter().any(|(p, _)| *p == rel) || kept.iter().any(|(k, _)| k.path == rel);
862        if !tools::paths::is_test_path(&rel)
863            || protected.write_class(&rel).is_some()
864            || a.project.join(&rel).exists()
865            || taken
866        {
867            discard(&rel, "path");
868            continue;
869        }
870        // A held-out test the runner would never collect judges nothing.
871        if crate::collection::statically_refused(a.project, &rel, content).is_some() {
872            discard(&rel, "not_collected");
873            continue;
874        }
875        // Nor one that skips, raises "not implemented", or calls code that
876        // is not there: it fails whatever the code does.
877        if crate::integrity::has_skip_marker(content) {
878            discard(&rel, "skip_marker");
879            continue;
880        }
881        if crate::integrity::has_placeholder(content) {
882            discard(&rel, "placeholder");
883            continue;
884        }
885        if !a.oracle && imagined(a.project, &rel, content, title).is_some() {
886            discard(&rel, "imagined");
887            continue;
888        }
889        if cited.is_empty() || cited.iter().any(|c| !known.contains(c.as_str())) {
890            discard(&rel, "unknown_scenario");
891            continue;
892        }
893        let composed = f["composition"].as_bool().unwrap_or(false);
894        if composed && (!composition || cited.len() < 2) {
895            discard(&rel, "composition");
896            continue;
897        }
898        if !test_command_allowed(a.policy, a.layout, &argv, &[rel.as_str()]) {
899            discard(&rel, "command");
900            continue;
901        }
902        if kept.len() >= crate::held_out::LIMIT {
903            discard(&rel, "limit");
904            continue;
905        }
906        kept.push((
907            crate::held_out::HeldOutFile {
908                path: rel,
909                sha256: crate::integrity::sha256_hex(content.as_bytes()),
910                scenarios: cited,
911                argv,
912                composition: composed,
913                red_tests: None,
914            },
915            content.to_owned(),
916        ));
917    }
918
919    // Red now, on the tree the attempt starts from.
920    let checkpoint = worktree::checkpoint(a.worktree, a.tmp)
921        .map(|c| c.0)
922        .unwrap_or_default();
923    // Red by what the file adds: a failure its command shows without it is
924    // another test's.
925    match crate::held_out::overlay_red(a.worktree, a.project, a.tmp, &kept, a.runner, a.policy) {
926        Ok(runs) => {
927            let mut red = Vec::new();
928            for ((mut file, content), ran) in kept.into_iter().zip(runs) {
929                if ran.result.is_none() {
930                    discard(&file.path, "collision");
931                } else if crate::held_out::red_class(&ran).is_red() {
932                    file.red_tests = ran
933                        .result
934                        .as_ref()
935                        .and_then(|(r, s)| speccoder_core::verify::collected_count(r, s.as_ref()));
936                    red.push((file, content));
937                } else {
938                    discard(&file.path, "not_red");
939                }
940            }
941            kept = red;
942        }
943        Err(e) => {
944            return absent(
945                "generation_failed",
946                json!({ "error": e, "run_id": base["run_id"] }),
947            );
948        }
949    }
950    let mut report = base;
951    report["discarded"] = json!(discarded);
952    if kept.is_empty() {
953        let only_uncollected =
954            !discarded.is_empty() && discarded.iter().all(|d| d.reason == "not_collected");
955        return absent(
956            if only_uncollected {
957                "not_collected"
958            } else {
959                "all_discarded"
960            },
961            report,
962        );
963    }
964    let manifest = crate::held_out::Manifest {
965        version: 1,
966        feature: a.feature.to_owned(),
967        task: a.task.to_owned(),
968        author_run: report["run_id"].as_str().unwrap_or_default().to_owned(),
969        checkpoint,
970        visible_scenarios: scenarios.iter().map(|s| s.id.clone()).collect(),
971        files: kept.iter().map(|(f, _)| f.clone()).collect(),
972        discarded,
973    };
974    let contents: Vec<(String, String)> = kept
975        .iter()
976        .map(|(f, c)| (f.path.clone(), c.clone()))
977        .collect();
978    match crate::held_out::store(store, &manifest, &contents) {
979        Ok(sha) => {
980            report["state"] = json!("on");
981            report["manifest"] = json!(format!("sha256:{sha}"));
982            report["kept"] = json!(kept.len());
983            (report, Some(sha))
984        }
985        Err(e) => absent("generation_failed", json!({ "error": e })),
986    }
987}
988
989/// The held-out verdict on the tree an attempt left: `Ok` with what the
990/// tests said, or `Err` with why a person must look (tampering, a leak, a
991/// run that would not leave the tree as it was).
992pub(crate) struct HeldOutJudged {
993    /// The verdict input.
994    pub(crate) held_out: speccoder_core::verify::HeldOut,
995    /// The failed scenarios, with the spec's words, for the retry digest.
996    pub(crate) failed: Vec<speccoder_core::spec::ScenarioRef>,
997}
998
999#[allow(clippy::too_many_arguments)]
1000pub(crate) fn judge_held_out(
1001    store: &Path,
1002    manifest_sha: &str,
1003    scenarios: &[speccoder_core::spec::ScenarioRef],
1004    known_text: &str,
1005    wt_root: &Path,
1006    project: &Path,
1007    tmp: &Path,
1008    start: &worktree::Checkpoint,
1009    patch: &str,
1010    runner: &sandbox::Runner,
1011    policy: &tools::policy::Policy,
1012) -> Result<HeldOutJudged, (&'static str, String)> {
1013    use speccoder_core::verify::{CheckStatus, HeldOut};
1014    let (manifest, contents) =
1015        crate::held_out::load_verified(store, manifest_sha).map_err(|t| ("tampered", t.0))?;
1016    let files: Vec<(crate::held_out::HeldOutFile, String)> = manifest
1017        .files
1018        .iter()
1019        .cloned()
1020        .zip(contents.into_iter().map(|(_, c)| c))
1021        .collect();
1022    let leaks = crate::held_out::leaked(&files, known_text, wt_root, start, patch);
1023    if !leaks.is_empty() {
1024        return Err((
1025            "leak_suspected",
1026            format!(
1027                "the attempt contains {} literal(s) only the held-out tests hold; a person reviews it",
1028                leaks.len()
1029            ),
1030        ));
1031    }
1032    let runs = crate::held_out::overlay_run(wt_root, project, tmp, &files, runner, policy)
1033        .map_err(|e| ("tampered", e))?;
1034    let mut failed_ids: Vec<String> = Vec::new();
1035    for ((file, _), ran) in files.iter().zip(&runs) {
1036        // Green is evidence only when it collected what red did.
1037        // Green collects what red did — red's count, else what the file
1038        // declares, at least one — and a count that cannot be read fails.
1039        let declared = speccoder_core::collection::toolchain_of(&file.path).map_or(0, |t| {
1040            files
1041                .iter()
1042                .find(|(f, _)| f.path == file.path)
1043                .map_or(0, |(_, c)| speccoder_core::collection::declared_tests(t, c))
1044        });
1045        let required = file.red_tests.unwrap_or(declared).max(1);
1046        let passed = ran.result.as_ref().is_some_and(|(r, sum)| {
1047            r.status == CheckStatus::Passed
1048                && speccoder_core::verify::collected_count(r, sum.as_ref())
1049                    .is_some_and(|green| green >= required)
1050        });
1051        if !passed {
1052            failed_ids.extend(file.scenarios.iter().cloned());
1053        }
1054    }
1055    failed_ids.sort();
1056    failed_ids.dedup();
1057    let held_out = if failed_ids.is_empty() {
1058        HeldOut::Passed { files: files.len() }
1059    } else {
1060        HeldOut::Failed {
1061            scenarios: failed_ids.clone(),
1062        }
1063    };
1064    let failed = failed_ids
1065        .iter()
1066        .map(|id| {
1067            scenarios
1068                .iter()
1069                .find(|s| &s.id == id)
1070                .cloned()
1071                .unwrap_or_else(|| speccoder_core::spec::ScenarioRef {
1072                    id: id.clone(),
1073                    text: String::new(),
1074                })
1075        })
1076        .collect();
1077    Ok(HeldOutJudged { held_out, failed })
1078}
1079
1080/// Added acceptance tests that strengthen a weak set.
1081pub(crate) struct Strengthened {
1082    /// The new test files, written into the worktree.
1083    pub(crate) files: Vec<String>,
1084    /// The command that runs them.
1085    pub(crate) argv: Vec<String>,
1086    /// The author's run and tokens.
1087    pub(crate) report: serde_json::Value,
1088}
1089
1090/// Asks the test author once more, telling it what got through (`weak`),
1091/// and keeps its new tests only if they fail on the attempt's starting tree
1092/// (`before`) and pass on its final tree (`after`), where they are left.
1093/// `Err`: why the set could not be strengthened (no worktree change stays).
1094pub(crate) fn strengthen(
1095    a: &AcceptanceSetup<'_>,
1096    visible: &[(String, String)],
1097    weak: serde_json::Value,
1098    before: &worktree::Checkpoint,
1099    after: &worktree::Checkpoint,
1100) -> Result<Strengthened, String> {
1101    use serde_json::json;
1102    let files: Vec<String> = worktree::files(a.worktree, after)
1103        .unwrap_or_default()
1104        .into_iter()
1105        .take(400)
1106        .collect();
1107    let manifest = MANIFESTS
1108        .iter()
1109        .find_map(|m| {
1110            std::fs::read_to_string(a.project.join(m))
1111                .ok()
1112                .map(|t| format!("{m}:\n{t}"))
1113        })
1114        .map(|t| t.chars().take(8192).collect::<String>())
1115        .unwrap_or_default();
1116    let input = substrate::acceptance::strengthen_input(
1117        a.bundle.clone(),
1118        a.scenarios,
1119        &files,
1120        &manifest,
1121        visible,
1122        weak,
1123        a.instructions,
1124    );
1125    let outcome =
1126        a.rt.block_on(a.plane.acceptance(a.feature, a.task, input))
1127            .map_err(|e| format!("the test author failed: {}", e.0))?;
1128    let output = outcome
1129        .output
1130        .as_ref()
1131        .map(|o| o.peek().clone())
1132        .unwrap_or_default();
1133    let report = json!({ "run_id": outcome.run_id.to_string(), "usage": output["usage"] });
1134    let answer = &output["answer"];
1135    if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null() {
1136        return Err(format!(
1137            "the test author did not answer ({})",
1138            substrate::why_not(&outcome.status)
1139        ));
1140    }
1141    // The same checks as every author's files.
1142    let title = a.bundle["task"]["title"].as_str().unwrap_or_default();
1143    let (valid, refused) = validate_answer(
1144        answer,
1145        a.project,
1146        a.policy,
1147        a.scenarios,
1148        (!a.oracle).then_some(title),
1149    );
1150    if let Some(r) = refused.first() {
1151        return Err(r.message.clone());
1152    }
1153    let planned: Vec<(String, String)> = valid.into_iter().map(|f| (f.path, f.content)).collect();
1154    if planned.is_empty() {
1155        return Err("the test author added no tests".to_owned());
1156    }
1157    let argv: Vec<String> = argv_of(&answer["argv"]);
1158    let paths: Vec<&str> = planned
1159        .iter()
1160        .map(|(p, _): &(String, String)| p.as_str())
1161        .collect();
1162    if !test_command_allowed(a.policy, a.layout, &argv, &paths) {
1163        return Err(format!("`{}` is not an allowed command", argv.join(" ")));
1164    }
1165    let write = || -> Result<(), String> {
1166        for (rel, content) in &planned {
1167            let full = a.project.join(rel);
1168            if let Some(parent) = full.parent() {
1169                let _ = std::fs::create_dir_all(parent);
1170            }
1171            std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
1172        }
1173        Ok(())
1174    };
1175    let back =
1176        |to: &worktree::Checkpoint| worktree::restore(a.worktree, a.tmp, to).map_err(|e| e.0);
1177    // Red on the starting tree: they fail without the task's work.
1178    back(before)?;
1179    let red = write().map(|()| {
1180        let (r, summary) = execute(a.project, &argv, a.runner);
1181        classify_red(&r, summary.as_ref())
1182    });
1183    back(after)?;
1184    let red = red?;
1185    if !red.is_red() {
1186        return Err(format!(
1187            "the added tests do not fail without the change ({})",
1188            class_word(red)
1189        ));
1190    }
1191    // Green on the final tree: the implementation passes them.
1192    write()?;
1193    let (green, _) = execute(a.project, &argv, a.runner);
1194    if green.status != speccoder_core::verify::CheckStatus::Passed {
1195        back(after)?;
1196        let cited: Vec<String> = answer["files"]
1197            .as_array()
1198            .into_iter()
1199            .flatten()
1200            .flat_map(|f| f["scenarios"].as_array().cloned().unwrap_or_default())
1201            .filter_map(|v| v.as_str().map(str::to_owned))
1202            .collect();
1203        return Err(format!(
1204            "the added tests fail the implementation{}",
1205            if cited.is_empty() {
1206                String::new()
1207            } else {
1208                format!(" (scenarios {})", cited.join(", "))
1209            }
1210        ));
1211    }
1212    Ok(Strengthened {
1213        files: planned.into_iter().map(|(p, _)| p).collect(),
1214        argv,
1215        report,
1216    })
1217}
1218
1219/// The names a task line (or a requirement's text) gives in backticks: a
1220/// function, a type, a file's stem.
1221fn named(title: &str) -> Vec<String> {
1222    let mut names: Vec<String> = title
1223        .split('`')
1224        .skip(1)
1225        .step_by(2)
1226        .flat_map(|span| {
1227            span.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '/' || c == '.'))
1228                .map(str::to_owned)
1229                .collect::<Vec<_>>()
1230        })
1231        .map(|w| {
1232            // A path names its file's stem; a call its function.
1233            let file = w.rsplit('/').next().unwrap_or(&w).to_owned();
1234            file.split('.').next().unwrap_or(&file).to_owned()
1235        })
1236        .filter(|w| w.len() >= 4 && w.chars().any(|c| c.is_alphabetic()))
1237        .collect();
1238    names.sort();
1239    names.dedup();
1240    names
1241}
1242
1243/// Whether a project path is a test's.
1244fn is_test(p: &str) -> bool {
1245    p.split('/').any(|seg| {
1246        let s = seg.to_ascii_lowercase();
1247        s == "tests"
1248            || s == "test"
1249            || s == "spec"
1250            || s == "__tests__"
1251            || s.starts_with("test_")
1252            || s.contains("_test.")
1253            || s.contains(".test.")
1254            || s.contains(".spec.")
1255    })
1256}
1257
1258/// The tasks whose tests a new test must agree with: those this task's
1259/// line names (`per FR-004, T029`) and those citing a requirement or
1260/// scenario it cites (`FR-004`, `US1/AC3`, `SC-002`).
1261pub(crate) fn related(
1262    this: &speccoder_core::snapshot::Task,
1263    all: &[speccoder_core::snapshot::Task],
1264) -> Vec<String> {
1265    let cites = |title: &str| -> Vec<String> {
1266        title
1267            .split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '/'))
1268            .map(|w| w.trim_matches('-').to_ascii_uppercase())
1269            .filter(|w| {
1270                let (head, num) = w.split_once('-').unwrap_or_default();
1271                (matches!(head, "FR" | "SC")
1272                    && !num.is_empty()
1273                    && num.chars().all(|c| c.is_ascii_digit()))
1274                    || w.split_once("/AC").is_some_and(|(us, ac)| {
1275                        us.strip_prefix("US")
1276                            .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
1277                            && !ac.is_empty()
1278                            && ac.chars().all(|c| c.is_ascii_digit())
1279                    })
1280            })
1281            .collect()
1282    };
1283    let mine = cites(&this.title);
1284    let named = |id: &str| {
1285        this.title
1286            .split(|c: char| !c.is_alphanumeric())
1287            .any(|w| w == id)
1288    };
1289    // The tasks the line names first: three files are shown at most.
1290    let others: Vec<&speccoder_core::snapshot::Task> =
1291        all.iter().filter(|t| t.id != this.id).collect();
1292    others
1293        .iter()
1294        .filter(|t| named(&t.id))
1295        .chain(
1296            others
1297                .iter()
1298                .filter(|t| !named(&t.id) && cites(&t.title).iter().any(|c| mine.contains(c))),
1299        )
1300        .map(|t| t.id.clone())
1301        .collect()
1302}
1303
1304/// The project's tests a new test must not contradict, at most three files,
1305/// 6 KiB in all, each trimmed with a note: first the related tasks' own
1306/// tests (named after their ids, `test_acceptance_t029.py`), then those
1307/// that name what the task line names (a backticked function, type or
1308/// file's stem).
1309pub(crate) fn suite(
1310    worktree: &Path,
1311    title: &str,
1312    related: &[String],
1313) -> Vec<substrate::acceptance::SuiteTest> {
1314    const FILES: usize = 3;
1315    const BUDGET: usize = 6 * 1024;
1316    let git = crate::git::Git::at(worktree);
1317    let ids: Vec<String> = related.iter().map(|t| t.to_ascii_lowercase()).collect();
1318    let mut paths: Vec<String> = if ids.is_empty() {
1319        Vec::new()
1320    } else {
1321        git.line(&["ls-files", "--cached", "--others", "--exclude-standard"])
1322            .unwrap_or_default()
1323            .lines()
1324            .filter(|p| is_test(p))
1325            .filter_map(|p| {
1326                let file = p.rsplit('/').next().unwrap_or(p).to_ascii_lowercase();
1327                let at = file
1328                    .split(|c: char| !c.is_alphanumeric())
1329                    .filter_map(|w| ids.iter().position(|id| id == w))
1330                    .min()?;
1331                Some((at, p.to_owned()))
1332            })
1333            .collect::<std::collections::BTreeSet<_>>()
1334            .into_iter()
1335            .map(|(_, p)| p)
1336            .collect()
1337    };
1338    let names = named(title);
1339    if !names.is_empty() {
1340        let mut args = vec!["grep", "-l", "-w", "-I"];
1341        for n in &names {
1342            args.extend(["-e", n.as_str()]);
1343        }
1344        if let Ok(out) = git.line(&args) {
1345            paths.extend(out.lines().filter(|p| is_test(p)).map(str::to_owned));
1346        }
1347    }
1348    let mut seen = std::collections::BTreeSet::new();
1349    paths.retain(|p| seen.insert(p.clone()));
1350    let mut left = BUDGET;
1351    paths
1352        .iter()
1353        .take(FILES)
1354        .filter_map(|p| {
1355            let text = std::fs::read_to_string(worktree.join(p)).ok()?;
1356            let room = left.min(BUDGET / FILES);
1357            let content = if text.len() > room {
1358                let cut = (0..=room)
1359                    .rev()
1360                    .find(|i| text.is_char_boundary(*i))
1361                    .unwrap_or(0);
1362                format!("{}\n… (trimmed)", &text[..cut])
1363            } else {
1364                text
1365            };
1366            left = left.saturating_sub(content.len());
1367            Some(substrate::acceptance::SuiteTest {
1368                path: p.clone(),
1369                content,
1370            })
1371        })
1372        .collect()
1373}
1374
1375/// The code a new test calls, as it is: each source file's declarations
1376/// (a function, type, method, field or re-export line, as written), the
1377/// files the task names first, 8 KiB in all — so the author calls the code
1378/// by the modules, names and shapes it has, never ones it imagines.
1379pub(crate) fn interface(worktree: &Path, title: &str) -> Vec<substrate::acceptance::SuiteTest> {
1380    const BUDGET: usize = 8 * 1024;
1381    const SOURCE: &[&str] = &[
1382        "py", "rs", "ts", "tsx", "js", "mjs", "go", "java", "kt", "rb", "cs", "swift",
1383    ];
1384    let Ok(out) = crate::git::Git::at(worktree).line(&[
1385        "ls-files",
1386        "--cached",
1387        "--others",
1388        "--exclude-standard",
1389    ]) else {
1390        return Vec::new();
1391    };
1392    let names = named(title);
1393    let stem = |p: &str| {
1394        let f = p.rsplit('/').next().unwrap_or(p);
1395        f.split('.').next().unwrap_or(f).to_owned()
1396    };
1397    // The product's code: never docs, a site, vendored or built files.
1398    let elsewhere = |p: &str| {
1399        p.split('/').any(|seg| {
1400            matches!(
1401                seg,
1402                "docs"
1403                    | "doc"
1404                    | "site"
1405                    | "examples"
1406                    | "vendor"
1407                    | "node_modules"
1408                    | "dist"
1409                    | "build"
1410                    | "target"
1411                    | "scripts"
1412            ) || seg.starts_with('.')
1413        })
1414    };
1415    let mut files: Vec<&str> = out
1416        .lines()
1417        .filter(|p| {
1418            !is_test(p)
1419                && !elsewhere(p)
1420                && p.rsplit_once('.')
1421                    .is_some_and(|(_, ext)| SOURCE.contains(&ext))
1422        })
1423        .collect();
1424    // The files the task names first, then the source tree's.
1425    files.sort_by_key(|p| (!names.contains(&stem(p)), !p.starts_with("src/")));
1426    let mut left = BUDGET;
1427    let mut shown = Vec::new();
1428    for p in files {
1429        let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
1430            continue;
1431        };
1432        let content = declarations(&text, p.ends_with("__init__.py"));
1433        if content.is_empty() {
1434            continue;
1435        }
1436        // A file over what is left gives way to smaller ones.
1437        if content.len() > left {
1438            continue;
1439        }
1440        left -= content.len();
1441        shown.push(substrate::acceptance::SuiteTest {
1442            path: p.to_owned(),
1443            content,
1444        });
1445    }
1446    shown
1447}
1448
1449/// A source file's declaration lines, indented as written: a signature
1450/// spanning lines is joined until its brackets close; a Python name with a
1451/// leading underscore is private and left out.
1452fn declarations(text: &str, init: bool) -> String {
1453    const STARTS: &[&str] = &[
1454        "def ",
1455        "async def ",
1456        "class ",
1457        "pub ",
1458        "pub(",
1459        "export ",
1460        "func ",
1461        "type ",
1462        "public ",
1463        "fun ",
1464        "interface ",
1465        "struct ",
1466        "enum ",
1467        "trait ",
1468    ];
1469    let mut out = String::new();
1470    let mut in_class = false;
1471    let mut lines = text.lines();
1472    while let Some(line) = lines.next() {
1473        let t = line.trim_start();
1474        let top = t.len() == line.len();
1475        if top && !t.is_empty() {
1476            in_class = t.starts_with("class ");
1477        }
1478        let private = ["def _", "async def _", "class _"]
1479            .iter()
1480            .any(|p| t.starts_with(p) && !t.starts_with("def __init__"));
1481        // A Python class body's field (`header: tuple[str, ...]`) and a
1482        // package's re-export (`from .x import Y`) are its shape too.
1483        let field = in_class
1484            && !top
1485            && line.len() - t.len() == 4
1486            && t.split_once(':').is_some_and(|(n, rest)| {
1487                !rest.trim().is_empty()
1488                    && !n.is_empty()
1489                    && n.chars().all(|c| c.is_alphanumeric() || c == '_')
1490            });
1491        let export = init && top && t.starts_with("from .");
1492        let decl = STARTS.iter().any(|s| t.starts_with(s)) || field || export;
1493        if !decl || private {
1494            continue;
1495        }
1496        let mut sig = line.trim_end().to_owned();
1497        let open = |s: &str| {
1498            s.chars().filter(|c| matches!(c, '(' | '[')).count() as isize
1499                - s.chars().filter(|c| matches!(c, ')' | ']')).count() as isize
1500        };
1501        let mut depth = open(&sig);
1502        while depth > 0 {
1503            let Some(next) = lines.next() else { break };
1504            sig.push(' ');
1505            sig.push_str(next.trim());
1506            depth += open(next);
1507        }
1508        let sig: String = sig.chars().take(200).collect();
1509        out.push_str(sig.trim_end_matches(['{', ' ']));
1510        out.push('\n');
1511    }
1512    out
1513}
1514
1515/// Whether a new test calls project code that is not there and that the
1516/// task does not add: a module no file is (and the task line does not
1517/// name), or a name the module has only in other letter case — the test
1518/// would be red for its own mistake, never for the task's gap. Python
1519/// imports and relative JavaScript/TypeScript imports; other languages'
1520/// compilers say it.
1521pub(crate) fn imagined(project: &Path, path: &str, content: &str, title: &str) -> Option<String> {
1522    let named = named(title);
1523    let adds = |module_path: &str| {
1524        let stem = module_path
1525            .rsplit('/')
1526            .next()
1527            .unwrap_or(module_path)
1528            .split('.')
1529            .next()
1530            .unwrap_or_default()
1531            .to_owned();
1532        title.contains(module_path) || named.contains(&stem)
1533    };
1534    let missing = |shown: &str| {
1535        format!(
1536            "`{path}` imports `{shown}`, which is not in the code and which this task does not add — call the code by the modules it has (under \"The code as it is\")"
1537        )
1538    };
1539    let ident = |c: char| c.is_alphanumeric() || c == '_';
1540    if path.ends_with(".py") {
1541        for (module, names) in python_imports(content) {
1542            let first = module.split('.').next().unwrap_or_default();
1543            // Only the project's own modules: at the root or under src/.
1544            let Some(base) = ["", "src/"].into_iter().find(|b| {
1545                project.join(format!("{b}{first}")).exists()
1546                    || project.join(format!("{b}{first}.py")).exists()
1547            }) else {
1548                continue;
1549            };
1550            let rel = format!("{base}{}", module.replace('.', "/"));
1551            let file = [
1552                format!("{rel}.py"),
1553                format!("{rel}.pyi"),
1554                format!("{rel}/__init__.py"),
1555                format!("{rel}/__init__.pyi"),
1556            ]
1557            .into_iter()
1558            .find(|f| project.join(f).is_file());
1559            let Some(file) = file else {
1560                // A namespace package (a directory without `__init__.py`).
1561                if project.join(&rel).is_dir() || adds(&format!("{rel}.py")) {
1562                    continue;
1563                }
1564                return Some(missing(&module));
1565            };
1566            let text = std::fs::read_to_string(project.join(&file)).unwrap_or_default();
1567            // Names it makes at run time are not read from its text.
1568            if text.contains("import *") || text.contains("__getattr__") {
1569                continue;
1570            }
1571            let declared: Vec<String> = declarations(&text, true)
1572                .lines()
1573                .flat_map(|l| {
1574                    l.split(|c: char| !ident(c))
1575                        .filter(|w| !w.is_empty())
1576                        .map(str::to_owned)
1577                        .collect::<Vec<_>>()
1578                })
1579                .collect();
1580            for n in names {
1581                let there = text.split(|c: char| !ident(c)).any(|t| t == n);
1582                if there || named.contains(&n) || project.join(format!("{rel}/{n}.py")).is_file() {
1583                    continue;
1584                }
1585                if let Some(real) = declared.iter().find(|t| t.eq_ignore_ascii_case(&n)) {
1586                    return Some(format!(
1587                        "`{path}` imports `{n}` from `{module}`, which has `{real}` — call the code by the names it has"
1588                    ));
1589                }
1590            }
1591        }
1592        return None;
1593    }
1594    if [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
1595        .iter()
1596        .any(|e| path.ends_with(e))
1597    {
1598        let dir = Path::new(path).parent().unwrap_or(Path::new(""));
1599        for spec in relative_imports(content) {
1600            let joined = dir.join(&spec);
1601            let rel = crate::tools::paths::normalise(&joined.to_string_lossy()).unwrap_or_default();
1602            // NodeNext writes `./x.js` for `x.ts`.
1603            let bare = [".js", ".mjs", ".cjs", ".jsx"]
1604                .iter()
1605                .find_map(|e| rel.strip_suffix(e))
1606                .unwrap_or(&rel);
1607            let found = [rel.as_str(), bare].iter().any(|r| {
1608                [
1609                    "",
1610                    ".ts",
1611                    ".tsx",
1612                    ".js",
1613                    ".jsx",
1614                    ".mjs",
1615                    ".svelte",
1616                    ".vue",
1617                    "/index.ts",
1618                    "/index.tsx",
1619                    "/index.js",
1620                ]
1621                .iter()
1622                .any(|e| project.join(format!("{r}{e}")).is_file())
1623            });
1624            if !found && !adds(&rel) {
1625                return Some(missing(&spec));
1626            }
1627        }
1628    }
1629    None
1630}
1631
1632/// A Python file's absolute imports: each module, with the names taken
1633/// from it (`from m import a, b`; none for `import m`).
1634fn python_imports(content: &str) -> Vec<(String, Vec<String>)> {
1635    let mut out = Vec::new();
1636    let mut lines = content.lines();
1637    while let Some(line) = lines.next() {
1638        let t = line.trim();
1639        if let Some(rest) = t.strip_prefix("from ") {
1640            let Some((module, names)) = rest.split_once(" import ") else {
1641                continue;
1642            };
1643            if module.starts_with('.') {
1644                continue;
1645            }
1646            let mut names = names.to_owned();
1647            if names.contains('(') && !names.contains(')') {
1648                for next in lines.by_ref() {
1649                    names.push_str(next);
1650                    if next.contains(')') {
1651                        break;
1652                    }
1653                }
1654            }
1655            let names = names
1656                .split([',', '(', ')'])
1657                .filter_map(|n| n.split_whitespace().next())
1658                .filter(|n| *n != "*" && !n.starts_with('#'))
1659                .map(str::to_owned)
1660                .collect();
1661            out.push((module.trim().to_owned(), names));
1662        } else if let Some(rest) = t.strip_prefix("import ") {
1663            for m in rest.split(',') {
1664                if let Some(m) = m.split_whitespace().next() {
1665                    out.push((m.to_owned(), Vec::new()));
1666                }
1667            }
1668        }
1669    }
1670    out
1671}
1672
1673/// A JavaScript/TypeScript file's relative module specifiers.
1674fn relative_imports(content: &str) -> Vec<String> {
1675    let mut out = Vec::new();
1676    for quote in ['\'', '"'] {
1677        for (i, _) in content.match_indices(quote) {
1678            let rest = &content[i + 1..];
1679            if !(rest.starts_with("./") || rest.starts_with("../")) {
1680                continue;
1681            }
1682            let before = content[..i].trim_end();
1683            if !(before.ends_with("from")
1684                || before.ends_with("import")
1685                || before.ends_with("require(")
1686                || before.ends_with("import("))
1687            {
1688                continue;
1689            }
1690            if let Some(end) = rest.find(quote) {
1691                out.push(rest[..end].to_owned());
1692            }
1693        }
1694    }
1695    out
1696}
1697
1698/// What judges a task outside any story (setup, polish, convergence):
1699/// exactly the scenarios it names, never the author's pick of the whole
1700/// spec — those the stories' oracles judge. `None` when it names none.
1701pub(crate) fn outside_story(
1702    t: &speccoder_core::snapshot::Task,
1703    scenarios: &[speccoder_core::spec::ScenarioRef],
1704    unit_dir: &str,
1705    judged: &dyn Fn(&str) -> bool,
1706) -> Option<speccoder_core::story::Judged> {
1707    if checks_only(t, unit_dir, judged) {
1708        return None;
1709    }
1710    let links: Vec<String> = t
1711        .scenarios
1712        .iter()
1713        .filter(|l| scenarios.iter().any(|s| &s.id == *l))
1714        .cloned()
1715        .collect();
1716    (!links.is_empty()).then_some(speccoder_core::story::Judged::Linked { scenarios: links })
1717}
1718
1719/// The story whose oracle judges a task outside any story: the one owning
1720/// every scenario its line cites (a convergence gap), with those scenarios.
1721/// `None` when it cites none, or scenarios of several stories.
1722pub(crate) fn linked_story(
1723    t: &speccoder_core::snapshot::Task,
1724    scenarios: &[speccoder_core::spec::ScenarioRef],
1725    unit_dir: &str,
1726    judged: &dyn Fn(&str) -> bool,
1727    reqs: &[crate::format::OracleRequirement],
1728) -> Option<(crate::format::OracleRequirement, Vec<String>)> {
1729    let Some(speccoder_core::story::Judged::Linked { scenarios: links }) =
1730        outside_story(t, scenarios, unit_dir, judged)
1731    else {
1732        return None;
1733    };
1734    let owner = |id: &String| {
1735        reqs.iter()
1736            .position(|r| r.scenarios.iter().any(|s| &s.id == id))
1737    };
1738    let owners: std::collections::BTreeSet<usize> =
1739        links.iter().map(owner).collect::<Option<_>>()?;
1740    match owners.into_iter().collect::<Vec<_>>()[..] {
1741        [one] => Some((reqs[one].clone(), links)),
1742        _ => None,
1743    }
1744}
1745
1746/// Judged by the checks alone: planning work, or a step only a person
1747/// takes (`[YOU]`) — never a story's checkpoint.
1748pub(crate) fn checks_only(
1749    t: &speccoder_core::snapshot::Task,
1750    unit_dir: &str,
1751    judged: &dyn Fn(&str) -> bool,
1752) -> bool {
1753    t.yours
1754        || speccoder_core::lane::by_paths(&t.title, unit_dir, judged)
1755            == speccoder_core::lane::Lane::Planning
1756}
1757
1758#[cfg(test)]
1759mod interface_tests {
1760    use super::{declarations, imagined, related};
1761
1762    /// The sample's T032 (2026-11-02) cites FR-004, FR-007 and T029, whose
1763    /// frozen test it then contradicted: T029 is related, by both.
1764    #[test]
1765    fn tasks_on_the_same_requirements_are_related() {
1766        let task = |id: &str, title: &str| speccoder_core::snapshot::Task {
1767            id: id.to_owned(),
1768            title: title.to_owned(),
1769            ..Default::default()
1770        };
1771        let all = [
1772            task(
1773                "T026",
1774                "Route both tables in `src/lib.rs` per US1/AC3 (partial)",
1775            ),
1776            task(
1777                "T029",
1778                "Add header-presence validation per FR-004, FR-007 (partial)",
1779            ),
1780            task("T030", "Propagate the source path per FR-010"),
1781            task("T031", "Escape pipes per FR-0041 and us1/ac3"),
1782            task(
1783                "T032",
1784                "Add explicit header-presence validation per FR-004, FR-007, T029 (partial)",
1785            ),
1786        ];
1787        assert_eq!(related(&all[4], &all), ["T029"]);
1788        assert_eq!(related(&all[0], &all), ["T031"]);
1789        let named = task("T033", "Finish what T030 began");
1790        assert_eq!(related(&named, &all), ["T030"]);
1791    }
1792
1793    /// The sample's T032 test (2026-11-02), against its code: the module it
1794    /// imagined, the class it misspelled — and what passes.
1795    #[test]
1796    fn a_test_s_imports_are_checked_against_the_code() {
1797        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1798        let w = dir.path();
1799        for (p, t) in [
1800            ("src/__init__.py", ""),
1801            (
1802                "src/models/__init__.py",
1803                "from .csv_output import CsvOutput\nfrom .conversion_error import ConversionError\n",
1804            ),
1805            ("src/models/csv_output.py", "class CsvOutput:\n    pass\n"),
1806            (
1807                "src/services/table_parser.py",
1808                "def parse_markdown_table(content):\n    return ()\n",
1809            ),
1810            ("web/src/lib/csv.ts", "export function toCsv() {}\n"),
1811        ] {
1812            std::fs::create_dir_all(w.join(p).parent().unwrap_or(w))
1813                .unwrap_or_else(|e| panic!("{e}"));
1814            std::fs::write(w.join(p), t).unwrap_or_else(|e| panic!("{e}"));
1815        }
1816        let task = "Add header-presence validation in the conversion flow per FR-004";
1817        let check = |content: &str| imagined(w, "tests/test_t032.py", content, task);
1818        // As the sample wrote it: the module is not there.
1819        let got = check(
1820            "def test():\n    from src.lib.convert_table_to_csv import convert_table_to_csv\n    from src.models.conversion_error import ConversionError\n",
1821        );
1822        assert!(
1823            got.as_deref()
1824                .is_some_and(|m| m.contains("`src.lib.convert_table_to_csv`")),
1825            "{got:?}"
1826        );
1827        // A task that names the file adds it.
1828        assert_eq!(
1829            imagined(
1830                w,
1831                "tests/test_t032.py",
1832                "from src.lib.convert_table_to_csv import convert\n",
1833                "Add `src/lib/convert_table_to_csv.py`"
1834            ),
1835            None
1836        );
1837        // A name the module has only in other letters.
1838        let got = check("from src.models import CSVOutput\n");
1839        assert!(
1840            got.as_deref()
1841                .is_some_and(|m| m.contains("which has `CsvOutput`")),
1842            "{got:?}"
1843        );
1844        // What is there, what the task may add, and what is not the project's.
1845        assert_eq!(
1846            check(
1847                "import pytest\nfrom dataclasses import dataclass\nfrom src.models import (\n    CsvOutput,\n    ConversionError,\n)\nfrom src.services.table_parser import parse_markdown_table, validate_header\n"
1848            ),
1849            None
1850        );
1851        // Relative JavaScript/TypeScript imports.
1852        let ts = |c: &str| imagined(w, "web/src/lib/csv.test.ts", c, task);
1853        assert_eq!(
1854            ts("import { toCsv } from \"./csv\";\nimport { it } from \"vitest\";\n"),
1855            None
1856        );
1857        assert!(ts("import { toCsv } from './convert';\n").is_some());
1858        assert_eq!(ts("import { toCsv } from './csv.js';\n"), None, "NodeNext");
1859        // Names made at run time, and namespace packages, are never refused.
1860        for (p, t) in [
1861            ("pkg/__init__.py", "from .parser import *\n"),
1862            ("pkg/parser.py", "class Parser:\n    pass\n"),
1863            ("ns/inner.py", "def f():\n    pass\n"),
1864        ] {
1865            std::fs::create_dir_all(w.join(p).parent().unwrap_or(w))
1866                .unwrap_or_else(|e| panic!("{e}"));
1867            std::fs::write(w.join(p), t).unwrap_or_else(|e| panic!("{e}"));
1868        }
1869        assert_eq!(check("from pkg import Parser\nimport ns\n"), None);
1870    }
1871
1872    /// Rust's `iter.skip(1)` is no skipped test; `it.skip(` and
1873    /// `pytest.skip(` are.
1874    #[test]
1875    fn only_a_test_framework_s_skip_is_a_skip() {
1876        assert!(!crate::integrity::has_skip_marker(
1877            "let rows = lines.iter().skip(1);"
1878        ));
1879        assert!(crate::integrity::has_skip_marker(
1880            "it.skip('later', () => {})"
1881        ));
1882        assert!(crate::integrity::has_skip_marker(
1883            "    pytest.skip(\"later\")"
1884        ));
1885    }
1886
1887    /// The sample's code (2026-11-02): the test author imagined a module,
1888    /// a class's spelling and a return shape; these lines say them.
1889    #[test]
1890    fn a_file_s_declarations_are_its_names_and_shapes() {
1891        let py = "from dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass MarkdownTable:\n    header: tuple[str, ...]\n    rows: tuple[tuple[str, ...], ...]\n\n    def width(self) -> int:\n        return len(self.header)\n\n    def _check(self) -> None:\n        pass\n\n\ndef parse_markdown_table(\n    content: str,\n) -> tuple[MarkdownTable, ...]:\n    x: int = 1\n    return ()\n\n\ndef _split_row(line: str) -> tuple[str, ...]:\n    return ()\n";
1892        assert_eq!(
1893            declarations(py, false),
1894            "class MarkdownTable:\n    header: tuple[str, ...]\n    rows: tuple[tuple[str, ...], ...]\n    def width(self) -> int:\ndef parse_markdown_table( content: str, ) -> tuple[MarkdownTable, ...]:\n"
1895        );
1896        assert_eq!(
1897            declarations("from .csv_output import CsvOutput\nimport os\n", true),
1898            "from .csv_output import CsvOutput\n"
1899        );
1900        assert_eq!(
1901            declarations(
1902                "pub struct Table {\n    pub header: Vec<String>,\n    rows: usize,\n}\nfn private() {}\npub fn parse(s: &str) -> Vec<Table> {\n",
1903                false
1904            ),
1905            "pub struct Table\n    pub header: Vec<String>,\npub fn parse(s: &str) -> Vec<Table>\n"
1906        );
1907        assert_eq!(
1908            declarations(
1909                "export function toCsv(t: Table): string {\nconst x = 1;\n",
1910                false
1911            ),
1912            "export function toCsv(t: Table): string\n"
1913        );
1914    }
1915}
1916
1917#[cfg(test)]
1918mod argv_tests {
1919    use super::argv_of;
1920    use serde_json::json;
1921
1922    /// The sample's real answer (2026-10-29): the command as one string.
1923    #[test]
1924    fn a_command_given_as_one_string_is_its_words() {
1925        assert_eq!(
1926            argv_of(&json!(["cargo test --test acceptance_us1"])),
1927            ["cargo", "test", "--test", "acceptance_us1"]
1928        );
1929        assert_eq!(
1930            argv_of(&json!(["cargo", "test", "--test", "acceptance_us1"])),
1931            ["cargo", "test", "--test", "acceptance_us1"]
1932        );
1933        // A shell line stays one word: refused as the command it is not.
1934        assert_eq!(
1935            argv_of(&json!(["cargo test; rm -rf ."])),
1936            ["cargo test; rm -rf ."]
1937        );
1938        assert_eq!(argv_of(&json!(["pytest"])), ["pytest"]);
1939    }
1940}
1941
1942#[cfg(test)]
1943mod tests {
1944    use super::*;
1945
1946    /// One pre-frozen record of `producer` naming `tests/a.txt`.
1947    fn record(
1948        producer: speccoder_core::acceptance::Producer,
1949        content: &str,
1950    ) -> speccoder_core::acceptance::PreFrozenAcceptance {
1951        speccoder_core::acceptance::PreFrozenAcceptance {
1952            unit: "001-x".to_owned(),
1953            requirement: "US1".to_owned(),
1954            wording_digest: "sha256:w".to_owned(),
1955            approval_digest: "sha256:a".to_owned(),
1956            visible: vec![speccoder_core::acceptance::VisibleTest {
1957                path: "tests/a.txt".to_owned(),
1958                sha256: format!(
1959                    "sha256:{}",
1960                    crate::integrity::sha256_hex(content.as_bytes())
1961                ),
1962                cites: vec!["US1-1".to_owned()],
1963            }],
1964            held_out_sealed: None,
1965            producer,
1966            decided: speccoder_core::acceptance::Decided {
1967                by: "Ada".to_owned(),
1968                via: speccoder_core::actions::Via::Terminal,
1969                at: 0,
1970            },
1971        }
1972    }
1973
1974    #[test]
1975    fn a_review_s_and_a_bug_s_frozen_sets_go_through_the_one_reuse_each_with_its_red_rule() {
1976        use speccoder_core::acceptance::Producer;
1977        let summary = |passed: u8, failed: u8| {
1978            format!(
1979                "echo 'test result: {}. {passed} passed; {failed} failed; 0 ignored; 0 measured; 0 filtered out'; exit {}",
1980                if failed > 0 { "FAILED" } else { "ok" },
1981                if failed > 0 { 101 } else { 0 }
1982            )
1983        };
1984        let content = "checks\n";
1985        for (producer, rule) in [
1986            (Producer::Review, RedRule::RunTime),
1987            (Producer::BugConfirmation, RedRule::BugAssertionOnly),
1988        ] {
1989            let rec = record(producer, content);
1990            let files = vec![("tests/a.txt".to_owned(), content.to_owned())];
1991            let run = |script: String| {
1992                let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1993                let tests = [ReusedTest {
1994                    path: "tests/a.txt".to_owned(),
1995                    cites: vec!["US1-1".to_owned()],
1996                    argv: vec!["sh".to_owned(), "-c".to_owned(), script],
1997                    green: false,
1998                }];
1999                let got = reuse_pre_frozen(
2000                    dir.path(),
2001                    &rec,
2002                    &files,
2003                    &tests,
2004                    &sandbox::Runner::Plain,
2005                    rule,
2006                    None,
2007                );
2008                assert!(
2009                    dir.path().join("tests/a.txt").is_file(),
2010                    "written at the checkpoint"
2011                );
2012                got
2013            };
2014            // Red by one assertion, twice: reused, the source the producer's.
2015            let red = run(summary(0, 1)).unwrap_or_else(|e| panic!("{producer:?}: {e}"));
2016            assert!(red.red);
2017            assert_eq!(red.report["source"], serde_json::json!(producer.source()));
2018            // Already passing: a review's set is not demonstrated, a bug's
2019            // reproduction no longer reproduces — the person looks.
2020            let passing = run(summary(1, 0));
2021            match rule {
2022                RedRule::RunTime => {
2023                    let p = passing.unwrap_or_else(|e| panic!("{e}"));
2024                    assert!(!p.red);
2025                    assert!(
2026                        p.report["note"]
2027                            .as_str()
2028                            .is_some_and(|n| n.contains("already passing"))
2029                    );
2030                }
2031                RedRule::BugAssertionOnly => assert!(passing.is_err()),
2032            }
2033            // A failure of more than one test is a bug's no reproduction.
2034            if rule == RedRule::BugAssertionOnly {
2035                assert!(run(summary(1, 1)).is_err(), "one test's assertion only");
2036            }
2037        }
2038    }
2039}
2040
2041/// How a reused set must be red at the checkpoint.
2042#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2043pub(crate) enum RedRule {
2044    /// A review's oracle: the run-time rule (an assertion, or an absent
2045    /// symbol), twice; a set that is no longer red is reported as at run
2046    /// time — not demonstrated — rather than refused.
2047    RunTime,
2048    /// A bug's reproductions: an assertion only, both runs.
2049    BugAssertionOnly,
2050}
2051
2052/// One test of a reused set: its file, what it cites and how it runs.
2053#[derive(Debug)]
2054pub(crate) struct ReusedTest {
2055    /// Project-relative path.
2056    pub(crate) path: String,
2057    /// The statement or scenario ids.
2058    pub(crate) cites: Vec<String>,
2059    /// The command that runs only it.
2060    pub(crate) argv: Vec<String>,
2061    /// It must pass at the checkpoint instead (an *Unchanged* test).
2062    pub(crate) green: bool,
2063}
2064
2065/// Where a reused set's sealed held-out tests are red-checked: overlaid on
2066/// the checkpoint, run and removed.
2067pub(crate) struct SealedCheck<'a> {
2068    /// The project's state directory.
2069    pub(crate) state: &'a Path,
2070    /// The unit the set is sealed under.
2071    pub(crate) unit: &'a str,
2072    /// The worktree's root.
2073    pub(crate) wt_root: &'a Path,
2074    /// Scratch for checkpoints.
2075    pub(crate) tmp: &'a Path,
2076    /// The task's policy.
2077    pub(crate) policy: &'a tools::policy::Policy,
2078}
2079
2080/// Acceptance tests decided before the run, reused at the checkpoint:
2081/// written into the worktree (the one function every pre-frozen producer
2082/// uses), each red twice by `rule` — or green, for a test that guards what
2083/// must not change — with no acceptance author call. `Err`: why the set
2084/// cannot stand (the person looks).
2085pub(crate) fn reuse_pre_frozen(
2086    project: &Path,
2087    record: &speccoder_core::acceptance::PreFrozenAcceptance,
2088    files: &[(String, String)],
2089    tests: &[ReusedTest],
2090    runner: &sandbox::Runner,
2091    rule: RedRule,
2092    sealed: Option<&SealedCheck<'_>>,
2093) -> Result<PreparedAcceptance, String> {
2094    use serde_json::json;
2095    for (rel, content) in files {
2096        let full = project.join(rel);
2097        if let Ok(now) = std::fs::read_to_string(&full) {
2098            if now != *content {
2099                return Err(format!(
2100                    "`{rel}` exists and differs from the confirmed test; it is not overwritten"
2101                ));
2102            }
2103            continue;
2104        }
2105        if let Some(parent) = full.parent() {
2106            let _ = std::fs::create_dir_all(parent);
2107        }
2108        std::fs::write(&full, content).map_err(|e| format!("`{rel}`: {e}"))?;
2109    }
2110    // A bug's reproduction is red only by its own test: a run of more than
2111    // one test cannot say whose assertion failed.
2112    let class_of = |r: &speccoder_core::verify::CheckResult,
2113                    s: Option<&speccoder_core::verify::RunnerSummary>| match rule
2114    {
2115        RedRule::BugAssertionOnly => speccoder_core::verify::classify_red_one(r, s, ""),
2116        RedRule::RunTime => classify_red(r, s),
2117    };
2118    let mut classes = Vec::new();
2119    for t in tests {
2120        let (first, summary) = execute(project, &t.argv, runner);
2121        let one = class_of(&first, summary.as_ref());
2122        let class = if t.green {
2123            one
2124        } else {
2125            match rule {
2126                RedRule::BugAssertionOnly if one == RedClass::Assertion => {
2127                    let (second, summary) = execute(project, &t.argv, runner);
2128                    speccoder_core::bug::bug_class(one, Some(class_of(&second, summary.as_ref())))
2129                }
2130                RedRule::BugAssertionOnly => one,
2131                RedRule::RunTime if one.is_red() => {
2132                    let (second, summary) = execute(project, &t.argv, runner);
2133                    if classify_red(&second, summary.as_ref()).is_red() {
2134                        one
2135                    } else {
2136                        RedClass::Flaky
2137                    }
2138                }
2139                RedRule::RunTime => one,
2140            }
2141        };
2142        let holds = if t.green {
2143            class == RedClass::Passed
2144        } else {
2145            match rule {
2146                RedRule::BugAssertionOnly => class == RedClass::Assertion,
2147                RedRule::RunTime => class.is_red(),
2148            }
2149        };
2150        if !holds && rule == RedRule::RunTime && !t.green {
2151            return Ok(not_red_frozen(record, files, tests, t, class));
2152        }
2153        if !holds {
2154            return Err(if t.green {
2155                format!(
2156                    "the confirmed test `{}` ({}) no longer passes on the branch ({})",
2157                    t.path,
2158                    t.cites.join(", "),
2159                    class_word(class)
2160                )
2161            } else {
2162                format!(
2163                    "the confirmed test `{}` ({}) no longer fails on an assertion ({}): the behaviour may have changed on the branch",
2164                    t.path,
2165                    t.cites.join(", "),
2166                    class_word(class)
2167                )
2168            });
2169        }
2170        classes.push(class);
2171    }
2172    let red: Vec<&ReusedTest> = tests.iter().filter(|t| !t.green).collect();
2173    let Some(first) = red.first() else {
2174        return Err("the confirmed set has no test that must fail first".to_owned());
2175    };
2176    let more: Vec<Vec<String>> = tests
2177        .iter()
2178        .filter(|t| !std::ptr::eq(*t, *first))
2179        .map(|t| t.argv.clone())
2180        .collect();
2181    let mut held_sha = record
2182        .held_out_sealed
2183        .as_ref()
2184        .map(|h| h.manifest_sha256.clone());
2185    let mut held_out = match &record.held_out_sealed {
2186        Some(h) => {
2187            json!({ "state": "on", "manifest": format!("sha256:{}", h.manifest_sha256), "kept": h.count })
2188        }
2189        None => json!({ "state": "absent", "reason": "none_kept" }),
2190    };
2191    // The sealed held-out set, red on the same checkpoint (overlaid, run,
2192    // removed): a set that is not red decides nothing.
2193    if let (Some(check), Some(h)) = (sealed, &record.held_out_sealed) {
2194        let (manifest, contents) = crate::held_out::open_sealed(check.state, check.unit, h)
2195            .map_err(|t| format!("the frozen held-out set was tampered with: {}", t.0))?;
2196        let set: Vec<(crate::held_out::HeldOutFile, String)> = manifest
2197            .files
2198            .iter()
2199            .cloned()
2200            .zip(contents.into_iter().map(|(_, c)| c))
2201            .collect();
2202        let runs = crate::held_out::overlay_red(
2203            check.wt_root,
2204            project,
2205            check.tmp,
2206            &set,
2207            runner,
2208            check.policy,
2209        )?;
2210        let all_red = runs
2211            .iter()
2212            .all(|r| r.result.is_some() && crate::held_out::red_class(r).is_red());
2213        if !all_red {
2214            held_sha = None;
2215            held_out = json!({ "state": "absent", "reason": "not_red" });
2216        }
2217    }
2218    let class = classes.first().copied().unwrap_or(RedClass::Assertion);
2219    Ok(PreparedAcceptance {
2220        report: json!({
2221            "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
2222            "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
2223            "argv": first.argv,
2224            "red": true,
2225            "red_class": class,
2226            "source": record.producer.source(),
2227            "pre_frozen": {
2228                "manifest": crate::pre_frozen::manifest_digest(record),
2229                "approval": record.approval_digest,
2230                "wording": record.wording_digest,
2231                "by": record.decided.by,
2232                "via": record.decided.via,
2233            },
2234            "held_out": held_out,
2235        }),
2236        argv: Some(first.argv.clone()),
2237        more_argv: more,
2238        red: true,
2239        red_class: Some(class),
2240        held_out: held_sha,
2241        generation_failed: None,
2242        skip_cause: None,
2243    })
2244}
2245
2246/// A frozen set no longer red at the checkpoint: reported as a run-time
2247/// set that is not red — not demonstrated — with nothing re-authored.
2248fn not_red_frozen(
2249    record: &speccoder_core::acceptance::PreFrozenAcceptance,
2250    files: &[(String, String)],
2251    tests: &[ReusedTest],
2252    failing: &ReusedTest,
2253    class: RedClass,
2254) -> PreparedAcceptance {
2255    use serde_json::json;
2256    let note = match class {
2257        RedClass::Passed => "frozen at review, already passing: the acceptance tests already pass before the attempt, so they are no evidence for it".to_owned(),
2258        RedClass::Flaky => "frozen at review: the acceptance tests failed once and passed when run again: not demonstrated".to_owned(),
2259        other => format!(
2260            "frozen at review: the acceptance tests did not fail on an assertion ({}): not demonstrated",
2261            class_word(other)
2262        ),
2263    };
2264    PreparedAcceptance {
2265        report: json!({
2266            "files": files.iter().map(|(p, _)| p).collect::<Vec<_>>(),
2267            "tests": tests.iter().map(|t| json!({ "path": t.path, "scenarios": t.cites })).collect::<Vec<_>>(),
2268            "argv": failing.argv,
2269            "red": false,
2270            "red_class": class,
2271            "note": note,
2272            "source": record.producer.source(),
2273            "pre_frozen": {
2274                "manifest": crate::pre_frozen::manifest_digest(record),
2275                "approval": record.approval_digest,
2276                "wording": record.wording_digest,
2277                "by": record.decided.by,
2278                "via": record.decided.via,
2279            },
2280            "held_out": { "state": "absent", "reason": "not_red" },
2281        }),
2282        argv: Some(failing.argv.clone()),
2283        more_argv: tests
2284            .iter()
2285            .filter(|t| !std::ptr::eq(*t, failing))
2286            .map(|t| t.argv.clone())
2287            .collect(),
2288        red: false,
2289        red_class: Some(class),
2290        held_out: None,
2291        generation_failed: None,
2292        skip_cause: None,
2293    }
2294}