claude_native/rules/project_specific/
monorepo.rs1use crate::detection::{PrimaryType, ProjectType};
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5pub struct RootClaudeMdThin;
8
9impl Rule for RootClaudeMdThin {
10 fn id(&self) -> &str { "M1" }
11 fn name(&self) -> &str { "Root CLAUDE.md is thin (<100 lines)" }
12 fn dimension(&self) -> Dimension { Dimension::Foundation }
13 fn severity(&self) -> Severity { Severity::High }
14
15 fn applies_to(&self, pt: &ProjectType) -> bool {
16 matches!(pt.primary, PrimaryType::Monorepo)
17 }
18
19 fn check(&self, ctx: &ProjectContext) -> RuleResult {
20 let lines = ctx.claude_md_line_count();
21 if lines == 0 {
22 return self.skip();
23 }
24 if lines <= 100 {
25 self.pass()
26 } else {
27 self.fail(
28 &format!("Root CLAUDE.md is {lines} lines (monorepo target: <100). Package-specific instructions should be in subdirectory CLAUDE.md files."),
29 Suggestion {
30 priority: SuggestionPriority::HighImpact,
31 title: "Slim root CLAUDE.md to <100 lines".into(),
32 description: "In a monorepo, root CLAUDE.md loads on EVERY request across ALL packages. Move package-specific instructions to packages/<name>/CLAUDE.md.".into(),
33 effort: Effort::Hour,
34 },
35 )
36 }
37 }
38}
39
40pub struct PerPackageClaudeMd;
43
44impl Rule for PerPackageClaudeMd {
45 fn id(&self) -> &str { "M2" }
46 fn name(&self) -> &str { "Per-package CLAUDE.md files" }
47 fn dimension(&self) -> Dimension { Dimension::Foundation }
48 fn severity(&self) -> Severity { Severity::High }
49
50 fn applies_to(&self, pt: &ProjectType) -> bool {
51 matches!(pt.primary, PrimaryType::Monorepo)
52 }
53
54 fn check(&self, ctx: &ProjectContext) -> RuleResult {
55 let workspace_dirs = ["packages", "apps", "services", "libs"];
56 let mut total_packages = 0;
57 let mut packages_with_claude_md = 0;
58
59 for wd in &workspace_dirs {
60 let dir = ctx.root.join(wd);
61 if !dir.is_dir() { continue; }
62 if let Ok(entries) = std::fs::read_dir(&dir) {
63 for entry in entries.flatten() {
64 if entry.path().is_dir() {
65 total_packages += 1;
66 if entry.path().join("CLAUDE.md").exists()
67 || entry.path().join(".claude").join("CLAUDE.md").exists()
68 {
69 packages_with_claude_md += 1;
70 }
71 }
72 }
73 }
74 }
75
76 if total_packages == 0 {
77 return self.skip();
78 }
79
80 if packages_with_claude_md == total_packages {
81 self.pass()
82 } else {
83 let missing = total_packages - packages_with_claude_md;
84 self.fail(
85 &format!("{missing}/{total_packages} packages lack their own CLAUDE.md"),
86 Suggestion {
87 priority: SuggestionPriority::HighImpact,
88 title: format!("Add CLAUDE.md to {missing} packages"),
89 description: "Each package should have its own CLAUDE.md with package-specific build/test commands and conventions. These load on-demand, saving tokens.".into(),
90 effort: Effort::Hour,
91 },
92 )
93 }
94 }
95}
96
97pub struct WorkspaceOutputsIgnored;
100
101impl Rule for WorkspaceOutputsIgnored {
102 fn id(&self) -> &str { "M3" }
103 fn name(&self) -> &str { ".claudeignore covers all workspace outputs" }
104 fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
105 fn severity(&self) -> Severity { Severity::High }
106
107 fn applies_to(&self, pt: &ProjectType) -> bool {
108 matches!(pt.primary, PrimaryType::Monorepo)
109 }
110
111 fn check(&self, ctx: &ProjectContext) -> RuleResult {
112 if ctx.claudeignore_content.is_none() {
113 return self.fail(
114 "No .claudeignore in monorepo — all workspace outputs are visible",
115 Suggestion {
116 priority: SuggestionPriority::QuickWin,
117 title: "Create .claudeignore with workspace globs".into(),
118 description: "Add patterns like: packages/*/dist/, apps/*/build/, **/node_modules/, **/target/. Monorepos multiply noise — 10 packages x unignored dist/ = 10x wasted tokens.".into(),
119 effort: Effort::Minutes,
120 },
121 );
122 }
123
124 let has_wildcard_patterns = ctx.claudeignore_contains("*/dist")
125 || ctx.claudeignore_contains("*/build")
126 || ctx.claudeignore_contains("**/dist")
127 || ctx.claudeignore_contains("**/build")
128 || ctx.claudeignore_contains("**/target");
129
130 if has_wildcard_patterns {
131 self.pass()
132 } else {
133 self.warn(
134 ".claudeignore may not cover all workspace package outputs",
135 Suggestion {
136 priority: SuggestionPriority::QuickWin,
137 title: "Add workspace-wide ignore patterns".into(),
138 description: "Use glob patterns: packages/*/dist/, apps/*/build/, **/node_modules/. These cover all current and future packages.".into(),
139 effort: Effort::Minutes,
140 },
141 )
142 }
143 }
144}
145
146pub struct PathScopedRulesPerPackage;
149
150impl Rule for PathScopedRulesPerPackage {
151 fn id(&self) -> &str { "M4" }
152 fn name(&self) -> &str { "Path-scoped rules per package" }
153 fn dimension(&self) -> Dimension { Dimension::Tooling }
154 fn severity(&self) -> Severity { Severity::Medium }
155
156 fn applies_to(&self, pt: &ProjectType) -> bool {
157 matches!(pt.primary, PrimaryType::Monorepo)
158 }
159
160 fn check(&self, ctx: &ProjectContext) -> RuleResult {
161 if ctx.has_claude_rules_dir {
162 self.pass()
163 } else {
164 self.fail(
165 "No .claude/rules/ directory in monorepo",
166 Suggestion {
167 priority: SuggestionPriority::HighImpact,
168 title: "Create path-scoped rules for packages".into(),
169 description: "Create .claude/rules/ with files scoped to packages via paths: frontmatter. A React rule shouldn't load when editing a Go backend.".into(),
170 effort: Effort::Hour,
171 },
172 )
173 }
174 }
175}
176
177pub struct PerPackageTestCommands;
180
181impl Rule for PerPackageTestCommands {
182 fn id(&self) -> &str { "M5" }
183 fn name(&self) -> &str { "Per-package test commands documented" }
184 fn dimension(&self) -> Dimension { Dimension::Foundation }
185 fn severity(&self) -> Severity { Severity::High }
186
187 fn applies_to(&self, pt: &ProjectType) -> bool {
188 matches!(pt.primary, PrimaryType::Monorepo)
189 }
190
191 fn check(&self, ctx: &ProjectContext) -> RuleResult {
192 let root_has_package_tests = ctx.claude_md_content.as_ref().map(|c| {
194 let lower = c.to_lowercase();
195 (lower.contains("cd ") && lower.contains("test"))
196 || lower.contains("package")
197 || lower.contains("workspace")
198 }).unwrap_or(false);
199
200 let subdir_count = ctx.subdirectory_claude_mds.len();
201
202 if root_has_package_tests || subdir_count >= 2 {
203 self.pass()
204 } else {
205 self.fail(
206 "No per-package test commands found",
207 Suggestion {
208 priority: SuggestionPriority::QuickWin,
209 title: "Document per-package test commands".into(),
210 description: "Running ALL tests after a single-package change wastes minutes. Document per-package commands: `cd packages/auth && npm test` or put them in per-package CLAUDE.md files.".into(),
211 effort: Effort::Minutes,
212 },
213 )
214 }
215 }
216}
217
218pub struct WorkspaceDepsNavigable;
221
222impl Rule for WorkspaceDepsNavigable {
223 fn id(&self) -> &str { "M6" }
224 fn name(&self) -> &str { "Workspace dependencies are explicit" }
225 fn dimension(&self) -> Dimension { Dimension::Navigation }
226 fn severity(&self) -> Severity { Severity::Medium }
227
228 fn applies_to(&self, pt: &ProjectType) -> bool {
229 matches!(pt.primary, PrimaryType::Monorepo)
230 }
231
232 fn check(&self, ctx: &ProjectContext) -> RuleResult {
233 let has_workspace_refs = ctx.package_manifests.iter().any(|m| {
235 if let Ok(content) = std::fs::read_to_string(&m.path) {
236 content.contains("workspace:") || content.contains("workspace = true")
237 || content.contains("\"link:") || content.contains("\"file:")
238 } else {
239 false
240 }
241 });
242
243 if has_workspace_refs || ctx.package_manifests.len() <= 1 {
244 self.pass()
245 } else {
246 self.warn(
247 "No workspace dependency references found between packages",
248 Suggestion {
249 priority: SuggestionPriority::NiceToHave,
250 title: "Use workspace dependency references".into(),
251 description: "Use workspace:* (npm/pnpm), path dependencies (Cargo), or replace directives (Go) for inter-package deps. This lets Claude trace cross-package imports.".into(),
252 effort: Effort::Hour,
253 },
254 )
255 }
256 }
257}
258
259pub struct SharedToolingConfig;
262
263impl Rule for SharedToolingConfig {
264 fn id(&self) -> &str { "M7" }
265 fn name(&self) -> &str { "Shared tooling config at root" }
266 fn dimension(&self) -> Dimension { Dimension::Foundation }
267 fn severity(&self) -> Severity { Severity::Low }
268
269 fn applies_to(&self, pt: &ProjectType) -> bool {
270 matches!(pt.primary, PrimaryType::Monorepo)
271 }
272
273 fn check(&self, ctx: &ProjectContext) -> RuleResult {
274 let shared_configs = [
275 "tsconfig.json", "tsconfig.base.json",
276 ".eslintrc", ".eslintrc.js", ".eslintrc.json", "eslint.config.js",
277 "prettier.config.js", ".prettierrc",
278 "rustfmt.toml", ".rustfmt.toml",
279 ".editorconfig",
280 ];
281
282 let found = shared_configs.iter().any(|c| ctx.has_file(c));
283 if found {
284 self.pass()
285 } else {
286 self.warn(
287 "No shared tooling configs found at monorepo root",
288 Suggestion {
289 priority: SuggestionPriority::NiceToHave,
290 title: "Add shared configs at root".into(),
291 description: "Place shared configs (tsconfig.base.json, .eslintrc, rustfmt.toml) at root for packages to extend. This ensures consistency Claude can rely on.".into(),
292 effort: Effort::Hour,
293 },
294 )
295 }
296 }
297}