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