Skip to main content

claude_native/rules/
token_rules.rs

1use crate::detection::ProjectType;
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5// ═══════════════════════════════════════════════════════════════════
6// R1: CLAUDE.md / README duplication check (suggestion only, no score)
7// ═══════════════════════════════════════════════════════════════════
8
9pub 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) // skip short/empty lines
54        .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
63// ═══════════════════════════════════════════════════════════════════
64// R2: Narrow .claude/rules/ path scopes
65// ═══════════════════════════════════════════════════════════════════
66
67pub 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
124// ═══════════════════════════════════════════════════════════════════
125// R11: Targeted test command in CLAUDE.md
126// ═══════════════════════════════════════════════════════════════════
127
128pub 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("::"); // Rust module path for targeted tests
151
152        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
173// ═══════════════════════════════════════════════════════════════════
174// R12: Test output filtering hooks
175// ═══════════════════════════════════════════════════════════════════
176
177pub 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
213// ═══════════════════════════════════════════════════════════════════
214// R14: Architecture decision records
215// ═══════════════════════════════════════════════════════════════════
216
217pub 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        // Also check if CLAUDE.md mentions architecture decisions
235        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() // Small projects don't need ADRs
244        } 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}