Skip to main content

claude_native/rules/
context.rs

1use std::path::Path;
2
3use crate::rules::*;
4use crate::scan::ProjectContext;
5
6/// Check if the longest function in a file is a simple registry/list
7/// (mostly Box::new, vec![], or simple match arms). These are long
8/// but not complex — they don't hurt Claude's ability to understand.
9fn 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
30// ── Rule 2.1: No mega-files ────────────────────────────────────────
31
32pub 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
83// ── Rule 2.2: No mega-functions ────────────────────────────────────
84
85pub 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            // Skip registry/list functions (just Box::new, vec![], match arms)
111            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
136// ── Rule 2.3: Lock files are ignored ────────────────────────────────
137
138pub 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(); // No lock files to worry about
149        }
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
195// ── Rule 2.4: Generated files are ignored ───────────────────────────
196
197pub 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// Rules 2.5-2.7 are in context_extra.rs