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