claude_native/rules/
token_checks.rs1use crate::rules::*;
2use crate::scan::ProjectContext;
3
4pub 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
57pub 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
97pub 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
151pub 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}