claude_native/rules/project_specific/
polyglot.rs1use crate::detection::ProjectType;
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5fn is_polyglot(pt: &ProjectType) -> bool {
6 pt.flags.is_polyglot
7}
8
9pub struct PerLanguageClaudeMd;
12
13impl Rule for PerLanguageClaudeMd {
14 fn id(&self) -> &str { "POLY1" }
15 fn name(&self) -> &str { "Per-language CLAUDE.md files" }
16 fn dimension(&self) -> Dimension { Dimension::Foundation }
17 fn severity(&self) -> Severity { Severity::High }
18
19 fn applies_to(&self, pt: &ProjectType) -> bool { is_polyglot(pt) }
20
21 fn check(&self, ctx: &ProjectContext) -> RuleResult {
22 let lang_dirs = ["backend", "frontend", "server", "client", "api", "web", "app", "service"];
23 let dirs_with_claude_md = lang_dirs.iter()
24 .filter(|d| {
25 let dir = ctx.root.join(d);
26 dir.is_dir() && (dir.join("CLAUDE.md").exists() || dir.join(".claude").join("CLAUDE.md").exists())
27 })
28 .count();
29
30 let relevant_dirs = lang_dirs.iter().filter(|d| ctx.root.join(d).is_dir()).count();
31
32 if relevant_dirs == 0 {
33 return self.pass();
34 }
35
36 if dirs_with_claude_md >= relevant_dirs / 2 {
37 self.pass()
38 } else {
39 self.fail(
40 &format!("Only {dirs_with_claude_md}/{relevant_dirs} language directories have CLAUDE.md"),
41 Suggestion {
42 priority: SuggestionPriority::HighImpact,
43 title: "Add CLAUDE.md per language directory".into(),
44 description: "Each language directory needs its own CLAUDE.md with language-specific conventions. Go conventions waste tokens when Claude works in TypeScript.".into(),
45 effort: Effort::Hour,
46 },
47 )
48 }
49 }
50}
51
52pub struct RootClaudeMdAgnostic;
55
56impl Rule for RootClaudeMdAgnostic {
57 fn id(&self) -> &str { "POLY2" }
58 fn name(&self) -> &str { "Root CLAUDE.md is language-agnostic" }
59 fn dimension(&self) -> Dimension { Dimension::Foundation }
60 fn severity(&self) -> Severity { Severity::High }
61
62 fn applies_to(&self, pt: &ProjectType) -> bool { is_polyglot(pt) }
63
64 fn check(&self, ctx: &ProjectContext) -> RuleResult {
65 let content = match &ctx.claude_md_content {
66 Some(c) => c,
67 None => return self.skip(),
68 };
69
70 let lower = content.to_lowercase();
72 let lang_specific_patterns = [
73 "import React", "from django", "func main()", "fn main()",
74 "package.json", "Cargo.toml", "go.mod", "requirements.txt",
75 ];
76
77 let lang_refs: Vec<&&str> = lang_specific_patterns.iter()
78 .filter(|p| content.contains(*p) || lower.contains(&p.to_lowercase()))
79 .collect();
80
81 if lang_refs.len() > 2 {
82 self.warn(
83 "Root CLAUDE.md contains language-specific details that should be in subdirectory CLAUDE.md files",
84 Suggestion {
85 priority: SuggestionPriority::HighImpact,
86 title: "Move language rules to subdirectories".into(),
87 description: "Root CLAUDE.md loads on EVERY request. Language-specific rules (50%+ of the time irrelevant) should live in backend/CLAUDE.md, frontend/CLAUDE.md, etc.".into(),
88 effort: Effort::Hour,
89 },
90 )
91 } else {
92 self.pass()
93 }
94 }
95}
96
97pub struct IndependentBuildTest;
100
101impl Rule for IndependentBuildTest {
102 fn id(&self) -> &str { "POLY3" }
103 fn name(&self) -> &str { "Independent build/test per language" }
104 fn dimension(&self) -> Dimension { Dimension::Foundation }
105 fn severity(&self) -> Severity { Severity::High }
106
107 fn applies_to(&self, pt: &ProjectType) -> bool { is_polyglot(pt) }
108
109 fn check(&self, ctx: &ProjectContext) -> RuleResult {
110 let content = match &ctx.claude_md_content {
111 Some(c) => c.to_lowercase(),
112 None => return self.skip(),
113 };
114
115 let has_multiple_cmds = (content.contains("cd ") && content.contains("test"))
117 || (content.matches("test").count() >= 2)
118 || content.contains("backend") && content.contains("frontend");
119
120 if has_multiple_cmds || !ctx.subdirectory_claude_mds.is_empty() {
121 self.pass()
122 } else {
123 self.fail(
124 "No per-language build/test commands found",
125 Suggestion {
126 priority: SuggestionPriority::QuickWin,
127 title: "Document per-language commands".into(),
128 description: "Add separate build/test commands per language: `cd backend && go test` and `cd frontend && npm test`. A single `make test` that runs everything takes too long.".into(),
129 effort: Effort::Minutes,
130 },
131 )
132 }
133 }
134}
135
136pub struct AllRuntimesIgnored;
139
140impl Rule for AllRuntimesIgnored {
141 fn id(&self) -> &str { "POLY4" }
142 fn name(&self) -> &str { ".claudeignore covers all language runtimes" }
143 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
144 fn severity(&self) -> Severity { Severity::High }
145
146 fn applies_to(&self, pt: &ProjectType) -> bool { is_polyglot(pt) }
147
148 fn check(&self, ctx: &ProjectContext) -> RuleResult {
149 if ctx.claudeignore_content.is_none() {
150 return self.fail(
151 "No .claudeignore in polyglot project — ALL language runtime dirs are visible",
152 Suggestion {
153 priority: SuggestionPriority::QuickWin,
154 title: "Create .claudeignore for all languages".into(),
155 description: "A polyglot project needs: node_modules/, vendor/, .venv/, target/, __pycache__/, .gradle/ — miss one and half the noise leaks through.".into(),
156 effort: Effort::Minutes,
157 },
158 );
159 }
160
161 let runtime_dirs = [
162 ("node_modules", "JavaScript/TypeScript"),
163 ("vendor", "Go/PHP"),
164 (".venv", "Python"),
165 ("venv", "Python"),
166 ("target", "Rust"),
167 ("__pycache__", "Python"),
168 (".gradle", "Kotlin/Java"),
169 ];
170
171 let missing: Vec<(&str, &str)> = runtime_dirs.iter()
172 .filter(|(dir, _)| ctx.root.join(dir).is_dir() && !ctx.claudeignore_contains(dir))
173 .copied()
174 .collect();
175
176 if missing.is_empty() {
177 self.pass()
178 } else {
179 let list: Vec<String> = missing.iter().map(|(d, l)| format!("{d}/ ({l})")).collect();
180 self.fail(
181 &format!("Runtime dirs not ignored: {}", list.join(", ")),
182 Suggestion {
183 priority: SuggestionPriority::QuickWin,
184 title: "Ignore all runtime directories".into(),
185 description: format!("Add to .claudeignore: {}", missing.iter().map(|(d, _)| format!("{d}/")).collect::<Vec<_>>().join(", ")),
186 effort: Effort::Minutes,
187 },
188 )
189 }
190 }
191}