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 four 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 four rules.
26pub const RULES: [Rule; 4] = [
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-SCENARIO-UNKNOWN",
37        preset: "Name, in an implementing task's line, the scenarios it makes hold — `(scenarios US1-1, US1-2)`, the story's number and the scenario's position in it; it must leave exactly those passing.",
38    },
39    Rule {
40        code: "PLAN-TESTS-UNCOLLECTED",
41        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).",
42    },
43];
44
45/// Who does a task, as the preset states it: design documents are the
46/// plan's, a person's steps are labelled (lanes: `speccoder_core::lane`).
47pub const LANE_RULES: [&str; 2] = [
48    "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).",
49    "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.",
50];
51
52/// One warning.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PlanLint {
55    /// Its code.
56    pub code: &'static str,
57    /// The tasks it names.
58    pub tasks: Vec<String>,
59    /// What it found and what will happen.
60    pub detail: String,
61    /// The ways out (commands or `→` lines).
62    pub fix: Vec<String>,
63    /// For `PLAN-BEHAVIOUR-EARLY`: the task and the story the acceptance
64    /// could be written at.
65    pub acceptance_at: Option<(String, String)>,
66}
67
68/// The tasks joined: `T002, T005, T006`.
69fn listed(ids: &[String]) -> String {
70    ids.join(", ")
71}
72
73/// The re-plan fix: the format's task skill while nothing is ticked.
74fn replan(adapter: &dyn Adapter, any_ticked: bool) -> String {
75    let skill = (adapter.planning().toward_tasks)(true)
76        .last()
77        .copied()
78        .unwrap_or("tasks");
79    if any_ticked {
80        "→ re-planning now would rewrite the tasks your ticks are bound to — apply it to the next feature".to_owned()
81    } else {
82        format!("speccoder spec {skill}  (re-plan with the spec tool)")
83    }
84}
85
86const KEYWORDS: &[&str] = &[
87    "fn",
88    "pub",
89    "let",
90    "mut",
91    "impl",
92    "struct",
93    "enum",
94    "trait",
95    "return",
96    "self",
97    "Self",
98    "str",
99    "String",
100    "bool",
101    "char",
102    "usize",
103    "isize",
104    "u8",
105    "u16",
106    "u32",
107    "u64",
108    "i8",
109    "i16",
110    "i32",
111    "i64",
112    "f32",
113    "f64",
114    "Vec",
115    "Option",
116    "Result",
117    "Box",
118    "def",
119    "class",
120    "async",
121    "await",
122    "const",
123    "var",
124    "func",
125    "int",
126    "float",
127    "string",
128    "number",
129    "void",
130    "null",
131    "None",
132    "true",
133    "false",
134    "new",
135    "type",
136    "interface",
137    "export",
138    "import",
139    "from",
140    "use",
141    "mod",
142    "crate",
143];
144
145/// The symbols a code span names: a lone identifier, or one called
146/// (`greet(…)`) — never a path, a parameter, a keyword or a type word.
147fn symbols(span: &str) -> Vec<String> {
148    let s = span.trim();
149    if s.contains('/') || (s.contains('.') && !s.contains('(')) {
150        return Vec::new();
151    }
152    let ident = |w: &str| {
153        w.len() >= 3
154            && w.chars()
155                .next()
156                .is_some_and(|c| c.is_alphabetic() || c == '_')
157            && w.chars().all(|c| c.is_alphanumeric() || c == '_')
158            && !KEYWORDS.contains(&w)
159    };
160    if ident(s) {
161        return vec![s.to_owned()];
162    }
163    let chars: Vec<char> = s.chars().collect();
164    let mut out = Vec::new();
165    let mut i = 0;
166    while i < chars.len() {
167        if chars[i].is_alphabetic() || chars[i] == '_' {
168            let st = i;
169            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
170                i += 1;
171            }
172            let w: String = chars[st..i].iter().collect();
173            if chars.get(i) == Some(&'(') && ident(&w) {
174                out.push(w);
175            }
176        } else {
177            i += 1;
178        }
179    }
180    out
181}
182
183/// Whether a task only checks (its verb): it implements nothing.
184fn only_checks(title: &str) -> bool {
185    let first = title
186        .split_whitespace()
187        .next()
188        .unwrap_or_default()
189        .to_ascii_lowercase();
190    matches!(
191        first.as_str(),
192        "verify" | "confirm" | "check" | "review" | "ensure" | "validate" | "run" | "document"
193    )
194}
195
196/// Every lint over the selected unit of `snapshot` at `root`.
197#[must_use]
198pub fn lints(adapter: &dyn Adapter, snapshot: &ProjectSnapshot, root: &Path) -> Vec<PlanLint> {
199    let Some(tasks) = snapshot.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
200        return Vec::new();
201    };
202    let any_ticked = tasks.tasks.iter().any(|t| t.done);
203    let mut out = Vec::new();
204    if let Some(l) = uncollected(adapter, tasks, root, any_ticked) {
205        out.push(l);
206    }
207    out.extend(story_links(adapter, snapshot, tasks));
208    if let Some(l) = other_stack(adapter, tasks, root, any_ticked) {
209        out.push(l);
210    }
211    if let Some(structure) = adapter.plan_structure(snapshot) {
212        out.extend(behaviour_early(adapter, &structure, any_ticked));
213        if let Some(l) = test_only(adapter, &structure, any_ticked) {
214            out.push(l);
215        }
216    }
217    out
218}
219
220/// `PLAN-SCENARIO-UNKNOWN`: a task naming a scenario its story does not
221/// have (the link is ignored); `PLAN-STORY-UNJUDGED`: a story whose every
222/// task is planning work, so no task is judged by its scenarios.
223fn story_links(
224    adapter: &dyn Adapter,
225    snapshot: &ProjectSnapshot,
226    tasks: &speccoder_core::snapshot::ParsedTasks,
227) -> Vec<PlanLint> {
228    let unit_dir = snapshot
229        .feature
230        .as_ref()
231        .map(|f| f.feature.dir.clone())
232        .unwrap_or_default();
233    let judged = adapter.rules().judged;
234    let mut out = Vec::new();
235    for t in tasks.tasks.iter().filter(|t| !t.scenarios.is_empty()) {
236        let known: Vec<String> = adapter
237            .scenarios(snapshot, &t.id)
238            .into_iter()
239            .map(|s| s.id)
240            .collect();
241        let unknown: Vec<&str> = t
242            .scenarios
243            .iter()
244            .filter(|s| !known.contains(s))
245            .map(String::as_str)
246            .collect();
247        if unknown.is_empty() {
248            continue;
249        }
250        out.push(PlanLint {
251            code: "PLAN-SCENARIO-UNKNOWN",
252            tasks: vec![t.id.clone()],
253            detail: format!(
254                "{} names {}, not a scenario of {} — the link is ignored: the task is judged by the scenarios it turns green",
255                t.id,
256                unknown.join(", "),
257                t.story.as_deref().unwrap_or("its story")
258            ),
259            fix: vec![format!(
260                "→ correct the link in {}'s line (its story's scenarios: {})",
261                t.id,
262                known.join(", ")
263            )],
264            acceptance_at: None,
265        });
266    }
267    let mut stories: Vec<&str> = Vec::new();
268    for s in tasks.tasks.iter().filter_map(|t| t.story.as_deref()) {
269        if !stories.contains(&s) {
270            stories.push(s);
271        }
272    }
273    for story in stories {
274        let members: Vec<&speccoder_core::snapshot::Task> = tasks
275            .tasks
276            .iter()
277            .filter(|t| t.story.as_deref() == Some(story))
278            .collect();
279        // Planning work and a person's steps are judged by the checks alone.
280        let all_planning = members.iter().all(|t| {
281            t.yours
282                || speccoder_core::lane::by_paths(&t.title, &unit_dir, &judged)
283                    == speccoder_core::lane::Lane::Planning
284        });
285        let has_scenarios = members
286            .first()
287            .is_some_and(|t| !adapter.scenarios(snapshot, &t.id).is_empty());
288        if all_planning && has_scenarios {
289            out.push(PlanLint {
290                code: "PLAN-STORY-UNJUDGED",
291                tasks: members.iter().map(|t| t.id.clone()).collect(),
292                detail: format!(
293                    "every task of {story} is planning work or a step only you take: no task is judged by its scenarios"
294                ),
295                fix: vec![format!(
296                    "→ add the task that implements {story}, or re-plan"
297                )],
298                acceptance_at: None,
299            });
300        }
301    }
302    out
303}
304
305/// `PLAN-TESTS-UNCOLLECTED`: test paths the tasks name that the runner's
306/// static rule would not collect (unless another task creates the file
307/// declaring them), and test files in the tree nothing runs.
308fn uncollected(
309    adapter: &dyn Adapter,
310    tasks: &speccoder_core::snapshot::ParsedTasks,
311    root: &Path,
312    any_ticked: bool,
313) -> Option<PlanLint> {
314    let files = crate::collection::static_cfg::files(root);
315    let packages = crate::collection::static_cfg::rust_packages(root, &files);
316    let named: Vec<(String, Vec<String>)> = tasks
317        .tasks
318        .iter()
319        .map(|t| (t.id.clone(), speccoder_core::scope::named_paths(&t.title)))
320        .collect();
321    let all_named: BTreeSet<&str> = named
322        .iter()
323        .flat_map(|(_, p)| p.iter().map(String::as_str))
324        .collect();
325    // By directory: the tasks naming paths under it, and whether Cargo's rule.
326    let mut by_dir: BTreeMap<String, (Vec<String>, bool)> = BTreeMap::new();
327    for (id, paths) in &named {
328        for p in paths {
329            let is_dir = p.ends_with('/');
330            let path = p.trim_end_matches('/');
331            if !crate::tools::paths::is_test_path(&format!(
332                "{path}{}",
333                if is_dir { "/x" } else { "" }
334            )) {
335                continue;
336            }
337            let rust_pkg = crate::collection::static_cfg::owner(&packages, path);
338            let (dir, never, rust) = if path.ends_with(".rs") || (is_dir && rust_pkg.is_some()) {
339                let Some(pkg) = rust_pkg else { continue };
340                let tests =
341                    speccoder_core::collection::join(&pkg.root, "tests").unwrap_or_default();
342                let Some(rest) = path.strip_prefix(&format!("{tests}/")) else {
343                    continue;
344                };
345                let parts: Vec<&str> = rest.split('/').collect();
346                let first = parts.first().copied().unwrap_or_default();
347                // tests/<x>.rs, or tests/<dir>/main.rs: collected by the rule.
348                if (!is_dir && parts.len() == 1) || (parts.len() == 2 && parts[1] == "main.rs") {
349                    continue;
350                }
351                let main = format!("{tests}/{first}/main.rs");
352                let declared = files.contains(&main)
353                    || all_named.contains(main.as_str())
354                    || files.contains(&format!("{tests}/{first}.rs"));
355                (
356                    format!("{tests}/{first}/"),
357                    !(declared && pkg.autotests),
358                    true,
359                )
360            } else if is_dir {
361                continue;
362            } else {
363                let never = crate::collection::statically_refused(root, path, "").is_some();
364                (
365                    path.rsplit_once('/')
366                        .map_or(String::new(), |(d, _)| format!("{d}/")),
367                    never,
368                    false,
369                )
370            };
371            if never {
372                let e = by_dir.entry(dir).or_insert((Vec::new(), rust));
373                if !e.0.contains(id) {
374                    e.0.push(id.clone());
375                }
376            }
377        }
378    }
379    let layout = crate::layout::detect(root, &crate::verify::check_argvs(root));
380    let dead: Vec<String> = layout
381        .runners
382        .iter()
383        .flat_map(|r| r.not_run.clone())
384        .collect();
385    if by_dir.is_empty() && dead.is_empty() {
386        return None;
387    }
388    let mut tasks_named: Vec<String> = Vec::new();
389    let mut sentences = Vec::new();
390    let mut fix = Vec::new();
391    for (dir, (ids, rust)) in &by_dir {
392        for id in ids {
393            if !tasks_named.contains(id) {
394                tasks_named.push(id.clone());
395            }
396        }
397        sentences.push(format!(
398            "{} name `{dir}` — {}",
399            listed(ids),
400            if *rust {
401                "Cargo won't build the tests there"
402            } else {
403                "the runner won't collect tests there"
404            }
405        ));
406        if *rust {
407            let tests = dir
408                .trim_end_matches('/')
409                .rsplit_once('/')
410                .map_or("tests", |(t, _)| t);
411            fix.push(format!("→ rename to `{tests}/<name>.rs`"));
412            fix.push(format!("→ add a task that creates `{dir}main.rs`"));
413        } else {
414            fix.push("→ name a path the runner collects (the test layout in `speccoder doctor` says where)".to_owned());
415        }
416    }
417    if !dead.is_empty() {
418        sentences.push(format!("test files nothing runs: {}", dead.join(", ")));
419    }
420    tasks_named.sort();
421    fix.push(replan(adapter, any_ticked));
422    fix.dedup();
423    Some(PlanLint {
424        code: "PLAN-TESTS-UNCOLLECTED",
425        tasks: tasks_named,
426        detail: sentences.join("; "),
427        fix,
428        acceptance_at: None,
429    })
430}
431
432/// `PLAN-BEHAVIOUR-EARLY`: a Setup or Foundational task that names, as
433/// code, a symbol a story names in its scenarios or tasks.
434fn behaviour_early(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Vec<PlanLint> {
435    let mut story_symbols: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
436    for (story, text) in &s.stories {
437        let set = story_symbols.entry(story.as_str()).or_default();
438        for span in crate::format::code_spans(text) {
439            set.extend(symbols(&span));
440        }
441    }
442    for t in s.tasks.iter().filter(|t| t.phase == PhaseKind::Story) {
443        if let Some(st) = &t.story {
444            let set = story_symbols.entry(st.as_str()).or_default();
445            for span in &t.spans {
446                set.extend(symbols(span));
447            }
448        }
449    }
450    let mut out = Vec::new();
451    for t in s
452        .tasks
453        .iter()
454        .filter(|t| matches!(t.phase, PhaseKind::Setup | PhaseKind::Foundational))
455        .filter(|t| !only_checks(&t.title))
456    {
457        let mine: BTreeSet<String> = t.spans.iter().flat_map(|sp| symbols(sp)).collect();
458        let Some((story, shared)) = story_symbols.iter().find_map(|(st, set)| {
459            let shared: Vec<&String> = mine.intersection(set).collect();
460            (!shared.is_empty()).then_some((*st, shared))
461        }) else {
462            continue;
463        };
464        let phase = if t.phase == PhaseKind::Setup {
465            "Setup"
466        } else {
467            "Foundational"
468        };
469        let names = shared
470            .iter()
471            .map(|n| format!("`{n}`"))
472            .collect::<Vec<_>>()
473            .join(", ");
474        out.push(PlanLint {
475            code: "PLAN-BEHAVIOUR-EARLY",
476            tasks: vec![t.id.clone()],
477            detail: format!(
478                "{} ({phase}) names {names}, which {story} is about — if {} implements it, {story}'s acceptance cannot go red and its tasks will tick regression-only",
479                t.id, t.id
480            ),
481            fix: vec![
482                format!("speccoder task acceptance-at {} --story {story}  (write {story}'s acceptance tests at {})", t.id, t.id),
483                replan(adapter, any_ticked),
484            ],
485            acceptance_at: Some((t.id.clone(), story.to_owned())),
486        });
487    }
488    out
489}
490
491/// `PLAN-TEST-ONLY-TASKS`: story tasks whose only named outputs are test
492/// paths, beside an implementing task of the same story.
493/// `PLAN-OTHER-STACK`: tasks that build in a stack the checkout is not
494/// (a Rust crate's `Cargo.toml` in a uv project): its manifest, or its
495/// language by name.
496fn other_stack(
497    adapter: &dyn Adapter,
498    tasks: &speccoder_core::snapshot::ParsedTasks,
499    root: &Path,
500    any_ticked: bool,
501) -> Option<PlanLint> {
502    let (here, elsewhere): (Vec<&(&str, &str)>, Vec<_>) = crate::layout::STACKS
503        .iter()
504        .partition(|(m, _)| root.join(m).is_file());
505    let lang = here.first()?.1;
506    let ids: Vec<String> = tasks
507        .tasks
508        .iter()
509        .filter(|t| !t.done)
510        .filter(|t| {
511            let words: Vec<&str> = t.title.split(|c: char| !c.is_alphanumeric()).collect();
512            elsewhere.iter().any(|(m, l)| {
513                t.title.contains(m) || l.split('/').any(|l| l.len() > 2 && words.contains(&l))
514            })
515        })
516        .map(|t| t.id.clone())
517        .collect();
518    // The plan itself was made without the stack: planned again from it.
519    let skills = (adapter.planning().toward_tasks)(false);
520    (!ids.is_empty()).then(|| PlanLint {
521        code: "PLAN-OTHER-STACK",
522        detail: format!(
523            "{} build in another stack than this {lang} project's — re-plan in it",
524            listed(&ids)
525        ),
526        tasks: ids,
527        fix: vec![if any_ticked {
528            replan(adapter, true)
529        } else {
530            format!(
531                "speccoder spec {} --through {}  (re-plan in the checkout's stack)",
532                skills.first().copied().unwrap_or("plan"),
533                skills.last().copied().unwrap_or("tasks")
534            )
535        }],
536        acceptance_at: None,
537    })
538}
539
540fn test_only(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Option<PlanLint> {
541    let is_test_only = |t: &crate::format::PlannedTask| {
542        let paths = speccoder_core::scope::named_paths(&t.title);
543        let verb = t
544            .title
545            .split_whitespace()
546            .next()
547            .unwrap_or_default()
548            .to_ascii_lowercase();
549        !paths.is_empty()
550            && paths.iter().all(|p| {
551                let p = p.trim_end_matches('/');
552                crate::tools::paths::is_test_path(p)
553                    || crate::tools::paths::is_test_path(&format!("{p}/x"))
554            })
555            && matches!(verb.as_str(), "add" | "write" | "create")
556            && t.title.to_ascii_lowercase().contains("test")
557    };
558    let mut ids = Vec::new();
559    for t in s
560        .tasks
561        .iter()
562        .filter(|t| t.story.is_some() && is_test_only(t))
563    {
564        let implementing = s
565            .tasks
566            .iter()
567            .any(|o| o.story == t.story && o.id != t.id && !is_test_only(o));
568        if implementing {
569            ids.push(t.id.clone());
570        }
571    }
572    (!ids.is_empty()).then(|| PlanLint {
573        code: "PLAN-TEST-ONLY-TASKS",
574        detail: format!(
575            "{} only write tests — speccoder writes each story's acceptance tests itself; merge them into the implementing tasks",
576            listed(&ids)
577        ),
578        tasks: ids,
579        fix: vec![replan(adapter, any_ticked)],
580        acceptance_at: None,
581    })
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn symbols_are_called_names_or_lone_identifiers() {
590        assert_eq!(symbols("greet(name: &str) -> String"), ["greet"]);
591        assert_eq!(symbols("greet"), ["greet"]);
592        assert!(symbols("src/lib.rs").is_empty());
593        assert!(symbols("Hello, <name>!").is_empty());
594        assert!(symbols("String").is_empty());
595    }
596
597    #[test]
598    fn the_preset_template_states_every_rule() {
599        let template = include_str!("../assets/preset/speccoder/templates/tasks-template.md");
600        for r in RULES {
601            assert!(
602                template.contains(r.preset),
603                "{} is not in the preset",
604                r.code
605            );
606            assert!(
607                speccoder_core::codes::lookup(r.code).is_some(),
608                "{}",
609                r.code
610            );
611        }
612        for rule in LANE_RULES {
613            assert!(template.contains(rule), "{rule} is not in the preset");
614        }
615        assert!(
616            template.contains("- **[YOU]**:"),
617            "the format line names [YOU]"
618        );
619    }
620}