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