Skip to main content

speccoder_runtime/
plan_lint.rs

1//! Checks of a plan before it runs: where the task list will
2//! produce no evidence. Deterministic, text-level, warnings only — they
3//! never block, delay or re-order a run, and never edit the plan.
4//!
5//! [`RULES`] is the one source of the three rules: the lints' codes and
6//! titles, and the sentence the `speccoder` Spec Kit preset's tasks
7//! template states (a test holds the template to it).
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::path::Path;
11
12use speccoder_core::snapshot::ProjectSnapshot;
13
14use crate::format::{Adapter, PhaseKind, PlanStructure};
15
16/// One rule, as a lint and as the preset says it.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Rule {
19    /// The lint's code.
20    pub code: &'static str,
21    /// The sentence the preset's tasks template states.
22    pub preset: &'static str,
23}
24
25/// The three rules.
26pub const RULES: [Rule; 3] = [
27    Rule {
28        code: "PLAN-BEHAVIOUR-EARLY",
29        preset: "Implement a story's behaviour inside that story's phase: Setup and Foundational tasks only create structure — manifests, empty modules, signatures that do not yet behave.",
30    },
31    Rule {
32        code: "PLAN-TEST-ONLY-TASKS",
33        preset: "Write no task whose only output is tests: speccoder writes each story's acceptance tests from its scenarios and judges the implementing tasks by them.",
34    },
35    Rule {
36        code: "PLAN-TESTS-UNCOLLECTED",
37        preset: "When a task names a test file, name a path the project's test runner collects (Cargo: `tests/<name>.rs`, or `tests/<dir>/main.rs` and the modules it declares; pytest: `test_*.py` under `testpaths`; vitest/jest: a file matching `include`, such as `<name>.test.ts`; Go: `<name>_test.go` beside the package's sources).",
38    },
39];
40
41/// One warning.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct PlanLint {
44    /// Its code.
45    pub code: &'static str,
46    /// The tasks it names.
47    pub tasks: Vec<String>,
48    /// What it found and what will happen.
49    pub detail: String,
50    /// The ways out (commands or `→` lines).
51    pub fix: Vec<String>,
52    /// For `PLAN-BEHAVIOUR-EARLY`: the task and the story the acceptance
53    /// could be written at.
54    pub acceptance_at: Option<(String, String)>,
55}
56
57/// The tasks joined: `T002, T005, T006`.
58fn listed(ids: &[String]) -> String {
59    ids.join(", ")
60}
61
62/// The re-plan fix: the format's task skill while nothing is ticked.
63fn replan(adapter: &dyn Adapter, any_ticked: bool) -> String {
64    let skill = (adapter.planning().toward_tasks)(true)
65        .last()
66        .copied()
67        .unwrap_or("tasks");
68    if any_ticked {
69        "→ re-planning now would rewrite the tasks your ticks are bound to — apply it to the next feature".to_owned()
70    } else {
71        format!("speccoder spec {skill}  (re-plan with the spec tool)")
72    }
73}
74
75const KEYWORDS: &[&str] = &[
76    "fn",
77    "pub",
78    "let",
79    "mut",
80    "impl",
81    "struct",
82    "enum",
83    "trait",
84    "return",
85    "self",
86    "Self",
87    "str",
88    "String",
89    "bool",
90    "char",
91    "usize",
92    "isize",
93    "u8",
94    "u16",
95    "u32",
96    "u64",
97    "i8",
98    "i16",
99    "i32",
100    "i64",
101    "f32",
102    "f64",
103    "Vec",
104    "Option",
105    "Result",
106    "Box",
107    "def",
108    "class",
109    "async",
110    "await",
111    "const",
112    "var",
113    "func",
114    "int",
115    "float",
116    "string",
117    "number",
118    "void",
119    "null",
120    "None",
121    "true",
122    "false",
123    "new",
124    "type",
125    "interface",
126    "export",
127    "import",
128    "from",
129    "use",
130    "mod",
131    "crate",
132];
133
134/// The symbols a code span names: a lone identifier, or one called
135/// (`greet(…)`) — never a path, a parameter, a keyword or a type word.
136fn symbols(span: &str) -> Vec<String> {
137    let s = span.trim();
138    if s.contains('/') || (s.contains('.') && !s.contains('(')) {
139        return Vec::new();
140    }
141    let ident = |w: &str| {
142        w.len() >= 3
143            && w.chars()
144                .next()
145                .is_some_and(|c| c.is_alphabetic() || c == '_')
146            && w.chars().all(|c| c.is_alphanumeric() || c == '_')
147            && !KEYWORDS.contains(&w)
148    };
149    if ident(s) {
150        return vec![s.to_owned()];
151    }
152    let chars: Vec<char> = s.chars().collect();
153    let mut out = Vec::new();
154    let mut i = 0;
155    while i < chars.len() {
156        if chars[i].is_alphabetic() || chars[i] == '_' {
157            let st = i;
158            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
159                i += 1;
160            }
161            let w: String = chars[st..i].iter().collect();
162            if chars.get(i) == Some(&'(') && ident(&w) {
163                out.push(w);
164            }
165        } else {
166            i += 1;
167        }
168    }
169    out
170}
171
172/// Whether a task only checks (its verb): it implements nothing.
173fn only_checks(title: &str) -> bool {
174    let first = title
175        .split_whitespace()
176        .next()
177        .unwrap_or_default()
178        .to_ascii_lowercase();
179    matches!(
180        first.as_str(),
181        "verify" | "confirm" | "check" | "review" | "ensure" | "validate" | "run" | "document"
182    )
183}
184
185/// Every lint over the selected unit of `snapshot` at `root`.
186#[must_use]
187pub fn lints(adapter: &dyn Adapter, snapshot: &ProjectSnapshot, root: &Path) -> Vec<PlanLint> {
188    let Some(tasks) = snapshot.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
189        return Vec::new();
190    };
191    let any_ticked = tasks.tasks.iter().any(|t| t.done);
192    let mut out = Vec::new();
193    if let Some(l) = uncollected(adapter, tasks, root, any_ticked) {
194        out.push(l);
195    }
196    if let Some(structure) = adapter.plan_structure(snapshot) {
197        out.extend(behaviour_early(adapter, &structure, any_ticked));
198        if let Some(l) = test_only(adapter, &structure, any_ticked) {
199            out.push(l);
200        }
201    }
202    out
203}
204
205/// `PLAN-TESTS-UNCOLLECTED`: test paths the tasks name that the runner's
206/// static rule would not collect (unless another task creates the file
207/// declaring them), and test files in the tree nothing runs.
208fn uncollected(
209    adapter: &dyn Adapter,
210    tasks: &speccoder_core::snapshot::ParsedTasks,
211    root: &Path,
212    any_ticked: bool,
213) -> Option<PlanLint> {
214    let files = crate::collection::static_cfg::files(root);
215    let packages = crate::collection::static_cfg::rust_packages(root, &files);
216    let named: Vec<(String, Vec<String>)> = tasks
217        .tasks
218        .iter()
219        .map(|t| (t.id.clone(), speccoder_core::scope::named_paths(&t.title)))
220        .collect();
221    let all_named: BTreeSet<&str> = named
222        .iter()
223        .flat_map(|(_, p)| p.iter().map(String::as_str))
224        .collect();
225    // By directory: the tasks naming paths under it, and whether Cargo's rule.
226    let mut by_dir: BTreeMap<String, (Vec<String>, bool)> = BTreeMap::new();
227    for (id, paths) in &named {
228        for p in paths {
229            let is_dir = p.ends_with('/');
230            let path = p.trim_end_matches('/');
231            if !crate::tools::paths::is_test_path(&format!(
232                "{path}{}",
233                if is_dir { "/x" } else { "" }
234            )) {
235                continue;
236            }
237            let rust_pkg = crate::collection::static_cfg::owner(&packages, path);
238            let (dir, never, rust) = if path.ends_with(".rs") || (is_dir && rust_pkg.is_some()) {
239                let Some(pkg) = rust_pkg else { continue };
240                let tests =
241                    speccoder_core::collection::join(&pkg.root, "tests").unwrap_or_default();
242                let Some(rest) = path.strip_prefix(&format!("{tests}/")) else {
243                    continue;
244                };
245                let parts: Vec<&str> = rest.split('/').collect();
246                let first = parts.first().copied().unwrap_or_default();
247                // tests/<x>.rs, or tests/<dir>/main.rs: collected by the rule.
248                if (!is_dir && parts.len() == 1) || (parts.len() == 2 && parts[1] == "main.rs") {
249                    continue;
250                }
251                let main = format!("{tests}/{first}/main.rs");
252                let declared = files.contains(&main)
253                    || all_named.contains(main.as_str())
254                    || files.contains(&format!("{tests}/{first}.rs"));
255                (
256                    format!("{tests}/{first}/"),
257                    !(declared && pkg.autotests),
258                    true,
259                )
260            } else if is_dir {
261                continue;
262            } else {
263                let never = crate::collection::statically_refused(root, path, "").is_some();
264                (
265                    path.rsplit_once('/')
266                        .map_or(String::new(), |(d, _)| format!("{d}/")),
267                    never,
268                    false,
269                )
270            };
271            if never {
272                let e = by_dir.entry(dir).or_insert((Vec::new(), rust));
273                if !e.0.contains(id) {
274                    e.0.push(id.clone());
275                }
276            }
277        }
278    }
279    let layout = crate::layout::detect(root, &crate::verify::check_argvs(root));
280    let dead: Vec<String> = layout
281        .runners
282        .iter()
283        .flat_map(|r| r.not_run.clone())
284        .collect();
285    if by_dir.is_empty() && dead.is_empty() {
286        return None;
287    }
288    let mut tasks_named: Vec<String> = Vec::new();
289    let mut sentences = Vec::new();
290    let mut fix = Vec::new();
291    for (dir, (ids, rust)) in &by_dir {
292        for id in ids {
293            if !tasks_named.contains(id) {
294                tasks_named.push(id.clone());
295            }
296        }
297        sentences.push(format!(
298            "{} name `{dir}` — {}",
299            listed(ids),
300            if *rust {
301                "Cargo won't build the tests there"
302            } else {
303                "the runner won't collect tests there"
304            }
305        ));
306        if *rust {
307            let tests = dir
308                .trim_end_matches('/')
309                .rsplit_once('/')
310                .map_or("tests", |(t, _)| t);
311            fix.push(format!("→ rename to `{tests}/<name>.rs`"));
312            fix.push(format!("→ add a task that creates `{dir}main.rs`"));
313        } else {
314            fix.push("→ name a path the runner collects (the test layout in `speccoder doctor` says where)".to_owned());
315        }
316    }
317    if !dead.is_empty() {
318        sentences.push(format!("test files nothing runs: {}", dead.join(", ")));
319    }
320    tasks_named.sort();
321    fix.push(replan(adapter, any_ticked));
322    fix.dedup();
323    Some(PlanLint {
324        code: "PLAN-TESTS-UNCOLLECTED",
325        tasks: tasks_named,
326        detail: sentences.join("; "),
327        fix,
328        acceptance_at: None,
329    })
330}
331
332/// `PLAN-BEHAVIOUR-EARLY`: a Setup or Foundational task that names, as
333/// code, a symbol a story names in its scenarios or tasks.
334fn behaviour_early(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Vec<PlanLint> {
335    let mut story_symbols: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
336    for (story, text) in &s.stories {
337        let set = story_symbols.entry(story.as_str()).or_default();
338        for span in crate::format::code_spans(text) {
339            set.extend(symbols(&span));
340        }
341    }
342    for t in s.tasks.iter().filter(|t| t.phase == PhaseKind::Story) {
343        if let Some(st) = &t.story {
344            let set = story_symbols.entry(st.as_str()).or_default();
345            for span in &t.spans {
346                set.extend(symbols(span));
347            }
348        }
349    }
350    let mut out = Vec::new();
351    for t in s
352        .tasks
353        .iter()
354        .filter(|t| matches!(t.phase, PhaseKind::Setup | PhaseKind::Foundational))
355        .filter(|t| !only_checks(&t.title))
356    {
357        let mine: BTreeSet<String> = t.spans.iter().flat_map(|sp| symbols(sp)).collect();
358        let Some((story, shared)) = story_symbols.iter().find_map(|(st, set)| {
359            let shared: Vec<&String> = mine.intersection(set).collect();
360            (!shared.is_empty()).then_some((*st, shared))
361        }) else {
362            continue;
363        };
364        let phase = if t.phase == PhaseKind::Setup {
365            "Setup"
366        } else {
367            "Foundational"
368        };
369        let names = shared
370            .iter()
371            .map(|n| format!("`{n}`"))
372            .collect::<Vec<_>>()
373            .join(", ");
374        out.push(PlanLint {
375            code: "PLAN-BEHAVIOUR-EARLY",
376            tasks: vec![t.id.clone()],
377            detail: format!(
378                "{} ({phase}) names {names}, which {story} is about — if {} implements it, {story}'s acceptance cannot go red and its tasks will tick regression-only",
379                t.id, t.id
380            ),
381            fix: vec![
382                format!("speccoder task acceptance-at {} --story {story}  (write {story}'s acceptance tests at {})", t.id, t.id),
383                replan(adapter, any_ticked),
384            ],
385            acceptance_at: Some((t.id.clone(), story.to_owned())),
386        });
387    }
388    out
389}
390
391/// `PLAN-TEST-ONLY-TASKS`: story tasks whose only named outputs are test
392/// paths, beside an implementing task of the same story.
393fn test_only(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Option<PlanLint> {
394    let is_test_only = |t: &crate::format::PlannedTask| {
395        let paths = speccoder_core::scope::named_paths(&t.title);
396        let verb = t
397            .title
398            .split_whitespace()
399            .next()
400            .unwrap_or_default()
401            .to_ascii_lowercase();
402        !paths.is_empty()
403            && paths.iter().all(|p| {
404                let p = p.trim_end_matches('/');
405                crate::tools::paths::is_test_path(p)
406                    || crate::tools::paths::is_test_path(&format!("{p}/x"))
407            })
408            && matches!(verb.as_str(), "add" | "write" | "create")
409            && t.title.to_ascii_lowercase().contains("test")
410    };
411    let mut ids = Vec::new();
412    for t in s
413        .tasks
414        .iter()
415        .filter(|t| t.story.is_some() && is_test_only(t))
416    {
417        let implementing = s
418            .tasks
419            .iter()
420            .any(|o| o.story == t.story && o.id != t.id && !is_test_only(o));
421        if implementing {
422            ids.push(t.id.clone());
423        }
424    }
425    (!ids.is_empty()).then(|| PlanLint {
426        code: "PLAN-TEST-ONLY-TASKS",
427        detail: format!(
428            "{} only write tests — speccoder writes each story's acceptance tests itself; merge them into the implementing tasks",
429            listed(&ids)
430        ),
431        tasks: ids,
432        fix: vec![replan(adapter, any_ticked)],
433        acceptance_at: None,
434    })
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn symbols_are_called_names_or_lone_identifiers() {
443        assert_eq!(symbols("greet(name: &str) -> String"), ["greet"]);
444        assert_eq!(symbols("greet"), ["greet"]);
445        assert!(symbols("src/lib.rs").is_empty());
446        assert!(symbols("Hello, <name>!").is_empty());
447        assert!(symbols("String").is_empty());
448    }
449
450    #[test]
451    fn the_preset_template_states_every_rule() {
452        let template = include_str!("../assets/preset/speccoder/templates/tasks-template.md");
453        for r in RULES {
454            assert!(
455                template.contains(r.preset),
456                "{} is not in the preset",
457                r.code
458            );
459            assert!(
460                speccoder_core::codes::lookup(r.code).is_some(),
461                "{}",
462                r.code
463            );
464        }
465    }
466}