Skip to main content

claude_native/rules/
navigation_extra.rs

1use crate::rules::*;
2use crate::scan::ProjectContext;
3
4// ── Rule 3.4: Clear module boundaries ───────────────────────────────
5
6pub 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
64// ── Rule 3.5: Predictable test locations ────────────────────────────
65
66pub 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
111// ── Rule 3.6: No deep nesting ──────────────────────────────────────
112
113pub 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
138// ── Rule 3.7: Descriptive names ────────────────────────────────────
139
140pub 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
177// ── Rule 3.8: Folder-level CLAUDE.md for navigation ─────────────────
178
179pub 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        // Only check source directories with 3+ source files
189        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
245/// Find directories that contain 3+ source files (worth having a CLAUDE.md).
246fn 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            // Skip root and non-source dirs
255            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}