claude_native/rules/
navigation_extra.rs1use crate::rules::*;
2use crate::scan::ProjectContext;
3
4pub struct ClearModuleBoundaries;
7
8impl Rule for ClearModuleBoundaries {
9 fn id(&self) -> &str { "3.4" }
10 fn name(&self) -> &str { "Clear module boundaries" }
11 fn dimension(&self) -> Dimension { Dimension::Navigation }
12 fn severity(&self) -> Severity { Severity::Medium }
13
14 fn check(&self, ctx: &ProjectContext) -> RuleResult {
15 let index_patterns = [
16 "index.ts", "index.js", "index.tsx", "mod.rs", "lib.rs",
17 "__init__.py", "index.dart", "main.rs",
18 ];
19
20 let skip_dir_names = [".claude", ".github", "tests", "test", "spec", "examples", "docs"];
21 let major_dirs: Vec<_> = ctx.directories.iter()
22 .filter(|d| {
23 if let Ok(rel) = d.strip_prefix(&ctx.root) {
24 let components: Vec<_> = rel.components().collect();
25 let dir_name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
26 components.len() <= 2
27 && !dir_name.starts_with('.')
28 && !skip_dir_names.contains(&dir_name)
29 && ctx.all_files.iter().any(|f| f.path.starts_with(d) && !f.is_test)
30 } else {
31 false
32 }
33 })
34 .collect();
35
36 if major_dirs.is_empty() {
37 return self.pass();
38 }
39
40 let dirs_with_index = major_dirs.iter()
41 .filter(|d| index_patterns.iter().any(|p| d.join(p).exists()))
42 .count();
43
44 let ratio = if major_dirs.is_empty() { 1.0 } else {
45 dirs_with_index as f64 / major_dirs.len() as f64
46 };
47
48 if ratio >= 0.5 {
49 self.pass()
50 } else {
51 self.warn(
52 "Most directories lack clear module entry points (index/mod files)",
53 Suggestion {
54 priority: SuggestionPriority::NiceToHave,
55 title: "Add index/barrel files to modules".into(),
56 description: "Add index.ts/mod.rs/__init__.py to major directories. These let Claude understand what a module exports without reading every file.".into(),
57 effort: Effort::Hour,
58 },
59 )
60 }
61 }
62}
63
64pub struct PredictableTestLocations;
67
68impl Rule for PredictableTestLocations {
69 fn id(&self) -> &str { "3.5" }
70 fn name(&self) -> &str { "Tests in predictable locations" }
71 fn dimension(&self) -> Dimension { Dimension::Navigation }
72 fn severity(&self) -> Severity { Severity::Medium }
73
74 fn check(&self, ctx: &ProjectContext) -> RuleResult {
75 if ctx.test_files.is_empty() {
76 return self.skip();
77 }
78
79 let mut co_located = 0;
80 let mut in_test_dir = 0;
81
82 for tf in &ctx.test_files {
83 let path_str = tf.to_string_lossy();
84 if path_str.contains("__tests__") || path_str.contains("/tests/") || path_str.contains("/test/") || path_str.contains("/spec/") {
85 in_test_dir += 1;
86 } else {
87 co_located += 1;
88 }
89 }
90
91 let total = co_located + in_test_dir;
92 let max_pattern = co_located.max(in_test_dir);
93 let consistency = max_pattern as f64 / total as f64;
94
95 if consistency >= 0.7 {
96 self.pass()
97 } else {
98 self.warn(
99 "Tests are split between co-located and test directories — inconsistent pattern",
100 Suggestion {
101 priority: SuggestionPriority::NiceToHave,
102 title: "Standardize test locations".into(),
103 description: "Pick one pattern: either co-located (*.test.ts next to source) or centralized (tests/ directory). Consistency helps Claude find tests instantly.".into(),
104 effort: Effort::Hour,
105 },
106 )
107 }
108 }
109}
110
111pub struct NoDeepNesting;
114
115impl Rule for NoDeepNesting {
116 fn id(&self) -> &str { "3.6" }
117 fn name(&self) -> &str { "No deeply nested directories (>4 levels)" }
118 fn dimension(&self) -> Dimension { Dimension::Navigation }
119 fn severity(&self) -> Severity { Severity::Low }
120
121 fn check(&self, ctx: &ProjectContext) -> RuleResult {
122 if ctx.max_depth <= 4 {
123 self.pass()
124 } else {
125 self.warn(
126 &format!("Directory nesting depth is {} (target: ≤4)", ctx.max_depth),
127 Suggestion {
128 priority: SuggestionPriority::NiceToHave,
129 title: "Flatten directory structure".into(),
130 description: "Deep nesting makes Glob patterns expensive and navigation confusing. Consider flattening to ≤4 levels.".into(),
131 effort: Effort::HalfDay,
132 },
133 )
134 }
135 }
136}
137
138pub struct DescriptiveNames;
141
142impl Rule for DescriptiveNames {
143 fn id(&self) -> &str { "3.7" }
144 fn name(&self) -> &str { "Descriptive directory and file names" }
145 fn dimension(&self) -> Dimension { Dimension::Navigation }
146 fn severity(&self) -> Severity { Severity::Low }
147
148 fn check(&self, ctx: &ProjectContext) -> RuleResult {
149 let cryptic_dirs: Vec<String> = ctx.directories.iter()
150 .filter_map(|d| {
151 let name = d.file_name()?.to_str()?;
152 let ok_short = ["db", "ui", "CI", "ci", "go", "js", "ts", "py"];
153 if name.len() <= 2 && !ok_short.contains(&name) && !name.starts_with('.') {
154 Some(name.to_string())
155 } else {
156 None
157 }
158 })
159 .collect();
160
161 if cryptic_dirs.is_empty() {
162 self.pass()
163 } else {
164 self.warn(
165 &format!("Cryptic directory names found: {}", cryptic_dirs.join(", ")),
166 Suggestion {
167 priority: SuggestionPriority::NiceToHave,
168 title: "Use descriptive directory names".into(),
169 description: format!("Rename these directories to something descriptive: {}. Claude uses names to decide what to read.", cryptic_dirs.join(", ")),
170 effort: Effort::Hour,
171 },
172 )
173 }
174 }
175}
176
177pub struct FolderClaudeMds;
180
181impl Rule for FolderClaudeMds {
182 fn id(&self) -> &str { "3.8" }
183 fn name(&self) -> &str { "Folders have CLAUDE.md for navigation" }
184 fn dimension(&self) -> Dimension { Dimension::Navigation }
185 fn severity(&self) -> Severity { Severity::Medium }
186
187 fn check(&self, ctx: &ProjectContext) -> RuleResult {
188 let source_dirs = find_source_dirs(ctx);
190 if source_dirs.is_empty() {
191 return self.pass();
192 }
193
194 let dirs_with_claude_md: Vec<_> = source_dirs.iter()
195 .filter(|d| d.join("CLAUDE.md").exists())
196 .collect();
197
198 let dirs_without: Vec<String> = source_dirs.iter()
199 .filter(|d| !d.join("CLAUDE.md").exists())
200 .filter_map(|d| d.strip_prefix(&ctx.root).ok())
201 .map(|p| p.to_string_lossy().to_string())
202 .collect();
203
204 let ratio = dirs_with_claude_md.len() as f64 / source_dirs.len() as f64;
205
206 if ratio >= 0.7 {
207 self.pass()
208 } else if dirs_without.len() <= 2 {
209 self.warn(
210 &format!("{} folder(s) lack a CLAUDE.md: {}", dirs_without.len(), dirs_without.join(", ")),
211 Suggestion {
212 priority: SuggestionPriority::NiceToHave,
213 title: "Add CLAUDE.md to source folders".into(),
214 description: format!(
215 "Add a small CLAUDE.md (3-5 lines) to each folder explaining what it contains. \
216 This lets Claude understand a folder's purpose by reading one file instead of scanning all files.\n\
217 Missing in: {}", dirs_without.join(", ")
218 ),
219 effort: Effort::Minutes,
220 },
221 )
222 } else {
223 self.fail(
224 &format!("{}/{} source folders lack CLAUDE.md", dirs_without.len(), source_dirs.len()),
225 Suggestion {
226 priority: SuggestionPriority::QuickWin,
227 title: "Add CLAUDE.md to source folders".into(),
228 description: format!(
229 "Add a small CLAUDE.md (3-5 lines) to each source folder. Example:\n\
230 ```\n\
231 # rules/\n\
232 Rule implementations. Each file = one scoring dimension.\n\
233 All rules implement the `Rule` trait from mod.rs.\n\
234 ```\n\
235 This saves Claude ~500 tokens per folder by avoiding full file scans.\n\
236 Missing in: {}", dirs_without.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
237 ),
238 effort: Effort::Hour,
239 },
240 )
241 }
242 }
243}
244
245fn find_source_dirs(ctx: &ProjectContext) -> Vec<std::path::PathBuf> {
247 use std::collections::HashMap;
248
249 let skip = [".claude", ".github", "tests", "test", "docs", "examples"];
250 let mut dir_counts: HashMap<std::path::PathBuf, usize> = HashMap::new();
251
252 for f in ctx.source_files() {
253 if let Some(parent) = f.path.parent() {
254 if parent == ctx.root { continue; }
256 let dir_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
257 if dir_name.starts_with('.') || skip.contains(&dir_name) { continue; }
258 *dir_counts.entry(parent.to_path_buf()).or_insert(0) += 1;
259 }
260 }
261
262 dir_counts.into_iter()
263 .filter(|(_, count)| *count >= 3)
264 .map(|(path, _)| path)
265 .collect()
266}