Skip to main content

claude_native/rules/project_specific/
backend.rs

1use crate::detection::{PrimaryType, ProjectType};
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5fn is_backend(pt: &ProjectType) -> bool {
6    matches!(pt.primary, PrimaryType::Backend(_))
7}
8
9// ── Rule BE1: Migration history is manageable ───────────────────────
10
11pub struct MigrationHistoryManageable;
12
13impl Rule for MigrationHistoryManageable {
14    fn id(&self) -> &str { "BE1" }
15    fn name(&self) -> &str { "Migration history is manageable" }
16    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
17    fn severity(&self) -> Severity { Severity::Medium }
18
19    fn applies_to(&self, pt: &ProjectType) -> bool { is_backend(pt) }
20
21    fn check(&self, ctx: &ProjectContext) -> RuleResult {
22        let migration_dirs = ["migrations", "db/migrate", "alembic/versions", "prisma/migrations"];
23        let mut total_migrations = 0;
24
25        for dir in &migration_dirs {
26            let path = ctx.root.join(dir);
27            if path.is_dir() {
28                if let Ok(entries) = std::fs::read_dir(&path) {
29                    total_migrations += entries.filter_map(|e| e.ok()).filter(|e| e.path().is_file()).count();
30                }
31            }
32        }
33
34        if total_migrations == 0 {
35            return self.pass();
36        }
37
38        if total_migrations <= 100 {
39            self.pass()
40        } else if total_migrations <= 200 {
41            self.warn(
42                &format!("{total_migrations} migration files — consider squashing old migrations"),
43                Suggestion {
44                    priority: SuggestionPriority::NiceToHave,
45                    title: "Squash old migrations".into(),
46                    description: "Large migration histories waste tokens when Claude reads them to understand schema. Squash migrations older than 6 months.".into(),
47                    effort: Effort::HalfDay,
48                },
49            )
50        } else {
51            self.fail(
52                &format!("{total_migrations} migration files — too many for Claude to navigate efficiently"),
53                Suggestion {
54                    priority: SuggestionPriority::HighImpact,
55                    title: "Squash migration history".into(),
56                    description: format!("{total_migrations} migrations waste thousands of tokens. Squash to <100 and document the current schema in CLAUDE.md."),
57                    effort: Effort::HalfDay,
58                },
59            )
60        }
61    }
62}
63
64// ── Rule BE2: Database files ignored ────────────────────────────────
65
66pub struct DatabaseFilesIgnored;
67
68impl Rule for DatabaseFilesIgnored {
69    fn id(&self) -> &str { "BE2" }
70    fn name(&self) -> &str { "Database state files are ignored" }
71    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
72    fn severity(&self) -> Severity { Severity::High }
73
74    fn applies_to(&self, pt: &ProjectType) -> bool { is_backend(pt) }
75
76    fn check(&self, ctx: &ProjectContext) -> RuleResult {
77        let db_files: Vec<&str> = ["db.sqlite3", "database.sqlite", "dev.db"]
78            .iter()
79            .filter(|f| ctx.has_file(f))
80            .copied()
81            .collect();
82
83        if db_files.is_empty() {
84            return self.pass();
85        }
86
87        let ignored = ctx.claudeignore_contains("sqlite") || ctx.claudeignore_contains(".db");
88        if ignored {
89            self.pass()
90        } else {
91            self.fail(
92                &format!("Database files found but not ignored: {}", db_files.join(", ")),
93                Suggestion {
94                    priority: SuggestionPriority::QuickWin,
95                    title: "Ignore database files".into(),
96                    description: "Add *.sqlite3, *.db to .claudeignore AND .gitignore. Database files can be 100MB+ and Claude reads schema from migrations/models, not data files.".into(),
97                    effort: Effort::Minutes,
98                },
99            )
100        }
101    }
102}
103
104// ── Rule BE3: Virtual environments ignored ──────────────────────────
105
106pub struct VirtualEnvsIgnored;
107
108impl Rule for VirtualEnvsIgnored {
109    fn id(&self) -> &str { "BE3" }
110    fn name(&self) -> &str { "Virtual environments are ignored" }
111    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
112    fn severity(&self) -> Severity { Severity::Critical }
113
114    fn applies_to(&self, pt: &ProjectType) -> bool { is_backend(pt) }
115
116    fn check(&self, ctx: &ProjectContext) -> RuleResult {
117        let venvs = [".venv", "venv", "vendor", "__pycache__"];
118        let present: Vec<&&str> = venvs.iter().filter(|v| ctx.root.join(v).is_dir()).collect();
119
120        if present.is_empty() {
121            return self.pass();
122        }
123
124        let all_ignored = present.iter().all(|v| ctx.claudeignore_contains(v));
125        if all_ignored {
126            self.pass()
127        } else {
128            self.fail(
129                &format!("Virtual environments present but not in .claudeignore: {}", present.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", ")),
130                Suggestion {
131                    priority: SuggestionPriority::QuickWin,
132                    title: "Ignore virtual environments".into(),
133                    description: "Add to .claudeignore: .venv/, venv/, vendor/, __pycache__/. These can be 100MB+ and provide zero context.".into(),
134                    effort: Effort::Minutes,
135                },
136            )
137        }
138    }
139}
140
141// ── Rule BE4: ORM/data access pattern documented ────────────────────
142
143pub struct DataAccessDocumented;
144
145impl Rule for DataAccessDocumented {
146    fn id(&self) -> &str { "BE4" }
147    fn name(&self) -> &str { "ORM/data access pattern documented" }
148    fn dimension(&self) -> Dimension { Dimension::Foundation }
149    fn severity(&self) -> Severity { Severity::Medium }
150
151    fn applies_to(&self, pt: &ProjectType) -> bool { is_backend(pt) }
152
153    fn check(&self, ctx: &ProjectContext) -> RuleResult {
154        let content = match &ctx.claude_md_content {
155            Some(c) => c.to_lowercase(),
156            None => return self.skip(),
157        };
158
159        let has_data_docs = content.contains("orm")
160            || content.contains("database")
161            || content.contains("query")
162            || content.contains("model")
163            || content.contains("repository")
164            || content.contains("prisma")
165            || content.contains("sqlalchemy")
166            || content.contains("active record");
167
168        if has_data_docs {
169            self.pass()
170        } else {
171            self.warn(
172                "CLAUDE.md doesn't document the data access pattern",
173                Suggestion {
174                    priority: SuggestionPriority::NiceToHave,
175                    title: "Document data access pattern".into(),
176                    description: "Add to CLAUDE.md which ORM/query pattern to use. Backend projects often have 3+ ways to query data — Claude picks wrong 33% of the time without guidance.".into(),
177                    effort: Effort::Minutes,
178                },
179            )
180        }
181    }
182}
183
184// ── Rule BE5: API spec exists ───────────────────────────────────────
185
186pub struct ApiSpecExists;
187
188impl Rule for ApiSpecExists {
189    fn id(&self) -> &str { "BE5" }
190    fn name(&self) -> &str { "API spec exists (OpenAPI/Swagger)" }
191    fn dimension(&self) -> Dimension { Dimension::Navigation }
192    fn severity(&self) -> Severity { Severity::Low }
193
194    fn applies_to(&self, pt: &ProjectType) -> bool { is_backend(pt) }
195
196    fn check(&self, ctx: &ProjectContext) -> RuleResult {
197        let has_spec = ctx.has_file("openapi.yaml")
198            || ctx.has_file("openapi.yml")
199            || ctx.has_file("openapi.json")
200            || ctx.has_file("swagger.yaml")
201            || ctx.has_file("swagger.yml")
202            || ctx.has_file("swagger.json")
203            || ctx.has_file("api-spec.yaml");
204
205        if has_spec {
206            self.pass()
207        } else {
208            self.warn(
209                "No OpenAPI/Swagger API spec found",
210                Suggestion {
211                    priority: SuggestionPriority::NiceToHave,
212                    title: "Add API specification".into(),
213                    description: "Create openapi.yaml or swagger.json. Claude uses API specs to understand endpoints and request/response shapes without reading every handler.".into(),
214                    effort: Effort::Hour,
215                },
216            )
217        }
218    }
219}
220
221// ── Rule BE6: Log files ignored ─────────────────────────────────────
222
223pub struct LogFilesIgnored;
224
225impl Rule for LogFilesIgnored {
226    fn id(&self) -> &str { "BE6" }
227    fn name(&self) -> &str { "Log files are ignored" }
228    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
229    fn severity(&self) -> Severity { Severity::High }
230
231    fn applies_to(&self, pt: &ProjectType) -> bool { is_backend(pt) }
232
233    fn check(&self, ctx: &ProjectContext) -> RuleResult {
234        let has_logs = ctx.root.join("logs").is_dir()
235            || ctx.root.join("log").is_dir()
236            || ctx.all_files.iter().any(|f| f.path.extension().map(|e| e == "log").unwrap_or(false));
237
238        if !has_logs {
239            return self.pass();
240        }
241
242        let ignored = ctx.claudeignore_contains("*.log")
243            || ctx.claudeignore_contains("logs/")
244            || ctx.claudeignore_contains("log/");
245
246        if ignored {
247            self.pass()
248        } else {
249            self.fail(
250                "Log files/directories found but not in .claudeignore",
251                Suggestion {
252                    priority: SuggestionPriority::QuickWin,
253                    title: "Ignore log files".into(),
254                    description: "Add *.log, logs/, tmp/ to .claudeignore. A single log file can be 100MB — Claude should use filtered commands instead.".into(),
255                    effort: Effort::Minutes,
256                },
257            )
258        }
259    }
260}