Skip to main content

speccoder_runtime/
layout.rs

1//! The project's test layout, detected by code: the runners of
2//! the checkout, each runner's collection rule as configured, where tests
3//! live today, the naming habit, and where speccoder puts a task's
4//! acceptance file. A fact handed to the implementer and every test author,
5//! never a judge input; undeterminable parts are listed as unknown, never
6//! guessed. Same checkout, same bytes.
7
8use std::collections::BTreeMap;
9use std::path::Path;
10
11use serde::Serialize;
12use speccoder_core::collection::{
13    CollectionRule, JsRunner, Toolchain, defines_tests, go_rule, rust_reachable, rust_rule,
14    rust_targets, strip_comments, toolchain_of,
15};
16
17use crate::collection::static_cfg;
18
19/// Where tests live today.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Location {
22    /// `inline_modules`, `tests_dir`, `beside_sources`, `package`.
23    pub kind: String,
24    /// The directory (or `*` for inline modules across the sources).
25    pub path: String,
26    /// How many test files.
27    pub files: usize,
28}
29
30/// One runner's layout.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct RunnerLayout {
33    /// The toolchain.
34    pub toolchain: Toolchain,
35    /// The member or package root (`""` at the checkout's root).
36    pub root: String,
37    /// Its collection rule as configured.
38    pub rule: CollectionRule,
39    /// Where tests live today.
40    pub locations: Vec<Location>,
41    /// Test files present that nothing runs.
42    pub not_run: Vec<String>,
43    /// The project's naming habit.
44    pub naming: Option<String>,
45    /// Where speccoder puts acceptance files; `None`: no place the runner collects.
46    pub story_dir: Option<String>,
47    /// The file extension acceptance files take (JS/TS: the project's majority).
48    pub extension: String,
49    /// What could not be determined.
50    pub unknown: Vec<String>,
51}
52
53/// The checkout's test layout.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct TestLayout {
56    /// Every runner found, in toolchain order.
57    pub runners: Vec<RunnerLayout>,
58    /// SHA-256 of the runners' canonical JSON.
59    pub digest: String,
60    /// How the project starts pytest: a check's words up to `pytest`
61    /// (`uv run python -m pytest`), else `python3 -m pytest`.
62    #[serde(skip)]
63    pub pytest: Vec<String>,
64}
65
66fn parent(p: &str) -> &str {
67    p.rsplit_once('/').map_or("", |(d, _)| d)
68}
69
70fn at(root: &str, rel: &str) -> String {
71    if root.is_empty() {
72        rel.to_owned()
73    } else {
74        format!("{root}/{rel}")
75    }
76}
77
78/// Detects the layout of the checkout at `root`; `check_argvs` are the
79/// checks' commands (they name the runners).
80#[must_use]
81pub fn detect(root: &Path, check_argvs: &[Vec<String>]) -> TestLayout {
82    let files = static_cfg::files(root);
83    let read = |p: &str| std::fs::read_to_string(root.join(p)).ok();
84    let tests_in = |pred: &dyn Fn(&str) -> bool| -> Vec<&String> {
85        files
86            .iter()
87            .filter(|f| pred(f))
88            .filter(|f| {
89                toolchain_of(f).is_some_and(|t| read(f).is_some_and(|s| defines_tests(t, &s)))
90            })
91            .collect()
92    };
93    let mut runners = Vec::new();
94
95    for pkg in static_cfg::rust_packages(root, &files) {
96        let targets = rust_targets(&pkg, &files);
97        let reach = rust_reachable(&targets, &|p| read(p));
98        let inside = |f: &str| {
99            f.ends_with(".rs")
100                && static_cfg::owner(std::slice::from_ref(&pkg), f).is_some()
101                && !f.starts_with(&at(&pkg.root, "target/"))
102        };
103        let tests_dir = at(&pkg.root, "tests");
104        let inline = tests_in(&|f| {
105            inside(f)
106                && !f.starts_with(&format!("{tests_dir}/"))
107                && read(f).is_some_and(|s| strip_comments(&s).contains("#[cfg(test)]"))
108        });
109        let top = tests_in(&|f| {
110            inside(f)
111                && f.strip_prefix(&format!("{tests_dir}/"))
112                    .is_some_and(|r| !r.contains('/'))
113        });
114        let not_run: Vec<String> =
115            tests_in(&|f| inside(f) && f.starts_with(&format!("{tests_dir}/")))
116                .into_iter()
117                .filter(|f| !reach.contains_key(*f))
118                .cloned()
119                .collect();
120        let mut locations = vec![
121            Location {
122                kind: "inline_modules".to_owned(),
123                path: at(&pkg.root, "src"),
124                files: inline.len(),
125            },
126            Location {
127                kind: "tests_dir".to_owned(),
128                path: format!("{tests_dir}/*.rs"),
129                files: top.len(),
130            },
131        ];
132        let mains = files
133            .iter()
134            .filter(|f| {
135                f.strip_prefix(&format!("{tests_dir}/"))
136                    .is_some_and(|r| r.matches('/').count() == 1 && r.ends_with("/main.rs"))
137            })
138            .count();
139        if mains > 0 {
140            locations.push(Location {
141                kind: "tests_dir".to_owned(),
142                path: format!("{tests_dir}/<dir>/main.rs"),
143                files: mains,
144            });
145        }
146        let mut unknown = Vec::new();
147        let story_dir = if pkg.autotests {
148            Some(tests_dir.clone())
149        } else {
150            unknown.push(format!(
151                "{}: `autotests = false` — a new integration test needs a `[[test]]` entry, which speccoder does not add",
152                at(&pkg.root, "Cargo.toml")
153            ));
154            None
155        };
156        runners.push(RunnerLayout {
157            toolchain: Toolchain::Rust,
158            root: pkg.root.clone(),
159            rule: rust_rule(&pkg),
160            locations,
161            not_run,
162            naming: Some(format!("{tests_dir}/<area>.rs")),
163            story_dir,
164            extension: "rs".to_owned(),
165            unknown,
166        });
167    }
168
169    if static_cfg::uses_pytest(root, &files, check_argvs) {
170        let rule = static_cfg::pytest_rule(root);
171        let py = tests_in(&|f| f.ends_with(".py"));
172        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
173        for f in &py {
174            *by_dir.entry(parent(f).to_owned()).or_default() += 1;
175        }
176        let exists = |d: &str| d.is_empty() || root.join(d).is_dir();
177        let mut unknown = Vec::new();
178        let story_dir = match rule.testpaths.first() {
179            Some(t) if exists(t.trim_end_matches('/')) => Some(t.trim_end_matches('/').to_owned()),
180            Some(t) => {
181                unknown.push(format!(
182                    "testpaths names `{t}`, which does not exist: no place pytest collects"
183                ));
184                None
185            }
186            None if exists("tests") => Some("tests".to_owned()),
187            None => Some(String::new()),
188        };
189        let prefix = py
190            .iter()
191            .filter(|f| f.rsplit('/').next().is_some_and(|n| n.starts_with("test_")))
192            .count();
193        runners.push(RunnerLayout {
194            toolchain: Toolchain::Python,
195            root: String::new(),
196            rule: rule.rule(),
197            locations: by_dir
198                .into_iter()
199                .map(|(path, files)| Location {
200                    kind: "tests_dir".to_owned(),
201                    path,
202                    files,
203                })
204                .collect(),
205            not_run: py
206                .iter()
207                .filter(|f| rule.breaks(f).is_some())
208                .map(|f| (*f).clone())
209                .collect(),
210            naming: Some(if prefix * 2 >= py.len() {
211                "test_<name>.py".to_owned()
212            } else {
213                "<name>_test.py".to_owned()
214            }),
215            story_dir,
216            extension: "py".to_owned(),
217            unknown,
218        });
219    }
220
221    for pkg in static_cfg::js_packages(root, &files, check_argvs) {
222        let inside = |f: &str| pkg.root.is_empty() || f.starts_with(&format!("{}/", pkg.root));
223        let js = tests_in(&|f| inside(f) && toolchain_of(f) == Some(Toolchain::JavaScript));
224        let rel = |f: &str| {
225            if pkg.root.is_empty() {
226                f.to_owned()
227            } else {
228                f.trim_start_matches(&format!("{}/", pkg.root)).to_owned()
229            }
230        };
231        let collected: Vec<&String> = js
232            .iter()
233            .copied()
234            .filter(|f| {
235                pkg.rule
236                    .as_ref()
237                    .map_or(true, |r| r.breaks(&rel(f)).is_none())
238            })
239            .collect();
240        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
241        for f in &collected {
242            *by_dir.entry(parent(f).to_owned()).or_default() += 1;
243        }
244        let ts = collected.iter().filter(|f| f.contains(".ts")).count();
245        let extension = if collected.is_empty() {
246            if files.iter().any(|f| inside(f) && f.ends_with(".ts")) {
247                "ts"
248            } else {
249                "js"
250            }
251        } else if ts * 2 >= collected.len() {
252            "ts"
253        } else {
254            "js"
255        }
256        .to_owned();
257        let habit = by_dir
258            .iter()
259            .max_by_key(|(_, n)| **n)
260            .map(|(d, _)| d.clone());
261        let src = at(&pkg.root, "src");
262        let story_dir = habit.clone().or_else(|| {
263            Some(if root.join(&src).is_dir() {
264                src.clone()
265            } else {
266                pkg.root.clone()
267            })
268        });
269        let mut unknown = Vec::new();
270        let rule = match &pkg.rule {
271            Ok(r) => r.rule(),
272            Err(why) => {
273                unknown.push(why.clone());
274                CollectionRule {
275                    toolchain: Toolchain::JavaScript,
276                    text: format!(
277                        "{} collects the files its configuration includes (not read: {why})",
278                        pkg.runner.name()
279                    ),
280                    source: at(&pkg.root, "package.json"),
281                }
282            }
283        };
284        runners.push(RunnerLayout {
285            toolchain: Toolchain::JavaScript,
286            root: pkg.root.clone(),
287            rule,
288            locations: by_dir
289                .into_iter()
290                .map(|(path, files)| Location {
291                    kind: if path.contains("__tests__") {
292                        "tests_dir"
293                    } else {
294                        "beside_sources"
295                    }
296                    .to_owned(),
297                    path,
298                    files,
299                })
300                .collect(),
301            not_run: js
302                .iter()
303                .filter(|f| !collected.contains(f))
304                .map(|f| (*f).clone())
305                .collect(),
306            naming: Some(match pkg.runner {
307                JsRunner::Vitest | JsRunner::Jest => format!("<name>.test.{extension}"),
308            }),
309            story_dir,
310            extension,
311            unknown,
312        });
313    }
314
315    for m in static_cfg::go_modules(root, &files) {
316        let inside = |f: &str| m.root.is_empty() || f.starts_with(&format!("{}/", m.root));
317        let go = tests_in(&|f| inside(f) && f.ends_with("_test.go"));
318        let mut by_dir: BTreeMap<String, usize> = BTreeMap::new();
319        for f in &go {
320            *by_dir.entry(parent(f).to_owned()).or_default() += 1;
321        }
322        let tags = static_cfg::go_tags(check_argvs);
323        runners.push(RunnerLayout {
324            toolchain: Toolchain::Go,
325            root: m.root.clone(),
326            rule: go_rule(),
327            locations: by_dir
328                .into_iter()
329                .map(|(path, files)| Location {
330                    kind: "package".to_owned(),
331                    path,
332                    files,
333                })
334                .collect(),
335            not_run: go
336                .iter()
337                .filter(|f| {
338                    speccoder_core::collection::go_breaks(f, &read(f).unwrap_or_default(), &tags)
339                        .is_some()
340                })
341                .map(|f| (*f).clone())
342                .collect(),
343            naming: Some("<name>_test.go beside the package's sources".to_owned()),
344            story_dir: Some(m.root.clone()),
345            extension: "go".to_owned(),
346            unknown: Vec::new(),
347        });
348    }
349
350    let canonical = serde_json::to_string(&runners).unwrap_or_default();
351    TestLayout {
352        digest: crate::integrity::sha256_hex(canonical.as_bytes()),
353        runners,
354        pytest: pytest_command(check_argvs),
355    }
356}
357
358/// The first check that runs pytest, cut after its `pytest` word (its own
359/// flags and selection dropped); `python3 -m pytest` when none does.
360fn pytest_command(check_argvs: &[Vec<String>]) -> Vec<String> {
361    check_argvs
362        .iter()
363        .find_map(|a| {
364            let at = a
365                .iter()
366                .position(|w| w == "pytest" || w.ends_with("/pytest"))?;
367            Some(a[..=at].to_vec())
368        })
369        .unwrap_or_else(|| ["python3", "-m", "pytest"].map(str::to_owned).to_vec())
370}
371
372/// The layout's text for the project at `root`, its checks read from its
373/// `speccoder.toml`.
374#[must_use]
375pub fn text_at(root: &Path) -> String {
376    detect(root, &crate::verify::check_argvs(root)).text()
377}
378
379impl TestLayout {
380    /// The runner speccoder's acceptance tests go to: the first with a
381    /// collected place, else the first at all.
382    #[must_use]
383    pub fn primary(&self) -> Option<&RunnerLayout> {
384        self.runners
385            .iter()
386            .find(|r| r.story_dir.is_some())
387            .or_else(|| self.runners.first())
388    }
389
390    /// The acceptance file named after `name` (a task, `T005`) — for Go, in
391    /// `package` (a directory). `None`: no place the runner collects.
392    #[must_use]
393    pub fn acceptance_file(&self, name: &str, package: Option<&str>) -> Option<String> {
394        let r = self.primary()?;
395        let dir = r.story_dir.as_ref()?;
396        let s = name.to_ascii_lowercase();
397        Some(match r.toolchain {
398            Toolchain::Rust => at(dir, &format!("acceptance_{s}.rs")),
399            Toolchain::Python => at(dir, &format!("test_acceptance_{s}.py")),
400            Toolchain::JavaScript => at(dir, &format!("acceptance-{s}.test.{}", r.extension)),
401            Toolchain::Go => at(package.unwrap_or(dir), &format!("acceptance_{s}_test.go")),
402        })
403    }
404
405    /// The command that runs only `file`, for the author's prompt.
406    #[must_use]
407    pub fn command_for(&self, file: &str) -> Option<String> {
408        let r = self.primary()?;
409        let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
410        Some(match r.toolchain {
411            Toolchain::Rust => format!("cargo test --test {stem}"),
412            Toolchain::Python => format!("{} {file}", self.pytest.join(" ")),
413            Toolchain::JavaScript => format!("npx vitest run {file}"),
414            Toolchain::Go => format!("go test ./{}", parent(file)),
415        })
416    }
417
418    /// Whether `argv` is the command that runs only one of `files` — the
419    /// one the author's prompt names, which an exact `[commands] allow`
420    /// (`cargo test`) would otherwise refuse.
421    #[must_use]
422    pub fn runs_only(&self, argv: &[String], files: &[&str]) -> bool {
423        files.iter().any(|f| {
424            self.command_for(f)
425                .is_some_and(|c| c.split_whitespace().eq(argv.iter().map(String::as_str)))
426        })
427    }
428
429    /// The part a model reads (contracts/prompts.md).
430    #[must_use]
431    pub fn text(&self) -> String {
432        let mut out = String::from("# Test layout (detected by speccoder from this checkout)\n");
433        if self.runners.is_empty() {
434            out.push_str("\nNo test runner speccoder knows (Cargo, pytest, vitest/jest, go test) was found.\n");
435        }
436        for r in &self.runners {
437            let name = match r.toolchain {
438                Toolchain::Rust => "cargo (Rust)",
439                Toolchain::Python => "pytest (Python)",
440                Toolchain::JavaScript => "vitest/jest (JavaScript/TypeScript)",
441                Toolchain::Go => "go test (Go)",
442            };
443            out.push_str(&format!(
444                "\nRunner: {name}{}\nRule: {} — from {}\n",
445                if r.root.is_empty() {
446                    String::new()
447                } else {
448                    format!(", in `{}`", r.root)
449                },
450                r.rule.text,
451                r.rule.source
452            ));
453            let today: Vec<String> = r
454                .locations
455                .iter()
456                .map(|l| match l.kind.as_str() {
457                    "inline_modules" => format!(
458                        "inline `#[cfg(test)]` modules in {} file(s) under {}",
459                        l.files, l.path
460                    ),
461                    _ => format!("{}: {} file(s)", l.path, l.files),
462                })
463                .collect();
464            if !today.is_empty() {
465                out.push_str(&format!("Tests today: {}\n", today.join("; ")));
466            }
467            if !r.not_run.is_empty() {
468                out.push_str(&format!("Not run by anything: {}\n", r.not_run.join(", ")));
469            }
470            if let Some(n) = &r.naming {
471                out.push_str(&format!("Naming: {n}\n"));
472            }
473            for u in &r.unknown {
474                out.push_str(&format!("Not determined: {u}\n"));
475            }
476        }
477        out.push_str(
478            "\nPut new tests where the runner collects them. A path the task names that the runner would not\ncollect is a mistake in the plan — use the nearest collected place and say so in your report.\nA test file that never runs fails the verdict.\n",
479        );
480        out
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn write(root: &Path, files: &[(&str, &str)]) {
489        for (p, t) in files {
490            let f = root.join(p);
491            std::fs::create_dir_all(f.parent().unwrap_or(root)).ok();
492            std::fs::write(f, t).ok();
493        }
494    }
495
496    #[test]
497    fn a_rust_crate_s_layout_names_its_rule_its_tests_and_its_dead_files() {
498        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
499        write(
500            d.path(),
501            &[
502                (
503                    "Cargo.toml",
504                    "[package]\nname = \"greet\"\nversion = \"0.1.0\"\n",
505                ),
506                (
507                    "src/lib.rs",
508                    "pub fn greet() {}\n#[cfg(test)]\nmod tests { #[test] fn t() {} }\n",
509                ),
510                ("tests/unit/greet_name.rs", "#[test]\nfn a() {}\n"),
511            ],
512        );
513        let l = detect(d.path(), &[]);
514        assert_eq!(
515            l.acceptance_file("US1", None).as_deref(),
516            Some("tests/acceptance_us1.rs")
517        );
518        assert_eq!(
519            l.command_for("tests/acceptance_us1.rs").as_deref(),
520            Some("cargo test --test acceptance_us1")
521        );
522        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
523        let file = ["tests/acceptance_us1.rs"];
524        assert!(l.runs_only(&argv("cargo test --test acceptance_us1"), &file));
525        assert!(!l.runs_only(&argv("cargo test --test acceptance_us2"), &file));
526        assert!(!l.runs_only(&argv("cargo test"), &file));
527        let text = l.text();
528        assert!(text.contains("Rule: Cargo builds `tests/*.rs`"), "{text}");
529        assert!(
530            text.contains("inline `#[cfg(test)]` modules in 1 file(s)"),
531            "{text}"
532        );
533        assert!(
534            text.contains("Not run by anything: tests/unit/greet_name.rs"),
535            "{text}"
536        );
537        // Same checkout, same bytes.
538        assert_eq!(detect(d.path(), &[]), l);
539    }
540
541    #[test]
542    fn a_python_acceptance_file_runs_with_the_project_s_pytest() {
543        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
544        write(
545            d.path(),
546            &[
547                ("pytest.ini", "[pytest]\n"),
548                ("tests/test_a.py", "def test_a(): pass\n"),
549            ],
550        );
551        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
552        let file = "tests/test_acceptance_us1.py";
553        let uv = detect(
554            d.path(),
555            &[argv(
556                "uv run --locked --offline python -m pytest -q --junitxml=.speccoder/junit.xml",
557            )],
558        );
559        assert_eq!(
560            uv.command_for(file).as_deref(),
561            Some("uv run --locked --offline python -m pytest tests/test_acceptance_us1.py")
562        );
563        assert!(uv.runs_only(
564            &argv("uv run --locked --offline python -m pytest tests/test_acceptance_us1.py"),
565            &[file]
566        ));
567        assert!(!uv.runs_only(
568            &argv("python3 -m pytest tests/test_acceptance_us1.py"),
569            &[file]
570        ));
571        assert_eq!(
572            detect(d.path(), &[argv(".venv/bin/pytest -q")])
573                .command_for(file)
574                .as_deref(),
575            Some(".venv/bin/pytest tests/test_acceptance_us1.py")
576        );
577        assert_eq!(
578            detect(d.path(), &[]).command_for(file).as_deref(),
579            Some("python3 -m pytest tests/test_acceptance_us1.py")
580        );
581    }
582
583    #[test]
584    fn no_collected_place_is_said_never_guessed() {
585        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
586        write(
587            d.path(),
588            &[
589                (
590                    "Cargo.toml",
591                    "[package]\nname = \"g\"\nversion = \"0.1.0\"\nautotests = false\n",
592                ),
593                ("src/lib.rs", ""),
594            ],
595        );
596        let l = detect(d.path(), &[]);
597        assert_eq!(l.acceptance_file("US1", None), None);
598        assert!(l.runners[0].unknown[0].contains("autotests = false"));
599        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
600        write(d.path(), &[("pytest.ini", "[pytest]\ntestpaths = spec\n")]);
601        assert_eq!(detect(d.path(), &[]).acceptance_file("US1", None), None);
602    }
603
604    #[test]
605    fn python_js_and_go_acceptance_files_follow_the_layout() {
606        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
607        write(
608            d.path(),
609            &[
610                ("pytest.ini", "[pytest]\n"),
611                ("tests/test_a.py", "def test_a():\n    pass\n"),
612            ],
613        );
614        assert_eq!(
615            detect(d.path(), &[])
616                .acceptance_file("US2", None)
617                .as_deref(),
618            Some("tests/test_acceptance_us2.py")
619        );
620        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
621        write(
622            d.path(),
623            &[
624                (
625                    "package.json",
626                    "{ \"devDependencies\": { \"vitest\": \"3\" } }",
627                ),
628                ("src/a.test.ts", "it('a', () => {})\n"),
629            ],
630        );
631        assert_eq!(
632            detect(d.path(), &[])
633                .acceptance_file("US1", None)
634                .as_deref(),
635            Some("src/acceptance-us1.test.ts")
636        );
637        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
638        write(
639            d.path(),
640            &[("go.mod", "module ex.com/m\n"), ("g/g.go", "package g\n")],
641        );
642        assert_eq!(
643            detect(d.path(), &[])
644                .acceptance_file("US1", Some("g"))
645                .as_deref(),
646            Some("g/acceptance_us1_test.go")
647        );
648    }
649}
650
651/// A command the project declares: what a person runs, and the words a
652/// test that runs it names (the command, `-m` its module, its binary): an
653/// import of the module behind it runs no command.
654#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct Command {
656    /// As a person types it: `md2csv`, `python -m pkg`.
657    pub shown: String,
658    /// Any of these in a test's text means it runs the command.
659    pub runs: Vec<String>,
660}
661
662impl Command {
663    /// Whether `test` runs this command.
664    #[must_use]
665    pub fn run_by(&self, test: &str) -> bool {
666        self.runs.iter().any(|w| test.contains(w.as_str()))
667    }
668}
669
670/// The commands `root` declares: `[project.scripts]` and a package's
671/// `__main__.py` (Python), a Cargo binary, a `package.json` `bin`.
672#[must_use]
673pub fn commands(root: &Path) -> Vec<Command> {
674    let read = |p: &str| std::fs::read_to_string(root.join(p)).ok();
675    let quoted = |w: &str| vec![format!("\"{w}\""), format!("'{w}'")];
676    let mut out = Vec::new();
677    if let Some(t) = read("pyproject.toml").and_then(|t| t.parse::<toml::Table>().ok()) {
678        let scripts = t
679            .get("project")
680            .and_then(|p| p.get("scripts"))
681            .and_then(toml::Value::as_table);
682        for (name, target) in scripts.into_iter().flatten() {
683            let module = target
684                .as_str()
685                .and_then(|s| s.split(':').next())
686                .unwrap_or_default();
687            let top = module.split('.').next().unwrap_or_default();
688            let mut runs = quoted(name);
689            if !top.is_empty() {
690                runs.extend([
691                    format!("-m {top}"),
692                    format!("\"{top}\""),
693                    format!("'{top}'"),
694                ]);
695            }
696            out.push(Command {
697                shown: name.clone(),
698                runs,
699            });
700        }
701    }
702    for base in ["src", "."] {
703        let Ok(dirs) = std::fs::read_dir(root.join(base)) else {
704            continue;
705        };
706        for d in dirs.flatten() {
707            let pkg = d.file_name().to_string_lossy().into_owned();
708            if d.path().join("__main__.py").is_file()
709                && !out.iter().any(|c| c.runs.contains(&format!("\"{pkg}\"")))
710            {
711                let mut runs = quoted(&pkg);
712                runs.push(format!("-m {pkg}"));
713                out.push(Command {
714                    shown: format!("python -m {pkg}"),
715                    runs,
716                });
717            }
718        }
719    }
720    if let Some(t) = read("Cargo.toml").and_then(|t| t.parse::<toml::Table>().ok()) {
721        let mut bins: Vec<String> = t
722            .get("bin")
723            .and_then(toml::Value::as_array)
724            .into_iter()
725            .flatten()
726            .filter_map(|b| {
727                b.get("name")
728                    .and_then(toml::Value::as_str)
729                    .map(str::to_owned)
730            })
731            .collect();
732        if root.join("src/main.rs").is_file()
733            && let Some(name) = t
734                .get("package")
735                .and_then(|p| p.get("name"))
736                .and_then(toml::Value::as_str)
737        {
738            bins.push(name.to_owned());
739        }
740        for name in bins {
741            let env = format!("CARGO_BIN_EXE_{name}");
742            out.push(Command {
743                shown: name.clone(),
744                runs: vec![
745                    env,
746                    format!("cargo_bin(\"{name}\")"),
747                    "\"cargo\", \"run\"".to_owned(),
748                    "cargo run".to_owned(),
749                ],
750            });
751        }
752    }
753    if let Some(v) =
754        read("package.json").and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
755    {
756        let name = v["name"]
757            .as_str()
758            .unwrap_or_default()
759            .rsplit('/')
760            .next()
761            .unwrap_or_default()
762            .to_owned();
763        let bins: Vec<(String, String)> = match &v["bin"] {
764            serde_json::Value::String(path) => vec![(name, path.clone())],
765            serde_json::Value::Object(m) => m
766                .iter()
767                .filter_map(|(k, p)| p.as_str().map(|p| (k.clone(), p.to_owned())))
768                .collect(),
769            _ => Vec::new(),
770        };
771        for (name, path) in bins {
772            let path = path.trim_start_matches("./").to_owned();
773            let mut runs = quoted(&name);
774            runs.push(path.clone());
775            runs.push(path.rsplit('/').next().unwrap_or(&path).to_owned());
776            out.push(Command { shown: name, runs });
777        }
778    }
779    out
780}
781
782/// The manifests a stack is planned in, by the language each says.
783pub const STACKS: &[(&str, &str)] = &[
784    ("Cargo.toml", "Rust"),
785    ("pyproject.toml", "Python"),
786    ("package.json", "JavaScript/TypeScript"),
787    ("go.mod", "Go"),
788    ("pom.xml", "Java"),
789];
790
791/// The checkout's stack, as planning is told it: its manifests and the
792/// checks that test it — so a plan is made in it, never invented. No
793/// manifest: `None`, the plan chooses.
794#[must_use]
795pub fn stack_text(root: &Path) -> Option<String> {
796    let read = |p: &str| std::fs::read_to_string(root.join(p)).ok();
797    let found: Vec<String> = STACKS
798        .iter()
799        .filter(|(m, _)| root.join(m).is_file())
800        .map(|(m, lang)| {
801            // The language version the manifest requires, as written.
802            let python = (*m == "pyproject.toml")
803                .then(|| read(m)?.parse::<toml::Table>().ok())
804                .flatten()
805                .and_then(|t| {
806                    t.get("project")?
807                        .get("requires-python")?
808                        .as_str()
809                        .map(str::to_owned)
810                });
811            match python {
812                Some(v) => format!("- {lang}: `{m}` (requires-python `{v}`)"),
813                None => format!("- {lang}: `{m}`"),
814            }
815        })
816        .collect();
817    if found.is_empty() {
818        return None;
819    }
820    let commands = commands(root)
821        .into_iter()
822        .map(|c| format!("- command: `{}`", c.shown));
823    // Where the code is: each source file's folder, the shallowest first.
824    let mut code: Vec<String> = crate::git::Git::at(root)
825        .line(&["ls-files"])
826        .unwrap_or_default()
827        .lines()
828        .filter(|f| {
829            f.rsplit_once('.')
830                .is_some_and(|(_, e)| matches!(e, "py" | "rs" | "ts" | "js" | "go" | "java"))
831                && !crate::tools::paths::is_test_path(f)
832        })
833        .map(|f| f.rsplit_once('/').map_or(".", |(d, _)| d).to_owned())
834        .collect();
835    code.sort_by_key(|d| (d.matches('/').count(), d.clone()));
836    code.dedup();
837    let code = code
838        .into_iter()
839        .take(6)
840        .map(|d| format!("- code: `{d}/` — extend it; the command runs only what it imports"));
841    let checks = crate::verify::check_argvs(root)
842        .into_iter()
843        .map(|a| format!("- check: `{}`", a.join(" ")));
844    let lines: Vec<String> = found
845        .into_iter()
846        .chain(commands)
847        .chain(code)
848        .chain(checks)
849        .collect();
850    Some(format!(
851        "# The project as it is (read by speccoder from this checkout)\n\nPlan in this stack — its language and version, its manifest, its code's place, its test runner — never another:\n\n{}\n",
852        lines.join("\n")
853    ))
854}
855
856/// Whether a story's *Independent Test* (its line in the story's text) or
857/// a scenario's *When* (what the person does) is about a command line, by
858/// whole words: the interface is then the project's command, never an
859/// internal module.
860#[must_use]
861pub fn via_command(story: &str) -> bool {
862    story.lines().any(|l| {
863        if l.contains("Independent Test") {
864            command_words(l)
865        } else {
866            // The action alone: what is given, or follows, is no interface.
867            l.split_once("**When**").is_some_and(|(_, when)| {
868                command_words(when.split("**Then**").next().unwrap_or(when))
869            })
870        }
871    })
872}
873
874/// Whether a task's tests run the project's command: its title, or a
875/// scenario it cites, is about a command line.
876#[must_use]
877pub fn task_via_command<'a>(title: &str, mut scenarios: impl Iterator<Item = &'a str>) -> bool {
878    command_words(title) || scenarios.any(via_command)
879}
880
881fn command_words(text: &str) -> bool {
882    let line = text.to_ascii_lowercase();
883    let words: Vec<&str> = line.split(|c: char| !c.is_alphanumeric()).collect();
884    let pair = |a: &str, b: &str| words.windows(2).any(|p| p[0] == a && p[1] == b);
885    ["command", "cli", "stdin", "stdout", "stderr", "terminal"]
886        .iter()
887        .any(|w| words.contains(w))
888        || pair("standard", "input")
889        || pair("standard", "output")
890        || pair("exit", "code")
891        || pair("exit", "status")
892}
893
894#[cfg(test)]
895mod command_tests {
896    use super::{commands, stack_text, task_via_command, via_command};
897
898    /// The example's plan (2026-11-03) left its language open in a uv
899    /// project and its tasks then built a Rust crate.
900    #[test]
901    fn planning_is_told_the_checkout_s_stack() {
902        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
903        assert_eq!(stack_text(dir.path()), None);
904        let r = dir.path();
905        let write = |p: &str, t: &str| {
906            std::fs::create_dir_all(r.join(p).parent().unwrap_or(r))
907                .unwrap_or_else(|e| panic!("{e}"));
908            std::fs::write(r.join(p), t).unwrap_or_else(|e| panic!("{e}"));
909        };
910        write(
911            "pyproject.toml",
912            "[project]\nname = \"md2csv\"\nrequires-python = \">=3.15\"\n\n[project.scripts]\nmd2csv = \"md2csv:main\"\n",
913        );
914        write("src/md2csv/__init__.py", "def main(): ...\n");
915        write("tests/test_x.py", "def test_x(): ...\n");
916        for args in [&["init", "-q"][..], &["add", "-A"]] {
917            std::process::Command::new("git")
918                .args(args)
919                .current_dir(r)
920                .output()
921                .unwrap_or_else(|e| panic!("{e}"));
922        }
923        let t = stack_text(r).unwrap_or_default();
924        for line in [
925            "- Python: `pyproject.toml` (requires-python `>=3.15`)",
926            "- command: `md2csv`",
927            "- code: `src/md2csv/` — extend it",
928        ] {
929            assert!(t.contains(line), "{line}\n{t}");
930        }
931        assert!(!t.contains("`tests/`"), "{t}");
932    }
933
934    /// The sample's project (2026-11-02): its oracle tests imported
935    /// `src.services…`, never the command its stories name.
936    #[test]
937    fn a_project_s_commands_and_what_runs_them() {
938        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
939        let r = dir.path();
940        let write = |p: &str, t: &str| {
941            std::fs::create_dir_all(r.join(p).parent().unwrap_or(r))
942                .unwrap_or_else(|e| panic!("{e}"));
943            std::fs::write(r.join(p), t).unwrap_or_else(|e| panic!("{e}"));
944        };
945        write(
946            "pyproject.toml",
947            "[project]\nname = \"speccoder-poc\"\n\n[project.scripts]\nspeccoder-poc = \"speccoder_poc:main\"\n",
948        );
949        write(
950            "src/speccoder_poc/__main__.py",
951            "from . import main\nmain()\n",
952        );
953        let c = commands(r);
954        assert_eq!(
955            c.iter().map(|c| c.shown.as_str()).collect::<Vec<_>>(),
956            ["speccoder-poc"]
957        );
958        assert!(
959            c[0].run_by("subprocess.run([sys.executable, \"-m\", \"speccoder_poc\"], input=t)")
960        );
961        assert!(c[0].run_by("subprocess.run([\"speccoder-poc\", \"in.md\"])"));
962        // In-process, the installed command's own errors never show.
963        assert!(!c[0].run_by("from speccoder_poc import main\nmain([\"in.md\"])"));
964        assert!(!c[0].run_by("from speccoder_poc.cli import CLI\nCLI.run(content=t)"));
965        assert!(!c[0].run_by("from src.services.table_parser import parse_markdown_table"));
966
967        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
968        let r = dir.path();
969        std::fs::create_dir_all(r.join("src")).unwrap_or_else(|e| panic!("{e}"));
970        std::fs::write(r.join("Cargo.toml"), "[package]\nname = \"md2csv\"\n")
971            .unwrap_or_else(|e| panic!("{e}"));
972        std::fs::write(r.join("src/main.rs"), "fn main() {}\n").unwrap_or_else(|e| panic!("{e}"));
973        std::fs::write(
974            r.join("package.json"),
975            "{\"name\": \"@x/tables\", \"bin\": {\"tables\": \"./bin/tables.js\"}}",
976        )
977        .unwrap_or_else(|e| panic!("{e}"));
978        let c = commands(r);
979        assert_eq!(
980            c.iter().map(|c| c.shown.as_str()).collect::<Vec<_>>(),
981            ["md2csv", "tables"]
982        );
983        assert!(c[0].run_by("Command::new(env!(\"CARGO_BIN_EXE_md2csv\"))"));
984        assert!(c[1].run_by("execFileSync('node', ['bin/tables.js'])"));
985        assert!(!c[0].run_by("use md2csv::parse;"));
986
987        assert!(via_command(
988            "**Independent Test**: Can be tested by piping a valid Markdown table into the command"
989        ));
990        assert!(!via_command(
991            "**Independent Test**: Can be tested by calling parse() with a table"
992        ));
993        // Whole words of that line only: a client, a recommendation, the
994        // scenarios' words are no command line.
995        assert!(!via_command(
996            "**Independent Test**: the client follows the recommended flow"
997        ));
998        assert!(!via_command("1. **Given** the command, **Then** CSV"));
999        // What the person does: the sample's US3, whose Independent Test
1000        // names only the input.
1001        assert!(via_command(
1002            "**Independent Test**: providing invalid Markdown table input\n1. **Given** malformed input, **When** the user runs the command, **Then** the tool reports it"
1003        ));
1004        assert!(!via_command(
1005            "1. **Given** a table, **When** parse() is called, **Then** the command prints CSV"
1006        ));
1007        // The sample's T038 and T037: a gap in the CLI path, one in the parser.
1008        assert!(task_via_command(
1009            "Thread the parser through the CLI execution path",
1010            std::iter::empty()
1011        ));
1012        assert!(!task_via_command(
1013            "Update Markdown cell splitting so it preserves in-cell whitespace",
1014            std::iter::empty()
1015        ));
1016        assert!(task_via_command(
1017            "Escape pipes in cells",
1018            ["**Given** `a \\| b`, **When** the user runs the command, **Then** `a | b`"]
1019                .into_iter()
1020        ));
1021    }
1022}