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(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
217fn 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: the task is judged by the scenarios it turns green",
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 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
302fn 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 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 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
429fn 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
488fn 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}