Skip to main content

claude_native/rules/project_specific/
polyglot.rs

1use crate::detection::ProjectType;
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5fn is_polyglot(pt: &ProjectType) -> bool {
6    pt.flags.is_polyglot
7}
8
9// ── Rule POLY1: Each language has its own CLAUDE.md ─────────────────
10
11pub 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
52// ── Rule POLY2: Root CLAUDE.md is language-agnostic ─────────────────
53
54pub 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        // Check if root CLAUDE.md contains language-specific details that should be in subdirs
71        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
97// ── Rule POLY3: Independent build/test per language ─────────────────
98
99pub 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        // Check for per-language commands
116        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
136// ── Rule POLY4: .claudeignore covers ALL runtimes ───────────────────
137
138pub 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}