claude_native/rules/
token_rules.rs1use crate::detection::ProjectType;
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5pub struct ClaudeMdReadmeDuplication;
10
11impl Rule for ClaudeMdReadmeDuplication {
12 fn id(&self) -> &str { "7.1" }
13 fn name(&self) -> &str { "CLAUDE.md doesn't duplicate README" }
14 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
15 fn severity(&self) -> Severity { Severity::Low }
16
17 fn check(&self, ctx: &ProjectContext) -> RuleResult {
18 let claude = match &ctx.claude_md_content {
19 Some(c) => c,
20 None => return self.skip(),
21 };
22 let readme = match &ctx.readme_content {
23 Some(r) => r,
24 None => return self.pass(),
25 };
26
27 let overlap = compute_line_overlap(claude, readme);
28 if overlap > 0.3 {
29 self.warn(
30 &format!("{:.0}% of CLAUDE.md duplicates README content", overlap * 100.0),
31 Suggestion {
32 priority: SuggestionPriority::NiceToHave,
33 title: "Remove duplicated content from CLAUDE.md".into(),
34 description: "CLAUDE.md should contain ONLY what Claude can't infer:\n\
35 - Build/test commands\n\
36 - Code patterns and conventions\n\
37 - Gotchas and non-obvious behaviors\n\
38 - Architecture decisions\n\n\
39 Move project description, install guide, and usage to README only. \
40 Every duplicated line costs tokens on EVERY request.".into(),
41 effort: Effort::Minutes,
42 },
43 )
44 } else {
45 self.pass()
46 }
47 }
48}
49
50fn compute_line_overlap(a: &str, b: &str) -> f64 {
51 let a_lines: Vec<&str> = a.lines()
52 .map(|l| l.trim())
53 .filter(|l| l.len() > 10) .collect();
55 if a_lines.is_empty() { return 0.0; }
56 let b_content = b.to_lowercase();
57 let matches = a_lines.iter()
58 .filter(|l| b_content.contains(&l.to_lowercase()))
59 .count();
60 matches as f64 / a_lines.len() as f64
61}
62
63pub struct NarrowRuleScopes;
68
69impl Rule for NarrowRuleScopes {
70 fn id(&self) -> &str { "7.2" }
71 fn name(&self) -> &str { "Rule files use narrow path scopes" }
72 fn dimension(&self) -> Dimension { Dimension::Tooling }
73 fn severity(&self) -> Severity { Severity::Low }
74
75 fn check(&self, ctx: &ProjectContext) -> RuleResult {
76 if !ctx.has_claude_rules_dir { return self.skip(); }
77
78 let rules_dir = ctx.root.join(".claude").join("rules");
79 let mut broad_rules = Vec::new();
80
81 if let Ok(entries) = std::fs::read_dir(&rules_dir) {
82 for entry in entries.flatten() {
83 if !entry.path().extension().map(|e| e == "md").unwrap_or(false) {
84 continue;
85 }
86 if let Ok(content) = std::fs::read_to_string(entry.path()) {
87 if has_broad_scope(&content) {
88 let name = entry.file_name().to_string_lossy().to_string();
89 broad_rules.push(name);
90 }
91 }
92 }
93 }
94
95 if broad_rules.is_empty() {
96 self.pass()
97 } else {
98 self.warn(
99 &format!("Rules with broad paths: {}", broad_rules.join(", ")),
100 Suggestion {
101 priority: SuggestionPriority::NiceToHave,
102 title: "Narrow rule path scopes".into(),
103 description: "Use specific paths like `src/api/**` instead of `**/*.rs`. Broad scopes load the rule on every file access, wasting ~60 tokens per load.".into(),
104 effort: Effort::Minutes,
105 },
106 )
107 }
108 }
109}
110
111fn has_broad_scope(content: &str) -> bool {
112 for line in content.lines() {
113 let t = line.trim().trim_start_matches('-').trim();
114 if t.starts_with("\"**/*") || t.starts_with("'**/*")
115 || t == "\"**\"" || t == "'**'"
116 || t.starts_with("\"src/**\"") || t.starts_with("'src/**'")
117 {
118 return true;
119 }
120 }
121 false
122}
123
124pub struct TargetedTestCommand;
129
130impl Rule for TargetedTestCommand {
131 fn id(&self) -> &str { "7.3" }
132 fn name(&self) -> &str { "Targeted test command documented" }
133 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
134 fn severity(&self) -> Severity { Severity::Medium }
135
136 fn check(&self, ctx: &ProjectContext) -> RuleResult {
137 let content = match &ctx.claude_md_content {
138 Some(c) => c.to_lowercase(),
139 None => return self.skip(),
140 };
141
142 let has_targeted = content.contains("test single")
143 || content.contains("test one")
144 || content.contains("test specific")
145 || content.contains("--test ")
146 || content.contains("--testpathpattern")
147 || content.contains("-t ")
148 || content.contains("test module")
149 || content.contains("test file")
150 || content.contains("::"); if has_targeted {
153 self.pass()
154 } else {
155 self.fail(
156 "CLAUDE.md only has full test suite command, no targeted test command",
157 Suggestion {
158 priority: SuggestionPriority::QuickWin,
159 title: "Add targeted test command to CLAUDE.md".into(),
160 description: "Add a command for testing single files/modules. This saves ~2000 tokens per test run.\n\
161 Examples:\n\
162 - Rust: `cargo test --test <name>` or `cargo test module::`\n\
163 - JS: `npm test -- --testPathPattern=<file>`\n\
164 - Python: `pytest tests/<file>.py`\n\
165 - Go: `go test ./pkg/<name>`".into(),
166 effort: Effort::Minutes,
167 },
168 )
169 }
170 }
171}
172
173pub struct TestOutputFilteringHook;
178
179impl Rule for TestOutputFilteringHook {
180 fn id(&self) -> &str { "7.4" }
181 fn name(&self) -> &str { "Test output filtering hook exists" }
182 fn dimension(&self) -> Dimension { Dimension::Tooling }
183 fn severity(&self) -> Severity { Severity::Low }
184
185 fn check(&self, ctx: &ProjectContext) -> RuleResult {
186 let has_filter = ctx.settings_json.as_ref()
187 .and_then(|v| v.get("hooks"))
188 .map(|h| {
189 let s = serde_json::to_string(h).unwrap_or_default().to_lowercase();
190 s.contains("grep") || s.contains("fail") || s.contains("error")
191 || s.contains("filter") || s.contains("tail")
192 })
193 .unwrap_or(false);
194
195 if has_filter {
196 self.pass()
197 } else {
198 self.warn(
199 "No hook to filter verbose test/build output",
200 Suggestion {
201 priority: SuggestionPriority::NiceToHave,
202 title: "Add test output filtering hook".into(),
203 description: "Add a PostToolUse hook that filters test output to failures only. \
204 Saves ~3000 tokens per test run.\n\
205 Example: grep -E '(FAIL|ERROR|panicked|test result:)' || echo 'All passed'".into(),
206 effort: Effort::Minutes,
207 },
208 )
209 }
210 }
211}
212
213pub struct ArchDecisionRecords;
218
219impl Rule for ArchDecisionRecords {
220 fn id(&self) -> &str { "7.5" }
221 fn name(&self) -> &str { "Architecture decision records exist" }
222 fn dimension(&self) -> Dimension { Dimension::CodeQuality }
223 fn severity(&self) -> Severity { Severity::Low }
224
225 fn check(&self, ctx: &ProjectContext) -> RuleResult {
226 let has_adr = ctx.root.join("docs").join("adr").is_dir()
227 || ctx.root.join("docs").join("decisions").is_dir()
228 || ctx.root.join("adr").is_dir()
229 || ctx.root.join("ADR").is_dir()
230 || ctx.directories.iter().any(|d| {
231 d.file_name().map(|n| n == "adr" || n == "decisions").unwrap_or(false)
232 });
233
234 let documented_in_claude = ctx.claude_md_content.as_ref().map(|c| {
236 let l = c.to_lowercase();
237 l.contains("decision") || l.contains("adr") || l.contains("why we")
238 }).unwrap_or(false);
239
240 if has_adr || documented_in_claude {
241 self.pass()
242 } else if ctx.source_file_count() < 20 {
243 self.pass() } else {
245 self.warn(
246 "No architecture decision records found",
247 Suggestion {
248 priority: SuggestionPriority::NiceToHave,
249 title: "Add architecture decision records".into(),
250 description: "Create `docs/adr/` with markdown files documenting key decisions. \
251 Claude reads one ADR (~30 tokens) instead of reverse-engineering intent from code (~900 tokens).\n\
252 Template: docs/adr/001-use-redis-for-sessions.md\n\
253 Content: ## Decision, ## Reason, ## Alternatives Considered".into(),
254 effort: Effort::Hour,
255 },
256 )
257 }
258 }
259}