claude_native/rules/
navigation.rs1use std::collections::HashMap;
2
3use crate::rules::*;
4use crate::scan::ProjectContext;
5
6pub struct ClearDirectoryStructure;
9
10impl Rule for ClearDirectoryStructure {
11 fn id(&self) -> &str { "3.1" }
12 fn name(&self) -> &str { "Clear directory structure" }
13 fn dimension(&self) -> Dimension { Dimension::Navigation }
14 fn severity(&self) -> Severity { Severity::Medium }
15
16 fn check(&self, ctx: &ProjectContext) -> RuleResult {
17 let root_source_files = ctx.all_files.iter()
18 .filter(|f| {
19 f.relative_path.parent().map(|p| p == std::path::Path::new("")).unwrap_or(true)
20 && !f.is_test
21 })
22 .count();
23
24 if root_source_files > 15 {
25 return self.fail(
26 &format!("{root_source_files} source files at project root (>15)"),
27 Suggestion {
28 priority: SuggestionPriority::HighImpact,
29 title: "Organize root files into directories".into(),
30 description: "Move source files into logical directories (src/, lib/, utils/).".into(),
31 effort: Effort::HalfDay,
32 },
33 );
34 }
35
36 let crowded = find_crowded_dirs(ctx);
37 if !crowded.is_empty() {
38 self.warn(
39 &format!("Directories with >15 source files:\n{}", crowded.join("\n")),
40 Suggestion {
41 priority: SuggestionPriority::NiceToHave,
42 title: "Split crowded directories".into(),
43 description: format!("Consider sub-grouping:\n{}", crowded.join("\n")),
44 effort: Effort::Hour,
45 },
46 )
47 } else {
48 self.pass()
49 }
50 }
51}
52
53fn find_crowded_dirs(ctx: &ProjectContext) -> Vec<String> {
54 let mut dir_counts: HashMap<String, usize> = HashMap::new();
55 for f in &ctx.all_files {
56 if !f.is_test && !f.is_generated {
57 let dir = f.relative_path.parent()
58 .map(|p| p.to_string_lossy().to_string())
59 .unwrap_or_default();
60 *dir_counts.entry(dir).or_insert(0) += 1;
61 }
62 }
63 dir_counts.iter()
64 .filter(|(_, &count)| count > 15)
65 .map(|(dir, count)| format!(" {dir}/ ({count} files)"))
66 .collect()
67}
68
69pub struct ConsistentNaming;
72
73const SKIP_NAMES: &[&str] = &[
74 "Makefile", "Dockerfile", "Gemfile", "Rakefile", "README",
75 "CLAUDE", "CHANGELOG", "LICENSE", "CONTRIBUTING", "GOLDEN",
76 "Cargo", "Pipfile", "Procfile", "Vagrantfile", "MEMORY",
77];
78
79impl Rule for ConsistentNaming {
80 fn id(&self) -> &str { "3.2" }
81 fn name(&self) -> &str { "Consistent file naming conventions" }
82 fn dimension(&self) -> Dimension { Dimension::Navigation }
83 fn severity(&self) -> Severity { Severity::Medium }
84
85 fn check(&self, ctx: &ProjectContext) -> RuleResult {
86 let (counts, total) = count_naming_conventions(ctx);
87 if total < 5 {
88 return self.pass();
89 }
90 let max_convention = counts.iter().max().copied().unwrap_or(0);
91 let consistency = max_convention as f64 / total as f64;
92 naming_result(self, consistency)
93 }
94}
95
96fn count_naming_conventions(ctx: &ProjectContext) -> ([usize; 4], usize) {
97 let mut counts = [0usize; 4]; let mut total = 0;
99
100 for f in &ctx.all_files {
101 let stem = match f.path.file_stem().and_then(|s| s.to_str()) {
102 Some(s) => s,
103 None => continue,
104 };
105 if should_skip_name(stem) { continue; }
106 total += 1;
107 counts[classify_name(stem)] += 1;
108 }
109 (counts, total)
110}
111
112fn should_skip_name(stem: &str) -> bool {
113 SKIP_NAMES.iter().any(|s| stem.starts_with(s))
114 || stem.starts_with('.')
115 || stem.len() < 2
116 || (stem == stem.to_uppercase() && !stem.contains('-'))
117}
118
119fn classify_name(stem: &str) -> usize {
120 let is_lower = stem == stem.to_lowercase();
121 let has_underscore = stem.contains('_');
122 let has_dash = stem.contains('-');
123 let has_upper = stem.chars().any(|c| c.is_uppercase());
124
125 if (has_underscore && is_lower) || (is_lower && !has_underscore && !has_dash) {
126 0 } else if has_dash && is_lower {
128 1 } else if stem.chars().next().map(|c| c.is_lowercase()).unwrap_or(false) && has_upper {
130 2 } else if stem.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
132 3 } else {
134 0 }
136}
137
138fn naming_result(rule: &ConsistentNaming, consistency: f64) -> RuleResult {
139 if consistency >= 0.8 {
140 rule.pass()
141 } else if consistency >= 0.6 {
142 rule.warn(
143 &format!("File naming is ~{:.0}% consistent (target: >80%)", consistency * 100.0),
144 Suggestion {
145 priority: SuggestionPriority::NiceToHave,
146 title: "Standardize file naming".into(),
147 description: "Pick one convention and apply consistently.".into(),
148 effort: Effort::Hour,
149 },
150 )
151 } else {
152 rule.fail(
153 &format!("File naming is only ~{:.0}% consistent", consistency * 100.0),
154 Suggestion {
155 priority: SuggestionPriority::NiceToHave,
156 title: "Standardize file naming".into(),
157 description: "Mixed naming conventions force Claude to search instead of predict.".into(),
158 effort: Effort::HalfDay,
159 },
160 )
161 }
162}
163
164pub struct ObviousEntryPoints;
167
168impl Rule for ObviousEntryPoints {
169 fn id(&self) -> &str { "3.3" }
170 fn name(&self) -> &str { "Entry points are obvious" }
171 fn dimension(&self) -> Dimension { Dimension::Navigation }
172 fn severity(&self) -> Severity { Severity::Low }
173
174 fn check(&self, ctx: &ProjectContext) -> RuleResult {
175 let entry_names = [
176 "main.rs", "main.go", "main.py", "main.dart", "main.ts", "main.js",
177 "index.ts", "index.js", "index.tsx", "index.jsx",
178 "app.py", "app.ts", "app.js", "app.rb",
179 "manage.py", "server.ts", "server.js", "lib.rs", "mod.rs",
180 ];
181 let has_entry = ctx.all_files.iter().any(|f| {
182 f.path.file_name().and_then(|n| n.to_str())
183 .map(|n| entry_names.contains(&n)).unwrap_or(false)
184 });
185 let documented = ctx.claude_md_content.as_ref().map(|c| {
186 let l = c.to_lowercase();
187 l.contains("entry point") || l.contains("entrypoint")
188 }).unwrap_or(false);
189
190 if has_entry || documented {
191 self.pass()
192 } else {
193 self.warn(
194 "No obvious entry point found (main.*, index.*, app.*)",
195 Suggestion {
196 priority: SuggestionPriority::NiceToHave,
197 title: "Document entry point in CLAUDE.md".into(),
198 description: "Add 'Entry point: src/server.ts' to CLAUDE.md.".into(),
199 effort: Effort::Minutes,
200 },
201 )
202 }
203 }
204}