forgedev 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +38 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/bin/devforge.js +4 -0
- package/package.json +33 -0
- package/src/claude-configurator.js +260 -0
- package/src/cli.js +119 -0
- package/src/composer.js +214 -0
- package/src/doctor-checks.js +743 -0
- package/src/doctor-prompts.js +295 -0
- package/src/doctor.js +281 -0
- package/src/guided.js +315 -0
- package/src/index.js +148 -0
- package/src/init-mode.js +134 -0
- package/src/prompts.js +155 -0
- package/src/recommender.js +186 -0
- package/src/scanner.js +368 -0
- package/src/uat-generator.js +189 -0
- package/src/utils.js +57 -0
- package/templates/auth/jwt-custom/backend/app/api/auth.py.template +45 -0
- package/templates/auth/jwt-custom/backend/app/api/deps.py.template +16 -0
- package/templates/auth/jwt-custom/backend/app/core/security.py.template +34 -0
- package/templates/auth/nextauth/src/app/api/auth/[...nextauth]/route.ts.template +3 -0
- package/templates/auth/nextauth/src/lib/auth.ts.template +30 -0
- package/templates/auth/nextauth/src/middleware.ts.template +14 -0
- package/templates/backend/fastapi/backend/Dockerfile.template +12 -0
- package/templates/backend/fastapi/backend/app/__init__.py +0 -0
- package/templates/backend/fastapi/backend/app/api/__init__.py +0 -0
- package/templates/backend/fastapi/backend/app/api/health.py.template +32 -0
- package/templates/backend/fastapi/backend/app/core/__init__.py +0 -0
- package/templates/backend/fastapi/backend/app/core/config.py.template +25 -0
- package/templates/backend/fastapi/backend/app/core/errors.py +37 -0
- package/templates/backend/fastapi/backend/app/core/retry.py +32 -0
- package/templates/backend/fastapi/backend/app/main.py.template +58 -0
- package/templates/backend/fastapi/backend/app/models/__init__.py +0 -0
- package/templates/backend/fastapi/backend/app/schemas/__init__.py +0 -0
- package/templates/backend/fastapi/backend/pyproject.toml.template +19 -0
- package/templates/backend/fastapi/backend/requirements.txt.template +14 -0
- package/templates/base/.gitignore.template +29 -0
- package/templates/base/README.md.template +25 -0
- package/templates/claude-code/agents/code-quality-reviewer.md +41 -0
- package/templates/claude-code/agents/production-readiness.md +55 -0
- package/templates/claude-code/agents/security-reviewer.md +41 -0
- package/templates/claude-code/agents/spec-validator.md +34 -0
- package/templates/claude-code/agents/uat-validator.md +37 -0
- package/templates/claude-code/claude-md/base.md +33 -0
- package/templates/claude-code/claude-md/fastapi.md +12 -0
- package/templates/claude-code/claude-md/fullstack.md +12 -0
- package/templates/claude-code/claude-md/nextjs.md +11 -0
- package/templates/claude-code/commands/audit-security.md +11 -0
- package/templates/claude-code/commands/audit-spec.md +9 -0
- package/templates/claude-code/commands/audit-wiring.md +17 -0
- package/templates/claude-code/commands/done.md +19 -0
- package/templates/claude-code/commands/generate-prd.md +45 -0
- package/templates/claude-code/commands/generate-uat.md +35 -0
- package/templates/claude-code/commands/help.md +26 -0
- package/templates/claude-code/commands/next.md +20 -0
- package/templates/claude-code/commands/optimize-claude-md.md +31 -0
- package/templates/claude-code/commands/pre-pr.md +19 -0
- package/templates/claude-code/commands/run-uat.md +21 -0
- package/templates/claude-code/commands/status.md +24 -0
- package/templates/claude-code/commands/verify-all.md +11 -0
- package/templates/claude-code/hooks/polyglot.json +36 -0
- package/templates/claude-code/hooks/python.json +36 -0
- package/templates/claude-code/hooks/scripts/autofix-polyglot.sh +16 -0
- package/templates/claude-code/hooks/scripts/autofix-python.sh +14 -0
- package/templates/claude-code/hooks/scripts/autofix-typescript.sh +14 -0
- package/templates/claude-code/hooks/scripts/guard-protected-files.sh +21 -0
- package/templates/claude-code/hooks/typescript.json +36 -0
- package/templates/claude-code/skills/ai-prompts/SKILL.md +43 -0
- package/templates/claude-code/skills/fastapi/SKILL.md +38 -0
- package/templates/claude-code/skills/nextjs/SKILL.md +39 -0
- package/templates/claude-code/skills/playwright/SKILL.md +37 -0
- package/templates/claude-code/skills/security-api/SKILL.md +47 -0
- package/templates/claude-code/skills/security-web/SKILL.md +41 -0
- package/templates/database/prisma-postgres/.env.example +1 -0
- package/templates/database/prisma-postgres/prisma/schema.prisma.template +18 -0
- package/templates/database/sqlalchemy-postgres/.env.example +1 -0
- package/templates/database/sqlalchemy-postgres/backend/alembic/env.py.template +40 -0
- package/templates/database/sqlalchemy-postgres/backend/alembic/versions/.gitkeep +0 -0
- package/templates/database/sqlalchemy-postgres/backend/alembic.ini.template +36 -0
- package/templates/database/sqlalchemy-postgres/backend/app/db/__init__.py +0 -0
- package/templates/database/sqlalchemy-postgres/backend/app/db/base.py +5 -0
- package/templates/database/sqlalchemy-postgres/backend/app/db/session.py.template +48 -0
- package/templates/frontend/nextjs/next.config.ts.template +7 -0
- package/templates/frontend/nextjs/package.json.template +41 -0
- package/templates/frontend/nextjs/postcss.config.mjs +7 -0
- package/templates/frontend/nextjs/src/app/api/health/route.ts.template +10 -0
- package/templates/frontend/nextjs/src/app/globals.css +1 -0
- package/templates/frontend/nextjs/src/app/layout.tsx.template +22 -0
- package/templates/frontend/nextjs/src/app/page.tsx.template +10 -0
- package/templates/frontend/nextjs/src/lib/db.ts.template +40 -0
- package/templates/frontend/nextjs/src/lib/errors.ts +28 -0
- package/templates/frontend/nextjs/src/lib/utils.ts +6 -0
- package/templates/frontend/nextjs/tsconfig.json +23 -0
- package/templates/infra/docker-compose/docker-compose.yml.template +19 -0
- package/templates/testing/playwright/e2e/example.spec.ts.template +15 -0
- package/templates/testing/playwright/playwright.config.ts.template +22 -0
- package/templates/testing/vitest/src/__tests__/example.test.ts.template +12 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function runAllChecks(projectDir, scan) {
|
|
5
|
+
const issues = [];
|
|
6
|
+
|
|
7
|
+
issues.push(...checkClaudeMdLength(projectDir, scan));
|
|
8
|
+
issues.push(...checkUnauthEndpoints(projectDir, scan));
|
|
9
|
+
issues.push(...checkFlakyTestPatterns(projectDir));
|
|
10
|
+
issues.push(...checkCrossDomainImports(projectDir, scan));
|
|
11
|
+
issues.push(...checkBareExcepts(projectDir));
|
|
12
|
+
issues.push(...checkMissingHealthEndpoint(projectDir, scan));
|
|
13
|
+
issues.push(...checkMissingGracefulShutdown(projectDir, scan));
|
|
14
|
+
issues.push(...checkMissingUAT(scan));
|
|
15
|
+
issues.push(...checkScatteredAPICalls(projectDir, scan));
|
|
16
|
+
issues.push(...checkLargeFiles(projectDir));
|
|
17
|
+
issues.push(...checkMissingScopedClaudeMd(projectDir, scan));
|
|
18
|
+
issues.push(...checkUnusedDependencies(projectDir, scan));
|
|
19
|
+
issues.push(...checkHardcodedValues(projectDir));
|
|
20
|
+
issues.push(...checkUnusedExports(projectDir, scan));
|
|
21
|
+
issues.push(...checkDuplicateCode(projectDir));
|
|
22
|
+
issues.push(...checkDeadFeatures(projectDir, scan));
|
|
23
|
+
issues.push(...checkAIPrompts(projectDir));
|
|
24
|
+
|
|
25
|
+
return issues.sort((a, b) => {
|
|
26
|
+
const order = { critical: 0, warning: 1, info: 2 };
|
|
27
|
+
return (order[a.severity] ?? 3) - (order[b.severity] ?? 3);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkClaudeMdLength(projectDir, scan) {
|
|
32
|
+
if (!scan.infrastructure.hasClaudeMd) return [];
|
|
33
|
+
const lines = scan.infrastructure.claudeMdLines;
|
|
34
|
+
if (lines <= 150) return [];
|
|
35
|
+
|
|
36
|
+
return [{
|
|
37
|
+
severity: lines > 500 ? 'critical' : 'warning',
|
|
38
|
+
title: `CLAUDE.md is ${lines} lines (recommended limit: 150)`,
|
|
39
|
+
impact: 'Instructions are being dropped — Claude Code ignores rules randomly when context is too large',
|
|
40
|
+
files: ['CLAUDE.md'],
|
|
41
|
+
autoFixable: false,
|
|
42
|
+
promptId: 'CLAUDE_MD_TOO_LONG',
|
|
43
|
+
effort: 'medium',
|
|
44
|
+
}];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkUnauthEndpoints(projectDir, scan) {
|
|
48
|
+
const issues = [];
|
|
49
|
+
|
|
50
|
+
// FastAPI: check for routes missing Depends(get_current_user)
|
|
51
|
+
if (scan.backend.detected && scan.backend.framework === 'fastapi') {
|
|
52
|
+
const apiDir = path.join(projectDir, scan.backend.directory || 'backend', 'app', 'api');
|
|
53
|
+
if (!fs.existsSync(apiDir)) return [];
|
|
54
|
+
|
|
55
|
+
const pyFiles = findFiles(apiDir, '.py');
|
|
56
|
+
for (const file of pyFiles) {
|
|
57
|
+
if (path.basename(file) === '__init__.py' || path.basename(file) === 'auth.py') continue;
|
|
58
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
59
|
+
const lines = content.split('\n');
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
if (/@router\.(get|post|put|delete|patch)\s*\(/.test(line)) {
|
|
64
|
+
// Look ahead for the function definition and check for auth dependency
|
|
65
|
+
const fnBlock = lines.slice(i, Math.min(i + 10, lines.length)).join('\n');
|
|
66
|
+
if (!fnBlock.includes('get_current_user') && !fnBlock.includes('current_user') && !fnBlock.includes('Depends(')) {
|
|
67
|
+
const relPath = path.relative(projectDir, file);
|
|
68
|
+
issues.push({
|
|
69
|
+
severity: 'critical',
|
|
70
|
+
title: `Unauthenticated endpoint: ${relPath}:${i + 1}`,
|
|
71
|
+
impact: 'Security vulnerability — data accessible without login',
|
|
72
|
+
files: [`${relPath}:${i + 1}`],
|
|
73
|
+
autoFixable: false,
|
|
74
|
+
promptId: 'UNAUTH_ENDPOINT',
|
|
75
|
+
effort: 'quick',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Next.js API routes: check for missing auth
|
|
84
|
+
if (scan.frontend.detected && scan.frontend.framework === 'nextjs') {
|
|
85
|
+
const srcDir = scan.frontend.directory || 'src';
|
|
86
|
+
const apiDir = path.join(projectDir, srcDir, 'app', 'api');
|
|
87
|
+
if (!fs.existsSync(apiDir)) return issues;
|
|
88
|
+
|
|
89
|
+
const routeFiles = findFiles(apiDir, '.ts').concat(findFiles(apiDir, '.tsx'));
|
|
90
|
+
for (const file of routeFiles) {
|
|
91
|
+
if (path.basename(file) === 'route.ts' || path.basename(file) === 'route.tsx') {
|
|
92
|
+
// Skip health endpoints
|
|
93
|
+
if (file.includes('/health/')) continue;
|
|
94
|
+
|
|
95
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
96
|
+
if (!content.includes('getServerSession') && !content.includes('auth(') &&
|
|
97
|
+
!content.includes('getToken') && !content.includes('currentUser') &&
|
|
98
|
+
!content.includes('session')) {
|
|
99
|
+
const relPath = path.relative(projectDir, file);
|
|
100
|
+
issues.push({
|
|
101
|
+
severity: 'warning',
|
|
102
|
+
title: `API route may lack auth: ${relPath}`,
|
|
103
|
+
impact: 'Endpoint may be accessible without authentication',
|
|
104
|
+
files: [relPath],
|
|
105
|
+
autoFixable: false,
|
|
106
|
+
promptId: 'UNAUTH_ENDPOINT',
|
|
107
|
+
effort: 'quick',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Consolidate multiple unauth endpoints into one issue
|
|
115
|
+
if (issues.length > 1) {
|
|
116
|
+
const allFiles = issues.flatMap(i => i.files);
|
|
117
|
+
const severity = issues.some(i => i.severity === 'critical') ? 'critical' : 'warning';
|
|
118
|
+
return [{
|
|
119
|
+
severity,
|
|
120
|
+
title: `${issues.length} endpoints have no authentication`,
|
|
121
|
+
impact: 'Security vulnerability — data accessible without login',
|
|
122
|
+
files: allFiles,
|
|
123
|
+
autoFixable: false,
|
|
124
|
+
promptId: 'UNAUTH_ENDPOINT',
|
|
125
|
+
effort: 'quick',
|
|
126
|
+
}];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return issues;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checkFlakyTestPatterns(projectDir) {
|
|
133
|
+
const issues = [];
|
|
134
|
+
const testDirs = ['tests', 'e2e', '__tests__', 'test', 'frontend/e2e', 'frontend/tests'];
|
|
135
|
+
const testFiles = [];
|
|
136
|
+
|
|
137
|
+
for (const dir of testDirs) {
|
|
138
|
+
const fullDir = path.join(projectDir, dir);
|
|
139
|
+
if (fs.existsSync(fullDir)) {
|
|
140
|
+
testFiles.push(...findFiles(fullDir, '.ts'), ...findFiles(fullDir, '.js'), ...findFiles(fullDir, '.py'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Also find test files in src
|
|
145
|
+
const srcDir = path.join(projectDir, 'src');
|
|
146
|
+
if (fs.existsSync(srcDir)) {
|
|
147
|
+
const allSrc = findFiles(srcDir, '.ts').concat(findFiles(srcDir, '.js'));
|
|
148
|
+
testFiles.push(...allSrc.filter(f => f.includes('.test.') || f.includes('.spec.')));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const flakyFiles = [];
|
|
152
|
+
for (const file of testFiles) {
|
|
153
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
154
|
+
if (content.includes('waitForTimeout') || content.includes('page.waitForTimeout') ||
|
|
155
|
+
/cy\.wait\(\d+\)/.test(content) || /setTimeout.*\d{3,}/.test(content)) {
|
|
156
|
+
flakyFiles.push(path.relative(projectDir, file));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (flakyFiles.length > 0) {
|
|
161
|
+
issues.push({
|
|
162
|
+
severity: 'critical',
|
|
163
|
+
title: `${flakyFiles.length} tests use waitForTimeout() or hardcoded delays`,
|
|
164
|
+
impact: 'Tests fail randomly — unreliable CI',
|
|
165
|
+
files: flakyFiles,
|
|
166
|
+
autoFixable: false,
|
|
167
|
+
promptId: 'FLAKY_TESTS',
|
|
168
|
+
effort: 'medium',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return issues;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function checkCrossDomainImports(projectDir, scan) {
|
|
176
|
+
if (!scan.frontend.detected || !scan.backend.detected) return [];
|
|
177
|
+
if (scan.stackId !== 'polyglot-fullstack') return [];
|
|
178
|
+
|
|
179
|
+
const issues = [];
|
|
180
|
+
const frontendDir = scan.frontend.directory || 'frontend';
|
|
181
|
+
const backendDir = scan.backend.directory || 'backend';
|
|
182
|
+
|
|
183
|
+
// Check frontend files importing from backend
|
|
184
|
+
const frontendPath = path.join(projectDir, frontendDir);
|
|
185
|
+
if (fs.existsSync(frontendPath)) {
|
|
186
|
+
const frontFiles = findFiles(frontendPath, '.ts').concat(findFiles(frontendPath, '.tsx'), findFiles(frontendPath, '.js'));
|
|
187
|
+
for (const file of frontFiles) {
|
|
188
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
189
|
+
if (content.includes(`from '../../${backendDir}`) || content.includes(`from '../${backendDir}`) ||
|
|
190
|
+
content.includes(`import '../../${backendDir}`) || content.includes(`require('../../${backendDir}`)) {
|
|
191
|
+
issues.push({
|
|
192
|
+
severity: 'critical',
|
|
193
|
+
title: `Cross-domain import: ${path.relative(projectDir, file)} imports from ${backendDir}`,
|
|
194
|
+
impact: 'Build will break or bundle server code into client — security risk',
|
|
195
|
+
files: [path.relative(projectDir, file)],
|
|
196
|
+
autoFixable: false,
|
|
197
|
+
promptId: 'CROSS_DOMAIN_IMPORT',
|
|
198
|
+
effort: 'medium',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (issues.length > 1) {
|
|
205
|
+
const allFiles = issues.flatMap(i => i.files);
|
|
206
|
+
return [{
|
|
207
|
+
severity: 'critical',
|
|
208
|
+
title: `${issues.length} cross-domain imports (frontend importing backend or vice versa)`,
|
|
209
|
+
impact: 'Build will break or bundle server code into client — security risk',
|
|
210
|
+
files: allFiles,
|
|
211
|
+
autoFixable: false,
|
|
212
|
+
promptId: 'CROSS_DOMAIN_IMPORT',
|
|
213
|
+
effort: 'medium',
|
|
214
|
+
}];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return issues;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function checkBareExcepts(projectDir) {
|
|
221
|
+
const pyFiles = [];
|
|
222
|
+
const dirs = ['backend', 'app', 'src', '.'];
|
|
223
|
+
for (const dir of dirs) {
|
|
224
|
+
const fullDir = path.join(projectDir, dir);
|
|
225
|
+
if (fs.existsSync(fullDir) && fs.statSync(fullDir).isDirectory()) {
|
|
226
|
+
pyFiles.push(...findFiles(fullDir, '.py'));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const bareExceptFiles = [];
|
|
231
|
+
for (const file of pyFiles) {
|
|
232
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
233
|
+
const lines = content.split('\n');
|
|
234
|
+
for (let i = 0; i < lines.length; i++) {
|
|
235
|
+
const trimmed = lines[i].trim();
|
|
236
|
+
if (trimmed === 'except:' || trimmed === 'except :') {
|
|
237
|
+
bareExceptFiles.push(`${path.relative(projectDir, file)}:${i + 1}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (bareExceptFiles.length === 0) return [];
|
|
243
|
+
|
|
244
|
+
return [{
|
|
245
|
+
severity: 'warning',
|
|
246
|
+
title: `${bareExceptFiles.length} bare except: blocks`,
|
|
247
|
+
impact: 'Silently swallows real errors, makes debugging impossible',
|
|
248
|
+
files: bareExceptFiles,
|
|
249
|
+
autoFixable: false,
|
|
250
|
+
promptId: 'BARE_EXCEPT',
|
|
251
|
+
effort: 'quick',
|
|
252
|
+
}];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function checkMissingHealthEndpoint(projectDir, scan) {
|
|
256
|
+
// Search for health endpoints by file path or content
|
|
257
|
+
const patterns = ['/health', '/healthz', '/api/health'];
|
|
258
|
+
const searchDirs = ['src', 'backend', 'app', 'frontend/src'];
|
|
259
|
+
|
|
260
|
+
for (const dir of searchDirs) {
|
|
261
|
+
const fullDir = path.join(projectDir, dir);
|
|
262
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
263
|
+
const files = findFiles(fullDir, '.ts').concat(findFiles(fullDir, '.js'), findFiles(fullDir, '.py'));
|
|
264
|
+
for (const file of files) {
|
|
265
|
+
// Check file path (e.g., src/app/api/health/route.ts)
|
|
266
|
+
const relPath = path.relative(projectDir, file).replace(/\\/g, '/');
|
|
267
|
+
if (relPath.includes('/health/') || relPath.includes('/healthz/')) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
// Check file content
|
|
271
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
272
|
+
if (patterns.some(p => content.includes(p))) {
|
|
273
|
+
return [];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return [{
|
|
279
|
+
severity: 'warning',
|
|
280
|
+
title: 'No health check endpoint',
|
|
281
|
+
impact: 'Load balancers and monitoring can\'t verify app is running',
|
|
282
|
+
files: [],
|
|
283
|
+
autoFixable: false,
|
|
284
|
+
promptId: 'MISSING_HEALTH',
|
|
285
|
+
effort: 'quick',
|
|
286
|
+
}];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function checkMissingGracefulShutdown(projectDir, scan) {
|
|
290
|
+
const searchDirs = ['src', 'backend', 'app', 'frontend/src'];
|
|
291
|
+
const signals = ['SIGTERM', 'SIGINT', 'process.on', 'signal.signal', 'lifespan'];
|
|
292
|
+
|
|
293
|
+
for (const dir of searchDirs) {
|
|
294
|
+
const fullDir = path.join(projectDir, dir);
|
|
295
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
296
|
+
const files = findFiles(fullDir, '.ts').concat(findFiles(fullDir, '.js'), findFiles(fullDir, '.py'));
|
|
297
|
+
for (const file of files) {
|
|
298
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
299
|
+
if (signals.some(s => content.includes(s))) {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return [{
|
|
306
|
+
severity: 'warning',
|
|
307
|
+
title: 'No graceful shutdown handler',
|
|
308
|
+
impact: 'Deployments may drop in-flight requests',
|
|
309
|
+
files: [],
|
|
310
|
+
autoFixable: false,
|
|
311
|
+
promptId: 'MISSING_SHUTDOWN',
|
|
312
|
+
effort: 'quick',
|
|
313
|
+
}];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function checkMissingUAT(scan) {
|
|
317
|
+
if (scan.infrastructure.hasUAT) return [];
|
|
318
|
+
return [{
|
|
319
|
+
severity: 'warning',
|
|
320
|
+
title: 'No UAT scenarios',
|
|
321
|
+
impact: 'No formal acceptance criteria — features may not work as intended',
|
|
322
|
+
files: [],
|
|
323
|
+
autoFixable: false,
|
|
324
|
+
promptId: 'MISSING_UAT',
|
|
325
|
+
effort: 'medium',
|
|
326
|
+
}];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function checkScatteredAPICalls(projectDir, scan) {
|
|
330
|
+
if (!scan.frontend.detected) return [];
|
|
331
|
+
|
|
332
|
+
const srcDir = path.join(projectDir, scan.frontend.directory || 'src');
|
|
333
|
+
if (!fs.existsSync(srcDir)) return [];
|
|
334
|
+
|
|
335
|
+
const files = findFiles(srcDir, '.tsx').concat(findFiles(srcDir, '.jsx'));
|
|
336
|
+
const scattered = [];
|
|
337
|
+
|
|
338
|
+
for (const file of files) {
|
|
339
|
+
const basename = path.basename(file);
|
|
340
|
+
// Skip files that ARE the API client
|
|
341
|
+
if (['api.ts', 'api.js', 'apiClient.ts', 'apiClient.js', 'fetcher.ts', 'fetcher.js'].includes(basename)) continue;
|
|
342
|
+
// Skip non-component files
|
|
343
|
+
if (file.includes('/lib/') || file.includes('/services/') || file.includes('/utils/')) continue;
|
|
344
|
+
|
|
345
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
346
|
+
if (/fetch\s*\(\s*['"`]\/api\//.test(content) || /fetch\s*\(\s*['"`]http/.test(content) ||
|
|
347
|
+
/axios\.(get|post|put|delete|patch)\s*\(/.test(content)) {
|
|
348
|
+
scattered.push(path.relative(projectDir, file));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (scattered.length <= 3) return [];
|
|
353
|
+
|
|
354
|
+
return [{
|
|
355
|
+
severity: 'warning',
|
|
356
|
+
title: `${scattered.length} components make direct API calls instead of using a centralized client`,
|
|
357
|
+
impact: 'API changes require updating every component instead of one file',
|
|
358
|
+
files: scattered,
|
|
359
|
+
autoFixable: false,
|
|
360
|
+
promptId: 'SCATTERED_API_CALLS',
|
|
361
|
+
effort: 'medium',
|
|
362
|
+
}];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function checkLargeFiles(projectDir) {
|
|
366
|
+
const sourceDirs = ['src', 'backend', 'app', 'frontend/src', 'frontend'];
|
|
367
|
+
const largeFiles = [];
|
|
368
|
+
|
|
369
|
+
for (const dir of sourceDirs) {
|
|
370
|
+
const fullDir = path.join(projectDir, dir);
|
|
371
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
372
|
+
const files = findFiles(fullDir, '.ts')
|
|
373
|
+
.concat(findFiles(fullDir, '.tsx'), findFiles(fullDir, '.js'), findFiles(fullDir, '.jsx'), findFiles(fullDir, '.py'));
|
|
374
|
+
|
|
375
|
+
for (const file of files) {
|
|
376
|
+
// Skip generated/vendor files
|
|
377
|
+
if (file.includes('node_modules') || file.includes('.next') || file.includes('__pycache__')) continue;
|
|
378
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
379
|
+
const lineCount = content.split('\n').length;
|
|
380
|
+
if (lineCount > 500) {
|
|
381
|
+
largeFiles.push({ file: path.relative(projectDir, file), lines: lineCount });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (largeFiles.length === 0) return [];
|
|
387
|
+
|
|
388
|
+
return [{
|
|
389
|
+
severity: 'info',
|
|
390
|
+
title: `${largeFiles.length} files over 500 lines — candidates for splitting`,
|
|
391
|
+
impact: 'Large files are hard to navigate, review, and test',
|
|
392
|
+
files: largeFiles.map(f => `${f.file} (${f.lines} lines)`),
|
|
393
|
+
autoFixable: false,
|
|
394
|
+
promptId: 'LARGE_FILES',
|
|
395
|
+
effort: 'large',
|
|
396
|
+
}];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function checkMissingScopedClaudeMd(projectDir, scan) {
|
|
400
|
+
if (!scan.frontend.detected || !scan.backend.detected) return [];
|
|
401
|
+
|
|
402
|
+
const issues = [];
|
|
403
|
+
const frontendDir = scan.frontend.directory || 'src';
|
|
404
|
+
const backendDir = scan.backend.directory || 'backend';
|
|
405
|
+
|
|
406
|
+
if (!fs.existsSync(path.join(projectDir, frontendDir, 'CLAUDE.md'))) {
|
|
407
|
+
issues.push({
|
|
408
|
+
severity: 'info',
|
|
409
|
+
title: `No ${frontendDir}/CLAUDE.md — frontend rules not scoped`,
|
|
410
|
+
impact: 'Claude Code loads all rules even when working only on frontend',
|
|
411
|
+
files: [],
|
|
412
|
+
autoFixable: false,
|
|
413
|
+
promptId: 'MISSING_SCOPED_CLAUDE_MD',
|
|
414
|
+
effort: 'quick',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!fs.existsSync(path.join(projectDir, backendDir, 'CLAUDE.md'))) {
|
|
419
|
+
issues.push({
|
|
420
|
+
severity: 'info',
|
|
421
|
+
title: `No ${backendDir}/CLAUDE.md — backend rules not scoped`,
|
|
422
|
+
impact: 'Claude Code loads all rules even when working only on backend',
|
|
423
|
+
files: [],
|
|
424
|
+
autoFixable: false,
|
|
425
|
+
promptId: 'MISSING_SCOPED_CLAUDE_MD',
|
|
426
|
+
effort: 'quick',
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return issues;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function checkUnusedDependencies(projectDir, scan) {
|
|
434
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
435
|
+
if (!fs.existsSync(pkgPath)) return [];
|
|
436
|
+
|
|
437
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
438
|
+
const deps = Object.keys(pkg.dependencies || {});
|
|
439
|
+
if (deps.length === 0) return [];
|
|
440
|
+
|
|
441
|
+
// Gather all source file contents
|
|
442
|
+
const srcDirs = ['src', 'app', 'frontend/src', 'pages', 'components', 'lib'];
|
|
443
|
+
let allContent = '';
|
|
444
|
+
for (const dir of srcDirs) {
|
|
445
|
+
const fullDir = path.join(projectDir, dir);
|
|
446
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
447
|
+
const files = findFiles(fullDir, '.ts').concat(findFiles(fullDir, '.tsx'), findFiles(fullDir, '.js'), findFiles(fullDir, '.jsx'));
|
|
448
|
+
for (const file of files) {
|
|
449
|
+
allContent += fs.readFileSync(file, 'utf-8') + '\n';
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Also check config files at root
|
|
454
|
+
const configFiles = ['next.config.ts', 'next.config.js', 'next.config.mjs', 'tailwind.config.ts', 'tailwind.config.js', 'postcss.config.js', 'postcss.config.mjs'];
|
|
455
|
+
for (const cf of configFiles) {
|
|
456
|
+
const cfPath = path.join(projectDir, cf);
|
|
457
|
+
if (fs.existsSync(cfPath)) {
|
|
458
|
+
allContent += fs.readFileSync(cfPath, 'utf-8') + '\n';
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Skip framework deps that are used implicitly
|
|
463
|
+
const implicitDeps = ['react', 'react-dom', 'next', '@prisma/client', 'typescript'];
|
|
464
|
+
const unused = deps.filter(dep => {
|
|
465
|
+
if (implicitDeps.includes(dep)) return false;
|
|
466
|
+
// Check if the package name appears in imports
|
|
467
|
+
const shortName = dep.startsWith('@') ? dep : dep.split('/')[0];
|
|
468
|
+
return !allContent.includes(shortName);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
if (unused.length === 0) return [];
|
|
472
|
+
|
|
473
|
+
return [{
|
|
474
|
+
severity: 'info',
|
|
475
|
+
title: `${unused.length} potentially unused dependencies`,
|
|
476
|
+
impact: 'Bloats install size and increases supply chain risk',
|
|
477
|
+
files: unused.map(d => `package.json → ${d}`),
|
|
478
|
+
autoFixable: false,
|
|
479
|
+
promptId: 'UNUSED_DEPENDENCIES',
|
|
480
|
+
effort: 'quick',
|
|
481
|
+
}];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function checkHardcodedValues(projectDir) {
|
|
485
|
+
const sourceDirs = ['src', 'backend', 'app', 'frontend/src'];
|
|
486
|
+
const hardcoded = [];
|
|
487
|
+
|
|
488
|
+
const patterns = [
|
|
489
|
+
{ regex: /['"]http:\/\/localhost:\d+/, label: 'hardcoded localhost URL' },
|
|
490
|
+
{ regex: /['"]postgresql:\/\//, label: 'hardcoded database URL' },
|
|
491
|
+
{ regex: /['"]mongodb:\/\//, label: 'hardcoded database URL' },
|
|
492
|
+
{ regex: /['"]redis:\/\//, label: 'hardcoded redis URL' },
|
|
493
|
+
{ regex: /['"]sk-[a-zA-Z0-9]{20,}['"]/, label: 'possible API key' },
|
|
494
|
+
{ regex: /port\s*[:=]\s*\d{4,5}\b/i, label: 'hardcoded port number' },
|
|
495
|
+
];
|
|
496
|
+
|
|
497
|
+
for (const dir of sourceDirs) {
|
|
498
|
+
const fullDir = path.join(projectDir, dir);
|
|
499
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
500
|
+
const files = findFiles(fullDir, '.ts').concat(findFiles(fullDir, '.tsx'), findFiles(fullDir, '.js'), findFiles(fullDir, '.jsx'), findFiles(fullDir, '.py'));
|
|
501
|
+
|
|
502
|
+
for (const file of files) {
|
|
503
|
+
// Skip config and env files
|
|
504
|
+
const basename = path.basename(file);
|
|
505
|
+
if (basename.includes('.config.') || basename.includes('.env') || basename.includes('.test.') || basename.includes('.spec.')) continue;
|
|
506
|
+
|
|
507
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
508
|
+
const lines = content.split('\n');
|
|
509
|
+
|
|
510
|
+
for (let i = 0; i < lines.length; i++) {
|
|
511
|
+
const line = lines[i];
|
|
512
|
+
// Skip comments
|
|
513
|
+
if (line.trim().startsWith('//') || line.trim().startsWith('#')) continue;
|
|
514
|
+
for (const pat of patterns) {
|
|
515
|
+
if (pat.regex.test(line)) {
|
|
516
|
+
hardcoded.push(`${path.relative(projectDir, file)}:${i + 1} (${pat.label})`);
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (hardcoded.length === 0) return [];
|
|
525
|
+
|
|
526
|
+
return [{
|
|
527
|
+
severity: 'warning',
|
|
528
|
+
title: `${hardcoded.length} hardcoded values that should be in environment variables`,
|
|
529
|
+
impact: 'Cannot change config without code changes — breaks deployment',
|
|
530
|
+
files: hardcoded,
|
|
531
|
+
autoFixable: false,
|
|
532
|
+
promptId: 'HARDCODED_VALUES',
|
|
533
|
+
effort: 'medium',
|
|
534
|
+
}];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function checkUnusedExports(projectDir, scan) {
|
|
538
|
+
const srcDir = path.join(projectDir, scan.frontend?.directory || 'src');
|
|
539
|
+
if (!fs.existsSync(srcDir)) return [];
|
|
540
|
+
|
|
541
|
+
const files = findFiles(srcDir, '.ts').concat(findFiles(srcDir, '.tsx'), findFiles(srcDir, '.js'), findFiles(srcDir, '.jsx'));
|
|
542
|
+
if (files.length === 0) return [];
|
|
543
|
+
|
|
544
|
+
// Collect all exports and imports
|
|
545
|
+
const exportMap = new Map(); // name -> file
|
|
546
|
+
const importedNames = new Set();
|
|
547
|
+
|
|
548
|
+
for (const file of files) {
|
|
549
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
550
|
+
const relPath = path.relative(projectDir, file);
|
|
551
|
+
|
|
552
|
+
// Skip barrel files and index files
|
|
553
|
+
if (path.basename(file).startsWith('index.')) continue;
|
|
554
|
+
|
|
555
|
+
// Find named exports
|
|
556
|
+
const exportMatches = content.matchAll(/export\s+(?:function|const|class|type|interface|enum)\s+(\w+)/g);
|
|
557
|
+
for (const m of exportMatches) {
|
|
558
|
+
// Skip default page/layout/route exports (Next.js convention)
|
|
559
|
+
if (['default', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'generateMetadata', 'generateStaticParams'].includes(m[1])) continue;
|
|
560
|
+
exportMap.set(`${relPath}:${m[1]}`, relPath);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Find imports
|
|
564
|
+
const importMatches = content.matchAll(/import\s+\{([^}]+)\}/g);
|
|
565
|
+
for (const m of importMatches) {
|
|
566
|
+
const names = m[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
|
|
567
|
+
names.forEach(n => importedNames.add(n));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const unused = [];
|
|
572
|
+
for (const [key] of exportMap) {
|
|
573
|
+
const name = key.substring(key.lastIndexOf(':') + 1);
|
|
574
|
+
if (!importedNames.has(name)) {
|
|
575
|
+
unused.push(key);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Only report if there's a meaningful number
|
|
580
|
+
if (unused.length < 5) return [];
|
|
581
|
+
|
|
582
|
+
return [{
|
|
583
|
+
severity: 'info',
|
|
584
|
+
title: `${unused.length} exported symbols may be unused`,
|
|
585
|
+
impact: 'Dead exports add confusion and increase bundle size',
|
|
586
|
+
files: unused.slice(0, 20),
|
|
587
|
+
autoFixable: false,
|
|
588
|
+
promptId: 'UNUSED_EXPORTS',
|
|
589
|
+
effort: 'medium',
|
|
590
|
+
}];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function checkDuplicateCode(projectDir) {
|
|
594
|
+
const sourceDirs = ['src', 'backend', 'app', 'frontend/src'];
|
|
595
|
+
const functionBodies = new Map(); // hash -> [{file, line, name}]
|
|
596
|
+
|
|
597
|
+
for (const dir of sourceDirs) {
|
|
598
|
+
const fullDir = path.join(projectDir, dir);
|
|
599
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
600
|
+
const files = findFiles(fullDir, '.ts').concat(findFiles(fullDir, '.tsx'), findFiles(fullDir, '.js'), findFiles(fullDir, '.jsx'), findFiles(fullDir, '.py'));
|
|
601
|
+
|
|
602
|
+
for (const file of files) {
|
|
603
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
604
|
+
const lines = content.split('\n');
|
|
605
|
+
|
|
606
|
+
// Look for consecutive blocks of 6+ identical lines
|
|
607
|
+
for (let i = 0; i <= lines.length - 6; i++) {
|
|
608
|
+
const block = lines.slice(i, i + 6).map(l => l.trim()).filter(l => l.length > 0);
|
|
609
|
+
if (block.length < 4) continue;
|
|
610
|
+
const key = block.join('|');
|
|
611
|
+
if (key.length < 50) continue; // Skip trivial blocks
|
|
612
|
+
|
|
613
|
+
if (!functionBodies.has(key)) {
|
|
614
|
+
functionBodies.set(key, []);
|
|
615
|
+
}
|
|
616
|
+
functionBodies.get(key).push({ file: path.relative(projectDir, file), line: i + 1 });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const duplicates = [];
|
|
622
|
+
for (const [, locations] of functionBodies) {
|
|
623
|
+
// Must appear in different files
|
|
624
|
+
const uniqueFiles = new Set(locations.map(l => l.file));
|
|
625
|
+
if (uniqueFiles.size > 1) {
|
|
626
|
+
duplicates.push(locations.map(l => `${l.file}:${l.line}`));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (duplicates.length === 0) return [];
|
|
631
|
+
|
|
632
|
+
const flatFiles = [...new Set(duplicates.flat())].slice(0, 15);
|
|
633
|
+
return [{
|
|
634
|
+
severity: 'info',
|
|
635
|
+
title: `${duplicates.length} code blocks appear duplicated across files`,
|
|
636
|
+
impact: 'Bug fixes need to be applied in multiple places',
|
|
637
|
+
files: flatFiles,
|
|
638
|
+
autoFixable: false,
|
|
639
|
+
promptId: 'DUPLICATE_CODE',
|
|
640
|
+
effort: 'medium',
|
|
641
|
+
}];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function checkDeadFeatures(projectDir, scan) {
|
|
645
|
+
// Look for route/page files that aren't linked from anywhere
|
|
646
|
+
if (!scan.frontend.detected) return [];
|
|
647
|
+
|
|
648
|
+
const srcDir = path.join(projectDir, scan.frontend.directory || 'src');
|
|
649
|
+
if (!fs.existsSync(srcDir)) return [];
|
|
650
|
+
|
|
651
|
+
const allFiles = findFiles(srcDir, '.ts').concat(findFiles(srcDir, '.tsx'), findFiles(srcDir, '.js'), findFiles(srcDir, '.jsx'));
|
|
652
|
+
const dead = [];
|
|
653
|
+
|
|
654
|
+
for (const file of allFiles) {
|
|
655
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
656
|
+
|
|
657
|
+
// Check for disabled/commented-out route handlers
|
|
658
|
+
if (content.includes('// export') && (content.includes('function GET') || content.includes('function POST'))) {
|
|
659
|
+
dead.push(`${path.relative(projectDir, file)} (commented-out route handler)`);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Check for components with only a TODO placeholder
|
|
663
|
+
if (content.includes('TODO') && content.split('\n').length < 10) {
|
|
664
|
+
dead.push(`${path.relative(projectDir, file)} (stub/placeholder only)`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (dead.length === 0) return [];
|
|
669
|
+
|
|
670
|
+
return [{
|
|
671
|
+
severity: 'info',
|
|
672
|
+
title: `${dead.length} potentially dead or stub features`,
|
|
673
|
+
impact: 'Dead code adds confusion and maintenance burden',
|
|
674
|
+
files: dead,
|
|
675
|
+
autoFixable: false,
|
|
676
|
+
promptId: 'DEAD_FEATURES',
|
|
677
|
+
effort: 'quick',
|
|
678
|
+
}];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function checkAIPrompts(projectDir) {
|
|
682
|
+
const sourceDirs = ['src', 'backend', 'app', 'frontend/src', 'lib'];
|
|
683
|
+
const issues = [];
|
|
684
|
+
|
|
685
|
+
for (const dir of sourceDirs) {
|
|
686
|
+
const fullDir = path.join(projectDir, dir);
|
|
687
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
688
|
+
const files = findFiles(fullDir, '.ts').concat(findFiles(fullDir, '.tsx'), findFiles(fullDir, '.js'), findFiles(fullDir, '.jsx'), findFiles(fullDir, '.py'));
|
|
689
|
+
|
|
690
|
+
for (const file of files) {
|
|
691
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
692
|
+
|
|
693
|
+
// Check for inline prompts (strings containing common prompt patterns)
|
|
694
|
+
if (/(?:system|user)\s*(?:message|prompt|role)\s*[:=]/i.test(content) ||
|
|
695
|
+
content.includes('openai.chat.completions') || content.includes('anthropic.messages') ||
|
|
696
|
+
content.includes('ChatCompletion') || content.includes('messages=[')) {
|
|
697
|
+
|
|
698
|
+
// Check if prompts are inline strings rather than imported from a prompts file
|
|
699
|
+
const basename = path.basename(file);
|
|
700
|
+
if (!basename.includes('prompt') && !basename.includes('template')) {
|
|
701
|
+
const relPath = path.relative(projectDir, file);
|
|
702
|
+
issues.push(relPath);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (issues.length === 0) return [];
|
|
709
|
+
|
|
710
|
+
return [{
|
|
711
|
+
severity: 'info',
|
|
712
|
+
title: `${issues.length} files have inline AI prompts — consider a prompts file`,
|
|
713
|
+
impact: 'Inline prompts are hard to version, test, and iterate on',
|
|
714
|
+
files: issues,
|
|
715
|
+
autoFixable: false,
|
|
716
|
+
promptId: 'INLINE_AI_PROMPTS',
|
|
717
|
+
effort: 'medium',
|
|
718
|
+
}];
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Helpers
|
|
722
|
+
|
|
723
|
+
function findFiles(dir, ext) {
|
|
724
|
+
const results = [];
|
|
725
|
+
if (!fs.existsSync(dir)) return results;
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
729
|
+
for (const entry of entries) {
|
|
730
|
+
const fullPath = path.join(dir, entry.name);
|
|
731
|
+
if (entry.isDirectory()) {
|
|
732
|
+
if (['node_modules', '.next', '__pycache__', '.git', 'venv', '.venv', 'dist', 'build'].includes(entry.name)) continue;
|
|
733
|
+
results.push(...findFiles(fullPath, ext));
|
|
734
|
+
} else if (entry.name.endsWith(ext)) {
|
|
735
|
+
results.push(fullPath);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
} catch {
|
|
739
|
+
// Permission errors, etc.
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return results;
|
|
743
|
+
}
|