@vibecheckai/cli 3.1.2 → 3.1.4
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/README.md +60 -33
- package/bin/registry.js +319 -34
- package/bin/runners/CLI_REFACTOR_SUMMARY.md +229 -0
- package/bin/runners/REPORT_AUDIT.md +64 -0
- package/bin/runners/lib/entitlements-v2.js +97 -28
- package/bin/runners/lib/entitlements.js +3 -6
- package/bin/runners/lib/init-wizard.js +1 -1
- package/bin/runners/lib/report-engine.js +459 -280
- package/bin/runners/lib/report-html.js +1154 -1423
- package/bin/runners/lib/report-output.js +187 -0
- package/bin/runners/lib/report-templates.js +848 -850
- package/bin/runners/lib/scan-output.js +545 -0
- package/bin/runners/lib/server-usage.js +0 -12
- package/bin/runners/lib/ship-output.js +641 -0
- package/bin/runners/lib/status-output.js +253 -0
- package/bin/runners/lib/terminal-ui.js +853 -0
- package/bin/runners/runCheckpoint.js +502 -0
- package/bin/runners/runContracts.js +105 -0
- package/bin/runners/runExport.js +93 -0
- package/bin/runners/runFix.js +31 -24
- package/bin/runners/runInit.js +377 -112
- package/bin/runners/runInstall.js +1 -5
- package/bin/runners/runLabs.js +3 -3
- package/bin/runners/runPolish.js +2452 -0
- package/bin/runners/runProve.js +2 -2
- package/bin/runners/runReport.js +251 -200
- package/bin/runners/runRuntime.js +110 -0
- package/bin/runners/runScan.js +477 -379
- package/bin/runners/runSecurity.js +92 -0
- package/bin/runners/runShip.js +137 -207
- package/bin/runners/runStatus.js +16 -68
- package/bin/runners/utils.js +5 -5
- package/bin/vibecheck.js +25 -11
- package/mcp-server/index.js +150 -18
- package/mcp-server/package.json +2 -2
- package/mcp-server/premium-tools.js +13 -13
- package/mcp-server/tier-auth.js +292 -27
- package/mcp-server/vibecheck-tools.js +9 -9
- package/package.json +1 -1
- package/bin/runners/runClaimVerifier.js +0 -483
- package/bin/runners/runContextCompiler.js +0 -385
- package/bin/runners/runGate.js +0 -17
- package/bin/runners/runInitGha.js +0 -164
- package/bin/runners/runInteractive.js +0 -388
- package/bin/runners/runMdc.js +0 -204
- package/bin/runners/runMissionGenerator.js +0 -282
- package/bin/runners/runTruthpack.js +0 -636
|
@@ -0,0 +1,2452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibecheck polish - Production Polish Analyzer
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* Finds all the small detailed things you forgot - the polish that makes
|
|
6
|
+
* projects production-ready.
|
|
7
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
8
|
+
*
|
|
9
|
+
* Categories:
|
|
10
|
+
* - Frontend: Error boundaries, 404 pages, loading states, empty states
|
|
11
|
+
* - Backend: Health endpoints, graceful shutdown, rate limiting, validation
|
|
12
|
+
* - Security: HTTPS, CORS, CSP, secrets management, auth headers
|
|
13
|
+
* - Performance: Caching, compression, lazy loading, bundle optimization
|
|
14
|
+
* - Accessibility: ARIA labels, focus management, color contrast, keyboard nav
|
|
15
|
+
* - SEO: Meta tags, sitemap, robots.txt, Open Graph, structured data
|
|
16
|
+
* - Configuration: Environment variables, feature flags, logging
|
|
17
|
+
* - Documentation: README, API docs, CHANGELOG, contributing guide
|
|
18
|
+
* - Infrastructure: Docker, CI/CD, monitoring, backups
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
25
|
+
// TERMINAL STYLING
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
const c = {
|
|
29
|
+
reset: '\x1b[0m',
|
|
30
|
+
bold: '\x1b[1m',
|
|
31
|
+
dim: '\x1b[2m',
|
|
32
|
+
italic: '\x1b[3m',
|
|
33
|
+
underline: '\x1b[4m',
|
|
34
|
+
red: '\x1b[31m',
|
|
35
|
+
green: '\x1b[32m',
|
|
36
|
+
yellow: '\x1b[33m',
|
|
37
|
+
blue: '\x1b[34m',
|
|
38
|
+
magenta: '\x1b[35m',
|
|
39
|
+
cyan: '\x1b[36m',
|
|
40
|
+
white: '\x1b[37m',
|
|
41
|
+
bgRed: '\x1b[41m',
|
|
42
|
+
bgGreen: '\x1b[42m',
|
|
43
|
+
bgYellow: '\x1b[43m',
|
|
44
|
+
bgBlue: '\x1b[44m',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const icons = {
|
|
48
|
+
critical: '🔴',
|
|
49
|
+
high: '🟠',
|
|
50
|
+
medium: '🟡',
|
|
51
|
+
low: '🔵',
|
|
52
|
+
check: '✓',
|
|
53
|
+
cross: '✗',
|
|
54
|
+
arrow: '→',
|
|
55
|
+
star: '★',
|
|
56
|
+
sparkle: '✨',
|
|
57
|
+
wrench: '🔧',
|
|
58
|
+
rocket: '🚀',
|
|
59
|
+
shield: '🛡️',
|
|
60
|
+
lightning: '⚡',
|
|
61
|
+
eye: '👁️',
|
|
62
|
+
search: '🔍',
|
|
63
|
+
book: '📖',
|
|
64
|
+
gear: '⚙️',
|
|
65
|
+
server: '🖥️',
|
|
66
|
+
lock: '🔒',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
70
|
+
// UTILITY FUNCTIONS
|
|
71
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
72
|
+
|
|
73
|
+
async function pathExists(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
await fs.promises.access(filePath);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function findFile(dir, pattern, maxDepth = 5, currentDepth = 0) {
|
|
83
|
+
if (currentDepth >= maxDepth) return null;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
87
|
+
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
// Skip node_modules, .git, dist, etc.
|
|
90
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const fullPath = path.join(dir, entry.name);
|
|
95
|
+
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
const found = await findFile(fullPath, pattern, maxDepth, currentDepth + 1);
|
|
98
|
+
if (found) return found;
|
|
99
|
+
} else if (pattern.test(entry.name)) {
|
|
100
|
+
return fullPath;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readFileSafe(filePath) {
|
|
110
|
+
try {
|
|
111
|
+
return await fs.promises.readFile(filePath, 'utf8');
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fileContains(filePath, pattern) {
|
|
118
|
+
const content = await readFileSafe(filePath);
|
|
119
|
+
if (!content) return false;
|
|
120
|
+
return pattern.test(content);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function findAllFiles(dir, pattern, maxDepth = 5, currentDepth = 0) {
|
|
124
|
+
if (currentDepth >= maxDepth) return [];
|
|
125
|
+
const results = [];
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
129
|
+
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const fullPath = path.join(dir, entry.name);
|
|
136
|
+
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
const found = await findAllFiles(fullPath, pattern, maxDepth, currentDepth + 1);
|
|
139
|
+
results.push(...found);
|
|
140
|
+
} else if (pattern.test(entry.name)) {
|
|
141
|
+
results.push(fullPath);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Ignore errors
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
152
|
+
// POLISH CHECKERS
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
154
|
+
|
|
155
|
+
const checkers = {
|
|
156
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
// FRONTEND CHECKER - Comprehensive UX/UI Polish Analysis
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
async frontend(projectPath) {
|
|
160
|
+
const issues = [];
|
|
161
|
+
const srcPath = path.join(projectPath, 'src');
|
|
162
|
+
const appPath = path.join(projectPath, 'app'); // Next.js app router
|
|
163
|
+
const pagesPath = path.join(projectPath, 'pages'); // Next.js pages router
|
|
164
|
+
const componentsPath = path.join(srcPath, 'components');
|
|
165
|
+
|
|
166
|
+
const hasSrc = await pathExists(srcPath);
|
|
167
|
+
const hasApp = await pathExists(appPath);
|
|
168
|
+
const hasPages = await pathExists(pagesPath);
|
|
169
|
+
|
|
170
|
+
if (!hasSrc && !hasApp && !hasPages) return issues;
|
|
171
|
+
|
|
172
|
+
const searchPath = hasSrc ? srcPath : (hasApp ? appPath : pagesPath);
|
|
173
|
+
const packageJson = await readFileSafe(path.join(projectPath, 'package.json'));
|
|
174
|
+
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
176
|
+
// ERROR HANDLING & BOUNDARIES
|
|
177
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
178
|
+
|
|
179
|
+
const hasErrorBoundary = await findFile(searchPath, /ErrorBoundary|error\.(tsx?|jsx?)$/i);
|
|
180
|
+
if (!hasErrorBoundary) {
|
|
181
|
+
issues.push({
|
|
182
|
+
id: 'missing-error-boundary',
|
|
183
|
+
category: 'Frontend',
|
|
184
|
+
severity: 'critical',
|
|
185
|
+
title: 'Missing Error Boundary',
|
|
186
|
+
description: 'No error boundary component found. React errors will crash the entire app.',
|
|
187
|
+
suggestion: 'Add ErrorBoundary component to catch and handle React errors gracefully.',
|
|
188
|
+
autoFixable: true,
|
|
189
|
+
aiPrompt: `Create a reusable ErrorBoundary component for React/Next.js that:
|
|
190
|
+
1. Catches JavaScript errors anywhere in the child component tree
|
|
191
|
+
2. Logs errors to console and optionally to an error tracking service
|
|
192
|
+
3. Displays a user-friendly fallback UI with:
|
|
193
|
+
- A friendly error message
|
|
194
|
+
- A "Try Again" button that resets the error state
|
|
195
|
+
- Option to report the error
|
|
196
|
+
4. Supports custom fallback UI via props
|
|
197
|
+
5. Uses TypeScript with proper typing
|
|
198
|
+
6. Includes a useErrorBoundary hook for functional components
|
|
199
|
+
7. Has smooth fade-in animation for the fallback UI
|
|
200
|
+
|
|
201
|
+
Place it in src/components/ErrorBoundary.tsx`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const has404 = await findFile(searchPath, /not-found|NotFound|404/i);
|
|
206
|
+
if (!has404) {
|
|
207
|
+
issues.push({
|
|
208
|
+
id: 'missing-404',
|
|
209
|
+
category: 'Frontend',
|
|
210
|
+
severity: 'medium',
|
|
211
|
+
title: 'Missing 404 Page',
|
|
212
|
+
description: 'No custom 404/NotFound page found. Users will see default browser error.',
|
|
213
|
+
suggestion: 'Add a custom 404 page with navigation options.',
|
|
214
|
+
autoFixable: true,
|
|
215
|
+
aiPrompt: `Create a beautiful, user-friendly 404 Not Found page that:
|
|
216
|
+
1. Has a visually appealing design with subtle animations
|
|
217
|
+
2. Shows a clear "Page Not Found" message
|
|
218
|
+
3. Includes helpful navigation options:
|
|
219
|
+
- Go back to previous page button
|
|
220
|
+
- Go to homepage button
|
|
221
|
+
- Search functionality (if applicable)
|
|
222
|
+
- Popular/suggested links
|
|
223
|
+
4. Has a fun illustration or animation (CSS-based, no external images)
|
|
224
|
+
5. Is fully responsive for mobile
|
|
225
|
+
6. Maintains the site's design language
|
|
226
|
+
7. Includes proper meta tags for SEO
|
|
227
|
+
|
|
228
|
+
For Next.js App Router: Create app/not-found.tsx
|
|
229
|
+
For Pages Router: Create pages/404.tsx`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
234
|
+
// LOADING STATES & SKELETONS
|
|
235
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
236
|
+
|
|
237
|
+
const hasLoadingSpinner = await findFile(searchPath, /Spinner|LoadingSpinner/i);
|
|
238
|
+
if (!hasLoadingSpinner) {
|
|
239
|
+
issues.push({
|
|
240
|
+
id: 'missing-spinner',
|
|
241
|
+
category: 'Frontend',
|
|
242
|
+
severity: 'high',
|
|
243
|
+
title: 'Missing Loading Spinner',
|
|
244
|
+
description: 'No spinner component found. Users need visual feedback during loading.',
|
|
245
|
+
suggestion: 'Add a reusable Spinner component with multiple sizes and variants.',
|
|
246
|
+
autoFixable: true,
|
|
247
|
+
aiPrompt: `Create a modern, reusable Spinner/Loading component that:
|
|
248
|
+
1. Has multiple size variants: sm, md, lg, xl
|
|
249
|
+
2. Supports custom colors via props or CSS variables
|
|
250
|
+
3. Has multiple styles: circular spinner, dots, pulse, bars
|
|
251
|
+
4. Uses pure CSS animations (no external dependencies)
|
|
252
|
+
5. Is accessible with proper ARIA attributes (role="status", aria-label)
|
|
253
|
+
6. Supports dark/light mode automatically
|
|
254
|
+
7. Can be used inline or as an overlay
|
|
255
|
+
8. Has smooth entrance/exit animations
|
|
256
|
+
9. TypeScript with proper prop types
|
|
257
|
+
|
|
258
|
+
Create: src/components/ui/Spinner.tsx and src/components/ui/Spinner.css`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const hasSkeleton = await findFile(searchPath, /Skeleton|Shimmer|Placeholder/i);
|
|
263
|
+
if (!hasSkeleton) {
|
|
264
|
+
issues.push({
|
|
265
|
+
id: 'missing-skeleton',
|
|
266
|
+
category: 'Frontend',
|
|
267
|
+
severity: 'high',
|
|
268
|
+
title: 'Missing Skeleton Loaders',
|
|
269
|
+
description: 'No skeleton/shimmer components found. Skeleton loaders provide better perceived performance than spinners.',
|
|
270
|
+
suggestion: 'Add skeleton components for content placeholders.',
|
|
271
|
+
autoFixable: true,
|
|
272
|
+
aiPrompt: `Create a comprehensive Skeleton loading system with:
|
|
273
|
+
|
|
274
|
+
1. Base Skeleton component with:
|
|
275
|
+
- Shimmer animation effect
|
|
276
|
+
- Multiple variants: text, circular, rectangular, rounded
|
|
277
|
+
- Customizable width, height, border-radius
|
|
278
|
+
- Animation that flows left-to-right with gradient
|
|
279
|
+
|
|
280
|
+
2. Pre-built skeleton patterns:
|
|
281
|
+
- SkeletonText (paragraph lines with varying widths)
|
|
282
|
+
- SkeletonAvatar (circular profile picture placeholder)
|
|
283
|
+
- SkeletonCard (card layout placeholder)
|
|
284
|
+
- SkeletonTable (table rows placeholder)
|
|
285
|
+
- SkeletonList (list items placeholder)
|
|
286
|
+
- SkeletonImage (image placeholder with aspect ratio)
|
|
287
|
+
|
|
288
|
+
3. Features:
|
|
289
|
+
- CSS-only animations (performant)
|
|
290
|
+
- Dark/light mode support
|
|
291
|
+
- Customizable animation speed
|
|
292
|
+
- Can disable animation for reduced motion preference
|
|
293
|
+
- TypeScript with proper types
|
|
294
|
+
|
|
295
|
+
Create: src/components/ui/Skeleton.tsx with all variants`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const hasLoadingPage = await findFile(searchPath, /loading\.(tsx?|jsx?)$/i);
|
|
300
|
+
if (!hasLoadingPage && hasApp) {
|
|
301
|
+
issues.push({
|
|
302
|
+
id: 'missing-loading-page',
|
|
303
|
+
category: 'Frontend',
|
|
304
|
+
severity: 'medium',
|
|
305
|
+
title: 'Missing Next.js Loading State',
|
|
306
|
+
description: 'No loading.tsx found. Next.js App Router supports automatic loading states.',
|
|
307
|
+
suggestion: 'Add loading.tsx files for route segments.',
|
|
308
|
+
autoFixable: true,
|
|
309
|
+
aiPrompt: `Create a loading.tsx file for Next.js App Router that:
|
|
310
|
+
1. Shows a full-page skeleton loader matching your layout
|
|
311
|
+
2. Uses your Skeleton components for consistency
|
|
312
|
+
3. Includes a subtle progress indicator
|
|
313
|
+
4. Maintains layout structure to prevent layout shift
|
|
314
|
+
5. Has smooth fade-out when content loads
|
|
315
|
+
|
|
316
|
+
Create: app/loading.tsx (root level) and consider adding to major route segments`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
321
|
+
// EMPTY STATES & FEEDBACK
|
|
322
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
323
|
+
|
|
324
|
+
const hasEmptyState = await findFile(searchPath, /EmptyState|Empty|NoData|NoResults/i);
|
|
325
|
+
if (!hasEmptyState) {
|
|
326
|
+
issues.push({
|
|
327
|
+
id: 'missing-empty-states',
|
|
328
|
+
category: 'Frontend',
|
|
329
|
+
severity: 'medium',
|
|
330
|
+
title: 'Missing Empty States',
|
|
331
|
+
description: 'No empty state components found. Users need helpful messages when data is empty.',
|
|
332
|
+
suggestion: 'Add EmptyState components for lists and data displays.',
|
|
333
|
+
autoFixable: true,
|
|
334
|
+
aiPrompt: `Create a versatile EmptyState component system with:
|
|
335
|
+
|
|
336
|
+
1. Base EmptyState component with:
|
|
337
|
+
- Icon slot (customizable)
|
|
338
|
+
- Title and description
|
|
339
|
+
- Action button(s)
|
|
340
|
+
- Illustration option
|
|
341
|
+
|
|
342
|
+
2. Pre-built variants:
|
|
343
|
+
- EmptySearch: "No results found" with search tips
|
|
344
|
+
- EmptyList: "No items yet" with add item CTA
|
|
345
|
+
- EmptyInbox: "All caught up!" positive empty state
|
|
346
|
+
- EmptyError: "Something went wrong" with retry
|
|
347
|
+
- EmptyPermission: "Access denied" with request access
|
|
348
|
+
- EmptyOffline: "You're offline" with retry when online
|
|
349
|
+
|
|
350
|
+
3. Features:
|
|
351
|
+
- Subtle entrance animation
|
|
352
|
+
- Responsive design
|
|
353
|
+
- Supports custom illustrations
|
|
354
|
+
- Action buttons with loading states
|
|
355
|
+
- TypeScript with proper props
|
|
356
|
+
|
|
357
|
+
Create: src/components/ui/EmptyState.tsx with all variants`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const hasToast = await findFile(searchPath, /Toast|Notification|Snackbar/i);
|
|
362
|
+
if (!hasToast) {
|
|
363
|
+
issues.push({
|
|
364
|
+
id: 'missing-toast',
|
|
365
|
+
category: 'Frontend',
|
|
366
|
+
severity: 'high',
|
|
367
|
+
title: 'Missing Toast/Notification System',
|
|
368
|
+
description: 'No toast or notification component found for user feedback.',
|
|
369
|
+
suggestion: 'Add a toast/notification system for user feedback on actions.',
|
|
370
|
+
autoFixable: true,
|
|
371
|
+
aiPrompt: `Create a complete Toast notification system with:
|
|
372
|
+
|
|
373
|
+
1. Toast component with:
|
|
374
|
+
- Variants: success, error, warning, info, loading
|
|
375
|
+
- Auto-dismiss with configurable duration
|
|
376
|
+
- Manual dismiss button
|
|
377
|
+
- Progress bar showing time remaining
|
|
378
|
+
- Stack multiple toasts
|
|
379
|
+
- Position options: top-right, top-center, bottom-right, etc.
|
|
380
|
+
|
|
381
|
+
2. Toast provider/context:
|
|
382
|
+
- useToast() hook for easy triggering
|
|
383
|
+
- toast.success(), toast.error(), etc. methods
|
|
384
|
+
- toast.promise() for async operations
|
|
385
|
+
- Global configuration
|
|
386
|
+
|
|
387
|
+
3. Features:
|
|
388
|
+
- Smooth slide-in/out animations
|
|
389
|
+
- Swipe to dismiss on mobile
|
|
390
|
+
- Pause on hover
|
|
391
|
+
- Accessible with ARIA live regions
|
|
392
|
+
- Queue management
|
|
393
|
+
- TypeScript with proper types
|
|
394
|
+
|
|
395
|
+
Create: src/components/ui/Toast.tsx and src/hooks/useToast.ts`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
400
|
+
// FORMS & INPUT FEEDBACK
|
|
401
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
402
|
+
|
|
403
|
+
const hasFormValidation = await findFile(searchPath, /FormError|FieldError|ValidationMessage/i);
|
|
404
|
+
const hasReactHookForm = packageJson && /react-hook-form/i.test(packageJson);
|
|
405
|
+
if (!hasFormValidation && !hasReactHookForm) {
|
|
406
|
+
issues.push({
|
|
407
|
+
id: 'missing-form-validation-ui',
|
|
408
|
+
category: 'Frontend',
|
|
409
|
+
severity: 'high',
|
|
410
|
+
title: 'Missing Form Validation UI',
|
|
411
|
+
description: 'No form validation components found. Users need clear feedback on form errors.',
|
|
412
|
+
suggestion: 'Add form validation components with clear error states.',
|
|
413
|
+
autoFixable: true,
|
|
414
|
+
aiPrompt: `Create a comprehensive form validation UI system with:
|
|
415
|
+
|
|
416
|
+
1. FormField wrapper component with:
|
|
417
|
+
- Label with required indicator
|
|
418
|
+
- Help text / description
|
|
419
|
+
- Error message display
|
|
420
|
+
- Success state indicator
|
|
421
|
+
- Character counter for text inputs
|
|
422
|
+
|
|
423
|
+
2. Input components with built-in validation states:
|
|
424
|
+
- TextInput with error/success borders
|
|
425
|
+
- SelectInput with validation
|
|
426
|
+
- Checkbox/Radio with error states
|
|
427
|
+
- TextArea with validation
|
|
428
|
+
|
|
429
|
+
3. Validation feedback:
|
|
430
|
+
- Inline error messages with icons
|
|
431
|
+
- Shake animation on error
|
|
432
|
+
- Real-time validation feedback
|
|
433
|
+
- Form-level error summary
|
|
434
|
+
- Success checkmark animation
|
|
435
|
+
|
|
436
|
+
4. Features:
|
|
437
|
+
- Works with react-hook-form or standalone
|
|
438
|
+
- Accessible error announcements
|
|
439
|
+
- Touch-friendly error targets
|
|
440
|
+
- TypeScript with proper types
|
|
441
|
+
|
|
442
|
+
Create: src/components/ui/FormField.tsx and related input components`,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
447
|
+
// MICRO-INTERACTIONS & ANIMATIONS
|
|
448
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
449
|
+
|
|
450
|
+
const hasAnimationLib = packageJson && /framer-motion|react-spring|@react-spring|gsap|animejs/i.test(packageJson);
|
|
451
|
+
const hasAnimations = await findFile(searchPath, /animate|motion|transition|useSpring/i);
|
|
452
|
+
if (!hasAnimationLib && !hasAnimations) {
|
|
453
|
+
issues.push({
|
|
454
|
+
id: 'missing-animations',
|
|
455
|
+
category: 'Frontend',
|
|
456
|
+
severity: 'medium',
|
|
457
|
+
title: 'Missing Animation System',
|
|
458
|
+
description: 'No animation library or utilities found. Animations make UX feel polished.',
|
|
459
|
+
suggestion: 'Add framer-motion or CSS animations for micro-interactions.',
|
|
460
|
+
autoFixable: true,
|
|
461
|
+
aiPrompt: `Create a lightweight animation system with:
|
|
462
|
+
|
|
463
|
+
1. CSS animation utilities (no library needed):
|
|
464
|
+
- Fade in/out
|
|
465
|
+
- Slide in from directions
|
|
466
|
+
- Scale up/down
|
|
467
|
+
- Bounce, pulse, shake
|
|
468
|
+
- Stagger children animations
|
|
469
|
+
|
|
470
|
+
2. Animation wrapper components:
|
|
471
|
+
- <FadeIn> - fade in on mount
|
|
472
|
+
- <SlideIn direction="up|down|left|right">
|
|
473
|
+
- <ScaleIn> - scale from 0 to 1
|
|
474
|
+
- <AnimatePresence> - animate on unmount
|
|
475
|
+
- <StaggerChildren> - stagger child animations
|
|
476
|
+
|
|
477
|
+
3. CSS utilities:
|
|
478
|
+
- .animate-fade-in, .animate-slide-up, etc.
|
|
479
|
+
- Animation delay utilities
|
|
480
|
+
- Reduced motion media query support
|
|
481
|
+
- Duration and easing variables
|
|
482
|
+
|
|
483
|
+
4. Hooks:
|
|
484
|
+
- useInView() - trigger animation when element is visible
|
|
485
|
+
- useAnimation() - programmatic control
|
|
486
|
+
|
|
487
|
+
Create: src/styles/animations.css and src/components/ui/Animate.tsx
|
|
488
|
+
|
|
489
|
+
Or install framer-motion: npm install framer-motion`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const hasHoverEffects = await findAllFiles(searchPath, /\.(css|scss|tsx?|jsx?)$/);
|
|
494
|
+
let foundHoverStates = false;
|
|
495
|
+
for (const file of hasHoverEffects.slice(0, 20)) {
|
|
496
|
+
const content = await readFileSafe(file);
|
|
497
|
+
if (content && /:hover|onMouseEnter|hover:/i.test(content)) {
|
|
498
|
+
foundHoverStates = true;
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (!foundHoverStates) {
|
|
503
|
+
issues.push({
|
|
504
|
+
id: 'missing-hover-states',
|
|
505
|
+
category: 'Frontend',
|
|
506
|
+
severity: 'medium',
|
|
507
|
+
title: 'Missing Hover States',
|
|
508
|
+
description: 'No hover effects detected. Interactive elements need visual feedback on hover.',
|
|
509
|
+
suggestion: 'Add hover states to buttons, links, and interactive elements.',
|
|
510
|
+
autoFixable: true,
|
|
511
|
+
aiPrompt: `Add comprehensive hover states to your components:
|
|
512
|
+
|
|
513
|
+
1. Button hover effects:
|
|
514
|
+
- Subtle background color shift
|
|
515
|
+
- Slight scale transform (1.02)
|
|
516
|
+
- Shadow elevation change
|
|
517
|
+
- Underline animation for text buttons
|
|
518
|
+
|
|
519
|
+
2. Card hover effects:
|
|
520
|
+
- Lift effect with shadow
|
|
521
|
+
- Border color change
|
|
522
|
+
- Subtle background shift
|
|
523
|
+
- Scale transform
|
|
524
|
+
|
|
525
|
+
3. Link hover effects:
|
|
526
|
+
- Underline animation (slide in)
|
|
527
|
+
- Color transition
|
|
528
|
+
- Arrow/icon movement
|
|
529
|
+
|
|
530
|
+
4. Interactive elements:
|
|
531
|
+
- Focus-visible states (keyboard users)
|
|
532
|
+
- Active/pressed states
|
|
533
|
+
- Disabled state styling
|
|
534
|
+
|
|
535
|
+
5. CSS utilities to add:
|
|
536
|
+
\`\`\`css
|
|
537
|
+
.hover-lift:hover {
|
|
538
|
+
transform: translateY(-2px);
|
|
539
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
.hover-scale:hover {
|
|
543
|
+
transform: scale(1.02);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.hover-glow:hover {
|
|
547
|
+
box-shadow: 0 0 20px rgba(var(--primary-rgb), 0.3);
|
|
548
|
+
}
|
|
549
|
+
\`\`\`
|
|
550
|
+
|
|
551
|
+
Add to: src/styles/interactions.css or your global styles`,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
556
|
+
// HAPTIC FEEDBACK & MOBILE UX
|
|
557
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
558
|
+
|
|
559
|
+
const hasHapticFeedback = await findFile(searchPath, /haptic|vibrate|navigator\.vibrate/i);
|
|
560
|
+
if (!hasHapticFeedback) {
|
|
561
|
+
issues.push({
|
|
562
|
+
id: 'missing-haptic-feedback',
|
|
563
|
+
category: 'Frontend',
|
|
564
|
+
severity: 'low',
|
|
565
|
+
title: 'Missing Haptic Feedback',
|
|
566
|
+
description: 'No haptic feedback found. Mobile users benefit from tactile feedback on interactions.',
|
|
567
|
+
suggestion: 'Add haptic feedback for key interactions on mobile.',
|
|
568
|
+
autoFixable: true,
|
|
569
|
+
aiPrompt: `Create a haptic feedback utility system:
|
|
570
|
+
|
|
571
|
+
1. useHaptic() hook:
|
|
572
|
+
\`\`\`typescript
|
|
573
|
+
const useHaptic = () => {
|
|
574
|
+
const trigger = (type: 'light' | 'medium' | 'heavy' | 'success' | 'error') => {
|
|
575
|
+
if (!navigator.vibrate) return;
|
|
576
|
+
|
|
577
|
+
const patterns = {
|
|
578
|
+
light: [10],
|
|
579
|
+
medium: [20],
|
|
580
|
+
heavy: [30],
|
|
581
|
+
success: [10, 50, 10],
|
|
582
|
+
error: [50, 30, 50, 30, 50],
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
navigator.vibrate(patterns[type]);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
return { trigger };
|
|
589
|
+
};
|
|
590
|
+
\`\`\`
|
|
591
|
+
|
|
592
|
+
2. Integration points:
|
|
593
|
+
- Button clicks (light)
|
|
594
|
+
- Form submission success (success)
|
|
595
|
+
- Form validation error (error)
|
|
596
|
+
- Toggle switches (light)
|
|
597
|
+
- Pull-to-refresh complete (medium)
|
|
598
|
+
- Delete/destructive actions (heavy)
|
|
599
|
+
|
|
600
|
+
3. Best practices:
|
|
601
|
+
- Always check navigator.vibrate support
|
|
602
|
+
- Respect user preferences (reduced motion)
|
|
603
|
+
- Don't overuse - only key interactions
|
|
604
|
+
- Different patterns for different actions
|
|
605
|
+
|
|
606
|
+
Create: src/hooks/useHaptic.ts`,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const hasPullToRefresh = await findFile(searchPath, /PullToRefresh|pull-to-refresh|usePullRefresh/i);
|
|
611
|
+
if (!hasPullToRefresh && packageJson && /react-native|mobile|pwa/i.test(packageJson)) {
|
|
612
|
+
issues.push({
|
|
613
|
+
id: 'missing-pull-refresh',
|
|
614
|
+
category: 'Frontend',
|
|
615
|
+
severity: 'low',
|
|
616
|
+
title: 'Missing Pull-to-Refresh',
|
|
617
|
+
description: 'No pull-to-refresh found. Mobile/PWA apps benefit from this gesture.',
|
|
618
|
+
suggestion: 'Add pull-to-refresh for mobile list views.',
|
|
619
|
+
autoFixable: true,
|
|
620
|
+
aiPrompt: `Create a pull-to-refresh component for mobile:
|
|
621
|
+
|
|
622
|
+
1. PullToRefresh wrapper component:
|
|
623
|
+
- Touch gesture detection
|
|
624
|
+
- Visual pull indicator
|
|
625
|
+
- Loading spinner during refresh
|
|
626
|
+
- Haptic feedback on trigger
|
|
627
|
+
- Smooth animations
|
|
628
|
+
|
|
629
|
+
2. Features:
|
|
630
|
+
- Configurable pull threshold
|
|
631
|
+
- Custom pull indicator
|
|
632
|
+
- Callback on refresh
|
|
633
|
+
- Disable on desktop
|
|
634
|
+
|
|
635
|
+
Create: src/components/ui/PullToRefresh.tsx`,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
640
|
+
// ACCESSIBILITY (ARIA & A11Y)
|
|
641
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
642
|
+
|
|
643
|
+
const hasAriaLive = await findFile(searchPath, /aria-live|role="alert"|role="status"/i);
|
|
644
|
+
if (!hasAriaLive) {
|
|
645
|
+
issues.push({
|
|
646
|
+
id: 'missing-aria-live',
|
|
647
|
+
category: 'Frontend',
|
|
648
|
+
severity: 'high',
|
|
649
|
+
title: 'Missing ARIA Live Regions',
|
|
650
|
+
description: 'No ARIA live regions found. Screen readers won\'t announce dynamic content changes.',
|
|
651
|
+
suggestion: 'Add aria-live regions for toasts, alerts, and dynamic updates.',
|
|
652
|
+
autoFixable: true,
|
|
653
|
+
aiPrompt: `Add ARIA live regions for accessibility:
|
|
654
|
+
|
|
655
|
+
1. Create an Announcer component for screen readers:
|
|
656
|
+
\`\`\`tsx
|
|
657
|
+
const Announcer = () => {
|
|
658
|
+
const [message, setMessage] = useState('');
|
|
659
|
+
|
|
660
|
+
// Expose via context or global
|
|
661
|
+
useEffect(() => {
|
|
662
|
+
window.announce = (msg: string) => setMessage(msg);
|
|
663
|
+
return () => delete window.announce;
|
|
664
|
+
}, []);
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
<div
|
|
668
|
+
role="status"
|
|
669
|
+
aria-live="polite"
|
|
670
|
+
aria-atomic="true"
|
|
671
|
+
className="sr-only"
|
|
672
|
+
>
|
|
673
|
+
{message}
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
};
|
|
677
|
+
\`\`\`
|
|
678
|
+
|
|
679
|
+
2. Places to add aria-live:
|
|
680
|
+
- Toast notifications: aria-live="polite"
|
|
681
|
+
- Error alerts: aria-live="assertive"
|
|
682
|
+
- Loading states: aria-live="polite" aria-busy="true"
|
|
683
|
+
- Search results count
|
|
684
|
+
- Form validation errors
|
|
685
|
+
|
|
686
|
+
3. Screen reader only utility:
|
|
687
|
+
\`\`\`css
|
|
688
|
+
.sr-only {
|
|
689
|
+
position: absolute;
|
|
690
|
+
width: 1px;
|
|
691
|
+
height: 1px;
|
|
692
|
+
padding: 0;
|
|
693
|
+
margin: -1px;
|
|
694
|
+
overflow: hidden;
|
|
695
|
+
clip: rect(0, 0, 0, 0);
|
|
696
|
+
white-space: nowrap;
|
|
697
|
+
border: 0;
|
|
698
|
+
}
|
|
699
|
+
\`\`\`
|
|
700
|
+
|
|
701
|
+
Create: src/components/ui/Announcer.tsx`,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const hasFocusTrap = await findFile(searchPath, /FocusTrap|focus-trap|useFocusTrap/i);
|
|
706
|
+
const hasModal = await findFile(searchPath, /Modal|Dialog|Drawer/i);
|
|
707
|
+
if (hasModal && !hasFocusTrap) {
|
|
708
|
+
issues.push({
|
|
709
|
+
id: 'missing-focus-trap',
|
|
710
|
+
category: 'Frontend',
|
|
711
|
+
severity: 'high',
|
|
712
|
+
title: 'Missing Focus Trap for Modals',
|
|
713
|
+
description: 'Modal/Dialog found but no focus trap. Keyboard users can tab outside the modal.',
|
|
714
|
+
suggestion: 'Add focus trap to modals and dialogs.',
|
|
715
|
+
autoFixable: true,
|
|
716
|
+
aiPrompt: `Add focus trapping to modals and dialogs:
|
|
717
|
+
|
|
718
|
+
1. Create useFocusTrap hook:
|
|
719
|
+
\`\`\`typescript
|
|
720
|
+
const useFocusTrap = (isOpen: boolean) => {
|
|
721
|
+
const ref = useRef<HTMLElement>(null);
|
|
722
|
+
|
|
723
|
+
useEffect(() => {
|
|
724
|
+
if (!isOpen || !ref.current) return;
|
|
725
|
+
|
|
726
|
+
const focusableElements = ref.current.querySelectorAll(
|
|
727
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
728
|
+
);
|
|
729
|
+
const firstElement = focusableElements[0] as HTMLElement;
|
|
730
|
+
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
|
731
|
+
|
|
732
|
+
// Focus first element on open
|
|
733
|
+
firstElement?.focus();
|
|
734
|
+
|
|
735
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
736
|
+
if (e.key !== 'Tab') return;
|
|
737
|
+
|
|
738
|
+
if (e.shiftKey && document.activeElement === firstElement) {
|
|
739
|
+
e.preventDefault();
|
|
740
|
+
lastElement?.focus();
|
|
741
|
+
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
742
|
+
e.preventDefault();
|
|
743
|
+
firstElement?.focus();
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
748
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
749
|
+
}, [isOpen]);
|
|
750
|
+
|
|
751
|
+
return ref;
|
|
752
|
+
};
|
|
753
|
+
\`\`\`
|
|
754
|
+
|
|
755
|
+
2. Additional requirements:
|
|
756
|
+
- Return focus to trigger element on close
|
|
757
|
+
- Handle Escape key to close
|
|
758
|
+
- Prevent body scroll when open
|
|
759
|
+
- aria-modal="true" on modal
|
|
760
|
+
|
|
761
|
+
Create: src/hooks/useFocusTrap.ts`,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const hasSkipLink = await findFile(searchPath, /SkipLink|skip-to-content|skip-nav/i);
|
|
766
|
+
if (!hasSkipLink) {
|
|
767
|
+
issues.push({
|
|
768
|
+
id: 'missing-skip-link',
|
|
769
|
+
category: 'Frontend',
|
|
770
|
+
severity: 'medium',
|
|
771
|
+
title: 'Missing Skip Link',
|
|
772
|
+
description: 'No skip-to-content link found. Keyboard users can\'t skip navigation.',
|
|
773
|
+
suggestion: 'Add a skip-to-main-content link at the top of pages.',
|
|
774
|
+
autoFixable: true,
|
|
775
|
+
aiPrompt: `Add a skip link for keyboard navigation:
|
|
776
|
+
|
|
777
|
+
\`\`\`tsx
|
|
778
|
+
// components/SkipLink.tsx
|
|
779
|
+
export const SkipLink = () => (
|
|
780
|
+
<a
|
|
781
|
+
href="#main-content"
|
|
782
|
+
className="skip-link"
|
|
783
|
+
>
|
|
784
|
+
Skip to main content
|
|
785
|
+
</a>
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
// styles
|
|
789
|
+
.skip-link {
|
|
790
|
+
position: absolute;
|
|
791
|
+
top: -40px;
|
|
792
|
+
left: 0;
|
|
793
|
+
background: var(--primary);
|
|
794
|
+
color: white;
|
|
795
|
+
padding: 8px 16px;
|
|
796
|
+
z-index: 100;
|
|
797
|
+
transition: top 0.3s;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.skip-link:focus {
|
|
801
|
+
top: 0;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Add id to main content
|
|
805
|
+
<main id="main-content">
|
|
806
|
+
\`\`\`
|
|
807
|
+
|
|
808
|
+
Place at the very top of your layout, before the header.`,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
813
|
+
// CACHING & PERFORMANCE
|
|
814
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
815
|
+
|
|
816
|
+
const hasSWR = packageJson && /swr|@tanstack\/react-query|react-query/i.test(packageJson);
|
|
817
|
+
if (!hasSWR) {
|
|
818
|
+
issues.push({
|
|
819
|
+
id: 'missing-data-caching',
|
|
820
|
+
category: 'Frontend',
|
|
821
|
+
severity: 'high',
|
|
822
|
+
title: 'Missing Data Caching Library',
|
|
823
|
+
description: 'No SWR or React Query found. Data fetching lacks caching, revalidation, and deduplication.',
|
|
824
|
+
suggestion: 'Add SWR or TanStack Query for data caching and synchronization.',
|
|
825
|
+
autoFixable: false,
|
|
826
|
+
aiPrompt: `Add a data fetching and caching solution:
|
|
827
|
+
|
|
828
|
+
Option 1: SWR (lighter weight)
|
|
829
|
+
\`\`\`bash
|
|
830
|
+
npm install swr
|
|
831
|
+
\`\`\`
|
|
832
|
+
|
|
833
|
+
\`\`\`typescript
|
|
834
|
+
// hooks/useApi.ts
|
|
835
|
+
import useSWR from 'swr';
|
|
836
|
+
|
|
837
|
+
const fetcher = (url: string) => fetch(url).then(r => r.json());
|
|
838
|
+
|
|
839
|
+
export const useUser = (id: string) => {
|
|
840
|
+
const { data, error, isLoading, mutate } = useSWR(
|
|
841
|
+
\`/api/users/\${id}\`,
|
|
842
|
+
fetcher,
|
|
843
|
+
{
|
|
844
|
+
revalidateOnFocus: true,
|
|
845
|
+
revalidateOnReconnect: true,
|
|
846
|
+
dedupingInterval: 2000,
|
|
847
|
+
}
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
return { user: data, error, isLoading, refresh: mutate };
|
|
851
|
+
};
|
|
852
|
+
\`\`\`
|
|
853
|
+
|
|
854
|
+
Option 2: TanStack Query (more features)
|
|
855
|
+
\`\`\`bash
|
|
856
|
+
npm install @tanstack/react-query
|
|
857
|
+
\`\`\`
|
|
858
|
+
|
|
859
|
+
Benefits you'll get:
|
|
860
|
+
- Automatic caching
|
|
861
|
+
- Background revalidation
|
|
862
|
+
- Request deduplication
|
|
863
|
+
- Optimistic updates
|
|
864
|
+
- Offline support
|
|
865
|
+
- Retry logic`,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const hasMemoization = await findFile(searchPath, /useMemo|useCallback|React\.memo/i);
|
|
870
|
+
if (!hasMemoization) {
|
|
871
|
+
issues.push({
|
|
872
|
+
id: 'missing-memoization',
|
|
873
|
+
category: 'Frontend',
|
|
874
|
+
severity: 'low',
|
|
875
|
+
title: 'No Memoization Detected',
|
|
876
|
+
description: 'No useMemo/useCallback/React.memo usage found. May have unnecessary re-renders.',
|
|
877
|
+
suggestion: 'Review components for memoization opportunities.',
|
|
878
|
+
autoFixable: false,
|
|
879
|
+
aiPrompt: `Review your components for memoization opportunities:
|
|
880
|
+
|
|
881
|
+
1. useMemo - for expensive calculations:
|
|
882
|
+
\`\`\`typescript
|
|
883
|
+
const sortedItems = useMemo(() =>
|
|
884
|
+
items.sort((a, b) => a.name.localeCompare(b.name)),
|
|
885
|
+
[items]
|
|
886
|
+
);
|
|
887
|
+
\`\`\`
|
|
888
|
+
|
|
889
|
+
2. useCallback - for functions passed as props:
|
|
890
|
+
\`\`\`typescript
|
|
891
|
+
const handleClick = useCallback((id: string) => {
|
|
892
|
+
setSelected(id);
|
|
893
|
+
}, []);
|
|
894
|
+
\`\`\`
|
|
895
|
+
|
|
896
|
+
3. React.memo - for components that receive same props:
|
|
897
|
+
\`\`\`typescript
|
|
898
|
+
const ExpensiveList = React.memo(({ items }) => {
|
|
899
|
+
return items.map(item => <Item key={item.id} {...item} />);
|
|
900
|
+
});
|
|
901
|
+
\`\`\`
|
|
902
|
+
|
|
903
|
+
Where to apply:
|
|
904
|
+
- List item components
|
|
905
|
+
- Charts and visualizations
|
|
906
|
+
- Components receiving objects/arrays as props
|
|
907
|
+
- Expensive filter/sort operations
|
|
908
|
+
- Event handlers passed to children`,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
913
|
+
// LOGGING & DEBUGGING
|
|
914
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
915
|
+
|
|
916
|
+
const hasLogger = await findFile(searchPath, /logger|logging|winston|pino|loglevel/i);
|
|
917
|
+
const hasConsoleRemoval = packageJson && /babel-plugin-transform-remove-console|terser/i.test(packageJson);
|
|
918
|
+
if (!hasLogger) {
|
|
919
|
+
issues.push({
|
|
920
|
+
id: 'missing-frontend-logger',
|
|
921
|
+
category: 'Frontend',
|
|
922
|
+
severity: 'medium',
|
|
923
|
+
title: 'Missing Logging System',
|
|
924
|
+
description: 'No structured logging found. console.log statements are not suitable for production.',
|
|
925
|
+
suggestion: 'Add a logging utility that can be disabled in production.',
|
|
926
|
+
autoFixable: true,
|
|
927
|
+
aiPrompt: `Create a frontend logging utility:
|
|
928
|
+
|
|
929
|
+
\`\`\`typescript
|
|
930
|
+
// lib/logger.ts
|
|
931
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
932
|
+
|
|
933
|
+
const isDev = process.env.NODE_ENV === 'development';
|
|
934
|
+
|
|
935
|
+
const logger = {
|
|
936
|
+
debug: (...args: any[]) => isDev && console.debug('[DEBUG]', ...args),
|
|
937
|
+
info: (...args: any[]) => isDev && console.info('[INFO]', ...args),
|
|
938
|
+
warn: (...args: any[]) => console.warn('[WARN]', ...args),
|
|
939
|
+
error: (...args: any[]) => {
|
|
940
|
+
console.error('[ERROR]', ...args);
|
|
941
|
+
// Send to error tracking service in production
|
|
942
|
+
if (!isDev && typeof window !== 'undefined') {
|
|
943
|
+
// Sentry.captureException(args[0]);
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
|
|
947
|
+
// Performance logging
|
|
948
|
+
time: (label: string) => isDev && console.time(label),
|
|
949
|
+
timeEnd: (label: string) => isDev && console.timeEnd(label),
|
|
950
|
+
|
|
951
|
+
// Group logs
|
|
952
|
+
group: (label: string) => isDev && console.group(label),
|
|
953
|
+
groupEnd: () => isDev && console.groupEnd(),
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
export default logger;
|
|
957
|
+
\`\`\`
|
|
958
|
+
|
|
959
|
+
Usage:
|
|
960
|
+
\`\`\`typescript
|
|
961
|
+
import logger from '@/lib/logger';
|
|
962
|
+
|
|
963
|
+
logger.debug('User data:', userData);
|
|
964
|
+
logger.error('Failed to fetch', error);
|
|
965
|
+
|
|
966
|
+
logger.time('render');
|
|
967
|
+
// ... expensive operation
|
|
968
|
+
logger.timeEnd('render');
|
|
969
|
+
\`\`\``,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const hasErrorTracking = packageJson && /sentry|bugsnag|rollbar|logrocket|@sentry/i.test(packageJson);
|
|
974
|
+
if (!hasErrorTracking) {
|
|
975
|
+
issues.push({
|
|
976
|
+
id: 'missing-error-tracking',
|
|
977
|
+
category: 'Frontend',
|
|
978
|
+
severity: 'high',
|
|
979
|
+
title: 'Missing Error Tracking',
|
|
980
|
+
description: 'No error tracking service found. Production errors will go unnoticed.',
|
|
981
|
+
suggestion: 'Add Sentry, Bugsnag, or similar for production error monitoring.',
|
|
982
|
+
autoFixable: false,
|
|
983
|
+
aiPrompt: `Set up error tracking with Sentry:
|
|
984
|
+
|
|
985
|
+
1. Install Sentry:
|
|
986
|
+
\`\`\`bash
|
|
987
|
+
npm install @sentry/nextjs
|
|
988
|
+
npx @sentry/wizard@latest -i nextjs
|
|
989
|
+
\`\`\`
|
|
990
|
+
|
|
991
|
+
2. This will create:
|
|
992
|
+
- sentry.client.config.ts
|
|
993
|
+
- sentry.server.config.ts
|
|
994
|
+
- sentry.edge.config.ts
|
|
995
|
+
|
|
996
|
+
3. Configure environment:
|
|
997
|
+
\`\`\`env
|
|
998
|
+
SENTRY_DSN=your-dsn-here
|
|
999
|
+
SENTRY_AUTH_TOKEN=your-auth-token
|
|
1000
|
+
\`\`\`
|
|
1001
|
+
|
|
1002
|
+
4. Custom error boundary integration:
|
|
1003
|
+
\`\`\`typescript
|
|
1004
|
+
import * as Sentry from '@sentry/nextjs';
|
|
1005
|
+
|
|
1006
|
+
// In your error boundary
|
|
1007
|
+
componentDidCatch(error, errorInfo) {
|
|
1008
|
+
Sentry.captureException(error, { extra: errorInfo });
|
|
1009
|
+
}
|
|
1010
|
+
\`\`\`
|
|
1011
|
+
|
|
1012
|
+
5. User context:
|
|
1013
|
+
\`\`\`typescript
|
|
1014
|
+
Sentry.setUser({ id: user.id, email: user.email });
|
|
1015
|
+
\`\`\``,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1020
|
+
// IMAGE & MEDIA OPTIMIZATION
|
|
1021
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1022
|
+
|
|
1023
|
+
const hasLazyImages = await findFile(searchPath, /loading="lazy"|LazyImage|Image.*priority/i);
|
|
1024
|
+
if (!hasLazyImages) {
|
|
1025
|
+
issues.push({
|
|
1026
|
+
id: 'missing-lazy-images',
|
|
1027
|
+
category: 'Frontend',
|
|
1028
|
+
severity: 'medium',
|
|
1029
|
+
title: 'Missing Lazy Loading for Images',
|
|
1030
|
+
description: 'No lazy loading for images detected. All images load immediately.',
|
|
1031
|
+
suggestion: 'Add lazy loading to below-the-fold images.',
|
|
1032
|
+
autoFixable: true,
|
|
1033
|
+
aiPrompt: `Implement lazy loading for images:
|
|
1034
|
+
|
|
1035
|
+
1. For Next.js - use next/image:
|
|
1036
|
+
\`\`\`tsx
|
|
1037
|
+
import Image from 'next/image';
|
|
1038
|
+
|
|
1039
|
+
// Lazy load by default, add priority for above-fold
|
|
1040
|
+
<Image
|
|
1041
|
+
src="/hero.jpg"
|
|
1042
|
+
alt="Hero"
|
|
1043
|
+
width={1200}
|
|
1044
|
+
height={600}
|
|
1045
|
+
priority // Only for above-the-fold images
|
|
1046
|
+
/>
|
|
1047
|
+
|
|
1048
|
+
// Below the fold - lazy loads automatically
|
|
1049
|
+
<Image
|
|
1050
|
+
src="/feature.jpg"
|
|
1051
|
+
alt="Feature"
|
|
1052
|
+
width={600}
|
|
1053
|
+
height={400}
|
|
1054
|
+
// No priority = lazy loaded
|
|
1055
|
+
/>
|
|
1056
|
+
\`\`\`
|
|
1057
|
+
|
|
1058
|
+
2. For regular img tags:
|
|
1059
|
+
\`\`\`tsx
|
|
1060
|
+
<img
|
|
1061
|
+
src="/image.jpg"
|
|
1062
|
+
alt="Description"
|
|
1063
|
+
loading="lazy"
|
|
1064
|
+
decoding="async"
|
|
1065
|
+
/>
|
|
1066
|
+
\`\`\`
|
|
1067
|
+
|
|
1068
|
+
3. Create a LazyImage component:
|
|
1069
|
+
\`\`\`tsx
|
|
1070
|
+
export const LazyImage = ({ src, alt, ...props }) => (
|
|
1071
|
+
<img
|
|
1072
|
+
src={src}
|
|
1073
|
+
alt={alt}
|
|
1074
|
+
loading="lazy"
|
|
1075
|
+
decoding="async"
|
|
1076
|
+
{...props}
|
|
1077
|
+
/>
|
|
1078
|
+
);
|
|
1079
|
+
\`\`\``,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1084
|
+
// THEME & DARK MODE
|
|
1085
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1086
|
+
|
|
1087
|
+
const hasDarkMode = await findFile(searchPath, /dark-mode|darkMode|theme-toggle|ThemeProvider|useTheme/i);
|
|
1088
|
+
if (!hasDarkMode) {
|
|
1089
|
+
issues.push({
|
|
1090
|
+
id: 'missing-dark-mode',
|
|
1091
|
+
category: 'Frontend',
|
|
1092
|
+
severity: 'low',
|
|
1093
|
+
title: 'Missing Dark Mode Support',
|
|
1094
|
+
description: 'No dark mode implementation found. Many users prefer dark mode.',
|
|
1095
|
+
suggestion: 'Add dark mode toggle with system preference detection.',
|
|
1096
|
+
autoFixable: true,
|
|
1097
|
+
aiPrompt: `Implement dark mode with system preference detection:
|
|
1098
|
+
|
|
1099
|
+
1. Create ThemeProvider and useTheme hook:
|
|
1100
|
+
\`\`\`typescript
|
|
1101
|
+
// contexts/ThemeContext.tsx
|
|
1102
|
+
type Theme = 'light' | 'dark' | 'system';
|
|
1103
|
+
|
|
1104
|
+
const ThemeContext = createContext<{
|
|
1105
|
+
theme: Theme;
|
|
1106
|
+
setTheme: (theme: Theme) => void;
|
|
1107
|
+
}>({ theme: 'system', setTheme: () => {} });
|
|
1108
|
+
|
|
1109
|
+
export const ThemeProvider = ({ children }) => {
|
|
1110
|
+
const [theme, setTheme] = useState<Theme>('system');
|
|
1111
|
+
|
|
1112
|
+
useEffect(() => {
|
|
1113
|
+
const saved = localStorage.getItem('theme') as Theme;
|
|
1114
|
+
if (saved) setTheme(saved);
|
|
1115
|
+
}, []);
|
|
1116
|
+
|
|
1117
|
+
useEffect(() => {
|
|
1118
|
+
const root = document.documentElement;
|
|
1119
|
+
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
1120
|
+
const isDark = theme === 'dark' || (theme === 'system' && systemDark);
|
|
1121
|
+
|
|
1122
|
+
root.classList.toggle('dark', isDark);
|
|
1123
|
+
localStorage.setItem('theme', theme);
|
|
1124
|
+
}, [theme]);
|
|
1125
|
+
|
|
1126
|
+
return (
|
|
1127
|
+
<ThemeContext.Provider value={{ theme, setTheme }}>
|
|
1128
|
+
{children}
|
|
1129
|
+
</ThemeContext.Provider>
|
|
1130
|
+
);
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
export const useTheme = () => useContext(ThemeContext);
|
|
1134
|
+
\`\`\`
|
|
1135
|
+
|
|
1136
|
+
2. CSS variables approach:
|
|
1137
|
+
\`\`\`css
|
|
1138
|
+
:root {
|
|
1139
|
+
--bg: #ffffff;
|
|
1140
|
+
--text: #1a1a1a;
|
|
1141
|
+
--primary: #0066cc;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
.dark {
|
|
1145
|
+
--bg: #1a1a1a;
|
|
1146
|
+
--text: #ffffff;
|
|
1147
|
+
--primary: #66b3ff;
|
|
1148
|
+
}
|
|
1149
|
+
\`\`\`
|
|
1150
|
+
|
|
1151
|
+
3. Theme toggle component:
|
|
1152
|
+
\`\`\`tsx
|
|
1153
|
+
export const ThemeToggle = () => {
|
|
1154
|
+
const { theme, setTheme } = useTheme();
|
|
1155
|
+
|
|
1156
|
+
return (
|
|
1157
|
+
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
|
1158
|
+
{theme === 'dark' ? '☀️' : '🌙'}
|
|
1159
|
+
</button>
|
|
1160
|
+
);
|
|
1161
|
+
};
|
|
1162
|
+
\`\`\``,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1167
|
+
// CONFIRMATION & DESTRUCTIVE ACTIONS
|
|
1168
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1169
|
+
|
|
1170
|
+
const hasConfirmDialog = await findFile(searchPath, /ConfirmDialog|Confirm|AlertDialog|useConfirm/i);
|
|
1171
|
+
if (!hasConfirmDialog) {
|
|
1172
|
+
issues.push({
|
|
1173
|
+
id: 'missing-confirm-dialog',
|
|
1174
|
+
category: 'Frontend',
|
|
1175
|
+
severity: 'high',
|
|
1176
|
+
title: 'Missing Confirmation Dialog',
|
|
1177
|
+
description: 'No confirmation dialog found. Destructive actions should require confirmation.',
|
|
1178
|
+
suggestion: 'Add a confirmation dialog for delete, logout, and irreversible actions.',
|
|
1179
|
+
autoFixable: true,
|
|
1180
|
+
aiPrompt: `Create a confirmation dialog system:
|
|
1181
|
+
|
|
1182
|
+
1. ConfirmDialog component:
|
|
1183
|
+
\`\`\`tsx
|
|
1184
|
+
interface ConfirmDialogProps {
|
|
1185
|
+
isOpen: boolean;
|
|
1186
|
+
onClose: () => void;
|
|
1187
|
+
onConfirm: () => void;
|
|
1188
|
+
title: string;
|
|
1189
|
+
message: string;
|
|
1190
|
+
confirmText?: string;
|
|
1191
|
+
cancelText?: string;
|
|
1192
|
+
variant?: 'danger' | 'warning' | 'info';
|
|
1193
|
+
isLoading?: boolean;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export const ConfirmDialog = ({
|
|
1197
|
+
isOpen, onClose, onConfirm, title, message,
|
|
1198
|
+
confirmText = 'Confirm',
|
|
1199
|
+
cancelText = 'Cancel',
|
|
1200
|
+
variant = 'danger',
|
|
1201
|
+
isLoading = false,
|
|
1202
|
+
}) => {
|
|
1203
|
+
return (
|
|
1204
|
+
<Dialog open={isOpen} onClose={onClose}>
|
|
1205
|
+
<div className="confirm-dialog">
|
|
1206
|
+
<h3>{title}</h3>
|
|
1207
|
+
<p>{message}</p>
|
|
1208
|
+
<div className="actions">
|
|
1209
|
+
<Button variant="ghost" onClick={onClose} disabled={isLoading}>
|
|
1210
|
+
{cancelText}
|
|
1211
|
+
</Button>
|
|
1212
|
+
<Button
|
|
1213
|
+
variant={variant}
|
|
1214
|
+
onClick={onConfirm}
|
|
1215
|
+
isLoading={isLoading}
|
|
1216
|
+
>
|
|
1217
|
+
{confirmText}
|
|
1218
|
+
</Button>
|
|
1219
|
+
</div>
|
|
1220
|
+
</div>
|
|
1221
|
+
</Dialog>
|
|
1222
|
+
);
|
|
1223
|
+
};
|
|
1224
|
+
\`\`\`
|
|
1225
|
+
|
|
1226
|
+
2. useConfirm hook for easy usage:
|
|
1227
|
+
\`\`\`typescript
|
|
1228
|
+
const useConfirm = () => {
|
|
1229
|
+
const [state, setState] = useState({ isOpen: false, config: null });
|
|
1230
|
+
|
|
1231
|
+
const confirm = (config) => new Promise((resolve) => {
|
|
1232
|
+
setState({
|
|
1233
|
+
isOpen: true,
|
|
1234
|
+
config: { ...config, resolve }
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const handleConfirm = () => {
|
|
1239
|
+
state.config?.resolve(true);
|
|
1240
|
+
setState({ isOpen: false, config: null });
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
const handleCancel = () => {
|
|
1244
|
+
state.config?.resolve(false);
|
|
1245
|
+
setState({ isOpen: false, config: null });
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
return { confirm, ConfirmDialog: /* render dialog */ };
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// Usage:
|
|
1252
|
+
const { confirm } = useConfirm();
|
|
1253
|
+
const shouldDelete = await confirm({
|
|
1254
|
+
title: 'Delete item?',
|
|
1255
|
+
message: 'This action cannot be undone.',
|
|
1256
|
+
});
|
|
1257
|
+
if (shouldDelete) deleteItem();
|
|
1258
|
+
\`\`\``,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1263
|
+
// OPTIMISTIC UPDATES
|
|
1264
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1265
|
+
|
|
1266
|
+
const hasOptimisticUpdates = await findFile(searchPath, /optimistic|mutate.*onMutate|useMutation/i);
|
|
1267
|
+
if (!hasOptimisticUpdates && hasSWR) {
|
|
1268
|
+
issues.push({
|
|
1269
|
+
id: 'missing-optimistic-updates',
|
|
1270
|
+
category: 'Frontend',
|
|
1271
|
+
severity: 'low',
|
|
1272
|
+
title: 'No Optimistic Updates',
|
|
1273
|
+
description: 'Data mutations may feel slow without optimistic updates.',
|
|
1274
|
+
suggestion: 'Add optimistic updates for better perceived performance.',
|
|
1275
|
+
autoFixable: false,
|
|
1276
|
+
aiPrompt: `Implement optimistic updates for mutations:
|
|
1277
|
+
|
|
1278
|
+
With SWR:
|
|
1279
|
+
\`\`\`typescript
|
|
1280
|
+
import useSWR, { useSWRConfig } from 'swr';
|
|
1281
|
+
|
|
1282
|
+
const useUpdateTodo = () => {
|
|
1283
|
+
const { mutate } = useSWRConfig();
|
|
1284
|
+
|
|
1285
|
+
const updateTodo = async (id: string, data: Partial<Todo>) => {
|
|
1286
|
+
// Optimistic update
|
|
1287
|
+
mutate(
|
|
1288
|
+
'/api/todos',
|
|
1289
|
+
(todos: Todo[]) => todos.map(t =>
|
|
1290
|
+
t.id === id ? { ...t, ...data } : t
|
|
1291
|
+
),
|
|
1292
|
+
false // Don't revalidate yet
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
try {
|
|
1296
|
+
await fetch(\`/api/todos/\${id}\`, {
|
|
1297
|
+
method: 'PATCH',
|
|
1298
|
+
body: JSON.stringify(data),
|
|
1299
|
+
});
|
|
1300
|
+
// Revalidate after success
|
|
1301
|
+
mutate('/api/todos');
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
// Revert on error
|
|
1304
|
+
mutate('/api/todos');
|
|
1305
|
+
throw error;
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
return { updateTodo };
|
|
1310
|
+
};
|
|
1311
|
+
\`\`\`
|
|
1312
|
+
|
|
1313
|
+
With TanStack Query:
|
|
1314
|
+
\`\`\`typescript
|
|
1315
|
+
const useUpdateTodo = () => {
|
|
1316
|
+
const queryClient = useQueryClient();
|
|
1317
|
+
|
|
1318
|
+
return useMutation({
|
|
1319
|
+
mutationFn: updateTodoApi,
|
|
1320
|
+
onMutate: async (newTodo) => {
|
|
1321
|
+
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
|
1322
|
+
const previous = queryClient.getQueryData(['todos']);
|
|
1323
|
+
|
|
1324
|
+
queryClient.setQueryData(['todos'], (old) =>
|
|
1325
|
+
old.map(t => t.id === newTodo.id ? { ...t, ...newTodo } : t)
|
|
1326
|
+
);
|
|
1327
|
+
|
|
1328
|
+
return { previous };
|
|
1329
|
+
},
|
|
1330
|
+
onError: (err, newTodo, context) => {
|
|
1331
|
+
queryClient.setQueryData(['todos'], context.previous);
|
|
1332
|
+
},
|
|
1333
|
+
onSettled: () => {
|
|
1334
|
+
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
};
|
|
1338
|
+
\`\`\``,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1343
|
+
// KEYBOARD SHORTCUTS
|
|
1344
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1345
|
+
|
|
1346
|
+
const hasKeyboardShortcuts = await findFile(searchPath, /useHotkeys|Keyboard|shortcut|Cmd\+|Ctrl\+/i);
|
|
1347
|
+
if (!hasKeyboardShortcuts) {
|
|
1348
|
+
issues.push({
|
|
1349
|
+
id: 'missing-keyboard-shortcuts',
|
|
1350
|
+
category: 'Frontend',
|
|
1351
|
+
severity: 'low',
|
|
1352
|
+
title: 'Missing Keyboard Shortcuts',
|
|
1353
|
+
description: 'No keyboard shortcuts detected. Power users benefit from keyboard navigation.',
|
|
1354
|
+
suggestion: 'Add keyboard shortcuts for common actions.',
|
|
1355
|
+
autoFixable: true,
|
|
1356
|
+
aiPrompt: `Implement keyboard shortcuts for power users:
|
|
1357
|
+
|
|
1358
|
+
1. Create useHotkey hook:
|
|
1359
|
+
\`\`\`typescript
|
|
1360
|
+
const useHotkey = (key: string, callback: () => void, deps: any[] = []) => {
|
|
1361
|
+
useEffect(() => {
|
|
1362
|
+
const handler = (e: KeyboardEvent) => {
|
|
1363
|
+
const isMac = navigator.platform.includes('Mac');
|
|
1364
|
+
const modifier = isMac ? e.metaKey : e.ctrlKey;
|
|
1365
|
+
|
|
1366
|
+
// Parse key combo like "mod+k" or "shift+enter"
|
|
1367
|
+
const parts = key.toLowerCase().split('+');
|
|
1368
|
+
const mainKey = parts[parts.length - 1];
|
|
1369
|
+
const needsModifier = parts.includes('mod');
|
|
1370
|
+
const needsShift = parts.includes('shift');
|
|
1371
|
+
|
|
1372
|
+
if (needsModifier && !modifier) return;
|
|
1373
|
+
if (needsShift && !e.shiftKey) return;
|
|
1374
|
+
if (e.key.toLowerCase() !== mainKey) return;
|
|
1375
|
+
|
|
1376
|
+
e.preventDefault();
|
|
1377
|
+
callback();
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
window.addEventListener('keydown', handler);
|
|
1381
|
+
return () => window.removeEventListener('keydown', handler);
|
|
1382
|
+
}, deps);
|
|
1383
|
+
};
|
|
1384
|
+
\`\`\`
|
|
1385
|
+
|
|
1386
|
+
2. Common shortcuts to implement:
|
|
1387
|
+
- Cmd/Ctrl + K: Search / Command palette
|
|
1388
|
+
- Cmd/Ctrl + S: Save
|
|
1389
|
+
- Cmd/Ctrl + Enter: Submit form
|
|
1390
|
+
- Escape: Close modal/dialog
|
|
1391
|
+
- ?: Show keyboard shortcuts help
|
|
1392
|
+
|
|
1393
|
+
3. Display shortcut hints in UI:
|
|
1394
|
+
\`\`\`tsx
|
|
1395
|
+
<Button>
|
|
1396
|
+
Save
|
|
1397
|
+
<kbd className="ml-2 opacity-50">⌘S</kbd>
|
|
1398
|
+
</Button>
|
|
1399
|
+
\`\`\``,
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1404
|
+
// SCROLL BEHAVIOR
|
|
1405
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1406
|
+
|
|
1407
|
+
const hasScrollToTop = await findFile(searchPath, /ScrollToTop|scroll-to-top|BackToTop/i);
|
|
1408
|
+
if (!hasScrollToTop) {
|
|
1409
|
+
issues.push({
|
|
1410
|
+
id: 'missing-scroll-to-top',
|
|
1411
|
+
category: 'Frontend',
|
|
1412
|
+
severity: 'low',
|
|
1413
|
+
title: 'Missing Scroll-to-Top Button',
|
|
1414
|
+
description: 'No scroll-to-top button found. Long pages need easy navigation back to top.',
|
|
1415
|
+
suggestion: 'Add a scroll-to-top button for long pages.',
|
|
1416
|
+
autoFixable: true,
|
|
1417
|
+
aiPrompt: `Create a scroll-to-top button:
|
|
1418
|
+
|
|
1419
|
+
\`\`\`tsx
|
|
1420
|
+
export const ScrollToTop = () => {
|
|
1421
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
1422
|
+
|
|
1423
|
+
useEffect(() => {
|
|
1424
|
+
const toggleVisibility = () => {
|
|
1425
|
+
setIsVisible(window.scrollY > 500);
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
window.addEventListener('scroll', toggleVisibility);
|
|
1429
|
+
return () => window.removeEventListener('scroll', toggleVisibility);
|
|
1430
|
+
}, []);
|
|
1431
|
+
|
|
1432
|
+
const scrollToTop = () => {
|
|
1433
|
+
window.scrollTo({
|
|
1434
|
+
top: 0,
|
|
1435
|
+
behavior: 'smooth'
|
|
1436
|
+
});
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
if (!isVisible) return null;
|
|
1440
|
+
|
|
1441
|
+
return (
|
|
1442
|
+
<button
|
|
1443
|
+
onClick={scrollToTop}
|
|
1444
|
+
className="scroll-to-top"
|
|
1445
|
+
aria-label="Scroll to top"
|
|
1446
|
+
>
|
|
1447
|
+
↑
|
|
1448
|
+
</button>
|
|
1449
|
+
);
|
|
1450
|
+
};
|
|
1451
|
+
\`\`\`
|
|
1452
|
+
|
|
1453
|
+
CSS:
|
|
1454
|
+
\`\`\`css
|
|
1455
|
+
.scroll-to-top {
|
|
1456
|
+
position: fixed;
|
|
1457
|
+
bottom: 2rem;
|
|
1458
|
+
right: 2rem;
|
|
1459
|
+
width: 48px;
|
|
1460
|
+
height: 48px;
|
|
1461
|
+
border-radius: 50%;
|
|
1462
|
+
background: var(--primary);
|
|
1463
|
+
color: white;
|
|
1464
|
+
border: none;
|
|
1465
|
+
cursor: pointer;
|
|
1466
|
+
opacity: 0.9;
|
|
1467
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
1468
|
+
z-index: 50;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
.scroll-to-top:hover {
|
|
1472
|
+
opacity: 1;
|
|
1473
|
+
transform: translateY(-2px);
|
|
1474
|
+
}
|
|
1475
|
+
\`\`\``,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1480
|
+
// PROGRESS INDICATORS
|
|
1481
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1482
|
+
|
|
1483
|
+
const hasProgressBar = await findFile(searchPath, /Progress|ProgressBar|NProgress|nprogress/i);
|
|
1484
|
+
if (!hasProgressBar) {
|
|
1485
|
+
issues.push({
|
|
1486
|
+
id: 'missing-progress-bar',
|
|
1487
|
+
category: 'Frontend',
|
|
1488
|
+
severity: 'medium',
|
|
1489
|
+
title: 'Missing Progress Indicators',
|
|
1490
|
+
description: 'No progress bar found. Users need visual feedback for page transitions and uploads.',
|
|
1491
|
+
suggestion: 'Add progress bar for navigation and long operations.',
|
|
1492
|
+
autoFixable: true,
|
|
1493
|
+
aiPrompt: `Add progress indicators:
|
|
1494
|
+
|
|
1495
|
+
1. Page transition progress (NProgress style):
|
|
1496
|
+
\`\`\`bash
|
|
1497
|
+
npm install nprogress
|
|
1498
|
+
npm install @types/nprogress -D
|
|
1499
|
+
\`\`\`
|
|
1500
|
+
|
|
1501
|
+
\`\`\`tsx
|
|
1502
|
+
// app/providers.tsx (Next.js App Router)
|
|
1503
|
+
'use client';
|
|
1504
|
+
import { useEffect } from 'react';
|
|
1505
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
1506
|
+
import NProgress from 'nprogress';
|
|
1507
|
+
import 'nprogress/nprogress.css';
|
|
1508
|
+
|
|
1509
|
+
NProgress.configure({ showSpinner: false });
|
|
1510
|
+
|
|
1511
|
+
export function NavigationProgress() {
|
|
1512
|
+
const pathname = usePathname();
|
|
1513
|
+
const searchParams = useSearchParams();
|
|
1514
|
+
|
|
1515
|
+
useEffect(() => {
|
|
1516
|
+
NProgress.done();
|
|
1517
|
+
}, [pathname, searchParams]);
|
|
1518
|
+
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
\`\`\`
|
|
1522
|
+
|
|
1523
|
+
2. Custom progress bar component:
|
|
1524
|
+
\`\`\`tsx
|
|
1525
|
+
export const ProgressBar = ({ value, max = 100 }) => (
|
|
1526
|
+
<div className="progress-bar" role="progressbar" aria-valuenow={value}>
|
|
1527
|
+
<div
|
|
1528
|
+
className="progress-bar-fill"
|
|
1529
|
+
style={{ width: \`\${(value / max) * 100}%\` }}
|
|
1530
|
+
/>
|
|
1531
|
+
</div>
|
|
1532
|
+
);
|
|
1533
|
+
\`\`\`
|
|
1534
|
+
|
|
1535
|
+
3. File upload progress:
|
|
1536
|
+
\`\`\`tsx
|
|
1537
|
+
export const UploadProgress = ({ progress, fileName }) => (
|
|
1538
|
+
<div className="upload-progress">
|
|
1539
|
+
<span>{fileName}</span>
|
|
1540
|
+
<ProgressBar value={progress} />
|
|
1541
|
+
<span>{progress}%</span>
|
|
1542
|
+
</div>
|
|
1543
|
+
);
|
|
1544
|
+
\`\`\``,
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
return issues;
|
|
1549
|
+
},
|
|
1550
|
+
|
|
1551
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1552
|
+
// BACKEND CHECKER
|
|
1553
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1554
|
+
async backend(projectPath) {
|
|
1555
|
+
const issues = [];
|
|
1556
|
+
const apiPath = path.join(projectPath, 'src', 'server');
|
|
1557
|
+
const apiAltPath = path.join(projectPath, 'api');
|
|
1558
|
+
const pagesApiPath = path.join(projectPath, 'pages', 'api');
|
|
1559
|
+
const appApiPath = path.join(projectPath, 'app', 'api');
|
|
1560
|
+
|
|
1561
|
+
const hasApi = await pathExists(apiPath) || await pathExists(apiAltPath) ||
|
|
1562
|
+
await pathExists(pagesApiPath) || await pathExists(appApiPath);
|
|
1563
|
+
|
|
1564
|
+
if (!hasApi) return issues;
|
|
1565
|
+
|
|
1566
|
+
// Check for health endpoint
|
|
1567
|
+
const hasHealth = await findFile(projectPath, /health|healthcheck|status/i);
|
|
1568
|
+
if (!hasHealth) {
|
|
1569
|
+
issues.push({
|
|
1570
|
+
id: 'missing-health-endpoint',
|
|
1571
|
+
category: 'Backend',
|
|
1572
|
+
severity: 'high',
|
|
1573
|
+
title: 'Missing Health Endpoint',
|
|
1574
|
+
description: 'No health check endpoint found. Load balancers and monitoring can\'t verify service status.',
|
|
1575
|
+
suggestion: 'Add a /health or /api/health endpoint that returns service status.',
|
|
1576
|
+
autoFixable: true,
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Check for validation library
|
|
1581
|
+
const packageJson = await readFileSafe(path.join(projectPath, 'package.json'));
|
|
1582
|
+
if (packageJson) {
|
|
1583
|
+
const hasValidation = /zod|yup|joi|class-validator|ajv/i.test(packageJson);
|
|
1584
|
+
if (!hasValidation) {
|
|
1585
|
+
issues.push({
|
|
1586
|
+
id: 'missing-validation',
|
|
1587
|
+
category: 'Backend',
|
|
1588
|
+
severity: 'high',
|
|
1589
|
+
title: 'Missing Input Validation',
|
|
1590
|
+
description: 'No validation library found. API inputs may not be properly validated.',
|
|
1591
|
+
suggestion: 'Add zod, yup, joi, or similar for input validation.',
|
|
1592
|
+
autoFixable: false,
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Check for rate limiting
|
|
1598
|
+
const hasRateLimiting = packageJson && /rate-limit|ratelimit|express-rate-limit|@upstash\/ratelimit/i.test(packageJson);
|
|
1599
|
+
if (!hasRateLimiting) {
|
|
1600
|
+
issues.push({
|
|
1601
|
+
id: 'missing-rate-limiting',
|
|
1602
|
+
category: 'Backend',
|
|
1603
|
+
severity: 'high',
|
|
1604
|
+
title: 'Missing Rate Limiting',
|
|
1605
|
+
description: 'No rate limiting found. API is vulnerable to abuse and DDoS.',
|
|
1606
|
+
suggestion: 'Add rate limiting middleware to protect your API.',
|
|
1607
|
+
autoFixable: false,
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Check for error handling middleware
|
|
1612
|
+
const hasErrorMiddleware = await findFile(projectPath, /errorHandler|error-handler|errorMiddleware/i);
|
|
1613
|
+
if (!hasErrorMiddleware) {
|
|
1614
|
+
issues.push({
|
|
1615
|
+
id: 'missing-error-handler',
|
|
1616
|
+
category: 'Backend',
|
|
1617
|
+
severity: 'medium',
|
|
1618
|
+
title: 'Missing Global Error Handler',
|
|
1619
|
+
description: 'No global error handler found. Unhandled errors may leak stack traces.',
|
|
1620
|
+
suggestion: 'Add a global error handler middleware that catches and formats errors.',
|
|
1621
|
+
autoFixable: true,
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return issues;
|
|
1626
|
+
},
|
|
1627
|
+
|
|
1628
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1629
|
+
// SECURITY CHECKER
|
|
1630
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1631
|
+
async security(projectPath) {
|
|
1632
|
+
const issues = [];
|
|
1633
|
+
|
|
1634
|
+
// Check for .env.example
|
|
1635
|
+
const hasEnvExample = await pathExists(path.join(projectPath, '.env.example')) ||
|
|
1636
|
+
await pathExists(path.join(projectPath, '.env.sample'));
|
|
1637
|
+
if (!hasEnvExample) {
|
|
1638
|
+
issues.push({
|
|
1639
|
+
id: 'missing-env-example',
|
|
1640
|
+
category: 'Security',
|
|
1641
|
+
severity: 'medium',
|
|
1642
|
+
title: 'Missing .env.example',
|
|
1643
|
+
description: 'No .env.example file found. Team members won\'t know what env vars are needed.',
|
|
1644
|
+
suggestion: 'Create .env.example with all required environment variables (without values).',
|
|
1645
|
+
autoFixable: true,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// Check for .gitignore with .env
|
|
1650
|
+
const gitignore = await readFileSafe(path.join(projectPath, '.gitignore'));
|
|
1651
|
+
if (gitignore && !gitignore.includes('.env')) {
|
|
1652
|
+
issues.push({
|
|
1653
|
+
id: 'env-not-gitignored',
|
|
1654
|
+
category: 'Security',
|
|
1655
|
+
severity: 'critical',
|
|
1656
|
+
title: '.env Not in .gitignore',
|
|
1657
|
+
description: 'Environment files may be committed to git, exposing secrets.',
|
|
1658
|
+
suggestion: 'Add .env* to .gitignore immediately.',
|
|
1659
|
+
autoFixable: true,
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Check for security headers
|
|
1664
|
+
const packageJson = await readFileSafe(path.join(projectPath, 'package.json'));
|
|
1665
|
+
const hasHelmet = packageJson && /helmet|next-secure-headers/i.test(packageJson);
|
|
1666
|
+
if (!hasHelmet) {
|
|
1667
|
+
issues.push({
|
|
1668
|
+
id: 'missing-security-headers',
|
|
1669
|
+
category: 'Security',
|
|
1670
|
+
severity: 'high',
|
|
1671
|
+
title: 'Missing Security Headers',
|
|
1672
|
+
description: 'No security headers library found (helmet, etc.).',
|
|
1673
|
+
suggestion: 'Add helmet or security headers middleware for CSP, HSTS, etc.',
|
|
1674
|
+
autoFixable: false,
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// Check for CORS configuration
|
|
1679
|
+
const hasCors = packageJson && /cors|@fastify\/cors/i.test(packageJson);
|
|
1680
|
+
const nextConfig = await readFileSafe(path.join(projectPath, 'next.config.js')) ||
|
|
1681
|
+
await readFileSafe(path.join(projectPath, 'next.config.mjs'));
|
|
1682
|
+
const hasNextCors = nextConfig && /headers|Access-Control/i.test(nextConfig);
|
|
1683
|
+
|
|
1684
|
+
if (!hasCors && !hasNextCors) {
|
|
1685
|
+
issues.push({
|
|
1686
|
+
id: 'missing-cors',
|
|
1687
|
+
category: 'Security',
|
|
1688
|
+
severity: 'medium',
|
|
1689
|
+
title: 'No CORS Configuration',
|
|
1690
|
+
description: 'No CORS configuration found. May have issues with cross-origin requests.',
|
|
1691
|
+
suggestion: 'Add CORS configuration to control which origins can access your API.',
|
|
1692
|
+
autoFixable: false,
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
return issues;
|
|
1697
|
+
},
|
|
1698
|
+
|
|
1699
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1700
|
+
// PERFORMANCE CHECKER
|
|
1701
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1702
|
+
async performance(projectPath) {
|
|
1703
|
+
const issues = [];
|
|
1704
|
+
const packageJson = await readFileSafe(path.join(projectPath, 'package.json'));
|
|
1705
|
+
|
|
1706
|
+
// Check for image optimization
|
|
1707
|
+
const nextConfig = await readFileSafe(path.join(projectPath, 'next.config.js')) ||
|
|
1708
|
+
await readFileSafe(path.join(projectPath, 'next.config.mjs'));
|
|
1709
|
+
const hasImageOptimization = (packageJson && /sharp|next\/image|@next\/image/i.test(packageJson)) ||
|
|
1710
|
+
(nextConfig && /images:/i.test(nextConfig));
|
|
1711
|
+
|
|
1712
|
+
if (!hasImageOptimization) {
|
|
1713
|
+
issues.push({
|
|
1714
|
+
id: 'missing-image-optimization',
|
|
1715
|
+
category: 'Performance',
|
|
1716
|
+
severity: 'medium',
|
|
1717
|
+
title: 'Missing Image Optimization',
|
|
1718
|
+
description: 'No image optimization setup found. Images may be served unoptimized.',
|
|
1719
|
+
suggestion: 'Use Next.js Image component or sharp for image optimization.',
|
|
1720
|
+
autoFixable: false,
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Check for caching headers
|
|
1725
|
+
const hasCaching = await findFile(projectPath, /cache|stale-while-revalidate/i);
|
|
1726
|
+
if (!hasCaching) {
|
|
1727
|
+
issues.push({
|
|
1728
|
+
id: 'missing-caching',
|
|
1729
|
+
category: 'Performance',
|
|
1730
|
+
severity: 'medium',
|
|
1731
|
+
title: 'No Caching Strategy',
|
|
1732
|
+
description: 'No caching configuration found. API responses may not be cached.',
|
|
1733
|
+
suggestion: 'Add Cache-Control headers or use SWR/React Query for client-side caching.',
|
|
1734
|
+
autoFixable: false,
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Check for bundle analyzer
|
|
1739
|
+
const hasBundleAnalyzer = packageJson && /@next\/bundle-analyzer|webpack-bundle-analyzer/i.test(packageJson);
|
|
1740
|
+
if (!hasBundleAnalyzer) {
|
|
1741
|
+
issues.push({
|
|
1742
|
+
id: 'missing-bundle-analyzer',
|
|
1743
|
+
category: 'Performance',
|
|
1744
|
+
severity: 'low',
|
|
1745
|
+
title: 'No Bundle Analyzer',
|
|
1746
|
+
description: 'No bundle analyzer installed. Can\'t visualize bundle size.',
|
|
1747
|
+
suggestion: 'Add @next/bundle-analyzer to track and optimize bundle size.',
|
|
1748
|
+
autoFixable: false,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
return issues;
|
|
1753
|
+
},
|
|
1754
|
+
|
|
1755
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1756
|
+
// ACCESSIBILITY CHECKER
|
|
1757
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1758
|
+
async accessibility(projectPath) {
|
|
1759
|
+
const issues = [];
|
|
1760
|
+
const packageJson = await readFileSafe(path.join(projectPath, 'package.json'));
|
|
1761
|
+
|
|
1762
|
+
// Check for accessibility testing
|
|
1763
|
+
const hasA11yTesting = packageJson && /axe-core|@axe-core|jest-axe|cypress-axe|pa11y/i.test(packageJson);
|
|
1764
|
+
if (!hasA11yTesting) {
|
|
1765
|
+
issues.push({
|
|
1766
|
+
id: 'missing-a11y-testing',
|
|
1767
|
+
category: 'Accessibility',
|
|
1768
|
+
severity: 'medium',
|
|
1769
|
+
title: 'No Accessibility Testing',
|
|
1770
|
+
description: 'No accessibility testing library found.',
|
|
1771
|
+
suggestion: 'Add jest-axe, cypress-axe, or pa11y for automated a11y testing.',
|
|
1772
|
+
autoFixable: false,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Check for skip link
|
|
1777
|
+
const hasSkipLink = await findFile(projectPath, /skip-link|skiplink|SkipNav/i);
|
|
1778
|
+
if (!hasSkipLink) {
|
|
1779
|
+
issues.push({
|
|
1780
|
+
id: 'missing-skip-link',
|
|
1781
|
+
category: 'Accessibility',
|
|
1782
|
+
severity: 'medium',
|
|
1783
|
+
title: 'Missing Skip Link',
|
|
1784
|
+
description: 'No skip-to-content link found. Keyboard users can\'t skip navigation.',
|
|
1785
|
+
suggestion: 'Add a skip-to-main-content link at the top of your pages.',
|
|
1786
|
+
autoFixable: true,
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// Check for focus-visible
|
|
1791
|
+
const globalCss = await readFileSafe(path.join(projectPath, 'src', 'styles', 'globals.css')) ||
|
|
1792
|
+
await readFileSafe(path.join(projectPath, 'app', 'globals.css')) ||
|
|
1793
|
+
await readFileSafe(path.join(projectPath, 'styles', 'globals.css'));
|
|
1794
|
+
|
|
1795
|
+
if (globalCss && /outline:\s*none|outline:\s*0/i.test(globalCss) && !/focus-visible/i.test(globalCss)) {
|
|
1796
|
+
issues.push({
|
|
1797
|
+
id: 'focus-removed',
|
|
1798
|
+
category: 'Accessibility',
|
|
1799
|
+
severity: 'high',
|
|
1800
|
+
title: 'Focus Outline Removed',
|
|
1801
|
+
description: 'Focus outlines are removed without alternative. Keyboard users can\'t see focus.',
|
|
1802
|
+
suggestion: 'Use :focus-visible instead of removing outlines entirely.',
|
|
1803
|
+
autoFixable: false,
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
return issues;
|
|
1808
|
+
},
|
|
1809
|
+
|
|
1810
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1811
|
+
// SEO CHECKER
|
|
1812
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1813
|
+
async seo(projectPath) {
|
|
1814
|
+
const issues = [];
|
|
1815
|
+
|
|
1816
|
+
// Check for robots.txt
|
|
1817
|
+
const hasRobots = await pathExists(path.join(projectPath, 'public', 'robots.txt'));
|
|
1818
|
+
if (!hasRobots) {
|
|
1819
|
+
issues.push({
|
|
1820
|
+
id: 'missing-robots',
|
|
1821
|
+
category: 'SEO',
|
|
1822
|
+
severity: 'medium',
|
|
1823
|
+
title: 'Missing robots.txt',
|
|
1824
|
+
description: 'No robots.txt file found. Search engines may not crawl correctly.',
|
|
1825
|
+
suggestion: 'Add robots.txt to public folder to guide search engine crawlers.',
|
|
1826
|
+
autoFixable: true,
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Check for sitemap
|
|
1831
|
+
const hasSitemap = await pathExists(path.join(projectPath, 'public', 'sitemap.xml')) ||
|
|
1832
|
+
await findFile(projectPath, /sitemap/i);
|
|
1833
|
+
if (!hasSitemap) {
|
|
1834
|
+
issues.push({
|
|
1835
|
+
id: 'missing-sitemap',
|
|
1836
|
+
category: 'SEO',
|
|
1837
|
+
severity: 'medium',
|
|
1838
|
+
title: 'Missing Sitemap',
|
|
1839
|
+
description: 'No sitemap found. Search engines may miss some pages.',
|
|
1840
|
+
suggestion: 'Generate sitemap.xml for better search engine indexing.',
|
|
1841
|
+
autoFixable: false,
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// Check for meta description component or setup
|
|
1846
|
+
const hasMetaTags = await findFile(projectPath, /MetaTags|Seo|Head|metadata/i);
|
|
1847
|
+
if (!hasMetaTags) {
|
|
1848
|
+
issues.push({
|
|
1849
|
+
id: 'missing-meta-setup',
|
|
1850
|
+
category: 'SEO',
|
|
1851
|
+
severity: 'high',
|
|
1852
|
+
title: 'Missing Meta Tag Setup',
|
|
1853
|
+
description: 'No meta tag component found. Pages may lack proper SEO metadata.',
|
|
1854
|
+
suggestion: 'Add a SEO/MetaTags component for consistent meta descriptions.',
|
|
1855
|
+
autoFixable: true,
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Check for Open Graph images
|
|
1860
|
+
const hasOgImage = await pathExists(path.join(projectPath, 'public', 'og-image.png')) ||
|
|
1861
|
+
await pathExists(path.join(projectPath, 'public', 'og.png')) ||
|
|
1862
|
+
await findFile(path.join(projectPath, 'public'), /og[-_]?image/i);
|
|
1863
|
+
if (!hasOgImage) {
|
|
1864
|
+
issues.push({
|
|
1865
|
+
id: 'missing-og-image',
|
|
1866
|
+
category: 'SEO',
|
|
1867
|
+
severity: 'low',
|
|
1868
|
+
title: 'Missing Open Graph Image',
|
|
1869
|
+
description: 'No OG image found. Social media shares will look plain.',
|
|
1870
|
+
suggestion: 'Add og-image.png to public folder for social media previews.',
|
|
1871
|
+
autoFixable: false,
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
return issues;
|
|
1876
|
+
},
|
|
1877
|
+
|
|
1878
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1879
|
+
// CONFIGURATION CHECKER
|
|
1880
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1881
|
+
async configuration(projectPath) {
|
|
1882
|
+
const issues = [];
|
|
1883
|
+
|
|
1884
|
+
// Check for TypeScript strict mode
|
|
1885
|
+
const tsConfig = await readFileSafe(path.join(projectPath, 'tsconfig.json'));
|
|
1886
|
+
if (tsConfig) {
|
|
1887
|
+
const parsed = JSON.parse(tsConfig);
|
|
1888
|
+
if (!parsed.compilerOptions?.strict) {
|
|
1889
|
+
issues.push({
|
|
1890
|
+
id: 'ts-not-strict',
|
|
1891
|
+
category: 'Configuration',
|
|
1892
|
+
severity: 'medium',
|
|
1893
|
+
title: 'TypeScript Strict Mode Disabled',
|
|
1894
|
+
description: 'TypeScript strict mode is not enabled. Type safety is reduced.',
|
|
1895
|
+
suggestion: 'Enable "strict": true in tsconfig.json for better type safety.',
|
|
1896
|
+
autoFixable: true,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// Check for ESLint config
|
|
1902
|
+
const hasEslint = await pathExists(path.join(projectPath, '.eslintrc.json')) ||
|
|
1903
|
+
await pathExists(path.join(projectPath, '.eslintrc.js')) ||
|
|
1904
|
+
await pathExists(path.join(projectPath, 'eslint.config.js'));
|
|
1905
|
+
if (!hasEslint) {
|
|
1906
|
+
issues.push({
|
|
1907
|
+
id: 'missing-eslint',
|
|
1908
|
+
category: 'Configuration',
|
|
1909
|
+
severity: 'medium',
|
|
1910
|
+
title: 'Missing ESLint Configuration',
|
|
1911
|
+
description: 'No ESLint config found. Code quality may not be enforced.',
|
|
1912
|
+
suggestion: 'Add ESLint configuration for code quality enforcement.',
|
|
1913
|
+
autoFixable: true,
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// Check for Prettier
|
|
1918
|
+
const hasPrettier = await pathExists(path.join(projectPath, '.prettierrc')) ||
|
|
1919
|
+
await pathExists(path.join(projectPath, '.prettierrc.json')) ||
|
|
1920
|
+
await pathExists(path.join(projectPath, 'prettier.config.js'));
|
|
1921
|
+
if (!hasPrettier) {
|
|
1922
|
+
issues.push({
|
|
1923
|
+
id: 'missing-prettier',
|
|
1924
|
+
category: 'Configuration',
|
|
1925
|
+
severity: 'low',
|
|
1926
|
+
title: 'Missing Prettier Configuration',
|
|
1927
|
+
description: 'No Prettier config found. Code formatting may be inconsistent.',
|
|
1928
|
+
suggestion: 'Add Prettier configuration for consistent code formatting.',
|
|
1929
|
+
autoFixable: true,
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Check for editor config
|
|
1934
|
+
const hasEditorConfig = await pathExists(path.join(projectPath, '.editorconfig'));
|
|
1935
|
+
if (!hasEditorConfig) {
|
|
1936
|
+
issues.push({
|
|
1937
|
+
id: 'missing-editorconfig',
|
|
1938
|
+
category: 'Configuration',
|
|
1939
|
+
severity: 'low',
|
|
1940
|
+
title: 'Missing .editorconfig',
|
|
1941
|
+
description: 'No .editorconfig found. Different editors may format differently.',
|
|
1942
|
+
suggestion: 'Add .editorconfig for consistent editor settings across team.',
|
|
1943
|
+
autoFixable: true,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
return issues;
|
|
1948
|
+
},
|
|
1949
|
+
|
|
1950
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1951
|
+
// DOCUMENTATION CHECKER
|
|
1952
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1953
|
+
async documentation(projectPath) {
|
|
1954
|
+
const issues = [];
|
|
1955
|
+
|
|
1956
|
+
// Check for README
|
|
1957
|
+
const hasReadme = await pathExists(path.join(projectPath, 'README.md'));
|
|
1958
|
+
if (!hasReadme) {
|
|
1959
|
+
issues.push({
|
|
1960
|
+
id: 'missing-readme',
|
|
1961
|
+
category: 'Documentation',
|
|
1962
|
+
severity: 'high',
|
|
1963
|
+
title: 'Missing README',
|
|
1964
|
+
description: 'No README.md found. New developers won\'t know how to start.',
|
|
1965
|
+
suggestion: 'Add README.md with project overview, setup, and usage instructions.',
|
|
1966
|
+
autoFixable: true,
|
|
1967
|
+
});
|
|
1968
|
+
} else {
|
|
1969
|
+
// Check README quality
|
|
1970
|
+
const readme = await readFileSafe(path.join(projectPath, 'README.md'));
|
|
1971
|
+
if (readme && readme.length < 500) {
|
|
1972
|
+
issues.push({
|
|
1973
|
+
id: 'insufficient-readme',
|
|
1974
|
+
category: 'Documentation',
|
|
1975
|
+
severity: 'medium',
|
|
1976
|
+
title: 'Insufficient README',
|
|
1977
|
+
description: 'README is very short. May be missing important sections.',
|
|
1978
|
+
suggestion: 'Expand README with installation, usage, API docs, and contributing guide.',
|
|
1979
|
+
autoFixable: false,
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// Check for CHANGELOG
|
|
1985
|
+
const hasChangelog = await pathExists(path.join(projectPath, 'CHANGELOG.md'));
|
|
1986
|
+
if (!hasChangelog) {
|
|
1987
|
+
issues.push({
|
|
1988
|
+
id: 'missing-changelog',
|
|
1989
|
+
category: 'Documentation',
|
|
1990
|
+
severity: 'low',
|
|
1991
|
+
title: 'Missing CHANGELOG',
|
|
1992
|
+
description: 'No CHANGELOG.md found. Version history is not documented.',
|
|
1993
|
+
suggestion: 'Add CHANGELOG.md to track version history and changes.',
|
|
1994
|
+
autoFixable: true,
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// Check for CONTRIBUTING guide
|
|
1999
|
+
const hasContributing = await pathExists(path.join(projectPath, 'CONTRIBUTING.md'));
|
|
2000
|
+
if (!hasContributing) {
|
|
2001
|
+
issues.push({
|
|
2002
|
+
id: 'missing-contributing',
|
|
2003
|
+
category: 'Documentation',
|
|
2004
|
+
severity: 'low',
|
|
2005
|
+
title: 'Missing CONTRIBUTING Guide',
|
|
2006
|
+
description: 'No CONTRIBUTING.md found. Contributors won\'t know the process.',
|
|
2007
|
+
suggestion: 'Add CONTRIBUTING.md with contribution guidelines.',
|
|
2008
|
+
autoFixable: true,
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Check for LICENSE
|
|
2013
|
+
const hasLicense = await pathExists(path.join(projectPath, 'LICENSE')) ||
|
|
2014
|
+
await pathExists(path.join(projectPath, 'LICENSE.md'));
|
|
2015
|
+
if (!hasLicense) {
|
|
2016
|
+
issues.push({
|
|
2017
|
+
id: 'missing-license',
|
|
2018
|
+
category: 'Documentation',
|
|
2019
|
+
severity: 'medium',
|
|
2020
|
+
title: 'Missing LICENSE',
|
|
2021
|
+
description: 'No LICENSE file found. Legal usage of code is unclear.',
|
|
2022
|
+
suggestion: 'Add a LICENSE file to clarify code usage rights.',
|
|
2023
|
+
autoFixable: false,
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
return issues;
|
|
2028
|
+
},
|
|
2029
|
+
|
|
2030
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2031
|
+
// INFRASTRUCTURE CHECKER
|
|
2032
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2033
|
+
async infrastructure(projectPath) {
|
|
2034
|
+
const issues = [];
|
|
2035
|
+
|
|
2036
|
+
// Check for Dockerfile
|
|
2037
|
+
const hasDocker = await pathExists(path.join(projectPath, 'Dockerfile')) ||
|
|
2038
|
+
await pathExists(path.join(projectPath, 'docker-compose.yml'));
|
|
2039
|
+
if (!hasDocker) {
|
|
2040
|
+
issues.push({
|
|
2041
|
+
id: 'missing-docker',
|
|
2042
|
+
category: 'Infrastructure',
|
|
2043
|
+
severity: 'low',
|
|
2044
|
+
title: 'Missing Docker Configuration',
|
|
2045
|
+
description: 'No Docker setup found. Deployment may be inconsistent.',
|
|
2046
|
+
suggestion: 'Add Dockerfile for consistent deployment environments.',
|
|
2047
|
+
autoFixable: true,
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// Check for CI/CD
|
|
2052
|
+
const hasGitHubActions = await pathExists(path.join(projectPath, '.github', 'workflows'));
|
|
2053
|
+
const hasGitLab = await pathExists(path.join(projectPath, '.gitlab-ci.yml'));
|
|
2054
|
+
const hasCircleCI = await pathExists(path.join(projectPath, '.circleci'));
|
|
2055
|
+
|
|
2056
|
+
if (!hasGitHubActions && !hasGitLab && !hasCircleCI) {
|
|
2057
|
+
issues.push({
|
|
2058
|
+
id: 'missing-ci',
|
|
2059
|
+
category: 'Infrastructure',
|
|
2060
|
+
severity: 'high',
|
|
2061
|
+
title: 'Missing CI/CD Configuration',
|
|
2062
|
+
description: 'No CI/CD pipeline found. Code changes are not automatically tested.',
|
|
2063
|
+
suggestion: 'Add GitHub Actions, GitLab CI, or CircleCI for automated testing.',
|
|
2064
|
+
autoFixable: true,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// Check for deployment config
|
|
2069
|
+
const hasVercel = await pathExists(path.join(projectPath, 'vercel.json'));
|
|
2070
|
+
const hasNetlify = await pathExists(path.join(projectPath, 'netlify.toml'));
|
|
2071
|
+
const hasRailway = await pathExists(path.join(projectPath, 'railway.json'));
|
|
2072
|
+
|
|
2073
|
+
if (!hasVercel && !hasNetlify && !hasRailway && !hasDocker) {
|
|
2074
|
+
issues.push({
|
|
2075
|
+
id: 'missing-deployment-config',
|
|
2076
|
+
category: 'Infrastructure',
|
|
2077
|
+
severity: 'medium',
|
|
2078
|
+
title: 'No Deployment Configuration',
|
|
2079
|
+
description: 'No deployment platform configuration found.',
|
|
2080
|
+
suggestion: 'Add vercel.json, netlify.toml, or similar for deployment settings.',
|
|
2081
|
+
autoFixable: false,
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// Check for environment validation
|
|
2086
|
+
const packageJson = await readFileSafe(path.join(projectPath, 'package.json'));
|
|
2087
|
+
const hasEnvValidation = packageJson && /@t3-oss\/env|envalid|dotenv-safe/i.test(packageJson);
|
|
2088
|
+
if (!hasEnvValidation) {
|
|
2089
|
+
issues.push({
|
|
2090
|
+
id: 'missing-env-validation',
|
|
2091
|
+
category: 'Infrastructure',
|
|
2092
|
+
severity: 'medium',
|
|
2093
|
+
title: 'No Environment Validation',
|
|
2094
|
+
description: 'Environment variables are not validated at startup.',
|
|
2095
|
+
suggestion: 'Add @t3-oss/env-nextjs or envalid to validate env vars at startup.',
|
|
2096
|
+
autoFixable: false,
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
return issues;
|
|
2101
|
+
},
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2105
|
+
// POLISH SERVICE
|
|
2106
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2107
|
+
|
|
2108
|
+
async function analyzeProject(projectPath, options = {}) {
|
|
2109
|
+
const allIssues = [];
|
|
2110
|
+
const categories = Object.keys(checkers);
|
|
2111
|
+
const selectedCategories = options.category
|
|
2112
|
+
? [options.category.toLowerCase()]
|
|
2113
|
+
: categories;
|
|
2114
|
+
|
|
2115
|
+
for (const category of selectedCategories) {
|
|
2116
|
+
if (checkers[category]) {
|
|
2117
|
+
try {
|
|
2118
|
+
const issues = await checkers[category](projectPath);
|
|
2119
|
+
allIssues.push(...issues);
|
|
2120
|
+
} catch (error) {
|
|
2121
|
+
console.error(`${c.red}Error in ${category} checker:${c.reset}`, error.message);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// Calculate score
|
|
2127
|
+
let score = 100;
|
|
2128
|
+
for (const issue of allIssues) {
|
|
2129
|
+
switch (issue.severity) {
|
|
2130
|
+
case 'critical': score -= 10; break;
|
|
2131
|
+
case 'high': score -= 5; break;
|
|
2132
|
+
case 'medium': score -= 2; break;
|
|
2133
|
+
case 'low': score -= 1; break;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
score = Math.max(0, Math.min(100, score));
|
|
2137
|
+
|
|
2138
|
+
// Generate recommendations
|
|
2139
|
+
const recommendations = [];
|
|
2140
|
+
const criticalCount = allIssues.filter(i => i.severity === 'critical').length;
|
|
2141
|
+
const highCount = allIssues.filter(i => i.severity === 'high').length;
|
|
2142
|
+
const autoFixable = allIssues.filter(i => i.autoFixable).length;
|
|
2143
|
+
|
|
2144
|
+
if (criticalCount > 0) {
|
|
2145
|
+
recommendations.push(`Fix ${criticalCount} critical issue(s) immediately - these affect security or functionality.`);
|
|
2146
|
+
}
|
|
2147
|
+
if (highCount > 0) {
|
|
2148
|
+
recommendations.push(`Address ${highCount} high-priority issue(s) - these impact user experience or production readiness.`);
|
|
2149
|
+
}
|
|
2150
|
+
if (autoFixable > 0) {
|
|
2151
|
+
recommendations.push(`${autoFixable} issue(s) can be auto-fixed. Run 'vibecheck polish --fix' to apply fixes.`);
|
|
2152
|
+
}
|
|
2153
|
+
if (recommendations.length === 0) {
|
|
2154
|
+
recommendations.push('Your project looks polished! Great job! 🎉');
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
return {
|
|
2158
|
+
projectPath,
|
|
2159
|
+
totalIssues: allIssues.length,
|
|
2160
|
+
critical: criticalCount,
|
|
2161
|
+
high: highCount,
|
|
2162
|
+
medium: allIssues.filter(i => i.severity === 'medium').length,
|
|
2163
|
+
low: allIssues.filter(i => i.severity === 'low').length,
|
|
2164
|
+
issues: allIssues,
|
|
2165
|
+
score,
|
|
2166
|
+
recommendations,
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2171
|
+
// CLI INTERFACE
|
|
2172
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2173
|
+
|
|
2174
|
+
function parseArgs(args) {
|
|
2175
|
+
const opts = {
|
|
2176
|
+
help: false,
|
|
2177
|
+
fix: false,
|
|
2178
|
+
json: false,
|
|
2179
|
+
prompts: false,
|
|
2180
|
+
verbose: false,
|
|
2181
|
+
category: null,
|
|
2182
|
+
path: process.cwd(),
|
|
2183
|
+
};
|
|
2184
|
+
|
|
2185
|
+
for (let i = 0; i < args.length; i++) {
|
|
2186
|
+
const arg = args[i];
|
|
2187
|
+
if (arg === '--help' || arg === '-h') opts.help = true;
|
|
2188
|
+
else if (arg === '--fix' || arg === '-f') opts.fix = true;
|
|
2189
|
+
else if (arg === '--json') opts.json = true;
|
|
2190
|
+
else if (arg === '--prompts' || arg === '--ai') opts.prompts = true;
|
|
2191
|
+
else if (arg === '--verbose' || arg === '-v') opts.verbose = true;
|
|
2192
|
+
else if (arg === '--category' || arg === '-c') opts.category = args[++i];
|
|
2193
|
+
else if (arg === '--path' || arg === '-p') opts.path = args[++i];
|
|
2194
|
+
else if (!arg.startsWith('-')) opts.path = arg;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
return opts;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function printHelp() {
|
|
2201
|
+
console.log(`
|
|
2202
|
+
${c.bold}${icons.sparkle} vibecheck polish${c.reset} - Production Polish Analyzer
|
|
2203
|
+
|
|
2204
|
+
${c.dim}Finds all the small detailed things you forgot - the polish that makes
|
|
2205
|
+
projects production-ready.${c.reset}
|
|
2206
|
+
|
|
2207
|
+
${c.bold}USAGE${c.reset}
|
|
2208
|
+
vibecheck polish [options] [path]
|
|
2209
|
+
|
|
2210
|
+
${c.bold}OPTIONS${c.reset}
|
|
2211
|
+
-h, --help Show this help message
|
|
2212
|
+
-f, --fix Auto-fix issues where possible
|
|
2213
|
+
--prompts, --ai Show AI prompts for each issue (copy to AI assistant)
|
|
2214
|
+
-v, --verbose Show detailed output including AI prompts inline
|
|
2215
|
+
-c, --category <name> Check only specific category
|
|
2216
|
+
-p, --path <path> Project path (defaults to current directory)
|
|
2217
|
+
--json Output as JSON (includes AI prompts)
|
|
2218
|
+
|
|
2219
|
+
${c.bold}CATEGORIES${c.reset}
|
|
2220
|
+
frontend Error boundaries, skeletons, ARIA, haptics, animations, caching
|
|
2221
|
+
backend Health endpoints, validation, rate limiting, error handling
|
|
2222
|
+
security .env files, security headers, CORS, secrets management
|
|
2223
|
+
performance Image optimization, caching, bundle analysis
|
|
2224
|
+
accessibility A11y testing, skip links, focus management
|
|
2225
|
+
seo Meta tags, sitemap, robots.txt, Open Graph
|
|
2226
|
+
configuration TypeScript, ESLint, Prettier, EditorConfig
|
|
2227
|
+
documentation README, CHANGELOG, CONTRIBUTING, LICENSE
|
|
2228
|
+
infrastructure Docker, CI/CD, deployment config, env validation
|
|
2229
|
+
|
|
2230
|
+
${c.bold}EXAMPLES${c.reset}
|
|
2231
|
+
vibecheck polish # Analyze current project
|
|
2232
|
+
vibecheck polish --prompts # Show AI prompts for all issues
|
|
2233
|
+
vibecheck polish -c frontend --ai # Frontend issues with AI prompts
|
|
2234
|
+
vibecheck polish --fix # Auto-fix issues
|
|
2235
|
+
vibecheck polish --json # Output JSON for CI
|
|
2236
|
+
|
|
2237
|
+
${c.bold}AI PROMPTS${c.reset}
|
|
2238
|
+
Each issue includes an AI-ready prompt you can copy to Cursor, Copilot, or Claude.
|
|
2239
|
+
Use --prompts to see all prompts, or --json for machine-readable output.
|
|
2240
|
+
|
|
2241
|
+
${c.bold}TIER${c.reset}
|
|
2242
|
+
${c.cyan}Free${c.reset} - Available on all tiers
|
|
2243
|
+
`);
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
function formatScore(score) {
|
|
2247
|
+
if (score >= 90) return `${c.green}${c.bold}${score}/100${c.reset} ${c.green}(Excellent)${c.reset}`;
|
|
2248
|
+
if (score >= 70) return `${c.yellow}${c.bold}${score}/100${c.reset} ${c.yellow}(Good)${c.reset}`;
|
|
2249
|
+
if (score >= 50) return `${c.yellow}${c.bold}${score}/100${c.reset} ${c.yellow}(Needs Work)${c.reset}`;
|
|
2250
|
+
return `${c.red}${c.bold}${score}/100${c.reset} ${c.red}(Critical Issues)${c.reset}`;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
function formatSeverity(severity) {
|
|
2254
|
+
const styles = {
|
|
2255
|
+
critical: `${icons.critical} ${c.red}${c.bold}CRITICAL${c.reset}`,
|
|
2256
|
+
high: `${icons.high} ${c.yellow}${c.bold}HIGH${c.reset}`,
|
|
2257
|
+
medium: `${icons.medium} ${c.yellow}MEDIUM${c.reset}`,
|
|
2258
|
+
low: `${icons.low} ${c.blue}LOW${c.reset}`,
|
|
2259
|
+
};
|
|
2260
|
+
return styles[severity] || severity;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function getCategoryIcon(category) {
|
|
2264
|
+
const categoryIcons = {
|
|
2265
|
+
Frontend: icons.sparkle,
|
|
2266
|
+
Backend: icons.server,
|
|
2267
|
+
Security: icons.lock,
|
|
2268
|
+
Performance: icons.lightning,
|
|
2269
|
+
Accessibility: icons.eye,
|
|
2270
|
+
SEO: icons.search,
|
|
2271
|
+
Configuration: icons.gear,
|
|
2272
|
+
Documentation: icons.book,
|
|
2273
|
+
Infrastructure: icons.rocket,
|
|
2274
|
+
};
|
|
2275
|
+
return categoryIcons[category] || icons.star;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
async function runPolish(args) {
|
|
2279
|
+
const opts = parseArgs(args);
|
|
2280
|
+
|
|
2281
|
+
if (opts.help) {
|
|
2282
|
+
printHelp();
|
|
2283
|
+
return 0;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const projectPath = path.resolve(opts.path);
|
|
2287
|
+
|
|
2288
|
+
// Verify project exists
|
|
2289
|
+
if (!await pathExists(projectPath)) {
|
|
2290
|
+
console.error(`${c.red}${icons.cross} Project path does not exist: ${projectPath}${c.reset}`);
|
|
2291
|
+
return 1;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
// JSON mode
|
|
2295
|
+
if (opts.json) {
|
|
2296
|
+
const report = await analyzeProject(projectPath, opts);
|
|
2297
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2298
|
+
return report.critical > 0 ? 1 : 0;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// Interactive mode
|
|
2302
|
+
console.log(`
|
|
2303
|
+
${c.bold}╔══════════════════════════════════════════════════════════════════════════════╗
|
|
2304
|
+
║ ║
|
|
2305
|
+
║ ${icons.sparkle} ${c.cyan}VIBECHECK POLISH${c.reset}${c.bold} - Production Readiness Analyzer ║
|
|
2306
|
+
║ ║
|
|
2307
|
+
║ ${c.dim}Finds all the small detailed things you forgot - the polish that${c.reset}${c.bold} ║
|
|
2308
|
+
║ ${c.dim}makes projects production-ready.${c.reset}${c.bold} ║
|
|
2309
|
+
║ ║
|
|
2310
|
+
╚══════════════════════════════════════════════════════════════════════════════╝${c.reset}
|
|
2311
|
+
`);
|
|
2312
|
+
|
|
2313
|
+
console.log(`${c.dim}Project:${c.reset} ${projectPath}\n`);
|
|
2314
|
+
|
|
2315
|
+
// Show spinner
|
|
2316
|
+
const isTTY = process.stdout.isTTY;
|
|
2317
|
+
if (isTTY) {
|
|
2318
|
+
process.stdout.write(`${icons.search} Scanning project for polish issues...`);
|
|
2319
|
+
} else {
|
|
2320
|
+
console.log(`${icons.search} Scanning project for polish issues...`);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const report = await analyzeProject(projectPath, opts);
|
|
2324
|
+
|
|
2325
|
+
// Clear spinner line
|
|
2326
|
+
if (isTTY && process.stdout.clearLine) {
|
|
2327
|
+
process.stdout.clearLine(0);
|
|
2328
|
+
process.stdout.cursorTo(0);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Summary
|
|
2332
|
+
console.log(`${c.bold}${icons.star} POLISH REPORT SUMMARY${c.reset}\n`);
|
|
2333
|
+
console.log(` ${c.bold}Score:${c.reset} ${formatScore(report.score)}`);
|
|
2334
|
+
console.log(` ${c.bold}Issues:${c.reset} ${report.totalIssues} total`);
|
|
2335
|
+
console.log(` ${icons.critical} ${report.critical} critical ${icons.high} ${report.high} high ${icons.medium} ${report.medium} medium ${icons.low} ${report.low} low\n`);
|
|
2336
|
+
|
|
2337
|
+
if (report.issues.length === 0) {
|
|
2338
|
+
console.log(`\n${c.green}${c.bold}${icons.check} Perfect!${c.reset} No polish issues found. Your project is production-ready! ${icons.rocket}\n`);
|
|
2339
|
+
return 0;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// Group by category
|
|
2343
|
+
const byCategory = {};
|
|
2344
|
+
for (const issue of report.issues) {
|
|
2345
|
+
if (!byCategory[issue.category]) {
|
|
2346
|
+
byCategory[issue.category] = [];
|
|
2347
|
+
}
|
|
2348
|
+
byCategory[issue.category].push(issue);
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// Show issues by category
|
|
2352
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}`);
|
|
2353
|
+
console.log(`${c.bold}${icons.wrench} ISSUES BY CATEGORY${c.reset}`);
|
|
2354
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}\n`);
|
|
2355
|
+
|
|
2356
|
+
const issuesWithPrompts = [];
|
|
2357
|
+
|
|
2358
|
+
for (const [category, issues] of Object.entries(byCategory)) {
|
|
2359
|
+
console.log(`${c.bold}${getCategoryIcon(category)} ${category}${c.reset} ${c.dim}(${issues.length} issues)${c.reset}`);
|
|
2360
|
+
console.log(`${'─'.repeat(60)}`);
|
|
2361
|
+
|
|
2362
|
+
for (const issue of issues) {
|
|
2363
|
+
console.log(`\n ${formatSeverity(issue.severity)}`);
|
|
2364
|
+
console.log(` ${c.bold}${issue.title}${c.reset}`);
|
|
2365
|
+
console.log(` ${c.dim}${issue.description}${c.reset}`);
|
|
2366
|
+
if (issue.file) {
|
|
2367
|
+
console.log(` ${c.dim}File: ${path.relative(projectPath, issue.file)}${c.reset}`);
|
|
2368
|
+
}
|
|
2369
|
+
console.log(` ${c.cyan}${icons.arrow} ${issue.suggestion}${c.reset}`);
|
|
2370
|
+
if (issue.autoFixable) {
|
|
2371
|
+
console.log(` ${c.green}${icons.check} Auto-fixable${c.reset}`);
|
|
2372
|
+
}
|
|
2373
|
+
if (issue.aiPrompt) {
|
|
2374
|
+
issuesWithPrompts.push(issue);
|
|
2375
|
+
if (opts.verbose) {
|
|
2376
|
+
console.log(`\n ${c.magenta}${c.bold}🤖 AI PROMPT:${c.reset}`);
|
|
2377
|
+
console.log(` ${c.dim}${'─'.repeat(50)}${c.reset}`);
|
|
2378
|
+
const promptLines = issue.aiPrompt.split('\n');
|
|
2379
|
+
for (const line of promptLines) {
|
|
2380
|
+
console.log(` ${c.dim}${line}${c.reset}`);
|
|
2381
|
+
}
|
|
2382
|
+
console.log(` ${c.dim}${'─'.repeat(50)}${c.reset}`);
|
|
2383
|
+
} else {
|
|
2384
|
+
console.log(` ${c.magenta}🤖 Has AI prompt${c.reset} ${c.dim}(use --prompts to see)${c.reset}`);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
console.log();
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// Recommendations
|
|
2392
|
+
if (report.recommendations.length > 0) {
|
|
2393
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}`);
|
|
2394
|
+
console.log(`${c.bold}💡 RECOMMENDATIONS${c.reset}`);
|
|
2395
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}\n`);
|
|
2396
|
+
report.recommendations.forEach((rec, i) => {
|
|
2397
|
+
console.log(` ${i + 1}. ${rec}`);
|
|
2398
|
+
});
|
|
2399
|
+
console.log();
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// Auto-fix prompt
|
|
2403
|
+
const autoFixable = report.issues.filter(i => i.autoFixable);
|
|
2404
|
+
if (autoFixable.length > 0 && opts.fix) {
|
|
2405
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}`);
|
|
2406
|
+
console.log(`${c.bold}${icons.wrench} AUTO-FIX${c.reset}`);
|
|
2407
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}\n`);
|
|
2408
|
+
console.log(` ${c.yellow}Auto-fix for ${autoFixable.length} issue(s) is coming soon!${c.reset}`);
|
|
2409
|
+
console.log(` ${c.dim}For now, please follow the suggestions above to fix issues manually.${c.reset}\n`);
|
|
2410
|
+
} else if (autoFixable.length > 0) {
|
|
2411
|
+
console.log(`${c.dim}Tip: Run 'vibecheck polish --fix' to auto-fix ${autoFixable.length} issue(s)${c.reset}\n`);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// AI Prompts section (when --prompts flag is used)
|
|
2415
|
+
if (opts.prompts && issuesWithPrompts.length > 0) {
|
|
2416
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}`);
|
|
2417
|
+
console.log(`${c.bold}🤖 AI PROMPTS - Copy to your AI assistant${c.reset}`);
|
|
2418
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}\n`);
|
|
2419
|
+
console.log(`${c.dim}Copy these prompts to Cursor, Copilot, Claude, or your preferred AI assistant.${c.reset}\n`);
|
|
2420
|
+
|
|
2421
|
+
for (let i = 0; i < issuesWithPrompts.length; i++) {
|
|
2422
|
+
const issue = issuesWithPrompts[i];
|
|
2423
|
+
console.log(`${c.bold}╭───────────────────────────────────────────────────────────────────────────────╮${c.reset}`);
|
|
2424
|
+
console.log(`${c.bold}│${c.reset} ${c.cyan}[${i + 1}/${issuesWithPrompts.length}]${c.reset} ${c.bold}${issue.title}${c.reset}`);
|
|
2425
|
+
console.log(`${c.bold}│${c.reset} ${c.dim}Severity: ${issue.severity} | Category: ${issue.category}${c.reset}`);
|
|
2426
|
+
console.log(`${c.bold}╰───────────────────────────────────────────────────────────────────────────────╯${c.reset}`);
|
|
2427
|
+
console.log();
|
|
2428
|
+
console.log(`${c.magenta}${issue.aiPrompt}${c.reset}`);
|
|
2429
|
+
console.log();
|
|
2430
|
+
console.log(`${c.dim}${'─'.repeat(79)}${c.reset}\n`);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
console.log(`${c.green}${c.bold}💡 Tip:${c.reset} Copy one prompt at a time and paste into your AI coding assistant.`);
|
|
2434
|
+
console.log(`${c.dim}The prompts are designed to give AI complete context to generate the right code.${c.reset}\n`);
|
|
2435
|
+
} else if (issuesWithPrompts.length > 0 && !opts.verbose) {
|
|
2436
|
+
console.log(`${c.dim}${icons.star} ${issuesWithPrompts.length} issue(s) have AI prompts. Run with ${c.cyan}--prompts${c.reset}${c.dim} to see them.${c.reset}\n`);
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// Next steps
|
|
2440
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}`);
|
|
2441
|
+
console.log(`${c.bold}${icons.rocket} NEXT STEPS${c.reset}`);
|
|
2442
|
+
console.log(`${c.bold}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${c.reset}\n`);
|
|
2443
|
+
console.log(` 1. Fix ${c.red}critical${c.reset} and ${c.yellow}high${c.reset} severity issues first`);
|
|
2444
|
+
console.log(` 2. Use ${c.cyan}vibecheck polish --prompts${c.reset} to get AI-ready fixes`);
|
|
2445
|
+
console.log(` 3. Copy prompts to your AI assistant (Cursor, Copilot, Claude)`);
|
|
2446
|
+
console.log(` 4. Re-run ${c.cyan}vibecheck polish${c.reset} to verify fixes`);
|
|
2447
|
+
console.log(` 5. Run ${c.cyan}vibecheck ship${c.reset} when score is 80+\n`);
|
|
2448
|
+
|
|
2449
|
+
return report.critical > 0 ? 1 : 0;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
module.exports = { runPolish };
|