prodlint 0.1.0 → 0.2.1

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/dist/mcp.js ADDED
@@ -0,0 +1,1109 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { stat as stat2 } from "fs/promises";
8
+ import { resolve as resolve4 } from "path";
9
+
10
+ // src/utils/file-walker.ts
11
+ import fg from "fast-glob";
12
+ import { readFile, stat, realpath } from "fs/promises";
13
+ import { resolve, extname, sep } from "path";
14
+
15
+ // src/utils/patterns.ts
16
+ function isApiRoute(relativePath) {
17
+ if (/app\/.*route\.(ts|js|tsx|jsx)$/.test(relativePath)) return true;
18
+ if (/pages\/api\//.test(relativePath)) return true;
19
+ return false;
20
+ }
21
+ function isClientComponent(content) {
22
+ const firstLines = content.slice(0, 500);
23
+ return /^(['"])use client\1/m.test(firstLines);
24
+ }
25
+ function buildCommentMap(lines) {
26
+ const map = new Array(lines.length).fill(false);
27
+ let inBlock = false;
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const line = lines[i];
30
+ if (inBlock) {
31
+ map[i] = true;
32
+ if (line.includes("*/")) {
33
+ inBlock = false;
34
+ }
35
+ continue;
36
+ }
37
+ const trimmed = line.trim();
38
+ if (trimmed.startsWith("/*")) {
39
+ map[i] = true;
40
+ if (!trimmed.includes("*/")) {
41
+ inBlock = true;
42
+ }
43
+ continue;
44
+ }
45
+ if (trimmed.startsWith("*")) {
46
+ map[i] = true;
47
+ }
48
+ }
49
+ return map;
50
+ }
51
+ function isCommentLine(lines, lineIndex, commentMap) {
52
+ if (commentMap[lineIndex]) return true;
53
+ const trimmed = lines[lineIndex]?.trim() ?? "";
54
+ return trimmed.startsWith("//");
55
+ }
56
+ function isLineSuppressed(lines, lineIndex, ruleId) {
57
+ for (const line of lines) {
58
+ const trimmed = line.trim();
59
+ if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("/*")) {
60
+ const match = trimmed.match(/prodlint-disable\s+(.+)/);
61
+ if (match) {
62
+ const ids = match[1].split(/[\s,]+/).filter(Boolean);
63
+ if (ids.includes(ruleId)) return true;
64
+ }
65
+ continue;
66
+ }
67
+ break;
68
+ }
69
+ if (lineIndex > 0) {
70
+ const prevLine = lines[lineIndex - 1].trim();
71
+ const match = prevLine.match(/prodlint-disable-next-line\s+(.+)/);
72
+ if (match) {
73
+ const ids = match[1].split(/[\s,]+/).filter(Boolean);
74
+ if (ids.includes(ruleId)) return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ var NODE_BUILTINS = /* @__PURE__ */ new Set([
80
+ "assert",
81
+ "async_hooks",
82
+ "buffer",
83
+ "child_process",
84
+ "cluster",
85
+ "console",
86
+ "constants",
87
+ "crypto",
88
+ "dgram",
89
+ "diagnostics_channel",
90
+ "dns",
91
+ "domain",
92
+ "events",
93
+ "fs",
94
+ "http",
95
+ "http2",
96
+ "https",
97
+ "inspector",
98
+ "module",
99
+ "net",
100
+ "os",
101
+ "path",
102
+ "perf_hooks",
103
+ "process",
104
+ "punycode",
105
+ "querystring",
106
+ "readline",
107
+ "repl",
108
+ "stream",
109
+ "string_decoder",
110
+ "sys",
111
+ "timers",
112
+ "tls",
113
+ "trace_events",
114
+ "tty",
115
+ "url",
116
+ "util",
117
+ "v8",
118
+ "vm",
119
+ "wasi",
120
+ "worker_threads",
121
+ "zlib"
122
+ // node: prefixed are handled separately
123
+ ]);
124
+
125
+ // src/utils/file-walker.ts
126
+ var DEFAULT_IGNORES = [
127
+ "**/node_modules/**",
128
+ "**/dist/**",
129
+ "**/build/**",
130
+ "**/.next/**",
131
+ "**/.git/**",
132
+ "**/coverage/**",
133
+ "**/*.min.js",
134
+ "**/*.min.css",
135
+ "**/package-lock.json",
136
+ "**/yarn.lock",
137
+ "**/pnpm-lock.yaml",
138
+ "**/bun.lockb",
139
+ "**/*.map",
140
+ "**/*.d.ts"
141
+ ];
142
+ var SCAN_EXTENSIONS = [
143
+ "ts",
144
+ "tsx",
145
+ "js",
146
+ "jsx",
147
+ "mjs",
148
+ "cjs",
149
+ "json"
150
+ ];
151
+ var MAX_FILE_SIZE = 1024 * 1024;
152
+ async function walkFiles(root, extraIgnores = []) {
153
+ const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
154
+ patterns.push("**/.env", "**/.env.*");
155
+ patterns.push("**/.gitignore");
156
+ const files = await fg(patterns, {
157
+ cwd: root,
158
+ ignore: [...DEFAULT_IGNORES, ...extraIgnores],
159
+ absolute: false,
160
+ dot: true,
161
+ followSymbolicLinks: false
162
+ });
163
+ return files.sort();
164
+ }
165
+ async function readFileContext(root, relativePath) {
166
+ try {
167
+ const absolutePath = resolve(root, relativePath);
168
+ const realRoot = await realpath(root);
169
+ const realFile = await realpath(absolutePath);
170
+ if (!realFile.startsWith(realRoot + sep) && realFile !== realRoot) return null;
171
+ const fileStats = await stat(absolutePath);
172
+ if (fileStats.size > MAX_FILE_SIZE) return null;
173
+ const content = await readFile(absolutePath, "utf-8");
174
+ const lines = content.split(/\r?\n|\r/);
175
+ return {
176
+ absolutePath,
177
+ relativePath,
178
+ content,
179
+ lines,
180
+ ext: extname(relativePath).slice(1),
181
+ // remove leading dot
182
+ commentMap: buildCommentMap(lines)
183
+ };
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+ async function buildProjectContext(root, files) {
189
+ let packageJson = null;
190
+ let declaredDependencies = /* @__PURE__ */ new Set();
191
+ let tsconfigPaths = /* @__PURE__ */ new Set();
192
+ let hasAuthMiddleware = false;
193
+ let gitignoreContent = null;
194
+ let envInGitignore = false;
195
+ try {
196
+ const raw = await readFile(resolve(root, "package.json"), "utf-8");
197
+ packageJson = JSON.parse(raw);
198
+ const deps = {
199
+ ...packageJson?.dependencies ?? {},
200
+ ...packageJson?.devDependencies ?? {},
201
+ ...packageJson?.peerDependencies ?? {}
202
+ };
203
+ declaredDependencies = new Set(Object.keys(deps));
204
+ } catch {
205
+ }
206
+ try {
207
+ const raw = await readFile(resolve(root, "tsconfig.json"), "utf-8");
208
+ const stripped = raw.replace(/\/\/.*$/gm, "");
209
+ const tsconfig = JSON.parse(stripped);
210
+ const paths = tsconfig?.compilerOptions?.paths;
211
+ if (paths) {
212
+ for (const alias of Object.keys(paths)) {
213
+ const prefix = alias.replace(/\/?\*$/, "");
214
+ if (prefix) tsconfigPaths.add(prefix);
215
+ }
216
+ }
217
+ } catch {
218
+ }
219
+ try {
220
+ for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
221
+ try {
222
+ const content = await readFile(resolve(root, name), "utf-8");
223
+ const authPatterns = [
224
+ /getSession/i,
225
+ /getUser/i,
226
+ /auth\(\)/,
227
+ /withAuth/i,
228
+ /clerkMiddleware/i,
229
+ /authMiddleware/i,
230
+ /NextAuth/i,
231
+ /supabase.*auth/i,
232
+ /createMiddlewareClient/i,
233
+ /getToken/i,
234
+ /verifyToken/i,
235
+ /jwt/i,
236
+ /updateSession/i
237
+ ];
238
+ if (authPatterns.some((p) => p.test(content))) {
239
+ hasAuthMiddleware = true;
240
+ break;
241
+ }
242
+ } catch {
243
+ }
244
+ }
245
+ } catch {
246
+ }
247
+ try {
248
+ gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
249
+ envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
250
+ } catch {
251
+ }
252
+ return {
253
+ root,
254
+ packageJson,
255
+ declaredDependencies,
256
+ tsconfigPaths,
257
+ hasAuthMiddleware,
258
+ gitignoreContent,
259
+ envInGitignore,
260
+ allFiles: files
261
+ };
262
+ }
263
+
264
+ // src/utils/version.ts
265
+ import { readFileSync } from "fs";
266
+ import { fileURLToPath } from "url";
267
+ import { dirname, resolve as resolve2 } from "path";
268
+ function getVersion() {
269
+ try {
270
+ const dir = dirname(fileURLToPath(import.meta.url));
271
+ const pkg = JSON.parse(
272
+ readFileSync(resolve2(dir, "..", "package.json"), "utf-8")
273
+ );
274
+ return pkg.version ?? "0.0.0";
275
+ } catch {
276
+ return "0.0.0";
277
+ }
278
+ }
279
+
280
+ // src/scorer.ts
281
+ var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
282
+ var DEDUCTIONS = {
283
+ critical: 10,
284
+ warning: 3,
285
+ info: 1
286
+ };
287
+ function calculateScores(findings) {
288
+ const categoryScores = CATEGORIES.map((category) => {
289
+ const categoryFindings = findings.filter((f) => f.category === category);
290
+ let score = 100;
291
+ for (const f of categoryFindings) {
292
+ score -= DEDUCTIONS[f.severity] ?? 0;
293
+ }
294
+ return {
295
+ category,
296
+ score: Math.max(0, score),
297
+ findingCount: categoryFindings.length
298
+ };
299
+ });
300
+ const overallScore = Math.round(
301
+ categoryScores.reduce((sum, c) => sum + c.score, 0) / CATEGORIES.length
302
+ );
303
+ return { overallScore, categoryScores };
304
+ }
305
+ function summarizeFindings(findings) {
306
+ return {
307
+ critical: findings.filter((f) => f.severity === "critical").length,
308
+ warning: findings.filter((f) => f.severity === "warning").length,
309
+ info: findings.filter((f) => f.severity === "info").length
310
+ };
311
+ }
312
+
313
+ // src/rules/secrets.ts
314
+ var SECRET_PATTERNS = [
315
+ { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/ },
316
+ { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
317
+ { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
318
+ { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
319
+ { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
320
+ { name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
321
+ { name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
322
+ { name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
323
+ { name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
324
+ { name: "SendGrid API key", pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/ }
325
+ ];
326
+ var secretsRule = {
327
+ id: "secrets",
328
+ name: "Hardcoded Secrets",
329
+ description: "Detects hardcoded API keys, tokens, and credentials in source code",
330
+ category: "security",
331
+ severity: "critical",
332
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs", "json"],
333
+ check(file, _project) {
334
+ const findings = [];
335
+ for (let i = 0; i < file.lines.length; i++) {
336
+ const line = file.lines[i];
337
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
338
+ for (const { name, pattern } of SECRET_PATTERNS) {
339
+ const match = pattern.exec(line);
340
+ if (match) {
341
+ findings.push({
342
+ ruleId: "secrets",
343
+ file: file.relativePath,
344
+ line: i + 1,
345
+ column: match.index + 1,
346
+ message: `Hardcoded ${name} detected`,
347
+ severity: "critical",
348
+ category: "security"
349
+ });
350
+ }
351
+ }
352
+ }
353
+ return findings;
354
+ }
355
+ };
356
+
357
+ // src/rules/hallucinated-imports.ts
358
+ var IMPLICIT_PACKAGES = /* @__PURE__ */ new Set([
359
+ "react",
360
+ "react-dom",
361
+ "react/jsx-runtime",
362
+ "react/jsx-dev-runtime",
363
+ "next",
364
+ "next/server",
365
+ "next/image",
366
+ "next/link",
367
+ "next/font",
368
+ "next/navigation",
369
+ "next/headers",
370
+ "next/dynamic",
371
+ "next/script",
372
+ "next/router",
373
+ "next/head",
374
+ "next/app",
375
+ "next/document"
376
+ ]);
377
+ var LINE_IMPORT_PATTERNS = [
378
+ /from\s+['"]([^'"./][^'"]*)['"]/,
379
+ /require\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/,
380
+ /import\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/
381
+ ];
382
+ function getPackageName(importPath) {
383
+ if (importPath.startsWith("@")) {
384
+ const parts = importPath.split("/");
385
+ return parts.slice(0, 2).join("/");
386
+ }
387
+ return importPath.split("/")[0];
388
+ }
389
+ function isNodeBuiltin(name) {
390
+ if (name.startsWith("node:")) return true;
391
+ return NODE_BUILTINS.has(name);
392
+ }
393
+ function isPathAlias(importPath, tsconfigPaths) {
394
+ if (importPath.startsWith("@/") || importPath === "@") return true;
395
+ if (importPath.startsWith("~/") || importPath.startsWith("#/")) return true;
396
+ for (const prefix of tsconfigPaths) {
397
+ if (importPath === prefix || importPath.startsWith(prefix + "/")) return true;
398
+ }
399
+ return false;
400
+ }
401
+ var hallucinatedImportsRule = {
402
+ id: "hallucinated-imports",
403
+ name: "Hallucinated Imports",
404
+ description: "Detects imports of packages not declared in package.json and not Node.js built-ins",
405
+ category: "reliability",
406
+ severity: "critical",
407
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
408
+ check(file, project) {
409
+ if (!project.packageJson) return [];
410
+ const findings = [];
411
+ const seen = /* @__PURE__ */ new Set();
412
+ for (let i = 0; i < file.lines.length; i++) {
413
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
414
+ const line = file.lines[i];
415
+ for (const pattern of LINE_IMPORT_PATTERNS) {
416
+ const match = pattern.exec(line);
417
+ if (!match) continue;
418
+ const importPath = match[1];
419
+ const pkgName = getPackageName(importPath);
420
+ if (seen.has(pkgName)) continue;
421
+ seen.add(pkgName);
422
+ if (isPathAlias(importPath, project.tsconfigPaths)) continue;
423
+ if (isNodeBuiltin(pkgName)) continue;
424
+ if (IMPLICIT_PACKAGES.has(importPath) || IMPLICIT_PACKAGES.has(pkgName)) continue;
425
+ if (project.declaredDependencies.has(pkgName)) continue;
426
+ findings.push({
427
+ ruleId: "hallucinated-imports",
428
+ file: file.relativePath,
429
+ line: i + 1,
430
+ column: match.index + 1,
431
+ message: `Package "${pkgName}" is imported but not in package.json`,
432
+ severity: "critical",
433
+ category: "reliability"
434
+ });
435
+ }
436
+ }
437
+ return findings;
438
+ }
439
+ };
440
+
441
+ // src/rules/auth-checks.ts
442
+ var AUTH_EXEMPT_PATTERNS = [
443
+ /auth/i,
444
+ /login/i,
445
+ /signup/i,
446
+ /register/i,
447
+ /callback/i,
448
+ /webhook/i,
449
+ /health/i,
450
+ /ping/i,
451
+ /cron/i,
452
+ /inngest/i,
453
+ /stripe/i,
454
+ /public/i
455
+ ];
456
+ var AUTH_PATTERNS = [
457
+ /getServerSession\s*\(/,
458
+ /getSession\s*\(/,
459
+ /\.auth\.getUser\s*\(/,
460
+ /auth\(\)/,
461
+ /authenticate\s*\(/,
462
+ /isAuthenticated/,
463
+ /requireAuth/,
464
+ /withAuth/,
465
+ /NextAuth/,
466
+ /getToken\s*\(/,
467
+ /verifyToken\s*\(/,
468
+ /jwt\.verify\s*\(/,
469
+ /createRouteHandlerClient/,
470
+ /createServerComponentClient/,
471
+ /authorization/i,
472
+ /bearer/i
473
+ ];
474
+ var authChecksRule = {
475
+ id: "auth-checks",
476
+ name: "Missing Auth Checks",
477
+ description: "Detects API routes that lack authentication checks",
478
+ category: "security",
479
+ severity: "critical",
480
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
481
+ check(file, project) {
482
+ if (!isApiRoute(file.relativePath)) return [];
483
+ for (const pattern of AUTH_EXEMPT_PATTERNS) {
484
+ if (pattern.test(file.relativePath)) return [];
485
+ }
486
+ const severity = project.hasAuthMiddleware ? "info" : "critical";
487
+ for (const pattern of AUTH_PATTERNS) {
488
+ if (pattern.test(file.content)) return [];
489
+ }
490
+ let handlerLine = 1;
491
+ for (let i = 0; i < file.lines.length; i++) {
492
+ if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
493
+ handlerLine = i + 1;
494
+ break;
495
+ }
496
+ }
497
+ const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
498
+ return [{
499
+ ruleId: "auth-checks",
500
+ file: file.relativePath,
501
+ line: handlerLine,
502
+ column: 1,
503
+ message,
504
+ severity,
505
+ category: "security"
506
+ }];
507
+ }
508
+ };
509
+
510
+ // src/rules/env-exposure.ts
511
+ var SERVER_ENV_PATTERN = /process\.env\.(?!NEXT_PUBLIC_)([A-Z][A-Z0-9_]*)/g;
512
+ var SENSITIVE_ENV_NAMES = /* @__PURE__ */ new Set([
513
+ "DATABASE_URL",
514
+ "SUPABASE_SERVICE_ROLE_KEY",
515
+ "STRIPE_SECRET_KEY",
516
+ "STRIPE_WEBHOOK_SECRET",
517
+ "OPENAI_API_KEY",
518
+ "ANTHROPIC_API_KEY",
519
+ "AWS_SECRET_ACCESS_KEY",
520
+ "JWT_SECRET",
521
+ "SESSION_SECRET",
522
+ "REDIS_URL",
523
+ "SMTP_PASSWORD",
524
+ "SENDGRID_API_KEY"
525
+ ]);
526
+ var envExposureRule = {
527
+ id: "env-exposure",
528
+ name: "Environment Variable Exposure",
529
+ description: "Detects server environment variables used in client components and .env files not in .gitignore",
530
+ category: "security",
531
+ severity: "critical",
532
+ fileExtensions: [],
533
+ check(file, project) {
534
+ const findings = [];
535
+ if (file.relativePath === ".gitignore") {
536
+ if (!project.envInGitignore) {
537
+ findings.push({
538
+ ruleId: "env-exposure",
539
+ file: file.relativePath,
540
+ line: 1,
541
+ column: 1,
542
+ message: ".env is not listed in .gitignore \u2014 secrets may be committed",
543
+ severity: "critical",
544
+ category: "security"
545
+ });
546
+ }
547
+ return findings;
548
+ }
549
+ if (!["ts", "tsx", "js", "jsx"].includes(file.ext)) return findings;
550
+ if (!isClientComponent(file.content)) return findings;
551
+ for (let i = 0; i < file.lines.length; i++) {
552
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
553
+ const line = file.lines[i];
554
+ const regex = new RegExp(SERVER_ENV_PATTERN.source, SERVER_ENV_PATTERN.flags);
555
+ let match;
556
+ while ((match = regex.exec(line)) !== null) {
557
+ const envName = match[1];
558
+ const isSensitive = SENSITIVE_ENV_NAMES.has(envName);
559
+ findings.push({
560
+ ruleId: "env-exposure",
561
+ file: file.relativePath,
562
+ line: i + 1,
563
+ column: match.index + 1,
564
+ message: isSensitive ? `Sensitive server env var "${envName}" used in client component` : `Server env var "${envName}" used in client component (will be undefined at runtime)`,
565
+ severity: isSensitive ? "critical" : "warning",
566
+ category: "security"
567
+ });
568
+ }
569
+ }
570
+ return findings;
571
+ }
572
+ };
573
+
574
+ // src/rules/error-handling.ts
575
+ var errorHandlingRule = {
576
+ id: "error-handling",
577
+ name: "Missing Error Handling",
578
+ description: "Detects API routes without try/catch and empty catch blocks",
579
+ category: "reliability",
580
+ severity: "warning",
581
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
582
+ check(file, _project) {
583
+ const findings = [];
584
+ if (isApiRoute(file.relativePath)) {
585
+ const hasTryCatch = /try\s*\{/.test(file.content);
586
+ if (!hasTryCatch) {
587
+ let handlerLine = 1;
588
+ for (let i = 0; i < file.lines.length; i++) {
589
+ if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
590
+ handlerLine = i + 1;
591
+ break;
592
+ }
593
+ }
594
+ findings.push({
595
+ ruleId: "error-handling",
596
+ file: file.relativePath,
597
+ line: handlerLine,
598
+ column: 1,
599
+ message: "API route handler has no try/catch block",
600
+ severity: "warning",
601
+ category: "reliability"
602
+ });
603
+ }
604
+ }
605
+ for (let i = 0; i < file.lines.length; i++) {
606
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
607
+ const line = file.lines[i];
608
+ if (/catch\s*(\([^)]*\))?\s*\{\s*\}/.test(line)) {
609
+ findings.push({
610
+ ruleId: "error-handling",
611
+ file: file.relativePath,
612
+ line: i + 1,
613
+ column: line.indexOf("catch") + 1,
614
+ message: "Empty catch block silently swallows errors",
615
+ severity: "warning",
616
+ category: "reliability"
617
+ });
618
+ continue;
619
+ }
620
+ if (/catch\s*(\([^)]*\))?\s*\{\s*$/.test(line)) {
621
+ const nextLine = file.lines[i + 1]?.trim();
622
+ if (nextLine === "}") {
623
+ findings.push({
624
+ ruleId: "error-handling",
625
+ file: file.relativePath,
626
+ line: i + 1,
627
+ column: line.indexOf("catch") + 1,
628
+ message: "Empty catch block silently swallows errors",
629
+ severity: "warning",
630
+ category: "reliability"
631
+ });
632
+ }
633
+ }
634
+ }
635
+ return findings;
636
+ }
637
+ };
638
+
639
+ // src/rules/input-validation.ts
640
+ var VALIDATION_PATTERNS = [
641
+ /\.parse\s*\(/,
642
+ /\.safeParse\s*\(/,
643
+ /\.validate\s*\(/,
644
+ /\.validateSync\s*\(/,
645
+ /Joi\.object/,
646
+ /z\.object/,
647
+ /z\.string/,
648
+ /z\.number/,
649
+ /z\.array/,
650
+ /yup\.object/,
651
+ /ajv/i,
652
+ /typebox/i,
653
+ /valibot/i,
654
+ /typeof\s+.*body/
655
+ ];
656
+ var BODY_ACCESS_PATTERNS = [
657
+ /req\.body/,
658
+ /request\.json\s*\(\)/,
659
+ /req\.json\s*\(\)/
660
+ ];
661
+ var inputValidationRule = {
662
+ id: "input-validation",
663
+ name: "Missing Input Validation",
664
+ description: "Detects API routes that access request body without validation",
665
+ category: "security",
666
+ severity: "warning",
667
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
668
+ check(file, _project) {
669
+ if (!isApiRoute(file.relativePath)) return [];
670
+ const accessesBody = BODY_ACCESS_PATTERNS.some((p) => p.test(file.content));
671
+ if (!accessesBody) return [];
672
+ const hasValidation = VALIDATION_PATTERNS.some((p) => p.test(file.content));
673
+ if (hasValidation) return [];
674
+ let bodyLine = 1;
675
+ for (let i = 0; i < file.lines.length; i++) {
676
+ if (BODY_ACCESS_PATTERNS.some((p) => p.test(file.lines[i]))) {
677
+ bodyLine = i + 1;
678
+ break;
679
+ }
680
+ }
681
+ return [{
682
+ ruleId: "input-validation",
683
+ file: file.relativePath,
684
+ line: bodyLine,
685
+ column: 1,
686
+ message: "Request body accessed without validation (consider using Zod, Yup, or similar)",
687
+ severity: "warning",
688
+ category: "security"
689
+ }];
690
+ }
691
+ };
692
+
693
+ // src/rules/rate-limiting.ts
694
+ var RATE_LIMIT_PATTERNS = [
695
+ /rateLimit/i,
696
+ /rateLimiter/i,
697
+ /rate-limit/i,
698
+ /upstash.*ratelimit/i,
699
+ /Ratelimit/,
700
+ /@upstash\/ratelimit/,
701
+ /express-rate-limit/,
702
+ /limiter/i,
703
+ /throttle/i,
704
+ /slidingWindow/,
705
+ /fixedWindow/,
706
+ /tokenBucket/
707
+ ];
708
+ var EXEMPT_PATTERNS = [
709
+ /health/i,
710
+ /ping/i,
711
+ /webhook/i,
712
+ /cron/i,
713
+ /inngest/i
714
+ ];
715
+ var rateLimitingRule = {
716
+ id: "rate-limiting",
717
+ name: "Missing Rate Limiting",
718
+ description: "Detects API routes without rate limiting",
719
+ category: "security",
720
+ severity: "warning",
721
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
722
+ check(file, _project) {
723
+ if (!isApiRoute(file.relativePath)) return [];
724
+ for (const pattern of EXEMPT_PATTERNS) {
725
+ if (pattern.test(file.relativePath)) return [];
726
+ }
727
+ for (const pattern of RATE_LIMIT_PATTERNS) {
728
+ if (pattern.test(file.content)) return [];
729
+ }
730
+ let handlerLine = 1;
731
+ for (let i = 0; i < file.lines.length; i++) {
732
+ if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
733
+ handlerLine = i + 1;
734
+ break;
735
+ }
736
+ }
737
+ return [{
738
+ ruleId: "rate-limiting",
739
+ file: file.relativePath,
740
+ line: handlerLine,
741
+ column: 1,
742
+ message: "API route has no rate limiting",
743
+ severity: "warning",
744
+ category: "security"
745
+ }];
746
+ }
747
+ };
748
+
749
+ // src/rules/cors-config.ts
750
+ var corsConfigRule = {
751
+ id: "cors-config",
752
+ name: "Permissive CORS",
753
+ description: "Detects overly permissive CORS configuration",
754
+ category: "security",
755
+ severity: "warning",
756
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
757
+ check(file, _project) {
758
+ const findings = [];
759
+ for (let i = 0; i < file.lines.length; i++) {
760
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
761
+ const line = file.lines[i];
762
+ if (/['"]Access-Control-Allow-Origin['"]\s*[,:]\s*['"]\*['"]/.test(line)) {
763
+ findings.push({
764
+ ruleId: "cors-config",
765
+ file: file.relativePath,
766
+ line: i + 1,
767
+ column: line.indexOf("Access-Control") + 1,
768
+ message: 'Access-Control-Allow-Origin set to "*" allows any domain',
769
+ severity: "warning",
770
+ category: "security"
771
+ });
772
+ }
773
+ if (/cors\(\s*\)/.test(line)) {
774
+ findings.push({
775
+ ruleId: "cors-config",
776
+ file: file.relativePath,
777
+ line: i + 1,
778
+ column: line.indexOf("cors(") + 1,
779
+ message: "cors() called without config allows all origins",
780
+ severity: "warning",
781
+ category: "security"
782
+ });
783
+ }
784
+ if (/origin\s*:\s*['"]\*['"]/.test(line)) {
785
+ findings.push({
786
+ ruleId: "cors-config",
787
+ file: file.relativePath,
788
+ line: i + 1,
789
+ column: line.indexOf("origin") + 1,
790
+ message: 'CORS origin set to "*" allows any domain',
791
+ severity: "warning",
792
+ category: "security"
793
+ });
794
+ }
795
+ if (/origin\s*:\s*true/.test(line)) {
796
+ findings.push({
797
+ ruleId: "cors-config",
798
+ file: file.relativePath,
799
+ line: i + 1,
800
+ column: line.indexOf("origin") + 1,
801
+ message: "CORS origin set to true mirrors any requesting origin",
802
+ severity: "warning",
803
+ category: "security"
804
+ });
805
+ }
806
+ }
807
+ return findings;
808
+ }
809
+ };
810
+
811
+ // src/rules/ai-smells.ts
812
+ var CONSOLE_LOG_THRESHOLD = 5;
813
+ var ANY_TYPE_THRESHOLD = 5;
814
+ var COMMENTED_CODE_THRESHOLD = 3;
815
+ var aiSmellsRule = {
816
+ id: "ai-smells",
817
+ name: "AI Code Smells",
818
+ description: "Detects TODOs, placeholder functions, excessive console.log, any types, and commented-out code",
819
+ category: "ai-quality",
820
+ severity: "info",
821
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
822
+ check(file, _project) {
823
+ const findings = [];
824
+ let consoleLogCount = 0;
825
+ let anyTypeCount = 0;
826
+ let commentedCodeRun = 0;
827
+ for (let i = 0; i < file.lines.length; i++) {
828
+ const line = file.lines[i];
829
+ const trimmed = line.trim();
830
+ if (file.commentMap[i]) {
831
+ commentedCodeRun = 0;
832
+ continue;
833
+ }
834
+ if (trimmed.startsWith("//")) {
835
+ const todoMatch = trimmed.match(/\/\/\s*(TODO|FIXME|HACK|XXX)\b(.*)/);
836
+ if (todoMatch) {
837
+ findings.push({
838
+ ruleId: "ai-smells",
839
+ file: file.relativePath,
840
+ line: i + 1,
841
+ column: line.indexOf(todoMatch[1]) + 1,
842
+ message: `${todoMatch[1]} comment: ${todoMatch[2].trim() || "(no description)"}`,
843
+ severity: "info",
844
+ category: "ai-quality"
845
+ });
846
+ }
847
+ const commentContent = trimmed.slice(2).trim();
848
+ const looksLikeCode = /^(import |export |const |let |var |function |if |for |while |return |await |async |class |switch )/.test(commentContent) || /[{};=]$/.test(commentContent) || /^\w+\(/.test(commentContent);
849
+ if (looksLikeCode) {
850
+ commentedCodeRun++;
851
+ if (commentedCodeRun === COMMENTED_CODE_THRESHOLD) {
852
+ findings.push({
853
+ ruleId: "ai-smells",
854
+ file: file.relativePath,
855
+ line: i + 1 - COMMENTED_CODE_THRESHOLD + 1,
856
+ column: 1,
857
+ message: `${COMMENTED_CODE_THRESHOLD}+ consecutive lines of commented-out code`,
858
+ severity: "info",
859
+ category: "ai-quality"
860
+ });
861
+ }
862
+ } else {
863
+ commentedCodeRun = 0;
864
+ }
865
+ continue;
866
+ }
867
+ commentedCodeRun = 0;
868
+ if (/(?:throw new Error|throw Error)\s*\(\s*['"]not implemented['"]/i.test(line)) {
869
+ findings.push({
870
+ ruleId: "ai-smells",
871
+ file: file.relativePath,
872
+ line: i + 1,
873
+ column: 1,
874
+ message: 'Placeholder "not implemented" function',
875
+ severity: "warning",
876
+ category: "ai-quality"
877
+ });
878
+ }
879
+ if (/console\.log\s*\(/.test(line)) {
880
+ consoleLogCount++;
881
+ }
882
+ if (/:\s*any\b/.test(line) || /\bas\s+any\b/.test(line) || /<any>/.test(line)) {
883
+ anyTypeCount++;
884
+ }
885
+ }
886
+ if (consoleLogCount > CONSOLE_LOG_THRESHOLD) {
887
+ findings.push({
888
+ ruleId: "ai-smells",
889
+ file: file.relativePath,
890
+ line: 1,
891
+ column: 1,
892
+ message: `${consoleLogCount} console.log statements (consider a proper logger)`,
893
+ severity: "warning",
894
+ category: "ai-quality"
895
+ });
896
+ }
897
+ if (anyTypeCount > ANY_TYPE_THRESHOLD) {
898
+ findings.push({
899
+ ruleId: "ai-smells",
900
+ file: file.relativePath,
901
+ line: 1,
902
+ column: 1,
903
+ message: `${anyTypeCount} uses of "any" type (consider proper typing)`,
904
+ severity: "warning",
905
+ category: "ai-quality"
906
+ });
907
+ }
908
+ return findings;
909
+ }
910
+ };
911
+
912
+ // src/rules/unsafe-html.ts
913
+ var unsafeHtmlRule = {
914
+ id: "unsafe-html",
915
+ name: "Unsafe HTML Rendering",
916
+ description: "Detects dangerouslySetInnerHTML and other XSS vectors in JSX/DOM code",
917
+ category: "security",
918
+ severity: "critical",
919
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
920
+ check(file, _project) {
921
+ const findings = [];
922
+ for (let i = 0; i < file.lines.length; i++) {
923
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
924
+ const line = file.lines[i];
925
+ if (/dangerouslySetInnerHTML\s*=/.test(line) || /dangerouslySetInnerHTML\s*:/.test(line)) {
926
+ findings.push({
927
+ ruleId: "unsafe-html",
928
+ file: file.relativePath,
929
+ line: i + 1,
930
+ column: line.indexOf("dangerouslySetInnerHTML") + 1,
931
+ message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
932
+ severity: "critical",
933
+ category: "security"
934
+ });
935
+ }
936
+ if (/\w\.innerHTML\s*=/.test(line)) {
937
+ findings.push({
938
+ ruleId: "unsafe-html",
939
+ file: file.relativePath,
940
+ line: i + 1,
941
+ column: line.indexOf(".innerHTML") + 1,
942
+ message: "Direct innerHTML assignment is an XSS risk",
943
+ severity: "critical",
944
+ category: "security"
945
+ });
946
+ }
947
+ }
948
+ return findings;
949
+ }
950
+ };
951
+
952
+ // src/rules/sql-injection.ts
953
+ var SQL_INJECTION_PATTERNS = [
954
+ // Template literals with SQL keywords and interpolation
955
+ { pattern: /`\s*(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE)\b[^`]*\$\{/, message: "SQL query built with template literal interpolation \u2014 use parameterized queries" },
956
+ // String concatenation with SQL
957
+ { pattern: /(?:SELECT|INSERT|UPDATE|DELETE|DROP)\b.*['"]?\s*\+\s*\w/, message: "SQL query built with string concatenation \u2014 use parameterized queries" },
958
+ // .query() or .execute() with template literal
959
+ { pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
960
+ ];
961
+ var sqlInjectionRule = {
962
+ id: "sql-injection",
963
+ name: "SQL Injection Risk",
964
+ description: "Detects SQL queries built with string interpolation or concatenation",
965
+ category: "security",
966
+ severity: "critical",
967
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
968
+ check(file, _project) {
969
+ const findings = [];
970
+ for (let i = 0; i < file.lines.length; i++) {
971
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
972
+ const line = file.lines[i];
973
+ for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
974
+ if (pattern.test(line)) {
975
+ findings.push({
976
+ ruleId: "sql-injection",
977
+ file: file.relativePath,
978
+ line: i + 1,
979
+ column: 1,
980
+ message,
981
+ severity: "critical",
982
+ category: "security"
983
+ });
984
+ break;
985
+ }
986
+ }
987
+ }
988
+ return findings;
989
+ }
990
+ };
991
+
992
+ // src/rules/index.ts
993
+ var rules = [
994
+ secretsRule,
995
+ hallucinatedImportsRule,
996
+ authChecksRule,
997
+ envExposureRule,
998
+ errorHandlingRule,
999
+ inputValidationRule,
1000
+ rateLimitingRule,
1001
+ corsConfigRule,
1002
+ aiSmellsRule,
1003
+ unsafeHtmlRule,
1004
+ sqlInjectionRule
1005
+ ];
1006
+
1007
+ // src/scanner.ts
1008
+ import { resolve as resolve3 } from "path";
1009
+ async function scan(options) {
1010
+ const start = performance.now();
1011
+ const root = resolve3(options.path);
1012
+ const filePaths = await walkFiles(root, options.ignore);
1013
+ const project = await buildProjectContext(root, filePaths);
1014
+ const findings = [];
1015
+ for (const relativePath of filePaths) {
1016
+ const file = await readFileContext(root, relativePath);
1017
+ if (!file) continue;
1018
+ for (const rule of rules) {
1019
+ if (rule.fileExtensions.length > 0 && !rule.fileExtensions.includes(file.ext)) {
1020
+ continue;
1021
+ }
1022
+ const ruleFindings = rule.check(file, project);
1023
+ for (const finding of ruleFindings) {
1024
+ if (!isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
1025
+ findings.push(finding);
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ const { overallScore, categoryScores } = calculateScores(findings);
1031
+ const summary = summarizeFindings(findings);
1032
+ return {
1033
+ version: getVersion(),
1034
+ scannedPath: options.path,
1035
+ filesScanned: filePaths.length,
1036
+ scanDurationMs: Math.round(performance.now() - start),
1037
+ findings,
1038
+ overallScore,
1039
+ categoryScores,
1040
+ summary
1041
+ };
1042
+ }
1043
+
1044
+ // src/mcp.ts
1045
+ var server = new McpServer({
1046
+ name: "prodlint",
1047
+ version: getVersion()
1048
+ });
1049
+ server.tool(
1050
+ "scan",
1051
+ "Scan a project for production readiness issues. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
1052
+ {
1053
+ path: z.string().describe("Absolute path to the project directory to scan"),
1054
+ ignore: z.array(z.string()).optional().describe("Glob patterns to ignore")
1055
+ },
1056
+ async ({ path, ignore }) => {
1057
+ const resolved = resolve4(path);
1058
+ try {
1059
+ const stats = await stat2(resolved);
1060
+ if (!stats.isDirectory()) {
1061
+ return {
1062
+ content: [{ type: "text", text: `Error: ${path} is not a directory` }],
1063
+ isError: true
1064
+ };
1065
+ }
1066
+ } catch {
1067
+ return {
1068
+ content: [{ type: "text", text: `Error: ${path} does not exist or is not accessible` }],
1069
+ isError: true
1070
+ };
1071
+ }
1072
+ const result = await scan({ path: resolved, ignore });
1073
+ const summary = [
1074
+ `## Production Readiness Score: ${result.overallScore}/100`,
1075
+ "",
1076
+ `Scanned ${result.filesScanned} files in ${result.scanDurationMs}ms`,
1077
+ "",
1078
+ "### Category Scores",
1079
+ ...result.categoryScores.map(
1080
+ (c) => `- **${c.category}**: ${c.score}/100 (${c.findingCount} issues)`
1081
+ ),
1082
+ "",
1083
+ `### Summary: ${result.summary.critical} critical, ${result.summary.warning} warnings, ${result.summary.info} info`
1084
+ ];
1085
+ if (result.findings.length > 0) {
1086
+ summary.push("", "### Findings");
1087
+ for (const f of result.findings.slice(0, 30)) {
1088
+ summary.push(
1089
+ `- **[${f.severity}]** \`${f.ruleId}\` ${f.file}:${f.line} \u2014 ${f.message}`
1090
+ );
1091
+ }
1092
+ if (result.findings.length > 30) {
1093
+ summary.push(`- ...and ${result.findings.length - 30} more findings`);
1094
+ }
1095
+ }
1096
+ return {
1097
+ content: [{ type: "text", text: summary.join("\n") }]
1098
+ };
1099
+ }
1100
+ );
1101
+ async function main() {
1102
+ const transport = new StdioServerTransport();
1103
+ await server.connect(transport);
1104
+ }
1105
+ main().catch((err) => {
1106
+ console.error("Prodlint MCP server error:", err instanceof Error ? err.message : "Unknown error");
1107
+ process.exit(1);
1108
+ });
1109
+ //# sourceMappingURL=mcp.js.map