Skip to main content

claude_native/rules/project_specific/
monorepo.rs

1use crate::detection::{PrimaryType, ProjectType};
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5// ── Rule M1: Root CLAUDE.md must be thin orchestrator ───────────────
6
7pub 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
40// ── Rule M2: Every package has its own CLAUDE.md ────────────────────
41
42pub 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
97// ── Rule M3: .claudeignore covers all workspace outputs ─────────────
98
99pub 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
146// ── Rule M4: Path-scoped rules per package ──────────────────────────
147
148pub 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
177// ── Rule M5: Per-package test commands documented ───────────────────
178
179pub 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        // Check if root CLAUDE.md or any subdirectory CLAUDE.md mentions per-package testing
193        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
218// ── Rule M6: Workspace dependency graph is navigable ────────────────
219
220pub 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        // Check if workspace references are used in package.json or Cargo.toml
234        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
259// ── Rule M7: Shared tooling config at root ──────────────────────────
260
261pub 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}