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