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 each 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; that task is judged by exactly them, and the story's checkpoint — its last open code task — by every scenario not yet verified.",
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(structure) = adapter.plan_structure(snapshot) {
209        out.extend(behaviour_early(adapter, &structure, any_ticked));
210        if let Some(l) = test_only(adapter, &structure, any_ticked) {
211            out.push(l);
212        }
213    }
214    out
215}
216
217/// `PLAN-SCENARIO-UNKNOWN`: a task naming a scenario its story does not
218/// have (the link is ignored); `PLAN-STORY-UNJUDGED`: a story whose every
219/// task is planning work, so no task is judged by its scenarios.
220fn story_links(
221    adapter: &dyn Adapter,
222    snapshot: &ProjectSnapshot,
223    tasks: &speccoder_core::snapshot::ParsedTasks,
224) -> Vec<PlanLint> {
225    let unit_dir = snapshot
226        .feature
227        .as_ref()
228        .map(|f| f.feature.dir.clone())
229        .unwrap_or_default();
230    let judged = adapter.rules().judged;
231    let mut out = Vec::new();
232    for t in tasks.tasks.iter().filter(|t| !t.scenarios.is_empty()) {
233        let known: Vec<String> = adapter
234            .scenarios(snapshot, &t.id)
235            .into_iter()
236            .map(|s| s.id)
237            .collect();
238        let unknown: Vec<&str> = t
239            .scenarios
240            .iter()
241            .filter(|s| !known.contains(s))
242            .map(String::as_str)
243            .collect();
244        if unknown.is_empty() {
245            continue;
246        }
247        out.push(PlanLint {
248            code: "PLAN-SCENARIO-UNKNOWN",
249            tasks: vec![t.id.clone()],
250            detail: format!(
251                "{} names {}, not a scenario of {} — the link is ignored: its test author chooses, and the story's checkpoint judges what is left",
252                t.id,
253                unknown.join(", "),
254                t.story.as_deref().unwrap_or("its story")
255            ),
256            fix: vec![format!(
257                "→ correct the link in {}'s line (its story's scenarios: {})",
258                t.id,
259                known.join(", ")
260            )],
261            acceptance_at: None,
262        });
263    }
264    let mut stories: Vec<&str> = Vec::new();
265    for s in tasks.tasks.iter().filter_map(|t| t.story.as_deref()) {
266        if !stories.contains(&s) {
267            stories.push(s);
268        }
269    }
270    for story in stories {
271        let members: Vec<&speccoder_core::snapshot::Task> = tasks
272            .tasks
273            .iter()
274            .filter(|t| t.story.as_deref() == Some(story))
275            .collect();
276        // Planning work and a person's steps are judged by the checks alone.
277        let all_planning = members.iter().all(|t| {
278            t.yours
279                || speccoder_core::lane::by_paths(&t.title, &unit_dir, &judged)
280                    == speccoder_core::lane::Lane::Planning
281        });
282        let has_scenarios = members
283            .first()
284            .is_some_and(|t| !adapter.scenarios(snapshot, &t.id).is_empty());
285        if all_planning && has_scenarios {
286            out.push(PlanLint {
287                code: "PLAN-STORY-UNJUDGED",
288                tasks: members.iter().map(|t| t.id.clone()).collect(),
289                detail: format!(
290                    "every task of {story} is planning work or a step only you take: no task is judged by its scenarios"
291                ),
292                fix: vec![format!(
293                    "→ add the task that implements {story}, or re-plan"
294                )],
295                acceptance_at: None,
296            });
297        }
298    }
299    out
300}
301
302/// `PLAN-TESTS-UNCOLLECTED`: test paths the tasks name that the runner's
303/// static rule would not collect (unless another task creates the file
304/// declaring them), and test files in the tree nothing runs.
305fn uncollected(
306    adapter: &dyn Adapter,
307    tasks: &speccoder_core::snapshot::ParsedTasks,
308    root: &Path,
309    any_ticked: bool,
310) -> Option<PlanLint> {
311    let files = crate::collection::static_cfg::files(root);
312    let packages = crate::collection::static_cfg::rust_packages(root, &files);
313    let named: Vec<(String, Vec<String>)> = tasks
314        .tasks
315        .iter()
316        .map(|t| (t.id.clone(), speccoder_core::scope::named_paths(&t.title)))
317        .collect();
318    let all_named: BTreeSet<&str> = named
319        .iter()
320        .flat_map(|(_, p)| p.iter().map(String::as_str))
321        .collect();
322    // By directory: the tasks naming paths under it, and whether Cargo's rule.
323    let mut by_dir: BTreeMap<String, (Vec<String>, bool)> = BTreeMap::new();
324    for (id, paths) in &named {
325        for p in paths {
326            let is_dir = p.ends_with('/');
327            let path = p.trim_end_matches('/');
328            if !crate::tools::paths::is_test_path(&format!(
329                "{path}{}",
330                if is_dir { "/x" } else { "" }
331            )) {
332                continue;
333            }
334            let rust_pkg = crate::collection::static_cfg::owner(&packages, path);
335            let (dir, never, rust) = if path.ends_with(".rs") || (is_dir && rust_pkg.is_some()) {
336                let Some(pkg) = rust_pkg else { continue };
337                let tests =
338                    speccoder_core::collection::join(&pkg.root, "tests").unwrap_or_default();
339                let Some(rest) = path.strip_prefix(&format!("{tests}/")) else {
340                    continue;
341                };
342                let parts: Vec<&str> = rest.split('/').collect();
343                let first = parts.first().copied().unwrap_or_default();
344                // tests/<x>.rs, or tests/<dir>/main.rs: collected by the rule.
345                if (!is_dir && parts.len() == 1) || (parts.len() == 2 && parts[1] == "main.rs") {
346                    continue;
347                }
348                let main = format!("{tests}/{first}/main.rs");
349                let declared = files.contains(&main)
350                    || all_named.contains(main.as_str())
351                    || files.contains(&format!("{tests}/{first}.rs"));
352                (
353                    format!("{tests}/{first}/"),
354                    !(declared && pkg.autotests),
355                    true,
356                )
357            } else if is_dir {
358                continue;
359            } else {
360                let never = crate::collection::statically_refused(root, path, "").is_some();
361                (
362                    path.rsplit_once('/')
363                        .map_or(String::new(), |(d, _)| format!("{d}/")),
364                    never,
365                    false,
366                )
367            };
368            if never {
369                let e = by_dir.entry(dir).or_insert((Vec::new(), rust));
370                if !e.0.contains(id) {
371                    e.0.push(id.clone());
372                }
373            }
374        }
375    }
376    let layout = crate::layout::detect(root, &crate::verify::check_argvs(root));
377    let dead: Vec<String> = layout
378        .runners
379        .iter()
380        .flat_map(|r| r.not_run.clone())
381        .collect();
382    if by_dir.is_empty() && dead.is_empty() {
383        return None;
384    }
385    let mut tasks_named: Vec<String> = Vec::new();
386    let mut sentences = Vec::new();
387    let mut fix = Vec::new();
388    for (dir, (ids, rust)) in &by_dir {
389        for id in ids {
390            if !tasks_named.contains(id) {
391                tasks_named.push(id.clone());
392            }
393        }
394        sentences.push(format!(
395            "{} name `{dir}` — {}",
396            listed(ids),
397            if *rust {
398                "Cargo won't build the tests there"
399            } else {
400                "the runner won't collect tests there"
401            }
402        ));
403        if *rust {
404            let tests = dir
405                .trim_end_matches('/')
406                .rsplit_once('/')
407                .map_or("tests", |(t, _)| t);
408            fix.push(format!("→ rename to `{tests}/<name>.rs`"));
409            fix.push(format!("→ add a task that creates `{dir}main.rs`"));
410        } else {
411            fix.push("→ name a path the runner collects (the test layout in `speccoder doctor` says where)".to_owned());
412        }
413    }
414    if !dead.is_empty() {
415        sentences.push(format!("test files nothing runs: {}", dead.join(", ")));
416    }
417    tasks_named.sort();
418    fix.push(replan(adapter, any_ticked));
419    fix.dedup();
420    Some(PlanLint {
421        code: "PLAN-TESTS-UNCOLLECTED",
422        tasks: tasks_named,
423        detail: sentences.join("; "),
424        fix,
425        acceptance_at: None,
426    })
427}
428
429/// `PLAN-BEHAVIOUR-EARLY`: a Setup or Foundational task that names, as
430/// code, a symbol a story names in its scenarios or tasks.
431fn behaviour_early(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Vec<PlanLint> {
432    let mut story_symbols: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
433    for (story, text) in &s.stories {
434        let set = story_symbols.entry(story.as_str()).or_default();
435        for span in crate::format::code_spans(text) {
436            set.extend(symbols(&span));
437        }
438    }
439    for t in s.tasks.iter().filter(|t| t.phase == PhaseKind::Story) {
440        if let Some(st) = &t.story {
441            let set = story_symbols.entry(st.as_str()).or_default();
442            for span in &t.spans {
443                set.extend(symbols(span));
444            }
445        }
446    }
447    let mut out = Vec::new();
448    for t in s
449        .tasks
450        .iter()
451        .filter(|t| matches!(t.phase, PhaseKind::Setup | PhaseKind::Foundational))
452        .filter(|t| !only_checks(&t.title))
453    {
454        let mine: BTreeSet<String> = t.spans.iter().flat_map(|sp| symbols(sp)).collect();
455        let Some((story, shared)) = story_symbols.iter().find_map(|(st, set)| {
456            let shared: Vec<&String> = mine.intersection(set).collect();
457            (!shared.is_empty()).then_some((*st, shared))
458        }) else {
459            continue;
460        };
461        let phase = if t.phase == PhaseKind::Setup {
462            "Setup"
463        } else {
464            "Foundational"
465        };
466        let names = shared
467            .iter()
468            .map(|n| format!("`{n}`"))
469            .collect::<Vec<_>>()
470            .join(", ");
471        out.push(PlanLint {
472            code: "PLAN-BEHAVIOUR-EARLY",
473            tasks: vec![t.id.clone()],
474            detail: format!(
475                "{} ({phase}) names {names}, which {story} is about — if {} implements it, {story}'s acceptance cannot go red and its tasks will tick regression-only",
476                t.id, t.id
477            ),
478            fix: vec![
479                format!("speccoder task acceptance-at {} --story {story}  (write {story}'s acceptance tests at {})", t.id, t.id),
480                replan(adapter, any_ticked),
481            ],
482            acceptance_at: Some((t.id.clone(), story.to_owned())),
483        });
484    }
485    out
486}
487
488/// `PLAN-TEST-ONLY-TASKS`: story tasks whose only named outputs are test
489/// paths, beside an implementing task of the same story.
490fn test_only(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Option<PlanLint> {
491    let is_test_only = |t: &crate::format::PlannedTask| {
492        let paths = speccoder_core::scope::named_paths(&t.title);
493        let verb = t
494            .title
495            .split_whitespace()
496            .next()
497            .unwrap_or_default()
498            .to_ascii_lowercase();
499        !paths.is_empty()
500            && paths.iter().all(|p| {
501                let p = p.trim_end_matches('/');
502                crate::tools::paths::is_test_path(p)
503                    || crate::tools::paths::is_test_path(&format!("{p}/x"))
504            })
505            && matches!(verb.as_str(), "add" | "write" | "create")
506            && t.title.to_ascii_lowercase().contains("test")
507    };
508    let mut ids = Vec::new();
509    for t in s
510        .tasks
511        .iter()
512        .filter(|t| t.story.is_some() && is_test_only(t))
513    {
514        let implementing = s
515            .tasks
516            .iter()
517            .any(|o| o.story == t.story && o.id != t.id && !is_test_only(o));
518        if implementing {
519            ids.push(t.id.clone());
520        }
521    }
522    (!ids.is_empty()).then(|| PlanLint {
523        code: "PLAN-TEST-ONLY-TASKS",
524        detail: format!(
525            "{} only write tests — speccoder writes each story's acceptance tests itself; merge them into the implementing tasks",
526            listed(&ids)
527        ),
528        tasks: ids,
529        fix: vec![replan(adapter, any_ticked)],
530        acceptance_at: None,
531    })
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn symbols_are_called_names_or_lone_identifiers() {
540        assert_eq!(symbols("greet(name: &str) -> String"), ["greet"]);
541        assert_eq!(symbols("greet"), ["greet"]);
542        assert!(symbols("src/lib.rs").is_empty());
543        assert!(symbols("Hello, <name>!").is_empty());
544        assert!(symbols("String").is_empty());
545    }
546
547    #[test]
548    fn the_preset_template_states_every_rule() {
549        let template = include_str!("../assets/preset/speccoder/templates/tasks-template.md");
550        for r in RULES {
551            assert!(
552                template.contains(r.preset),
553                "{} is not in the preset",
554                r.code
555            );
556            assert!(
557                speccoder_core::codes::lookup(r.code).is_some(),
558                "{}",
559                r.code
560            );
561        }
562        for rule in LANE_RULES {
563            assert!(template.contains(rule), "{rule} is not in the preset");
564        }
565        assert!(
566            template.contains("- **[YOU]**:"),
567            "the format line names [YOU]"
568        );
569    }
570}