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