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