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; 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
45pub 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#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PlanLint {
55 pub code: &'static str,
57 pub tasks: Vec<String>,
59 pub detail: String,
61 pub fix: Vec<String>,
63 pub acceptance_at: Option<(String, String)>,
66}
67
68fn listed(ids: &[String]) -> String {
70 ids.join(", ")
71}
72
73fn 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
145fn 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
183fn 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#[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
220fn 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 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
305fn 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 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 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
432fn 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
491fn 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 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}