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