prodlint 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -67,6 +67,51 @@ function isLineSuppressed(lines, lineIndex, ruleId) {
67
67
  }
68
68
  return false;
69
69
  }
70
+ function isTestFile(relativePath) {
71
+ return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || /(?:^|\/)__tests__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
72
+ }
73
+ function isScriptFile(relativePath) {
74
+ return /(?:^|\/)scripts?\//.test(relativePath);
75
+ }
76
+ function isConfigFile(relativePath) {
77
+ const name = relativePath.split("/").pop() ?? "";
78
+ return /\.config\.[jt]sx?$/.test(name) || /\.config\.(mjs|cjs)$/.test(name) || name.startsWith(".env") || name === "next.config.js" || name === "next.config.ts" || name === "next.config.mjs" || name === "tailwind.config.ts" || name === "tailwind.config.js" || name === "postcss.config.js" || name === "postcss.config.mjs" || name === "tsconfig.json" || name === "jest.config.ts" || name === "jest.config.js" || name === "vitest.config.ts" || name === "vitest.config.mts";
79
+ }
80
+ function findLoopBodies(lines, commentMap) {
81
+ const results = [];
82
+ for (let i = 0; i < lines.length; i++) {
83
+ if (commentMap[i]) continue;
84
+ const trimmed = lines[i].trim();
85
+ const isLoop = /^\s*(for\s*\(|for\s+await\s*\(|while\s*\()/.test(lines[i]) || /\.(forEach|map)\s*\(/.test(trimmed);
86
+ if (!isLoop) continue;
87
+ let braceCount = 0;
88
+ let bodyStart = -1;
89
+ let foundOpen = false;
90
+ for (let j = i; j < lines.length; j++) {
91
+ if (commentMap[j]) continue;
92
+ const line = lines[j];
93
+ for (let k = 0; k < line.length; k++) {
94
+ const ch = line[k];
95
+ if (ch === "{") {
96
+ if (!foundOpen) {
97
+ bodyStart = j;
98
+ foundOpen = true;
99
+ }
100
+ braceCount++;
101
+ } else if (ch === "}") {
102
+ braceCount--;
103
+ if (foundOpen && braceCount === 0) {
104
+ results.push({ loopLine: i, bodyStart, bodyEnd: j });
105
+ i = j;
106
+ j = lines.length;
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return results;
114
+ }
70
115
  var NODE_BUILTINS = /* @__PURE__ */ new Set([
71
116
  "assert",
72
117
  "async_hooks",
@@ -400,6 +445,7 @@ var hallucinatedImportsRule = {
400
445
  if (!project.packageJson) return [];
401
446
  const findings = [];
402
447
  const seen = /* @__PURE__ */ new Set();
448
+ const isNonProd = isTestFile(file.relativePath) || isScriptFile(file.relativePath);
403
449
  for (let i = 0; i < file.lines.length; i++) {
404
450
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
405
451
  const line = file.lines[i];
@@ -420,7 +466,7 @@ var hallucinatedImportsRule = {
420
466
  line: i + 1,
421
467
  column: match.index + 1,
422
468
  message: `Package "${pkgName}" is imported but not in package.json`,
423
- severity: "critical",
469
+ severity: isNonProd ? "warning" : "critical",
424
470
  category: "reliability"
425
471
  });
426
472
  }
@@ -566,64 +612,31 @@ var envExposureRule = {
566
612
  var errorHandlingRule = {
567
613
  id: "error-handling",
568
614
  name: "Missing Error Handling",
569
- description: "Detects API routes without try/catch and empty catch blocks",
615
+ description: "Detects API routes without try/catch",
570
616
  category: "reliability",
571
617
  severity: "warning",
572
618
  fileExtensions: ["ts", "tsx", "js", "jsx"],
573
619
  check(file, _project) {
574
- const findings = [];
575
- if (isApiRoute(file.relativePath)) {
576
- const hasTryCatch = /try\s*\{/.test(file.content);
577
- if (!hasTryCatch) {
578
- let handlerLine = 1;
579
- for (let i = 0; i < file.lines.length; i++) {
580
- if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
581
- handlerLine = i + 1;
582
- break;
583
- }
584
- }
585
- findings.push({
586
- ruleId: "error-handling",
587
- file: file.relativePath,
588
- line: handlerLine,
589
- column: 1,
590
- message: "API route handler has no try/catch block",
591
- severity: "warning",
592
- category: "reliability"
593
- });
594
- }
595
- }
620
+ if (!isApiRoute(file.relativePath)) return [];
621
+ const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
622
+ const hasTryCatch = /try\s*\{/.test(file.content);
623
+ if (hasTryCatch || hasFrameworkServe) return [];
624
+ let handlerLine = 1;
596
625
  for (let i = 0; i < file.lines.length; i++) {
597
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
598
- const line = file.lines[i];
599
- if (/catch\s*(\([^)]*\))?\s*\{\s*\}/.test(line)) {
600
- findings.push({
601
- ruleId: "error-handling",
602
- file: file.relativePath,
603
- line: i + 1,
604
- column: line.indexOf("catch") + 1,
605
- message: "Empty catch block silently swallows errors",
606
- severity: "warning",
607
- category: "reliability"
608
- });
609
- continue;
610
- }
611
- if (/catch\s*(\([^)]*\))?\s*\{\s*$/.test(line)) {
612
- const nextLine = file.lines[i + 1]?.trim();
613
- if (nextLine === "}") {
614
- findings.push({
615
- ruleId: "error-handling",
616
- file: file.relativePath,
617
- line: i + 1,
618
- column: line.indexOf("catch") + 1,
619
- message: "Empty catch block silently swallows errors",
620
- severity: "warning",
621
- category: "reliability"
622
- });
623
- }
626
+ if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
627
+ handlerLine = i + 1;
628
+ break;
624
629
  }
625
630
  }
626
- return findings;
631
+ return [{
632
+ ruleId: "error-handling",
633
+ file: file.relativePath,
634
+ line: handlerLine,
635
+ column: 1,
636
+ message: "API route handler has no try/catch block",
637
+ severity: "warning",
638
+ category: "reliability"
639
+ }];
627
640
  }
628
641
  };
629
642
 
@@ -642,7 +655,11 @@ var VALIDATION_PATTERNS = [
642
655
  /ajv/i,
643
656
  /typebox/i,
644
657
  /valibot/i,
645
- /typeof\s+.*body/
658
+ /typeof\s+.*body/,
659
+ // Inline guard clauses on parsed body/data (\b prevents matching inside metadata, database, etc.)
660
+ /if\s*\(\s*!\b(body|data)\b\./,
661
+ /\b(body|data)\b\?\.\w+\s*(!==|===)/,
662
+ /typeof\s+\b(body|data)\b/
646
663
  ];
647
664
  var BODY_ACCESS_PATTERNS = [
648
665
  /req\.body/,
@@ -730,7 +747,7 @@ var rateLimitingRule = {
730
747
  file: file.relativePath,
731
748
  line: handlerLine,
732
749
  column: 1,
733
- message: "API route has no rate limiting",
750
+ message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
734
751
  severity: "warning",
735
752
  category: "security"
736
753
  }];
@@ -811,6 +828,8 @@ var aiSmellsRule = {
811
828
  severity: "info",
812
829
  fileExtensions: ["ts", "tsx", "js", "jsx"],
813
830
  check(file, _project) {
831
+ if (isTestFile(file.relativePath)) return [];
832
+ if (isScriptFile(file.relativePath)) return [];
814
833
  const findings = [];
815
834
  let consoleLogCount = 0;
816
835
  let anyTypeCount = 0;
@@ -914,6 +933,14 @@ var unsafeHtmlRule = {
914
933
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
915
934
  const line = file.lines[i];
916
935
  if (/dangerouslySetInnerHTML\s*=/.test(line) || /dangerouslySetInnerHTML\s*:/.test(line)) {
936
+ const context = [line];
937
+ for (let j = 1; j <= 2 && i + j < file.lines.length; j++) {
938
+ const nextLine = file.lines[i + j];
939
+ if (/^\s*<[^/]|^\s*(const|let|var|return|export|import)\s/.test(nextLine)) break;
940
+ context.push(nextLine);
941
+ }
942
+ const expr = context.join(" ");
943
+ if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
917
944
  findings.push({
918
945
  ruleId: "unsafe-html",
919
946
  file: file.relativePath,
@@ -980,19 +1007,1031 @@ var sqlInjectionRule = {
980
1007
  }
981
1008
  };
982
1009
 
1010
+ // src/rules/placeholder-content.ts
1011
+ var PLACEHOLDERS = [
1012
+ { pattern: /Lorem ipsum/i, label: "Lorem ipsum placeholder text" },
1013
+ { pattern: /example@example\.com/, label: 'Placeholder email "example@example.com"' },
1014
+ { pattern: /user@example\.com/, label: 'Placeholder email "user@example.com"' },
1015
+ { pattern: /test@test\.com/, label: 'Placeholder email "test@test.com"' },
1016
+ { pattern: /['"]John Doe['"]/, label: 'Placeholder name "John Doe"' },
1017
+ { pattern: /['"]Jane Doe['"]/, label: 'Placeholder name "Jane Doe"' },
1018
+ { pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
1019
+ { pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
1020
+ { pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
1021
+ { pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
1022
+ { pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
1023
+ { pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
1024
+ ];
1025
+ var placeholderContentRule = {
1026
+ id: "placeholder-content",
1027
+ name: "Placeholder Content",
1028
+ description: "Detects Lorem ipsum, example emails, placeholder names, and dummy values in non-test files",
1029
+ category: "ai-quality",
1030
+ severity: "info",
1031
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1032
+ check(file, _project) {
1033
+ if (isTestFile(file.relativePath)) return [];
1034
+ const findings = [];
1035
+ for (let i = 0; i < file.lines.length; i++) {
1036
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1037
+ const line = file.lines[i];
1038
+ for (const { pattern, label } of PLACEHOLDERS) {
1039
+ const match = pattern.exec(line);
1040
+ if (match) {
1041
+ findings.push({
1042
+ ruleId: "placeholder-content",
1043
+ file: file.relativePath,
1044
+ line: i + 1,
1045
+ column: match.index + 1,
1046
+ message: label,
1047
+ severity: "info",
1048
+ category: "ai-quality"
1049
+ });
1050
+ break;
1051
+ }
1052
+ }
1053
+ }
1054
+ return findings;
1055
+ }
1056
+ };
1057
+
1058
+ // src/rules/stale-fallback.ts
1059
+ var STALE_PATTERNS = [
1060
+ { pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
1061
+ { pattern: /['"]https?:\/\/127\.0\.0\.1[:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1062
+ { pattern: /['"]redis:\/\/localhost['"]/, label: "Hardcoded Redis localhost URL" },
1063
+ { pattern: /['"]mongodb:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1064
+ { pattern: /['"]mongodb\+srv:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1065
+ { pattern: /['"]postgres:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1066
+ { pattern: /['"]postgresql:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1067
+ { pattern: /['"]amqp:\/\/localhost['"]/, label: "Hardcoded AMQP localhost URL" }
1068
+ ];
1069
+ var staleFallbackRule = {
1070
+ id: "stale-fallback",
1071
+ name: "Stale Fallback",
1072
+ description: "Detects hardcoded localhost URLs in non-config, non-test files",
1073
+ category: "ai-quality",
1074
+ severity: "warning",
1075
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1076
+ check(file, _project) {
1077
+ if (isTestFile(file.relativePath)) return [];
1078
+ if (isConfigFile(file.relativePath)) return [];
1079
+ if (isScriptFile(file.relativePath)) return [];
1080
+ const findings = [];
1081
+ for (let i = 0; i < file.lines.length; i++) {
1082
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1083
+ const line = file.lines[i];
1084
+ for (const { pattern, label } of STALE_PATTERNS) {
1085
+ const match = pattern.exec(line);
1086
+ if (match) {
1087
+ findings.push({
1088
+ ruleId: "stale-fallback",
1089
+ file: file.relativePath,
1090
+ line: i + 1,
1091
+ column: match.index + 1,
1092
+ message: `${label} \u2014 use environment variable instead`,
1093
+ severity: "warning",
1094
+ category: "ai-quality"
1095
+ });
1096
+ break;
1097
+ }
1098
+ }
1099
+ }
1100
+ return findings;
1101
+ }
1102
+ };
1103
+
1104
+ // src/rules/hallucinated-api.ts
1105
+ var HALLUCINATED_APIS = [
1106
+ { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()" },
1107
+ { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()" },
1108
+ { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()" },
1109
+ { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()" },
1110
+ { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()" },
1111
+ { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()" },
1112
+ { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()" },
1113
+ { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)" },
1114
+ { pattern: /\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()" }
1115
+ ];
1116
+ var hallucinatedApiRule = {
1117
+ id: "hallucinated-api",
1118
+ name: "Hallucinated API",
1119
+ description: "Detects non-existent or deprecated JavaScript/DOM APIs often generated by AI",
1120
+ category: "ai-quality",
1121
+ severity: "warning",
1122
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1123
+ check(file, _project) {
1124
+ const findings = [];
1125
+ for (let i = 0; i < file.lines.length; i++) {
1126
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1127
+ const line = file.lines[i];
1128
+ for (const { pattern, fix } of HALLUCINATED_APIS) {
1129
+ const match = pattern.exec(line);
1130
+ if (match) {
1131
+ findings.push({
1132
+ ruleId: "hallucinated-api",
1133
+ file: file.relativePath,
1134
+ line: i + 1,
1135
+ column: match.index + 1,
1136
+ message: fix,
1137
+ severity: "warning",
1138
+ category: "ai-quality"
1139
+ });
1140
+ }
1141
+ }
1142
+ }
1143
+ return findings;
1144
+ }
1145
+ };
1146
+
1147
+ // src/rules/open-redirect.ts
1148
+ var CRITICAL_PATTERNS = [
1149
+ // redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
1150
+ /redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
1151
+ // redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
1152
+ /redirect\s*\(\s*req(?:uest)?\.(?:query|nextUrl\.searchParams\.get)\s*[.(]/,
1153
+ // NextResponse.redirect(new URL(userInput))
1154
+ /NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
1155
+ ];
1156
+ var WARNING_PATTERNS = [
1157
+ /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
1158
+ ];
1159
+ var openRedirectRule = {
1160
+ id: "open-redirect",
1161
+ name: "Open Redirect",
1162
+ description: "Detects user-controlled input passed directly to redirect functions",
1163
+ category: "security",
1164
+ severity: "critical",
1165
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1166
+ check(file, _project) {
1167
+ const findings = [];
1168
+ for (let i = 0; i < file.lines.length; i++) {
1169
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1170
+ const line = file.lines[i];
1171
+ for (const pattern of CRITICAL_PATTERNS) {
1172
+ const match = pattern.exec(line);
1173
+ if (match) {
1174
+ findings.push({
1175
+ ruleId: "open-redirect",
1176
+ file: file.relativePath,
1177
+ line: i + 1,
1178
+ column: match.index + 1,
1179
+ message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1180
+ severity: "critical",
1181
+ category: "security"
1182
+ });
1183
+ break;
1184
+ }
1185
+ }
1186
+ for (const pattern of WARNING_PATTERNS) {
1187
+ const match = pattern.exec(line);
1188
+ if (match) {
1189
+ if (findings.some((f) => f.line === i + 1)) break;
1190
+ findings.push({
1191
+ ruleId: "open-redirect",
1192
+ file: file.relativePath,
1193
+ line: i + 1,
1194
+ column: match.index + 1,
1195
+ message: "Possible user input in redirect \u2014 verify the URL is validated before use",
1196
+ severity: "warning",
1197
+ category: "security"
1198
+ });
1199
+ break;
1200
+ }
1201
+ }
1202
+ }
1203
+ return findings;
1204
+ }
1205
+ };
1206
+
1207
+ // src/rules/no-sync-fs.ts
1208
+ var SYNC_FS_PATTERN = /(?:readFileSync|writeFileSync|existsSync|mkdirSync|readdirSync|statSync|unlinkSync|copyFileSync|renameSync|appendFileSync|accessSync)\s*\(/;
1209
+ var noSyncFsRule = {
1210
+ id: "no-sync-fs",
1211
+ name: "No Synchronous FS",
1212
+ description: "Detects synchronous fs operations in API routes and server code",
1213
+ category: "performance",
1214
+ severity: "warning",
1215
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1216
+ check(file, _project) {
1217
+ if (isTestFile(file.relativePath)) return [];
1218
+ if (isConfigFile(file.relativePath)) return [];
1219
+ if (/(?:^|\/)scripts?\//.test(file.relativePath)) return [];
1220
+ const findings = [];
1221
+ for (let i = 0; i < file.lines.length; i++) {
1222
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1223
+ const line = file.lines[i];
1224
+ const match = SYNC_FS_PATTERN.exec(line);
1225
+ if (match) {
1226
+ const fnName = match[0].replace(/\s*\($/, "");
1227
+ const severity = isApiRoute(file.relativePath) ? "warning" : "info";
1228
+ findings.push({
1229
+ ruleId: "no-sync-fs",
1230
+ file: file.relativePath,
1231
+ line: i + 1,
1232
+ column: match.index + 1,
1233
+ message: `Synchronous ${fnName}() blocks the event loop \u2014 use async alternative`,
1234
+ severity,
1235
+ category: "performance"
1236
+ });
1237
+ }
1238
+ }
1239
+ return findings;
1240
+ }
1241
+ };
1242
+
1243
+ // src/rules/no-n-plus-one.ts
1244
+ var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
1245
+ var noNPlusOneRule = {
1246
+ id: "no-n-plus-one",
1247
+ name: "No N+1 Queries",
1248
+ description: "Detects database or fetch calls inside loops (N+1 query pattern)",
1249
+ category: "performance",
1250
+ severity: "warning",
1251
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1252
+ check(file, _project) {
1253
+ if (isTestFile(file.relativePath)) return [];
1254
+ if (isScriptFile(file.relativePath)) return [];
1255
+ const findings = [];
1256
+ const loops = findLoopBodies(file.lines, file.commentMap);
1257
+ const reported = /* @__PURE__ */ new Set();
1258
+ for (const loop of loops) {
1259
+ if (reported.has(loop.loopLine)) continue;
1260
+ for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
1261
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1262
+ const line = file.lines[i];
1263
+ const match = DB_CALL_PATTERN.exec(line);
1264
+ if (match) {
1265
+ reported.add(loop.loopLine);
1266
+ findings.push({
1267
+ ruleId: "no-n-plus-one",
1268
+ file: file.relativePath,
1269
+ line: i + 1,
1270
+ column: match.index + 1,
1271
+ message: "Database/fetch call inside loop \u2014 potential N+1 query, consider batching",
1272
+ severity: "warning",
1273
+ category: "performance"
1274
+ });
1275
+ break;
1276
+ }
1277
+ }
1278
+ }
1279
+ return findings;
1280
+ }
1281
+ };
1282
+
1283
+ // src/rules/no-dynamic-import-loop.ts
1284
+ var DYNAMIC_IMPORT_PATTERN = /\bimport\s*\(/;
1285
+ var noDynamicImportLoopRule = {
1286
+ id: "no-dynamic-import-loop",
1287
+ name: "No Dynamic Import in Loop",
1288
+ description: "Detects dynamic import() calls inside loops",
1289
+ category: "performance",
1290
+ severity: "warning",
1291
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1292
+ check(file, _project) {
1293
+ const findings = [];
1294
+ const loops = findLoopBodies(file.lines, file.commentMap);
1295
+ for (const loop of loops) {
1296
+ for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
1297
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1298
+ const line = file.lines[i];
1299
+ if (/^\s*import\s+/.test(line) && /\bfrom\b/.test(line)) continue;
1300
+ const match = DYNAMIC_IMPORT_PATTERN.exec(line);
1301
+ if (match) {
1302
+ findings.push({
1303
+ ruleId: "no-dynamic-import-loop",
1304
+ file: file.relativePath,
1305
+ line: i + 1,
1306
+ column: match.index + 1,
1307
+ message: "Dynamic import() inside loop \u2014 move import outside or use Promise.all",
1308
+ severity: "warning",
1309
+ category: "performance"
1310
+ });
1311
+ }
1312
+ }
1313
+ }
1314
+ return findings;
1315
+ }
1316
+ };
1317
+
1318
+ // src/rules/no-unbounded-query.ts
1319
+ var noUnboundedQueryRule = {
1320
+ id: "no-unbounded-query",
1321
+ name: "No Unbounded Query",
1322
+ description: "Detects database queries without LIMIT/take constraints",
1323
+ category: "performance",
1324
+ severity: "warning",
1325
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1326
+ check(file, _project) {
1327
+ if (isTestFile(file.relativePath)) return [];
1328
+ if (isScriptFile(file.relativePath)) return [];
1329
+ const findings = [];
1330
+ for (let i = 0; i < file.lines.length; i++) {
1331
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1332
+ const line = file.lines[i];
1333
+ if (/\.findMany\s*\(\s*\)/.test(line)) {
1334
+ findings.push({
1335
+ ruleId: "no-unbounded-query",
1336
+ file: file.relativePath,
1337
+ line: i + 1,
1338
+ column: line.indexOf(".findMany") + 1,
1339
+ message: ".findMany() without take/limit \u2014 query may return unbounded results",
1340
+ severity: "warning",
1341
+ category: "performance"
1342
+ });
1343
+ continue;
1344
+ }
1345
+ if (/\.findMany\s*\(\s*\{/.test(line)) {
1346
+ const context = file.lines.slice(i, Math.min(i + 6, file.lines.length)).join(" ");
1347
+ if (!/\btake\s*:/.test(context) && !/\blimit\s*[:(]/.test(context)) {
1348
+ findings.push({
1349
+ ruleId: "no-unbounded-query",
1350
+ file: file.relativePath,
1351
+ line: i + 1,
1352
+ column: line.indexOf(".findMany") + 1,
1353
+ message: ".findMany() without take \u2014 add pagination or limit",
1354
+ severity: "warning",
1355
+ category: "performance"
1356
+ });
1357
+ }
1358
+ continue;
1359
+ }
1360
+ if (/\.select\s*\(\s*['"`]\*['"`]\s*\)/.test(line)) {
1361
+ const context = file.lines.slice(Math.max(0, i - 2), Math.min(i + 4, file.lines.length)).join(" ");
1362
+ const hasBound = /\.limit\s*\(/.test(context) || /\.range\s*\(/.test(context) || /LIMIT\s+\d/i.test(context) || /\.eq\s*\(/.test(context) || /\.single\s*\(/.test(context) || /\.maybeSingle\s*\(/.test(context) || /\.match\s*\(/.test(context);
1363
+ if (!hasBound) {
1364
+ findings.push({
1365
+ ruleId: "no-unbounded-query",
1366
+ file: file.relativePath,
1367
+ line: i + 1,
1368
+ column: line.indexOf(".select") + 1,
1369
+ message: ".select('*') without .limit() or filter \u2014 add pagination or a where clause",
1370
+ severity: "warning",
1371
+ category: "performance"
1372
+ });
1373
+ }
1374
+ }
1375
+ }
1376
+ return findings;
1377
+ }
1378
+ };
1379
+
1380
+ // src/rules/unhandled-promise.ts
1381
+ var ASYNC_CALL_PATTERN = /(?:fetch\s*\(|prisma\.\w+\.\w+\(|\.findMany\s*\(|\.findFirst\s*\(|\.findUnique\s*\(|\.upsert\s*\()/;
1382
+ var HANDLED_PATTERNS = [
1383
+ /\bawait\b/,
1384
+ /\breturn\b/,
1385
+ /\bconst\s+\w/,
1386
+ /\blet\s+\w/,
1387
+ /\bvar\s+\w/,
1388
+ /=\s*(?:await\b)?/,
1389
+ /\.then\s*\(/,
1390
+ /\.catch\s*\(/,
1391
+ /void\s+/,
1392
+ /Promise\.all/,
1393
+ /Promise\.allSettled/,
1394
+ /Promise\.race/
1395
+ ];
1396
+ var unhandledPromiseRule = {
1397
+ id: "unhandled-promise",
1398
+ name: "Unhandled Promise",
1399
+ description: "Detects async calls (fetch, DB) without await, return, or assignment",
1400
+ category: "reliability",
1401
+ severity: "warning",
1402
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1403
+ check(file, _project) {
1404
+ const findings = [];
1405
+ for (let i = 0; i < file.lines.length; i++) {
1406
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1407
+ const line = file.lines[i];
1408
+ const trimmed = line.trim();
1409
+ if (/^\.\w/.test(trimmed)) continue;
1410
+ if (/^['"`]/.test(trimmed)) continue;
1411
+ const asyncMatch = ASYNC_CALL_PATTERN.exec(trimmed);
1412
+ if (!asyncMatch) continue;
1413
+ const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
1414
+ if (isHandled) continue;
1415
+ let handledAbove = false;
1416
+ for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
1417
+ const prevTrimmed = file.lines[j].trim();
1418
+ if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
1419
+ if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed)) {
1420
+ handledAbove = true;
1421
+ break;
1422
+ }
1423
+ }
1424
+ if (handledAbove) continue;
1425
+ const col = ASYNC_CALL_PATTERN.exec(line);
1426
+ findings.push({
1427
+ ruleId: "unhandled-promise",
1428
+ file: file.relativePath,
1429
+ line: i + 1,
1430
+ column: col ? col.index + 1 : 1,
1431
+ message: "Async call without await, return, or assignment \u2014 promise result is lost",
1432
+ severity: "warning",
1433
+ category: "reliability"
1434
+ });
1435
+ }
1436
+ return findings;
1437
+ }
1438
+ };
1439
+
1440
+ // src/rules/missing-loading-state.ts
1441
+ var missingLoadingStateRule = {
1442
+ id: "missing-loading-state",
1443
+ name: "Missing Loading State",
1444
+ description: "Detects client components with useEffect+fetch but no loading state",
1445
+ category: "reliability",
1446
+ severity: "info",
1447
+ fileExtensions: ["tsx", "jsx"],
1448
+ check(file, _project) {
1449
+ if (!isClientComponent(file.content)) return [];
1450
+ const content = file.content;
1451
+ if (!/\buseEffect\s*\(/.test(content)) return [];
1452
+ const hasFetch = /\bfetch\s*\(/.test(content) || /\baxios\b/.test(content) || /\.get\s*\(/.test(content) || /\.post\s*\(/.test(content);
1453
+ if (!hasFetch) return [];
1454
+ const hasLoadingState = /\b(?:loading|isLoading|pending|isPending|isFetching)\b/.test(content) || /useState\s*<?\s*boolean\s*>?\s*\(\s*(?:true|false)\s*\)/.test(content) || /\bSkeleton\b/.test(content) || /\bSpinner\b/.test(content) || /\buseSWR\b/.test(content) || /\buseQuery\b/.test(content);
1455
+ if (hasLoadingState) return [];
1456
+ for (let i = 0; i < file.lines.length; i++) {
1457
+ if (/\buseEffect\s*\(/.test(file.lines[i])) {
1458
+ return [{
1459
+ ruleId: "missing-loading-state",
1460
+ file: file.relativePath,
1461
+ line: i + 1,
1462
+ column: 1,
1463
+ message: "useEffect with fetch but no loading/pending state \u2014 users see empty content during load",
1464
+ severity: "info",
1465
+ category: "reliability"
1466
+ }];
1467
+ }
1468
+ }
1469
+ return [];
1470
+ }
1471
+ };
1472
+
1473
+ // src/rules/missing-error-boundary.ts
1474
+ var missingErrorBoundaryRule = {
1475
+ id: "missing-error-boundary",
1476
+ name: "Missing Error Boundary",
1477
+ description: "Detects Next.js layout files without a matching error.tsx in the same directory",
1478
+ category: "reliability",
1479
+ severity: "info",
1480
+ fileExtensions: ["tsx", "jsx", "ts", "js"],
1481
+ check(file, project) {
1482
+ const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
1483
+ if (!match) return [];
1484
+ const dir = "app/" + match[1];
1485
+ const hasErrorBoundary = project.allFiles.some(
1486
+ (f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
1487
+ );
1488
+ if (hasErrorBoundary) return [];
1489
+ return [{
1490
+ ruleId: "missing-error-boundary",
1491
+ file: file.relativePath,
1492
+ line: 1,
1493
+ column: 1,
1494
+ message: `Layout without error.tsx \u2014 errors in ${dir} will bubble up to parent error boundary`,
1495
+ severity: "info",
1496
+ category: "reliability"
1497
+ }];
1498
+ }
1499
+ };
1500
+
1501
+ // src/rules/codebase-consistency.ts
1502
+ function tallyDimension(label, files, detector) {
1503
+ const variants = /* @__PURE__ */ new Map();
1504
+ for (const file of files) {
1505
+ const variant = detector(file);
1506
+ if (variant) {
1507
+ const list = variants.get(variant) ?? [];
1508
+ list.push(file.relativePath);
1509
+ variants.set(variant, list);
1510
+ }
1511
+ }
1512
+ return { label, variants };
1513
+ }
1514
+ function detectNamingConvention(file) {
1515
+ let camel = 0;
1516
+ let snake = 0;
1517
+ for (const line of file.lines) {
1518
+ const match = line.match(/export\s+(?:function|const|let)\s+(\w+)/);
1519
+ if (match) {
1520
+ const name = match[1];
1521
+ if (/[a-z][A-Z]/.test(name)) camel++;
1522
+ else if (/_[a-z]/.test(name)) snake++;
1523
+ }
1524
+ }
1525
+ if (camel > 0 && snake === 0) return "camelCase";
1526
+ if (snake > 0 && camel === 0) return "snake_case";
1527
+ if (camel > 0 && snake > 0) return "mixed";
1528
+ return null;
1529
+ }
1530
+ function detectImportStyle(file) {
1531
+ let esm = 0;
1532
+ let cjs = 0;
1533
+ for (const line of file.lines) {
1534
+ if (/^\s*import\s+/.test(line)) esm++;
1535
+ if (/\brequire\s*\(/.test(line)) cjs++;
1536
+ }
1537
+ if (esm > 0 && cjs === 0) return "ESM import";
1538
+ if (cjs > 0 && esm === 0) return "CJS require";
1539
+ if (esm > 0 && cjs > 0) return "mixed";
1540
+ return null;
1541
+ }
1542
+ function detectHttpClient(file) {
1543
+ const content = file.content;
1544
+ if (/\baxios[\s.(]/.test(content)) return "axios";
1545
+ if (/\bgot[\s.(]/.test(content) && /from\s+['"]got['"]/.test(content)) return "got";
1546
+ if (/\bky[\s.(]/.test(content) && /from\s+['"]ky['"]/.test(content)) return "ky";
1547
+ if (/\bfetch\s*\(/.test(content)) return "fetch";
1548
+ return null;
1549
+ }
1550
+ function detectAsyncPattern(file) {
1551
+ let awaits = 0;
1552
+ let thens = 0;
1553
+ let callbacks = 0;
1554
+ for (const line of file.lines) {
1555
+ if (/\bawait\b/.test(line)) awaits++;
1556
+ if (/\.then\s*\(/.test(line)) thens++;
1557
+ if (/,\s*(?:function\s*\(|(?:err|error|cb|callback)\s*=>)/.test(line)) callbacks++;
1558
+ }
1559
+ const total = awaits + thens + callbacks;
1560
+ if (total === 0) return null;
1561
+ if (awaits > 0 && thens === 0 && callbacks === 0) return "async/await";
1562
+ if (thens > 0 && awaits === 0) return ".then() chains";
1563
+ if (callbacks > 0 && awaits === 0 && thens === 0) return "callbacks";
1564
+ if (awaits > 0 && thens > 0) return "mixed async";
1565
+ return null;
1566
+ }
1567
+ function detectQuoteStyle(file) {
1568
+ let single = 0;
1569
+ let double = 0;
1570
+ for (const line of file.lines) {
1571
+ const imports = line.match(/from\s+(['"])/g);
1572
+ if (imports) {
1573
+ for (const m of imports) {
1574
+ if (m.includes("'")) single++;
1575
+ else double++;
1576
+ }
1577
+ }
1578
+ }
1579
+ if (single > 0 && double === 0) return "single quotes";
1580
+ if (double > 0 && single === 0) return "double quotes";
1581
+ if (single > 0 && double > 0) return "mixed quotes";
1582
+ return null;
1583
+ }
1584
+ var codebaseConsistencyRule = {
1585
+ id: "codebase-consistency",
1586
+ name: "Codebase Consistency",
1587
+ description: "Detects conflicting conventions across files \u2014 naming, imports, async patterns, HTTP clients",
1588
+ category: "ai-quality",
1589
+ severity: "info",
1590
+ fileExtensions: [],
1591
+ check() {
1592
+ return [];
1593
+ },
1594
+ checkProject(files, _project) {
1595
+ const sourceFiles = files.filter(
1596
+ (f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath) && !isConfigFile(f.relativePath)
1597
+ );
1598
+ if (sourceFiles.length < 3) return [];
1599
+ const dimensions = [
1600
+ tallyDimension("Naming convention", sourceFiles, detectNamingConvention),
1601
+ tallyDimension("Import style", sourceFiles, detectImportStyle),
1602
+ tallyDimension("HTTP client", sourceFiles, detectHttpClient),
1603
+ tallyDimension("Async pattern", sourceFiles, detectAsyncPattern),
1604
+ tallyDimension("Quote style", sourceFiles, detectQuoteStyle)
1605
+ ];
1606
+ const findings = [];
1607
+ for (const dim of dimensions) {
1608
+ if (dim.variants.size < 2) continue;
1609
+ let total = 0;
1610
+ let dominant = "";
1611
+ let dominantCount = 0;
1612
+ for (const [variant, fileList] of dim.variants) {
1613
+ total += fileList.length;
1614
+ if (fileList.length > dominantCount) {
1615
+ dominantCount = fileList.length;
1616
+ dominant = variant;
1617
+ }
1618
+ }
1619
+ const consistency = Math.round(dominantCount / total * 100);
1620
+ if (consistency >= 90) continue;
1621
+ const minorities = [];
1622
+ for (const [variant, fileList] of dim.variants) {
1623
+ if (variant !== dominant) {
1624
+ minorities.push(`${variant} (${fileList.length} files)`);
1625
+ }
1626
+ }
1627
+ findings.push({
1628
+ ruleId: "codebase-consistency",
1629
+ file: "(project)",
1630
+ line: 1,
1631
+ column: 1,
1632
+ message: `${dim.label}: ${consistency}% consistent \u2014 dominant: ${dominant} (${dominantCount} files), minority: ${minorities.join(", ")}`,
1633
+ severity: consistency < 60 ? "warning" : "info",
1634
+ category: "ai-quality"
1635
+ });
1636
+ }
1637
+ return findings;
1638
+ }
1639
+ };
1640
+
1641
+ // src/rules/dead-exports.ts
1642
+ function isEntryPoint(relativePath) {
1643
+ const name = relativePath.split("/").pop() ?? "";
1644
+ return /^(page|layout|loading|error|not-found|route|middleware|instrumentation)\.(tsx?|jsx?)$/.test(name) || /^index\.(tsx?|jsx?)$/.test(name) || name === "global-error.tsx" || name === "global-error.jsx";
1645
+ }
1646
+ var THRESHOLD = 5;
1647
+ var deadExportsRule = {
1648
+ id: "dead-exports",
1649
+ name: "Dead Exports",
1650
+ description: "Detects exported symbols never imported anywhere in the project \u2014 context pollution for AI tools",
1651
+ category: "ai-quality",
1652
+ severity: "info",
1653
+ fileExtensions: [],
1654
+ check() {
1655
+ return [];
1656
+ },
1657
+ checkProject(files, _project) {
1658
+ const sourceFiles = files.filter(
1659
+ (f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
1660
+ );
1661
+ const exports = /* @__PURE__ */ new Map();
1662
+ const imports = /* @__PURE__ */ new Set();
1663
+ const importedFiles = /* @__PURE__ */ new Set();
1664
+ for (const file of sourceFiles) {
1665
+ if (isEntryPoint(file.relativePath)) continue;
1666
+ for (let i = 0; i < file.lines.length; i++) {
1667
+ const line = file.lines[i];
1668
+ let match;
1669
+ const namedRe = /export\s+(?:async\s+)?(?:function|const|let|class|enum)\s+(\w+)/g;
1670
+ while ((match = namedRe.exec(line)) !== null) {
1671
+ if (/export\s+(type|interface)\s/.test(line)) continue;
1672
+ exports.set(`${file.relativePath}::${match[1]}`, { file: file.relativePath, line: i + 1 });
1673
+ }
1674
+ const braceRe = /export\s*\{([^}]+)\}/g;
1675
+ while ((match = braceRe.exec(line)) !== null) {
1676
+ const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/).pop()?.trim()).filter(Boolean);
1677
+ for (const sym of symbols) {
1678
+ if (sym) exports.set(`${file.relativePath}::${sym}`, { file: file.relativePath, line: i + 1 });
1679
+ }
1680
+ }
1681
+ }
1682
+ }
1683
+ for (const file of files) {
1684
+ for (const line of file.lines) {
1685
+ let match;
1686
+ const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
1687
+ while ((match = bracesRe.exec(line)) !== null) {
1688
+ const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
1689
+ for (const sym of symbols) imports.add(sym);
1690
+ }
1691
+ const defaultRe = /import\s+(\w+)\s+from/g;
1692
+ while ((match = defaultRe.exec(line)) !== null) {
1693
+ imports.add(match[1]);
1694
+ }
1695
+ const fromRe = /from\s+['"]([^'"]+)['"]/g;
1696
+ while ((match = fromRe.exec(line)) !== null) {
1697
+ importedFiles.add(match[1]);
1698
+ }
1699
+ }
1700
+ }
1701
+ const deadByFile = /* @__PURE__ */ new Map();
1702
+ for (const [key, loc] of exports) {
1703
+ const symbolName = key.split("::")[1];
1704
+ if (!imports.has(symbolName)) {
1705
+ deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
1706
+ }
1707
+ }
1708
+ const findings = [];
1709
+ let totalDead = 0;
1710
+ for (const [file, count] of deadByFile) {
1711
+ totalDead += count;
1712
+ }
1713
+ if (totalDead >= THRESHOLD) {
1714
+ const topFiles = [...deadByFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([f, c]) => `${f} (${c})`);
1715
+ findings.push({
1716
+ ruleId: "dead-exports",
1717
+ file: "(project)",
1718
+ line: 1,
1719
+ column: 1,
1720
+ message: `${totalDead} exported symbols never imported \u2014 dead exports pollute AI context. Top files: ${topFiles.join(", ")}`,
1721
+ severity: totalDead > 20 ? "warning" : "info",
1722
+ category: "ai-quality"
1723
+ });
1724
+ }
1725
+ return findings;
1726
+ }
1727
+ };
1728
+
1729
+ // src/rules/shallow-catch.ts
1730
+ function scoreCatchBody(bodyLines) {
1731
+ if (bodyLines.length === 0 || bodyLines.every((l) => l.trim() === "")) {
1732
+ return { score: 0, label: "empty catch" };
1733
+ }
1734
+ const body = bodyLines.join("\n");
1735
+ let score = 0;
1736
+ if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
1737
+ if (/console\.(error|warn)\s*\(/.test(body) && /\b(err|error|e)\b/.test(body)) score = 2;
1738
+ if (/\bthrow\b/.test(body) || /\breturn\b.*(?:error|err|status|Response|NextResponse|json)/.test(body) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body)) {
1739
+ score = 3;
1740
+ }
1741
+ const labels = {
1742
+ 0: "empty catch",
1743
+ 1: "catch only logs (no recovery or propagation)",
1744
+ 2: "catch logs error but does not propagate or recover",
1745
+ 3: "catch handles error properly"
1746
+ };
1747
+ return { score, label: labels[score] };
1748
+ }
1749
+ var shallowCatchRule = {
1750
+ id: "shallow-catch",
1751
+ name: "Shallow Error Handler",
1752
+ description: "Detects catch blocks that exist but do nothing useful \u2014 decorative error handling",
1753
+ category: "reliability",
1754
+ severity: "warning",
1755
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1756
+ check(file, _project) {
1757
+ if (isTestFile(file.relativePath)) return [];
1758
+ if (isScriptFile(file.relativePath)) return [];
1759
+ const findings = [];
1760
+ for (let i = 0; i < file.lines.length; i++) {
1761
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1762
+ const trimmed = file.lines[i].trim();
1763
+ if (!/\bcatch\s*\(/.test(trimmed) && !/\bcatch\s*\{/.test(trimmed)) continue;
1764
+ let braceStart = -1;
1765
+ for (let j = i; j < Math.min(i + 3, file.lines.length); j++) {
1766
+ if (file.lines[j].includes("{")) {
1767
+ braceStart = j;
1768
+ break;
1769
+ }
1770
+ }
1771
+ if (braceStart === -1) continue;
1772
+ let depth = 0;
1773
+ let bodyEnd = braceStart;
1774
+ for (let j = braceStart; j < file.lines.length; j++) {
1775
+ const line = file.lines[j];
1776
+ const startPos = j === braceStart ? line.indexOf("{") : 0;
1777
+ for (let k = startPos; k < line.length; k++) {
1778
+ if (line[k] === "{") depth++;
1779
+ if (line[k] === "}") {
1780
+ depth--;
1781
+ if (depth === 0) {
1782
+ bodyEnd = j;
1783
+ break;
1784
+ }
1785
+ }
1786
+ }
1787
+ if (depth === 0) break;
1788
+ }
1789
+ const bodyLines = file.lines.slice(braceStart + 1, bodyEnd);
1790
+ const { score, label } = scoreCatchBody(bodyLines);
1791
+ if (score <= 1) {
1792
+ findings.push({
1793
+ ruleId: "shallow-catch",
1794
+ file: file.relativePath,
1795
+ line: i + 1,
1796
+ column: file.lines[i].indexOf("catch") + 1,
1797
+ message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
1798
+ severity: score === 0 ? "warning" : "info",
1799
+ category: "reliability"
1800
+ });
1801
+ }
1802
+ i = bodyEnd;
1803
+ }
1804
+ return findings;
1805
+ }
1806
+ };
1807
+
1808
+ // src/rules/comprehension-debt.ts
1809
+ var MAX_FUNCTION_LENGTH = 80;
1810
+ var MAX_NESTING_DEPTH = 5;
1811
+ var MAX_PARAMS = 5;
1812
+ function isContentFile(relativePath) {
1813
+ return /(?:^|\/)content\//.test(relativePath) || /(?:^|\/)blog\//.test(relativePath) || /\(legal\)\//.test(relativePath);
1814
+ }
1815
+ var comprehensionDebtRule = {
1816
+ id: "comprehension-debt",
1817
+ name: "Comprehension Debt",
1818
+ description: "Detects code that is hard to understand \u2014 long functions, deep nesting, excessive parameters",
1819
+ category: "ai-quality",
1820
+ severity: "info",
1821
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
1822
+ check(file, _project) {
1823
+ if (isTestFile(file.relativePath)) return [];
1824
+ if (isScriptFile(file.relativePath)) return [];
1825
+ if (isConfigFile(file.relativePath)) return [];
1826
+ if (isContentFile(file.relativePath)) return [];
1827
+ const findings = [];
1828
+ let fnStart = -1;
1829
+ let fnName = "";
1830
+ let braceDepth = 0;
1831
+ let maxDepthInFn = 0;
1832
+ let fnBraceStart = -1;
1833
+ let inFunction = false;
1834
+ for (let i = 0; i < file.lines.length; i++) {
1835
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
1836
+ const line = file.lines[i];
1837
+ const trimmed = line.trim();
1838
+ const fnMatch = trimmed.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/) ?? trimmed.match(/(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?:=>|:)/);
1839
+ if (fnMatch && !inFunction) {
1840
+ fnName = fnMatch[1];
1841
+ fnStart = i;
1842
+ const params = fnMatch[2].split(",").filter((p) => p.trim()).length;
1843
+ if (params > MAX_PARAMS) {
1844
+ findings.push({
1845
+ ruleId: "comprehension-debt",
1846
+ file: file.relativePath,
1847
+ line: i + 1,
1848
+ column: 1,
1849
+ message: `${fnName}() has ${params} parameters (max ${MAX_PARAMS}) \u2014 hard to call correctly`,
1850
+ severity: "info",
1851
+ category: "ai-quality"
1852
+ });
1853
+ }
1854
+ }
1855
+ for (const ch of line) {
1856
+ if (ch === "{") {
1857
+ braceDepth++;
1858
+ if (fnStart >= 0 && fnBraceStart === -1) {
1859
+ fnBraceStart = braceDepth;
1860
+ inFunction = true;
1861
+ }
1862
+ if (inFunction && braceDepth - fnBraceStart > maxDepthInFn) {
1863
+ maxDepthInFn = braceDepth - fnBraceStart;
1864
+ }
1865
+ }
1866
+ if (ch === "}") {
1867
+ braceDepth--;
1868
+ if (inFunction && braceDepth < fnBraceStart) {
1869
+ const length = i - fnStart + 1;
1870
+ if (length > MAX_FUNCTION_LENGTH) {
1871
+ findings.push({
1872
+ ruleId: "comprehension-debt",
1873
+ file: file.relativePath,
1874
+ line: fnStart + 1,
1875
+ column: 1,
1876
+ message: `${fnName}() is ${length} lines long (max ${MAX_FUNCTION_LENGTH}) \u2014 consider splitting`,
1877
+ severity: "info",
1878
+ category: "ai-quality"
1879
+ });
1880
+ }
1881
+ if (maxDepthInFn > MAX_NESTING_DEPTH) {
1882
+ findings.push({
1883
+ ruleId: "comprehension-debt",
1884
+ file: file.relativePath,
1885
+ line: fnStart + 1,
1886
+ column: 1,
1887
+ message: `${fnName}() has nesting depth ${maxDepthInFn} (max ${MAX_NESTING_DEPTH}) \u2014 flatten with early returns`,
1888
+ severity: "info",
1889
+ category: "ai-quality"
1890
+ });
1891
+ }
1892
+ inFunction = false;
1893
+ fnStart = -1;
1894
+ fnBraceStart = -1;
1895
+ maxDepthInFn = 0;
1896
+ fnName = "";
1897
+ }
1898
+ }
1899
+ }
1900
+ }
1901
+ return findings;
1902
+ }
1903
+ };
1904
+
1905
+ // src/rules/phantom-dependency.ts
1906
+ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
1907
+ "huggingface-cli",
1908
+ // hallucinated, real: huggingface_hub
1909
+ "flask-hierarchical",
1910
+ // hallucinated
1911
+ "beautifulsoup",
1912
+ // real is beautifulsoup4
1913
+ "python-dotenv",
1914
+ // real is python-dotenv (this one exists but confusable)
1915
+ "openai-sdk",
1916
+ // hallucinated, real: openai
1917
+ "anthropic-sdk",
1918
+ // hallucinated, real: @anthropic-ai/sdk
1919
+ "langchain-core",
1920
+ // confusable with @langchain/core
1921
+ "react-native-utils",
1922
+ // hallucinated generic
1923
+ "next-middleware",
1924
+ // hallucinated
1925
+ "supabase-client",
1926
+ // hallucinated, real: @supabase/supabase-js
1927
+ "stripe-sdk",
1928
+ // hallucinated, real: stripe
1929
+ "prisma-client",
1930
+ // hallucinated, real: @prisma/client
1931
+ "tailwind-utils",
1932
+ // hallucinated
1933
+ "express-validator-v2",
1934
+ // hallucinated
1935
+ "node-postgres-pool",
1936
+ // hallucinated, real: pg
1937
+ "mongo-client",
1938
+ // hallucinated, real: mongodb
1939
+ "redis-client",
1940
+ // hallucinated, real: redis or ioredis
1941
+ "aws-s3-upload",
1942
+ // hallucinated
1943
+ "gpt-tokenizer"
1944
+ // exists but often confused
1945
+ ]);
1946
+ var SUSPICIOUS_PATTERNS = [
1947
+ /^[a-z]{1,2}$/,
1948
+ // 1-2 char names
1949
+ /-js$/,
1950
+ // redundant -js suffix often hallucinated
1951
+ /^(the|my|simple|easy|fast|super|mega|ultra)-/
1952
+ // vanity prefixes
1953
+ ];
1954
+ var phantomDependencyRule = {
1955
+ id: "phantom-dependency",
1956
+ name: "Phantom Dependency",
1957
+ description: "Detects commonly hallucinated or suspicious package names \u2014 slopsquatting prevention",
1958
+ category: "security",
1959
+ severity: "warning",
1960
+ fileExtensions: [],
1961
+ check() {
1962
+ return [];
1963
+ },
1964
+ checkProject(_files, project) {
1965
+ if (!project.packageJson) return [];
1966
+ const findings = [];
1967
+ const deps = {
1968
+ ...project.packageJson.dependencies ?? {},
1969
+ ...project.packageJson.devDependencies ?? {}
1970
+ };
1971
+ for (const [name, _version] of Object.entries(deps)) {
1972
+ if (PHANTOM_PACKAGES.has(name)) {
1973
+ findings.push({
1974
+ ruleId: "phantom-dependency",
1975
+ file: "package.json",
1976
+ line: 1,
1977
+ column: 1,
1978
+ message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
1979
+ severity: "warning",
1980
+ category: "security"
1981
+ });
1982
+ }
1983
+ for (const pattern of SUSPICIOUS_PATTERNS) {
1984
+ if (pattern.test(name) && !name.startsWith("@")) {
1985
+ findings.push({
1986
+ ruleId: "phantom-dependency",
1987
+ file: "package.json",
1988
+ line: 1,
1989
+ column: 1,
1990
+ message: `"${name}" has a suspicious package name pattern \u2014 verify it's legitimate`,
1991
+ severity: "info",
1992
+ category: "security"
1993
+ });
1994
+ break;
1995
+ }
1996
+ }
1997
+ }
1998
+ return findings;
1999
+ }
2000
+ };
2001
+
983
2002
  // src/rules/index.ts
984
2003
  var rules = [
2004
+ // Security
985
2005
  secretsRule,
986
- hallucinatedImportsRule,
987
2006
  authChecksRule,
988
2007
  envExposureRule,
989
- errorHandlingRule,
990
2008
  inputValidationRule,
991
- rateLimitingRule,
992
2009
  corsConfigRule,
993
- aiSmellsRule,
994
2010
  unsafeHtmlRule,
995
- sqlInjectionRule
2011
+ sqlInjectionRule,
2012
+ openRedirectRule,
2013
+ rateLimitingRule,
2014
+ phantomDependencyRule,
2015
+ // Reliability
2016
+ hallucinatedImportsRule,
2017
+ errorHandlingRule,
2018
+ unhandledPromiseRule,
2019
+ shallowCatchRule,
2020
+ missingLoadingStateRule,
2021
+ missingErrorBoundaryRule,
2022
+ // Performance
2023
+ noSyncFsRule,
2024
+ noNPlusOneRule,
2025
+ noUnboundedQueryRule,
2026
+ noDynamicImportLoopRule,
2027
+ // AI Quality
2028
+ aiSmellsRule,
2029
+ placeholderContentRule,
2030
+ hallucinatedApiRule,
2031
+ staleFallbackRule,
2032
+ comprehensionDebtRule,
2033
+ codebaseConsistencyRule,
2034
+ deadExportsRule
996
2035
  ];
997
2036
 
998
2037
  // src/scanner.ts
@@ -1003,9 +2042,11 @@ async function scan(options) {
1003
2042
  const filePaths = await walkFiles(root, options.ignore);
1004
2043
  const project = await buildProjectContext(root, filePaths);
1005
2044
  const findings = [];
2045
+ const allFiles = [];
1006
2046
  for (const relativePath of filePaths) {
1007
2047
  const file = await readFileContext(root, relativePath);
1008
2048
  if (!file) continue;
2049
+ allFiles.push(file);
1009
2050
  for (const rule of rules) {
1010
2051
  if (rule.fileExtensions.length > 0 && !rule.fileExtensions.includes(file.ext)) {
1011
2052
  continue;
@@ -1018,6 +2059,12 @@ async function scan(options) {
1018
2059
  }
1019
2060
  }
1020
2061
  }
2062
+ for (const rule of rules) {
2063
+ if (rule.checkProject) {
2064
+ const projectFindings = rule.checkProject(allFiles, project);
2065
+ findings.push(...projectFindings);
2066
+ }
2067
+ }
1021
2068
  const { overallScore, categoryScores } = calculateScores(findings);
1022
2069
  const summary = summarizeFindings(findings);
1023
2070
  return {