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
41pub const LANE_RULES: [&str; 2] = [
44 "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).",
45 "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.",
46];
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PlanLint {
51 pub code: &'static str,
53 pub tasks: Vec<String>,
55 pub detail: String,
57 pub fix: Vec<String>,
59 pub acceptance_at: Option<(String, String)>,
62}
63
64fn listed(ids: &[String]) -> String {
66 ids.join(", ")
67}
68
69fn replan(adapter: &dyn Adapter, any_ticked: bool) -> String {
71 let skill = (adapter.planning().toward_tasks)(true)
72 .last()
73 .copied()
74 .unwrap_or("tasks");
75 if any_ticked {
76 "→ re-planning now would rewrite the tasks your ticks are bound to — apply it to the next feature".to_owned()
77 } else {
78 format!("speccoder spec {skill} (re-plan with the spec tool)")
79 }
80}
81
82const KEYWORDS: &[&str] = &[
83 "fn",
84 "pub",
85 "let",
86 "mut",
87 "impl",
88 "struct",
89 "enum",
90 "trait",
91 "return",
92 "self",
93 "Self",
94 "str",
95 "String",
96 "bool",
97 "char",
98 "usize",
99 "isize",
100 "u8",
101 "u16",
102 "u32",
103 "u64",
104 "i8",
105 "i16",
106 "i32",
107 "i64",
108 "f32",
109 "f64",
110 "Vec",
111 "Option",
112 "Result",
113 "Box",
114 "def",
115 "class",
116 "async",
117 "await",
118 "const",
119 "var",
120 "func",
121 "int",
122 "float",
123 "string",
124 "number",
125 "void",
126 "null",
127 "None",
128 "true",
129 "false",
130 "new",
131 "type",
132 "interface",
133 "export",
134 "import",
135 "from",
136 "use",
137 "mod",
138 "crate",
139];
140
141fn symbols(span: &str) -> Vec<String> {
144 let s = span.trim();
145 if s.contains('/') || (s.contains('.') && !s.contains('(')) {
146 return Vec::new();
147 }
148 let ident = |w: &str| {
149 w.len() >= 3
150 && w.chars()
151 .next()
152 .is_some_and(|c| c.is_alphabetic() || c == '_')
153 && w.chars().all(|c| c.is_alphanumeric() || c == '_')
154 && !KEYWORDS.contains(&w)
155 };
156 if ident(s) {
157 return vec![s.to_owned()];
158 }
159 let chars: Vec<char> = s.chars().collect();
160 let mut out = Vec::new();
161 let mut i = 0;
162 while i < chars.len() {
163 if chars[i].is_alphabetic() || chars[i] == '_' {
164 let st = i;
165 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
166 i += 1;
167 }
168 let w: String = chars[st..i].iter().collect();
169 if chars.get(i) == Some(&'(') && ident(&w) {
170 out.push(w);
171 }
172 } else {
173 i += 1;
174 }
175 }
176 out
177}
178
179fn only_checks(title: &str) -> bool {
181 let first = title
182 .split_whitespace()
183 .next()
184 .unwrap_or_default()
185 .to_ascii_lowercase();
186 matches!(
187 first.as_str(),
188 "verify" | "confirm" | "check" | "review" | "ensure" | "validate" | "run" | "document"
189 )
190}
191
192#[must_use]
194pub fn lints(adapter: &dyn Adapter, snapshot: &ProjectSnapshot, root: &Path) -> Vec<PlanLint> {
195 let Some(tasks) = snapshot.feature.as_ref().and_then(|f| f.tasks.as_ref()) else {
196 return Vec::new();
197 };
198 let any_ticked = tasks.tasks.iter().any(|t| t.done);
199 let mut out = Vec::new();
200 if let Some(l) = uncollected(adapter, tasks, root, any_ticked) {
201 out.push(l);
202 }
203 if let Some(structure) = adapter.plan_structure(snapshot) {
204 out.extend(behaviour_early(adapter, &structure, any_ticked));
205 if let Some(l) = test_only(adapter, &structure, any_ticked) {
206 out.push(l);
207 }
208 }
209 out
210}
211
212fn uncollected(
216 adapter: &dyn Adapter,
217 tasks: &speccoder_core::snapshot::ParsedTasks,
218 root: &Path,
219 any_ticked: bool,
220) -> Option<PlanLint> {
221 let files = crate::collection::static_cfg::files(root);
222 let packages = crate::collection::static_cfg::rust_packages(root, &files);
223 let named: Vec<(String, Vec<String>)> = tasks
224 .tasks
225 .iter()
226 .map(|t| (t.id.clone(), speccoder_core::scope::named_paths(&t.title)))
227 .collect();
228 let all_named: BTreeSet<&str> = named
229 .iter()
230 .flat_map(|(_, p)| p.iter().map(String::as_str))
231 .collect();
232 let mut by_dir: BTreeMap<String, (Vec<String>, bool)> = BTreeMap::new();
234 for (id, paths) in &named {
235 for p in paths {
236 let is_dir = p.ends_with('/');
237 let path = p.trim_end_matches('/');
238 if !crate::tools::paths::is_test_path(&format!(
239 "{path}{}",
240 if is_dir { "/x" } else { "" }
241 )) {
242 continue;
243 }
244 let rust_pkg = crate::collection::static_cfg::owner(&packages, path);
245 let (dir, never, rust) = if path.ends_with(".rs") || (is_dir && rust_pkg.is_some()) {
246 let Some(pkg) = rust_pkg else { continue };
247 let tests =
248 speccoder_core::collection::join(&pkg.root, "tests").unwrap_or_default();
249 let Some(rest) = path.strip_prefix(&format!("{tests}/")) else {
250 continue;
251 };
252 let parts: Vec<&str> = rest.split('/').collect();
253 let first = parts.first().copied().unwrap_or_default();
254 if (!is_dir && parts.len() == 1) || (parts.len() == 2 && parts[1] == "main.rs") {
256 continue;
257 }
258 let main = format!("{tests}/{first}/main.rs");
259 let declared = files.contains(&main)
260 || all_named.contains(main.as_str())
261 || files.contains(&format!("{tests}/{first}.rs"));
262 (
263 format!("{tests}/{first}/"),
264 !(declared && pkg.autotests),
265 true,
266 )
267 } else if is_dir {
268 continue;
269 } else {
270 let never = crate::collection::statically_refused(root, path, "").is_some();
271 (
272 path.rsplit_once('/')
273 .map_or(String::new(), |(d, _)| format!("{d}/")),
274 never,
275 false,
276 )
277 };
278 if never {
279 let e = by_dir.entry(dir).or_insert((Vec::new(), rust));
280 if !e.0.contains(id) {
281 e.0.push(id.clone());
282 }
283 }
284 }
285 }
286 let layout = crate::layout::detect(root, &crate::verify::check_argvs(root));
287 let dead: Vec<String> = layout
288 .runners
289 .iter()
290 .flat_map(|r| r.not_run.clone())
291 .collect();
292 if by_dir.is_empty() && dead.is_empty() {
293 return None;
294 }
295 let mut tasks_named: Vec<String> = Vec::new();
296 let mut sentences = Vec::new();
297 let mut fix = Vec::new();
298 for (dir, (ids, rust)) in &by_dir {
299 for id in ids {
300 if !tasks_named.contains(id) {
301 tasks_named.push(id.clone());
302 }
303 }
304 sentences.push(format!(
305 "{} name `{dir}` — {}",
306 listed(ids),
307 if *rust {
308 "Cargo won't build the tests there"
309 } else {
310 "the runner won't collect tests there"
311 }
312 ));
313 if *rust {
314 let tests = dir
315 .trim_end_matches('/')
316 .rsplit_once('/')
317 .map_or("tests", |(t, _)| t);
318 fix.push(format!("→ rename to `{tests}/<name>.rs`"));
319 fix.push(format!("→ add a task that creates `{dir}main.rs`"));
320 } else {
321 fix.push("→ name a path the runner collects (the test layout in `speccoder doctor` says where)".to_owned());
322 }
323 }
324 if !dead.is_empty() {
325 sentences.push(format!("test files nothing runs: {}", dead.join(", ")));
326 }
327 tasks_named.sort();
328 fix.push(replan(adapter, any_ticked));
329 fix.dedup();
330 Some(PlanLint {
331 code: "PLAN-TESTS-UNCOLLECTED",
332 tasks: tasks_named,
333 detail: sentences.join("; "),
334 fix,
335 acceptance_at: None,
336 })
337}
338
339fn behaviour_early(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Vec<PlanLint> {
342 let mut story_symbols: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new();
343 for (story, text) in &s.stories {
344 let set = story_symbols.entry(story.as_str()).or_default();
345 for span in crate::format::code_spans(text) {
346 set.extend(symbols(&span));
347 }
348 }
349 for t in s.tasks.iter().filter(|t| t.phase == PhaseKind::Story) {
350 if let Some(st) = &t.story {
351 let set = story_symbols.entry(st.as_str()).or_default();
352 for span in &t.spans {
353 set.extend(symbols(span));
354 }
355 }
356 }
357 let mut out = Vec::new();
358 for t in s
359 .tasks
360 .iter()
361 .filter(|t| matches!(t.phase, PhaseKind::Setup | PhaseKind::Foundational))
362 .filter(|t| !only_checks(&t.title))
363 {
364 let mine: BTreeSet<String> = t.spans.iter().flat_map(|sp| symbols(sp)).collect();
365 let Some((story, shared)) = story_symbols.iter().find_map(|(st, set)| {
366 let shared: Vec<&String> = mine.intersection(set).collect();
367 (!shared.is_empty()).then_some((*st, shared))
368 }) else {
369 continue;
370 };
371 let phase = if t.phase == PhaseKind::Setup {
372 "Setup"
373 } else {
374 "Foundational"
375 };
376 let names = shared
377 .iter()
378 .map(|n| format!("`{n}`"))
379 .collect::<Vec<_>>()
380 .join(", ");
381 out.push(PlanLint {
382 code: "PLAN-BEHAVIOUR-EARLY",
383 tasks: vec![t.id.clone()],
384 detail: format!(
385 "{} ({phase}) names {names}, which {story} is about — if {} implements it, {story}'s acceptance cannot go red and its tasks will tick regression-only",
386 t.id, t.id
387 ),
388 fix: vec![
389 format!("speccoder task acceptance-at {} --story {story} (write {story}'s acceptance tests at {})", t.id, t.id),
390 replan(adapter, any_ticked),
391 ],
392 acceptance_at: Some((t.id.clone(), story.to_owned())),
393 });
394 }
395 out
396}
397
398fn test_only(adapter: &dyn Adapter, s: &PlanStructure, any_ticked: bool) -> Option<PlanLint> {
401 let is_test_only = |t: &crate::format::PlannedTask| {
402 let paths = speccoder_core::scope::named_paths(&t.title);
403 let verb = t
404 .title
405 .split_whitespace()
406 .next()
407 .unwrap_or_default()
408 .to_ascii_lowercase();
409 !paths.is_empty()
410 && paths.iter().all(|p| {
411 let p = p.trim_end_matches('/');
412 crate::tools::paths::is_test_path(p)
413 || crate::tools::paths::is_test_path(&format!("{p}/x"))
414 })
415 && matches!(verb.as_str(), "add" | "write" | "create")
416 && t.title.to_ascii_lowercase().contains("test")
417 };
418 let mut ids = Vec::new();
419 for t in s
420 .tasks
421 .iter()
422 .filter(|t| t.story.is_some() && is_test_only(t))
423 {
424 let implementing = s
425 .tasks
426 .iter()
427 .any(|o| o.story == t.story && o.id != t.id && !is_test_only(o));
428 if implementing {
429 ids.push(t.id.clone());
430 }
431 }
432 (!ids.is_empty()).then(|| PlanLint {
433 code: "PLAN-TEST-ONLY-TASKS",
434 detail: format!(
435 "{} only write tests — speccoder writes each story's acceptance tests itself; merge them into the implementing tasks",
436 listed(&ids)
437 ),
438 tasks: ids,
439 fix: vec![replan(adapter, any_ticked)],
440 acceptance_at: None,
441 })
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn symbols_are_called_names_or_lone_identifiers() {
450 assert_eq!(symbols("greet(name: &str) -> String"), ["greet"]);
451 assert_eq!(symbols("greet"), ["greet"]);
452 assert!(symbols("src/lib.rs").is_empty());
453 assert!(symbols("Hello, <name>!").is_empty());
454 assert!(symbols("String").is_empty());
455 }
456
457 #[test]
458 fn the_preset_template_states_every_rule() {
459 let template = include_str!("../assets/preset/speccoder/templates/tasks-template.md");
460 for r in RULES {
461 assert!(
462 template.contains(r.preset),
463 "{} is not in the preset",
464 r.code
465 );
466 assert!(
467 speccoder_core::codes::lookup(r.code).is_some(),
468 "{}",
469 r.code
470 );
471 }
472 for rule in LANE_RULES {
473 assert!(template.contains(rule), "{rule} is not in the preset");
474 }
475 assert!(
476 template.contains("- **[YOU]**:"),
477 "the format line names [YOU]"
478 );
479 }
480}