Skip to main content

claude_native/rules/
token_checks.rs

1use crate::rules::*;
2use crate::scan::ProjectContext;
3
4// ═══════════════════════════════════════════════════════════════════
5// R10: Mixed generated + handwritten code
6// ═══════════════════════════════════════════════════════════════════
7
8pub struct MixedGeneratedCode;
9
10impl Rule for MixedGeneratedCode {
11    fn id(&self) -> &str { "7.10" }
12    fn name(&self) -> &str { "No mixed generated + handwritten files" }
13    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
14    fn severity(&self) -> Severity { Severity::Low }
15
16    fn check(&self, ctx: &ProjectContext) -> RuleResult {
17        let gen_markers = ["@generated", "AUTO-GENERATED", "DO NOT EDIT",
18            "Code generated by", "auto-generated", "THIS FILE IS GENERATED"];
19
20        let mut mixed_files = Vec::new();
21        let source = ctx.source_files();
22
23        for f in source.iter().take(30) {
24            let content = match std::fs::read_to_string(&f.path) {
25                Ok(c) => c,
26                Err(_) => continue,
27            };
28            if !gen_markers.iter().any(|m| content.contains(m)) { continue; }
29
30            let total = content.lines().count();
31            let marker_line = content.lines().enumerate()
32                .filter(|(_, l)| gen_markers.iter().any(|m| l.contains(m)))
33                .map(|(i, _)| i).last().unwrap_or(0);
34
35            let after = total.saturating_sub(marker_line);
36            if after as f64 / total.max(1) as f64 > 0.3 {
37                mixed_files.push(f.relative_path.to_string_lossy().to_string());
38            }
39        }
40
41        if mixed_files.is_empty() {
42            self.pass()
43        } else {
44            self.warn(
45                &format!("{} file(s) mix generated and handwritten code", mixed_files.len()),
46                Suggestion {
47                    priority: SuggestionPriority::NiceToHave,
48                    title: "Separate generated from handwritten code".into(),
49                    description: format!("Files: {}. Split so Claude doesn't edit generated sections.", mixed_files.join(", ")),
50                    effort: Effort::Hour,
51                },
52            )
53        }
54    }
55}
56
57// ═══════════════════════════════════════════════════════════════════
58// R13: Claude memory directory
59// ═══════════════════════════════════════════════════════════════════
60
61pub struct MemoryDirectoryExists;
62
63impl Rule for MemoryDirectoryExists {
64    fn id(&self) -> &str { "7.11" }
65    fn name(&self) -> &str { "Claude memory is being used" }
66    fn dimension(&self) -> Dimension { Dimension::Foundation }
67    fn severity(&self) -> Severity { Severity::Low }
68
69    fn check(&self, ctx: &ProjectContext) -> RuleResult {
70        let home = std::env::var("HOME").unwrap_or_default();
71        let claude_dir = std::path::PathBuf::from(&home).join(".claude");
72        let has_memory = claude_dir.join("projects").is_dir();
73
74        let mentions_memory = ctx.claude_md_content.as_ref().map(|c| {
75            let l = c.to_lowercase();
76            l.contains("memory") || l.contains("remember") || l.contains("context")
77        }).unwrap_or(false);
78
79        if has_memory || mentions_memory {
80            self.pass()
81        } else {
82            self.warn(
83                "Claude memory system not detected",
84                Suggestion {
85                    priority: SuggestionPriority::NiceToHave,
86                    title: "Use Claude's memory system".into(),
87                    description: "Claude Code persists project context across sessions via memory. \
88                        This saves ~2000 tokens/session by avoiding re-exploration. \
89                        Memories are created automatically during Claude Code sessions.".into(),
90                    effort: Effort::Minutes,
91                },
92            )
93        }
94    }
95}
96
97// ═══════════════════════════════════════════════════════════════════
98// R6: Circular dependency detection
99// ═══════════════════════════════════════════════════════════════════
100
101pub struct CircularDependencyCheck;
102
103impl Rule for CircularDependencyCheck {
104    fn id(&self) -> &str { "7.8" }
105    fn name(&self) -> &str { "No circular dependencies detected" }
106    fn dimension(&self) -> Dimension { Dimension::Navigation }
107    fn severity(&self) -> Severity { Severity::Low }
108
109    fn check(&self, ctx: &ProjectContext) -> RuleResult {
110        let cycles = find_simple_cycles(ctx);
111        if cycles.is_empty() { self.pass() }
112        else {
113            self.warn(
114                &format!("{} potential circular dep(s)", cycles.len()),
115                Suggestion {
116                    priority: SuggestionPriority::NiceToHave,
117                    title: "Review circular dependencies".into(),
118                    description: format!("Found:\n{}", cycles.iter().take(3).cloned().collect::<Vec<_>>().join("\n")),
119                    effort: Effort::HalfDay,
120                },
121            )
122        }
123    }
124}
125
126fn find_simple_cycles(ctx: &ProjectContext) -> Vec<String> {
127    let mut cycles = Vec::new();
128    let source = ctx.source_files();
129    let files: Vec<_> = source.iter().filter(|f| f.line_count > 10).take(30).collect();
130    for (i, a) in files.iter().enumerate() {
131        let a_name = a.relative_path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
132        let a_content = std::fs::read_to_string(&a.path).unwrap_or_default();
133        for b in files.iter().skip(i + 1) {
134            let b_name = b.relative_path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
135            let b_content = std::fs::read_to_string(&b.path).unwrap_or_default();
136            if has_import(&a_content, b_name) && has_import(&b_content, a_name) {
137                cycles.push(format!("  {} <-> {}", a.relative_path.display(), b.relative_path.display()));
138            }
139        }
140    }
141    cycles
142}
143
144fn has_import(content: &str, module: &str) -> bool {
145    content.lines().any(|l| {
146        let t = l.trim();
147        (t.starts_with("use ") || t.starts_with("import ") || t.starts_with("from ")) && t.contains(module)
148    })
149}
150
151// ═══════════════════════════════════════════════════════════════════
152// R7: Scattered code cross-references
153// ═══════════════════════════════════════════════════════════════════
154
155pub struct ScatteredCodeCrossRefs;
156
157impl Rule for ScatteredCodeCrossRefs {
158    fn id(&self) -> &str { "7.9" }
159    fn name(&self) -> &str { "Scattered code has cross-references" }
160    fn dimension(&self) -> Dimension { Dimension::Navigation }
161    fn severity(&self) -> Severity { Severity::Low }
162
163    fn check(&self, ctx: &ProjectContext) -> RuleResult {
164        let scattered = find_scattered_concepts(ctx);
165        if scattered.is_empty() { return self.pass(); }
166        let has_cross_refs = ctx.subdirectory_claude_mds.iter().any(|p| {
167            let c = std::fs::read_to_string(p).unwrap_or_default().to_lowercase();
168            c.contains("also in") || c.contains("related:") || c.contains("see also")
169        });
170        if has_cross_refs { self.pass() }
171        else {
172            self.warn(
173                &format!("Scattered: {}", scattered.join(", ")),
174                Suggestion {
175                    priority: SuggestionPriority::NiceToHave,
176                    title: "Add cross-references for scattered code".into(),
177                    description: "Add 'also in:' references in folder CLAUDE.md files.".into(),
178                    effort: Effort::Minutes,
179                },
180            )
181        }
182    }
183}
184
185fn find_scattered_concepts(ctx: &ProjectContext) -> Vec<String> {
186    use std::collections::{HashMap, HashSet};
187    let skip = ["mod", "index", "lib", "main", "test", "CLAUDE"];
188    let mut concept_dirs: HashMap<String, HashSet<String>> = HashMap::new();
189    for f in ctx.source_files() {
190        let stem = f.path.file_stem().and_then(|n| n.to_str()).unwrap_or("");
191        let dir = f.path.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()).unwrap_or("");
192        let concept = stem.split('_').next().unwrap_or(stem);
193        if concept.len() < 3 || skip.contains(&concept) { continue; }
194        concept_dirs.entry(concept.to_string()).or_default().insert(dir.to_string());
195    }
196    concept_dirs.into_iter()
197        .filter(|(_, dirs)| dirs.len() >= 3)
198        .map(|(c, _)| c)
199        .take(5)
200        .collect()
201}