claude_native/rules/
context.rs1use std::path::Path;
2
3use crate::rules::*;
4use crate::scan::ProjectContext;
5
6fn is_registry_function(path: &Path, _longest: usize) -> bool {
10 let content = match std::fs::read_to_string(path) {
11 Ok(c) => c,
12 Err(_) => return false,
13 };
14 let total_lines = content.lines().count();
15 if total_lines == 0 { return false; }
16 let simple_lines = content.lines().filter(|l| {
17 let t = l.trim();
18 t.starts_with("Box::new(") || t.starts_with("rules.push(")
19 || t.starts_with("vec![") || t.starts_with("]")
20 || t.starts_with("Some(") || t.starts_with("None")
21 || t.starts_with("if ") || t.starts_with("} else")
22 || t.starts_with("match ") || t.starts_with("=>")
23 || t.starts_with("let ") || t.starts_with("pub ")
24 || t.is_empty() || t.starts_with("//") || t.starts_with("use ")
25 || t == "}" || t == "{" || t.starts_with("return ")
26 }).count();
27 simple_lines as f64 / total_lines as f64 > 0.7
28}
29
30pub struct NoMegaFiles;
33
34impl Rule for NoMegaFiles {
35 fn id(&self) -> &str { "2.1" }
36 fn name(&self) -> &str { "No mega-files (>500 lines)" }
37 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
38 fn severity(&self) -> Severity { Severity::High }
39
40 fn check(&self, ctx: &ProjectContext) -> RuleResult {
41 let threshold = 500;
42 let warn_threshold = 300;
43
44 let mega = ctx.mega_files(threshold);
45 let large = ctx.all_files.iter()
46 .filter(|f| f.line_count > warn_threshold && f.line_count <= threshold && !f.is_test && !f.is_generated)
47 .count();
48
49 if mega.is_empty() && large == 0 {
50 return self.pass();
51 }
52
53 if mega.is_empty() {
54 return self.warn(
55 &format!("{large} files exceed {warn_threshold} lines (approaching the {threshold}-line limit)"),
56 Suggestion {
57 priority: SuggestionPriority::NiceToHave,
58 title: "Consider splitting large files".into(),
59 description: format!("{large} files are between {warn_threshold}-{threshold} lines. Smaller files = cheaper reads for Claude. Consider splitting by concern."),
60 effort: Effort::Hour,
61 },
62 );
63 }
64
65 let count = mega.len();
66 let examples: Vec<String> = mega.iter()
67 .take(3)
68 .map(|f| format!(" {} ({} lines)", f.relative_path.display(), f.line_count))
69 .collect();
70
71 self.fail(
72 &format!("{count} files exceed {threshold} lines:\n{}", examples.join("\n")),
73 Suggestion {
74 priority: SuggestionPriority::HighImpact,
75 title: format!("Split {count} mega-file(s)"),
76 description: format!("Files over {threshold} lines cost Claude ~{} tokens per read even if only 10 lines are relevant. Split by responsibility.\n{}", threshold * 2, examples.join("\n")),
77 effort: Effort::HalfDay,
78 },
79 )
80 }
81}
82
83pub struct NoMegaFunctions;
86
87impl Rule for NoMegaFunctions {
88 fn id(&self) -> &str { "2.2" }
89 fn name(&self) -> &str { "No mega-functions (>80 lines)" }
90 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
91 fn severity(&self) -> Severity { Severity::Medium }
92
93 fn check(&self, ctx: &ProjectContext) -> RuleResult {
94 use crate::scan::file_stats;
95
96 let threshold = 80;
97 let warn_threshold = 50;
98 let mut worst_file = String::new();
99 let mut worst_len = 0;
100 let mut offending_count = 0;
101
102 for f in &ctx.all_files {
103 if f.is_test || f.is_generated || f.line_count < warn_threshold {
104 continue;
105 }
106 if ctx.is_claudeignored(&f.relative_path.to_string_lossy()) {
107 continue;
108 }
109 let (longest, _count) = file_stats::longest_function(&f.path);
110 if longest > threshold && !is_registry_function(&f.path, longest) {
112 offending_count += 1;
113 if longest > worst_len {
114 worst_len = longest;
115 worst_file = f.relative_path.to_string_lossy().to_string();
116 }
117 }
118 }
119
120 if offending_count == 0 {
121 self.pass()
122 } else {
123 self.warn(
124 &format!("{offending_count} file(s) contain functions >80 lines (worst: {worst_file} at {worst_len} lines)"),
125 Suggestion {
126 priority: SuggestionPriority::HighImpact,
127 title: "Break down large functions".into(),
128 description: format!("Functions over 80 lines force Claude to read more context. Split by responsibility. Worst offender: {worst_file} ({worst_len} lines)."),
129 effort: Effort::HalfDay,
130 },
131 )
132 }
133 }
134}
135
136pub struct LockFilesIgnored;
139
140impl Rule for LockFilesIgnored {
141 fn id(&self) -> &str { "2.3" }
142 fn name(&self) -> &str { "Lock files are in .claudeignore" }
143 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
144 fn severity(&self) -> Severity { Severity::High }
145
146 fn check(&self, ctx: &ProjectContext) -> RuleResult {
147 if ctx.lock_files.is_empty() {
148 return self.pass(); }
150
151 if ctx.claudeignore_content.is_none() {
152 return self.fail(
153 "Lock files exist but no .claudeignore to exclude them",
154 Suggestion {
155 priority: SuggestionPriority::QuickWin,
156 title: "Add lock files to .claudeignore".into(),
157 description: "Add these to .claudeignore: package-lock.json, yarn.lock, Cargo.lock, Gemfile.lock, poetry.lock, go.sum. Lock files can be 10,000+ lines with zero useful context.".into(),
158 effort: Effort::Minutes,
159 },
160 );
161 }
162
163 let lock_patterns = [
164 "package-lock", "yarn.lock", "pnpm-lock", "Cargo.lock",
165 "Gemfile.lock", "poetry.lock", "go.sum", "composer.lock",
166 "pubspec.lock", "Pipfile.lock",
167 ];
168
169 let missing: Vec<&str> = ctx.lock_files.iter()
170 .filter_map(|lf| {
171 let name = lf.file_name()?.to_str()?;
172 let is_covered = lock_patterns.iter().any(|p| {
173 ctx.claudeignore_contains(p)
174 });
175 if is_covered { None } else { Some(name) }
176 })
177 .collect();
178
179 if missing.is_empty() {
180 self.pass()
181 } else {
182 self.fail(
183 &format!("Lock files not in .claudeignore: {}", missing.join(", ")),
184 Suggestion {
185 priority: SuggestionPriority::QuickWin,
186 title: "Add lock files to .claudeignore".into(),
187 description: format!("Add these patterns to .claudeignore: {}", missing.join(", ")),
188 effort: Effort::Minutes,
189 },
190 )
191 }
192 }
193}
194
195pub struct GeneratedFilesIgnored;
198
199impl Rule for GeneratedFilesIgnored {
200 fn id(&self) -> &str { "2.4" }
201 fn name(&self) -> &str { "Generated/compiled files are ignored" }
202 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
203 fn severity(&self) -> Severity { Severity::High }
204
205 fn check(&self, ctx: &ProjectContext) -> RuleResult {
206 let generated_count = ctx.all_files.iter().filter(|f| f.is_generated).count();
207
208 if generated_count == 0 {
209 return self.pass();
210 }
211
212 let build_dirs = ["dist", "build", "target", ".next", "out", "coverage"];
213 let has_build_dirs_ignored = ctx.claudeignore_content.as_ref().map(|c| {
214 build_dirs.iter().any(|d| c.contains(d))
215 }).unwrap_or(false);
216
217 if has_build_dirs_ignored {
218 self.pass()
219 } else {
220 self.fail(
221 &format!("{generated_count} generated files found but build directories not in .claudeignore"),
222 Suggestion {
223 priority: SuggestionPriority::QuickWin,
224 title: "Exclude build/generated directories".into(),
225 description: "Add to .claudeignore: dist/, build/, target/, .next/, out/, coverage/, **/generated/. Generated files waste tokens and can confuse Claude.".into(),
226 effort: Effort::Minutes,
227 },
228 )
229 }
230 }
231}
232
233