1use std::collections::{BTreeMap, BTreeSet};
10use std::path::Path;
11
12use speccoder_core::snapshot::ProjectSnapshot;
13
14use crate::format::{Adapter, PhaseKind, PlanStructure};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Rule {
19 pub code: &'static str,
21 pub preset: &'static str,
23}
24
25pub 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#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct PlanLint {
44 pub code: &'static str,
46 pub tasks: Vec<String>,
48 pub detail: String,
50 pub fix: Vec<String>,
52 pub acceptance_at: Option<(String, String)>,
55}
56
57fn listed(ids: &[String]) -> String {
59 ids.join(", ")
60}
61
62fn 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
134fn 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
172fn 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#[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
205fn 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 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 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
332fn 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
391fn 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}