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