prodlint 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -10,7 +10,7 @@ import { resolve as resolve4, sep as sep2 } from "path";
10
10
  // src/utils/file-walker.ts
11
11
  import fg from "fast-glob";
12
12
  import { readFile, stat, realpath } from "fs/promises";
13
- import { resolve, extname, sep } from "path";
13
+ import { resolve, extname, basename, sep } from "path";
14
14
 
15
15
  // src/utils/patterns.ts
16
16
  function isApiRoute(relativePath) {
@@ -53,6 +53,19 @@ function isCommentLine(lines, lineIndex, commentMap) {
53
53
  const trimmed = lines[lineIndex]?.trim() ?? "";
54
54
  return trimmed.startsWith("//");
55
55
  }
56
+ var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
57
+ function blankOut(source, strings) {
58
+ const blank = (s) => s.replace(/[^\n]/g, " ");
59
+ return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
60
+ }
61
+ function closingBrace(code, open) {
62
+ let depth = 0;
63
+ for (let i = open; i < code.length; i++) {
64
+ if (code[i] === "{") depth++;
65
+ else if (code[i] === "}" && --depth === 0) return i;
66
+ }
67
+ return -1;
68
+ }
56
69
  function isLineSuppressed(lines, lineIndex, ruleId) {
57
70
  for (const line of lines) {
58
71
  const trimmed = line.trim();
@@ -443,7 +456,8 @@ async function readFileContext(root, relativePath) {
443
456
  if (fileStats.size > MAX_FILE_SIZE) return null;
444
457
  const content = await readFile(absolutePath, "utf-8");
445
458
  const lines = content.split(/\r?\n|\r/);
446
- const ext = extname(relativePath).slice(1);
459
+ const rawExt = extname(relativePath).slice(1);
460
+ const ext = /^\.env(?:\.|$)/.test(basename(relativePath)) && !SCAN_EXTENSIONS.includes(rawExt) ? "env" : rawExt;
447
461
  let ast = void 0;
448
462
  if (AST_EXTENSIONS.has(ext)) {
449
463
  try {
@@ -712,12 +726,12 @@ function calculateScores(findings) {
712
726
  }
713
727
  let totalDeduction = 0;
714
728
  for (const [, ruleFindings] of byRule) {
715
- const bySeverity = { critical: 0, warning: 0, info: 0 };
729
+ const bySeverity2 = { critical: 0, warning: 0, info: 0 };
716
730
  for (const f of ruleFindings) {
717
- bySeverity[f.severity]++;
731
+ bySeverity2[f.severity]++;
718
732
  }
719
733
  for (const sev of ["critical", "warning", "info"]) {
720
- const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
734
+ const count = Math.min(bySeverity2[sev], PER_RULE_CAP[sev]);
721
735
  totalDeduction += count * DEDUCTIONS[sev];
722
736
  }
723
737
  }
@@ -749,12 +763,21 @@ function summarizeFindings(findings) {
749
763
  }
750
764
 
751
765
  // src/rules/secrets.ts
766
+ function nameJwt(token) {
767
+ try {
768
+ const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
769
+ if (claims?.role === "anon") return null;
770
+ if (claims?.role === "service_role") return "Supabase service role key";
771
+ } catch {
772
+ }
773
+ return "JWT";
774
+ }
752
775
  var SECRET_PATTERNS = [
753
776
  { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
754
777
  { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
755
778
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
756
779
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
757
- { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
780
+ { name: "JWT", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/, nameMatch: nameJwt },
758
781
  { name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
759
782
  { name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
760
783
  { name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)[0-9]{2}-[a-zA-Z0-9_\-]{20,}/ },
@@ -789,15 +812,17 @@ var secretsRule = {
789
812
  for (let i = 0; i < file.lines.length; i++) {
790
813
  const line = file.lines[i];
791
814
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
792
- for (const { name, pattern } of SECRET_PATTERNS) {
815
+ for (const { name, pattern, nameMatch } of SECRET_PATTERNS) {
793
816
  const match = pattern.exec(line);
794
817
  if (match) {
818
+ const reported = nameMatch ? nameMatch(match[0]) : name;
819
+ if (!reported) continue;
795
820
  findings.push({
796
821
  ruleId: "secrets",
797
822
  file: file.relativePath,
798
823
  line: i + 1,
799
824
  column: match.index + 1,
800
- message: `Hardcoded ${name} detected`,
825
+ message: `Hardcoded ${reported} detected`,
801
826
  severity: "critical",
802
827
  category: "security",
803
828
  fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
@@ -953,12 +978,208 @@ var AUTH_PATTERNS = [
953
978
  /createRouteHandlerClient/,
954
979
  /createServerComponentClient/,
955
980
  /createMiddlewareClient/,
956
- /authorization/i,
981
+ // Reading the request's Authorization header, not sending one upstream:
982
+ // headers.get('authorization'), req.headers.authorization, req.headers['authorization'], req.header('Authorization')
983
+ /\.(?:get|header)\(\s*['"`]authorization['"`]/i,
984
+ /\.headers\??\.authorization\b(?!\s*=(?!=))/i,
985
+ /\.headers\[\s*['"`]authorization['"`]\s*\](?!\s*=(?!=))/i,
957
986
  /getAuth\s*\(/,
958
987
  /withPageAuth/,
959
988
  /cookies\(\).*auth/s
960
989
  ];
961
- var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
990
+ var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
991
+ var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
992
+ var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
993
+ var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
994
+ var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
995
+ function words(name) {
996
+ return name.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
997
+ }
998
+ function saysAuth(name) {
999
+ const w = words(name);
1000
+ return w.some((x) => AUTH_WORD.test(x)) && !w.some((x) => NO_AUTH_WORD.test(x));
1001
+ }
1002
+ function isAuthHelper(name, source) {
1003
+ if (words(name).some((x) => NO_AUTH_WORD.test(x))) return false;
1004
+ return saysAuth(name) || /^with[A-Z0-9_$]/.test(name) && source !== void 0 && saysAuth(source);
1005
+ }
1006
+ var AUTHED = { authed: true, local: true };
1007
+ var LOCAL = { authed: false, local: true };
1008
+ var ELSEWHERE = { authed: false, local: false };
1009
+ function handlersFromAst(program) {
1010
+ const imports = /* @__PURE__ */ new Map();
1011
+ const bindings = /* @__PURE__ */ new Map();
1012
+ for (const st of program.body) {
1013
+ if (st.type === "ImportDeclaration") {
1014
+ for (const s of st.specifiers) imports.set(s.local.name, st.source.value);
1015
+ continue;
1016
+ }
1017
+ const decl = st.type === "ExportNamedDeclaration" || st.type === "ExportDefaultDeclaration" ? st.declaration : st;
1018
+ if (decl?.type === "FunctionDeclaration" && decl.id) bindings.set(decl.id.name, decl);
1019
+ if (decl?.type === "VariableDeclaration") {
1020
+ for (const d of decl.declarations) {
1021
+ if (d.id.type === "Identifier" && d.init) bindings.set(d.id.name, d.init);
1022
+ }
1023
+ }
1024
+ }
1025
+ const helperOf = (callee) => {
1026
+ if (callee.type === "CallExpression") return helperOf(callee.callee);
1027
+ if (callee.type === "Identifier") return { name: callee.name, source: imports.get(callee.name) };
1028
+ if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
1029
+ let root = callee.object;
1030
+ while (root.type === "MemberExpression") root = root.object;
1031
+ return { name: callee.property.name, source: root.type === "Identifier" ? imports.get(root.name) : void 0 };
1032
+ }
1033
+ return null;
1034
+ };
1035
+ const classify = (node, seen) => {
1036
+ while (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression" || node.type === "AwaitExpression") {
1037
+ node = node.type === "AwaitExpression" ? node.argument : node.expression;
1038
+ }
1039
+ switch (node.type) {
1040
+ case "FunctionDeclaration":
1041
+ case "FunctionExpression":
1042
+ case "ArrowFunctionExpression":
1043
+ return LOCAL;
1044
+ case "Identifier": {
1045
+ const bound = bindings.get(node.name);
1046
+ if (!bound || seen.has(node.name)) return ELSEWHERE;
1047
+ return classify(bound, new Set(seen).add(node.name));
1048
+ }
1049
+ case "CallExpression": {
1050
+ const helper = helperOf(node.callee);
1051
+ if (helper && isAuthHelper(helper.name, helper.source)) return AUTHED;
1052
+ let local = false;
1053
+ for (const arg of node.callee.type === "CallExpression" ? [node.callee, ...node.arguments] : node.arguments) {
1054
+ const verdict = classify(arg, seen);
1055
+ if (verdict.authed) return verdict;
1056
+ local ||= verdict.local;
1057
+ }
1058
+ return { authed: false, local };
1059
+ }
1060
+ }
1061
+ return ELSEWHERE;
1062
+ };
1063
+ const handlers = [];
1064
+ const add = (method, at, verdict) => {
1065
+ handlers.push({ method, line: at.loc?.start.line ?? 1, ...verdict });
1066
+ };
1067
+ for (const st of program.body) {
1068
+ if (st.type === "ExportNamedDeclaration") {
1069
+ const decl = st.declaration;
1070
+ if (decl?.type === "FunctionDeclaration") {
1071
+ if (decl.id && HANDLER_METHODS.has(decl.id.name)) add(decl.id.name, st, LOCAL);
1072
+ } else if (decl?.type === "VariableDeclaration") {
1073
+ for (const d of decl.declarations) {
1074
+ if (d.id.type === "Identifier" && HANDLER_METHODS.has(d.id.name)) {
1075
+ add(d.id.name, d, d.init ? classify(d.init, /* @__PURE__ */ new Set()) : ELSEWHERE);
1076
+ } else if (d.id.type === "ObjectPattern") {
1077
+ for (const p of d.id.properties) {
1078
+ if (p.type === "ObjectProperty" && p.value.type === "Identifier" && HANDLER_METHODS.has(p.value.name)) {
1079
+ add(p.value.name, d, ELSEWHERE);
1080
+ }
1081
+ }
1082
+ }
1083
+ }
1084
+ } else if (!decl) {
1085
+ for (const s of st.specifiers) {
1086
+ if (s.type !== "ExportSpecifier") continue;
1087
+ const name = s.exported.type === "Identifier" ? s.exported.name : s.exported.value;
1088
+ if (name === "default" || HANDLER_METHODS.has(name)) add(name, s, st.source ? ELSEWHERE : classify(s.local, /* @__PURE__ */ new Set()));
1089
+ }
1090
+ }
1091
+ } else if (st.type === "ExportDefaultDeclaration") {
1092
+ add("default", st, classify(st.declaration, /* @__PURE__ */ new Set()));
1093
+ } else if (st.type === "ExportAllDeclaration") {
1094
+ add("*", st, ELSEWHERE);
1095
+ }
1096
+ }
1097
+ return handlers;
1098
+ }
1099
+ var METHOD = "(GET|HEAD|POST|PUT|PATCH|DELETE)";
1100
+ var EXPORT_FUNCTION = new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s*\\*?\\s*${METHOD}(?![\\w$])`);
1101
+ var EXPORT_CONST = new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+${METHOD}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
1102
+ var EXPORT_DESTRUCTURED = /^\s*export\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/;
1103
+ var EXPORT_DEFAULT = /^\s*export\s+default\s+(.*)$/;
1104
+ var EXPORT_ALL = /^\s*export\s*\*\s*from\b/;
1105
+ var EXPORT_LIST = /\bexport\s*\{([^}]*)\}(\s*from\b)?/g;
1106
+ var IMPORT = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s+from\s*['"]([^'"]+)['"]/g;
1107
+ var INLINE_FUNCTION = /^(?:async\b|function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/;
1108
+ var CALL = /^([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))*\s*(?:<[^>()]*>)?\s*\(/;
1109
+ var IDENTIFIER = /^([A-Za-z_$][\w$]*)\s*(?:[,);\n]|$)/;
1110
+ function handlersFromText(file) {
1111
+ const imports = /* @__PURE__ */ new Map();
1112
+ for (const m of file.content.matchAll(IMPORT)) {
1113
+ for (const part of m[1].replace(/[{}]/g, ",").split(",")) {
1114
+ const spec = /^\s*(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
1115
+ if (spec) imports.set(spec[2] ?? spec[1], m[2]);
1116
+ }
1117
+ }
1118
+ const code = (i) => !isCommentLine(file.lines, i, file.commentMap);
1119
+ const value = (i, head) => [head, ...file.lines.slice(i + 1, i + 5).filter((_, k) => code(i + 1 + k))].join("\n");
1120
+ const resolve5 = (name, seen) => {
1121
+ const id = name.replace(/\$/g, "\\$");
1122
+ const fn = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${id}(?![\\w$])`);
1123
+ const variable = new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${id}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
1124
+ for (let i = 0; i < file.lines.length; i++) {
1125
+ if (!code(i)) continue;
1126
+ if (fn.test(file.lines[i])) return LOCAL;
1127
+ const m = variable.exec(file.lines[i]);
1128
+ if (m) return classify(value(i, m[1]), seen);
1129
+ }
1130
+ return ELSEWHERE;
1131
+ };
1132
+ const classify = (text, seen) => {
1133
+ const t = text.trim().replace(/^await\s+/, "");
1134
+ if (INLINE_FUNCTION.test(t)) return LOCAL;
1135
+ const call = CALL.exec(t);
1136
+ if (call) {
1137
+ if (isAuthHelper(call[2] ?? call[1], imports.get(call[1]))) return AUTHED;
1138
+ let args = t.slice(call[0].length);
1139
+ const curried = /^[^()]*\)\s*\(/.exec(args);
1140
+ if (curried) args = args.slice(curried[0].length);
1141
+ return classify(args, seen);
1142
+ }
1143
+ const id = IDENTIFIER.exec(t);
1144
+ if (id && !seen.has(id[1])) return resolve5(id[1], new Set(seen).add(id[1]));
1145
+ return ELSEWHERE;
1146
+ };
1147
+ const handlers = [];
1148
+ for (let i = 0; i < file.lines.length; i++) {
1149
+ if (!code(i)) continue;
1150
+ const line = file.lines[i];
1151
+ let m;
1152
+ if (m = EXPORT_FUNCTION.exec(line)) {
1153
+ handlers.push({ method: m[1], line: i + 1, ...LOCAL });
1154
+ } else if (m = EXPORT_CONST.exec(line)) {
1155
+ handlers.push({ method: m[1], line: i + 1, ...classify(value(i, m[2]), /* @__PURE__ */ new Set()) });
1156
+ } else if (m = EXPORT_DESTRUCTURED.exec(line)) {
1157
+ for (const part of m[1].split(",")) {
1158
+ const name = part.split(":").pop()?.trim() ?? "";
1159
+ if (HANDLER_METHODS.has(name)) handlers.push({ method: name, line: i + 1, ...ELSEWHERE });
1160
+ }
1161
+ } else if (m = EXPORT_DEFAULT.exec(line)) {
1162
+ handlers.push({ method: "default", line: i + 1, ...classify(value(i, m[1]), /* @__PURE__ */ new Set()) });
1163
+ } else if (EXPORT_ALL.test(line)) {
1164
+ handlers.push({ method: "*", line: i + 1, ...ELSEWHERE });
1165
+ }
1166
+ }
1167
+ const lineAt = (offset) => file.content.slice(0, offset).split("\n").length;
1168
+ for (const m of file.content.matchAll(EXPORT_LIST)) {
1169
+ if (!code(lineAt(m.index) - 1)) continue;
1170
+ let offset = m.index + m[0].indexOf("{") + 1;
1171
+ for (const part of m[1].split(",")) {
1172
+ const spec = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
1173
+ const name = spec ? spec[2] ?? spec[1] : "";
1174
+ if (spec && (name === "default" || HANDLER_METHODS.has(name))) {
1175
+ const line = lineAt(offset + part.length - part.trimStart().length);
1176
+ handlers.push({ method: name, line, ...m[2] ? ELSEWHERE : classify(spec[1], /* @__PURE__ */ new Set()) });
1177
+ }
1178
+ offset += part.length + 1;
1179
+ }
1180
+ }
1181
+ return handlers.sort((a, b) => a.line - b.line);
1182
+ }
962
1183
  var authChecksRule = {
963
1184
  id: "auth-checks",
964
1185
  name: "Missing Auth Checks",
@@ -974,21 +1195,18 @@ var authChecksRule = {
974
1195
  for (const pattern of AUTH_PATTERNS) {
975
1196
  if (pattern.test(file.content)) return [];
976
1197
  }
1198
+ const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
1199
+ const exposed = handlers.filter((h) => !h.authed);
1200
+ if (handlers.length > 0 && exposed.length === 0) return [];
977
1201
  let severity;
978
1202
  if (project.hasAuthMiddleware) {
979
1203
  severity = "info";
980
- } else if (MUTATION_EXPORT.test(file.content)) {
1204
+ } else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
981
1205
  severity = "critical";
982
1206
  } else {
983
1207
  severity = "info";
984
1208
  }
985
- let handlerLine = 1;
986
- for (let i = 0; i < file.lines.length; i++) {
987
- if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
988
- handlerLine = i + 1;
989
- break;
990
- }
991
- }
1209
+ const handlerLine = exposed[0]?.line ?? 1;
992
1210
  const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
993
1211
  return [{
994
1212
  ruleId: "auth-checks",
@@ -1107,7 +1325,8 @@ var errorHandlingRule = {
1107
1325
 
1108
1326
  // src/rules/input-validation.ts
1109
1327
  var VALIDATION_PATTERNS = [
1110
- /\.parse\s*\(/,
1328
+ // A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
1329
+ /(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
1111
1330
  /\.safeParse\s*\(/,
1112
1331
  /\.validate\s*\(/,
1113
1332
  /\.validateSync\s*\(/,
@@ -1408,6 +1627,40 @@ var aiSmellsRule = {
1408
1627
  };
1409
1628
 
1410
1629
  // src/rules/unsafe-html.ts
1630
+ var SANITIZER_NAME = /^(?:sanitize\w*|xss|filterXSS)$/i;
1631
+ var SANITIZER_CALL_TEXT = /^\s*(?:await\s+)?(?:[\w$.]*\.sanitize|sanitize\w*|xss|filterXSS)\s*\(/i;
1632
+ function isSanitizerCall(node) {
1633
+ if (node.type === "AwaitExpression") return isSanitizerCall(node.argument);
1634
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression") {
1635
+ return isSanitizerCall(node.expression);
1636
+ }
1637
+ if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return false;
1638
+ const callee = node.callee;
1639
+ if (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") {
1640
+ return callee.property.type === "Identifier" && callee.property.name === "sanitize";
1641
+ }
1642
+ return callee.type === "Identifier" && SANITIZER_NAME.test(callee.name);
1643
+ }
1644
+ function containsJsonStringify(node) {
1645
+ return subtreeContains(node, (n) => {
1646
+ if (n.type !== "CallExpression") return false;
1647
+ const call = n;
1648
+ if (call.callee.type === "MemberExpression") {
1649
+ const mem = call.callee;
1650
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1651
+ }
1652
+ return false;
1653
+ });
1654
+ }
1655
+ function isSanitized(value, sanitizedNames) {
1656
+ if (value.type === "JSXExpressionContainer") return isSanitized(value.expression, sanitizedNames);
1657
+ if (value.type === "ObjectExpression") {
1658
+ const html = value.properties.find((p) => p.type === "ObjectProperty" && (p.key.type === "Identifier" && p.key.name === "__html" || p.key.type === "StringLiteral" && p.key.value === "__html"));
1659
+ return html?.type === "ObjectProperty" && isSanitized(html.value, sanitizedNames);
1660
+ }
1661
+ if (value.type === "Identifier") return sanitizedNames.has(value.name);
1662
+ return isSanitizerCall(value);
1663
+ }
1411
1664
  var unsafeHtmlRule = {
1412
1665
  id: "unsafe-html",
1413
1666
  name: "Unsafe HTML Rendering",
@@ -1419,21 +1672,17 @@ var unsafeHtmlRule = {
1419
1672
  const findings = [];
1420
1673
  if (file.ast) {
1421
1674
  try {
1675
+ const sanitizedNames = /* @__PURE__ */ new Set();
1676
+ walkAST(file.ast.program, (node) => {
1677
+ if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && subtreeContains(node.init, isSanitizerCall)) {
1678
+ sanitizedNames.add(node.id.name);
1679
+ }
1680
+ });
1422
1681
  walkAST(file.ast.program, (node) => {
1423
1682
  if (node.type === "JSXAttribute") {
1424
1683
  const attr = node;
1425
1684
  if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
1426
- if (attr.value && subtreeContains(attr.value, (n) => {
1427
- if (n.type !== "CallExpression") return false;
1428
- const call = n;
1429
- if (call.callee.type === "MemberExpression") {
1430
- const mem = call.callee;
1431
- return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1432
- }
1433
- return false;
1434
- })) {
1435
- return;
1436
- }
1685
+ if (attr.value && (containsJsonStringify(attr.value) || isSanitized(attr.value, sanitizedNames))) return;
1437
1686
  const line = attr.loc.start.line;
1438
1687
  findings.push({
1439
1688
  ruleId: "unsafe-html",
@@ -1450,17 +1699,7 @@ var unsafeHtmlRule = {
1450
1699
  if (node.type === "ObjectProperty") {
1451
1700
  const prop = node;
1452
1701
  if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
1453
- if (prop.value && subtreeContains(prop.value, (n) => {
1454
- if (n.type !== "CallExpression") return false;
1455
- const call = n;
1456
- if (call.callee.type === "MemberExpression") {
1457
- const mem = call.callee;
1458
- return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1459
- }
1460
- return false;
1461
- })) {
1462
- return;
1463
- }
1702
+ if (prop.value && (containsJsonStringify(prop.value) || isSanitized(prop.value, sanitizedNames))) return;
1464
1703
  const line = prop.loc.start.line;
1465
1704
  findings.push({
1466
1705
  ruleId: "unsafe-html",
@@ -1477,6 +1716,7 @@ var unsafeHtmlRule = {
1477
1716
  if (node.type === "AssignmentExpression") {
1478
1717
  const assign = node;
1479
1718
  if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
1719
+ if (isSanitized(assign.right, sanitizedNames)) return;
1480
1720
  const line = assign.loc.start.line;
1481
1721
  findings.push({
1482
1722
  ruleId: "unsafe-html",
@@ -1507,6 +1747,8 @@ var unsafeHtmlRule = {
1507
1747
  }
1508
1748
  const expr = context.join(" ");
1509
1749
  if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
1750
+ const html = /__html\s*:/.exec(expr);
1751
+ if (html && SANITIZER_CALL_TEXT.test(expr.slice(html.index + html[0].length))) continue;
1510
1752
  findings.push({
1511
1753
  ruleId: "unsafe-html",
1512
1754
  file: file.relativePath,
@@ -1518,7 +1760,8 @@ var unsafeHtmlRule = {
1518
1760
  fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1519
1761
  });
1520
1762
  }
1521
- if (/\w\.innerHTML\s*=/.test(line)) {
1763
+ const innerHtml = /\w\.innerHTML\s*=/.exec(line);
1764
+ if (innerHtml && !SANITIZER_CALL_TEXT.test(line.slice(innerHtml.index + innerHtml[0].length))) {
1522
1765
  findings.push({
1523
1766
  ruleId: "unsafe-html",
1524
1767
  file: file.relativePath,
@@ -1697,9 +1940,16 @@ var staleFallbackRule = {
1697
1940
  };
1698
1941
 
1699
1942
  // src/rules/hallucinated-api.ts
1943
+ var DOM_CONTAINS_RECEIVER = /(?:\b(?:classList|relList)|\.current|\bdocument(?:\.(?:body|documentElement|head))?)\??$/;
1944
+ var DOM_CONTAINS_ARGUMENT = /^\s*(?:[\w$.?]*\.(?:target|relatedTarget|currentTarget|activeElement)\b|[^)]*\bas\s+(?:Node|Element|HTML\w*Element)\b)/;
1700
1945
  var HALLUCINATED_APIS = [
1701
1946
  { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
1702
- { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
1947
+ {
1948
+ pattern: /\.contains\s*\(/,
1949
+ fix: "Use .includes() instead of .contains() on arrays and strings \u2014 DOM classList and Node .contains() are valid",
1950
+ methodName: "contains",
1951
+ isValid: (before, after) => DOM_CONTAINS_RECEIVER.test(before) || DOM_CONTAINS_ARGUMENT.test(after)
1952
+ },
1703
1953
  { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
1704
1954
  { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
1705
1955
  { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
@@ -1721,10 +1971,11 @@ var hallucinatedApiRule = {
1721
1971
  for (let i = 0; i < file.lines.length; i++) {
1722
1972
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1723
1973
  const line = file.lines[i];
1724
- for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
1974
+ for (const { pattern, fix, methodName, isValid } of HALLUCINATED_APIS) {
1725
1975
  const match = pattern.exec(line);
1726
1976
  if (match) {
1727
1977
  if (isFrameworkSafeMethod(methodName, frameworks)) continue;
1978
+ if (isValid?.(line.slice(0, match.index), line.slice(match.index + match[0].length))) continue;
1728
1979
  findings.push({
1729
1980
  ruleId: "hallucinated-api",
1730
1981
  file: file.relativePath,
@@ -2364,6 +2615,13 @@ function isEntryPoint(relativePath) {
2364
2615
  const name = relativePath.split("/").pop() ?? "";
2365
2616
  return /^(page|layout|loading|error|not-found|route|middleware|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest)\.(tsx?|jsx?)$/.test(name) || /^index\.(tsx?|jsx?)$/.test(name) || name === "global-error.tsx" || name === "global-error.jsx";
2366
2617
  }
2618
+ var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
2619
+ var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
2620
+ var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
2621
+ var FROM_SOURCE = /from\s+['"]([^'"]+)['"]/g;
2622
+ function sourceBasename(source) {
2623
+ return source.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2624
+ }
2367
2625
  var THRESHOLD = 5;
2368
2626
  var deadExportsRule = {
2369
2627
  id: "dead-exports",
@@ -2383,6 +2641,7 @@ var deadExportsRule = {
2383
2641
  const imports = /* @__PURE__ */ new Map();
2384
2642
  const allImportedSymbols = /* @__PURE__ */ new Set();
2385
2643
  const importedFiles = /* @__PURE__ */ new Set();
2644
+ const namespaceImported = /* @__PURE__ */ new Set();
2386
2645
  for (const file of sourceFiles) {
2387
2646
  if (isEntryPoint(file.relativePath)) continue;
2388
2647
  let inTemplateLiteral = false;
@@ -2425,42 +2684,35 @@ var deadExportsRule = {
2425
2684
  }
2426
2685
  }
2427
2686
  }
2687
+ const addImport = (sym, source) => {
2688
+ allImportedSymbols.add(sym);
2689
+ const fromBasename = sourceBasename(source);
2690
+ if (fromBasename) {
2691
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2692
+ set.add(sym);
2693
+ imports.set(fromBasename, set);
2694
+ }
2695
+ };
2428
2696
  for (const file of files) {
2429
- for (const line of file.lines) {
2430
- let match;
2431
- const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
2432
- const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
2433
- const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
2434
- while ((match = bracesRe.exec(line)) !== null) {
2435
- const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
2436
- for (const sym of symbols) {
2437
- allImportedSymbols.add(sym);
2438
- if (fromBasename) {
2439
- const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2440
- set.add(sym);
2441
- imports.set(fromBasename, set);
2442
- }
2443
- }
2444
- }
2445
- const defaultRe = /import\s+(\w+)\s+from/g;
2446
- while ((match = defaultRe.exec(line)) !== null) {
2447
- allImportedSymbols.add(match[1]);
2448
- if (fromBasename) {
2449
- const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2450
- set.add(match[1]);
2451
- imports.set(fromBasename, set);
2452
- }
2453
- }
2454
- const fromRe = /from\s+['"]([^'"]+)['"]/g;
2455
- while ((match = fromRe.exec(line)) !== null) {
2456
- importedFiles.add(match[1]);
2457
- }
2697
+ for (const match of file.content.matchAll(IMPORT_NAMED)) {
2698
+ const symbols = match[1].replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()).filter(Boolean);
2699
+ for (const sym of symbols) addImport(sym, match[2]);
2700
+ }
2701
+ for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
2702
+ addImport(match[1], match[2]);
2703
+ }
2704
+ for (const match of file.content.matchAll(IMPORT_NAMESPACE)) {
2705
+ namespaceImported.add(sourceBasename(match[1]));
2706
+ }
2707
+ for (const match of file.content.matchAll(FROM_SOURCE)) {
2708
+ importedFiles.add(match[1]);
2458
2709
  }
2459
2710
  }
2460
2711
  const deadByFile = /* @__PURE__ */ new Map();
2461
2712
  for (const [key, loc] of exports) {
2462
2713
  const symbolName = key.split("::")[1];
2463
2714
  const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2715
+ if (namespaceImported.has(exportFileBasename)) continue;
2464
2716
  const importSet = imports.get(exportFileBasename);
2465
2717
  const isImported = importSet?.has(symbolName) ?? false;
2466
2718
  if (!isImported && !allImportedSymbols.has(symbolName)) {
@@ -2490,15 +2742,16 @@ var deadExportsRule = {
2490
2742
  };
2491
2743
 
2492
2744
  // src/rules/shallow-catch.ts
2493
- function scoreCatchBody(bodyLines) {
2494
- if (bodyLines.length === 0 || bodyLines.every((l) => l.trim() === "")) {
2495
- return { score: 0, label: "empty catch" };
2496
- }
2497
- const body = bodyLines.join("\n");
2745
+ function reportsError(value, param) {
2746
+ if (/error/i.test(value) || /\berr|Err|status|Response|json/.test(value)) return true;
2747
+ return param !== void 0 && new RegExp(`(?<![\\w$])${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(value);
2748
+ }
2749
+ function scoreCatchBody({ text: body, empty, returned, param }) {
2750
+ if (empty) return { score: 0, label: "empty catch" };
2498
2751
  let score = 0;
2499
2752
  if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
2500
2753
  if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
2501
- 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) || // Toast notifications (react-toastify, sonner, shadcn)
2754
+ if (/\bthrow\b/.test(body) || returned.some((value) => reportsError(value, param)) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body) || // Toast notifications (react-toastify, sonner, shadcn)
2502
2755
  /\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
2503
2756
  /\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
2504
2757
  /\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
@@ -2508,13 +2761,43 @@ function scoreCatchBody(bodyLines) {
2508
2761
  score = 3;
2509
2762
  }
2510
2763
  const labels = {
2511
- 0: "empty catch",
2764
+ 0: "catch swallows the error",
2512
2765
  1: "catch only logs (no recovery or propagation)",
2513
2766
  2: "catch logs error but does not propagate or recover",
2514
2767
  3: "catch handles error properly"
2515
2768
  };
2516
2769
  return { score, label: labels[score] };
2517
2770
  }
2771
+ function catchFinding(file, catchLine, body) {
2772
+ const { score, label } = scoreCatchBody(body);
2773
+ if (score > 1) return null;
2774
+ return {
2775
+ ruleId: "shallow-catch",
2776
+ file: file.relativePath,
2777
+ line: catchLine + 1,
2778
+ column: file.lines[catchLine].indexOf("catch") + 1,
2779
+ message: body.empty ? "Empty catch block \u2014 errors are silently swallowed" : score === 0 ? "Catch block swallows the error \u2014 it isn't logged, rethrown or returned" : `Decorative error handler: ${label}`,
2780
+ severity: score === 0 ? "warning" : "info",
2781
+ category: "reliability",
2782
+ fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
2783
+ };
2784
+ }
2785
+ function returnedValues(code, text) {
2786
+ const values = [];
2787
+ for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
2788
+ const start = m.index + "return".length;
2789
+ let end = start;
2790
+ for (let depth = 0; end < code.length; end++) {
2791
+ const ch = code[end];
2792
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
2793
+ else if (ch === ")" || ch === "]" || ch === "}") {
2794
+ if (depth-- === 0) break;
2795
+ } else if (depth === 0 && (ch === ";" || ch === "\n")) break;
2796
+ }
2797
+ values.push(text.slice(start, end));
2798
+ }
2799
+ return values;
2800
+ }
2518
2801
  var shallowCatchRule = {
2519
2802
  id: "shallow-catch",
2520
2803
  name: "Shallow Error Handler",
@@ -2531,95 +2814,45 @@ var shallowCatchRule = {
2531
2814
  walkAST(file.ast.program, (node) => {
2532
2815
  if (node.type !== "CatchClause") return;
2533
2816
  if (!node.loc) return;
2534
- const catchLine = node.loc.start.line - 1;
2817
+ const catchLine2 = node.loc.start.line - 1;
2535
2818
  const body = node.body;
2536
- if (!body || !body.loc) return;
2537
- const bodyStart = body.loc.start.line - 1;
2538
- const bodyEnd = body.loc.end.line - 1;
2539
- const bodyLines = bodyStart === bodyEnd ? [file.lines[bodyStart]] : file.lines.slice(bodyStart + 1, bodyEnd);
2540
- const { score, label } = scoreCatchBody(bodyLines);
2541
- if (score <= 1) {
2542
- findings.push({
2543
- ruleId: "shallow-catch",
2544
- file: file.relativePath,
2545
- line: catchLine + 1,
2546
- column: file.lines[catchLine].indexOf("catch") + 1,
2547
- message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
2548
- severity: score === 0 ? "warning" : "info",
2549
- category: "reliability",
2550
- fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
2551
- });
2552
- }
2819
+ if (body?.start == null || body.end == null) return;
2820
+ const returned = [];
2821
+ walkAST(body, (n) => {
2822
+ if (n.type === "ReturnStatement" && n.argument?.start != null && n.argument.end != null) {
2823
+ returned.push(file.content.slice(n.argument.start, n.argument.end));
2824
+ }
2825
+ });
2826
+ const finding = catchFinding(file, catchLine2, {
2827
+ text: file.content.slice(body.start + 1, body.end - 1),
2828
+ empty: body.body.length === 0,
2829
+ returned,
2830
+ param: node.param?.type === "Identifier" ? node.param.name : void 0
2831
+ });
2832
+ if (finding) findings.push(finding);
2553
2833
  });
2554
2834
  return findings;
2555
2835
  } catch {
2556
2836
  }
2557
2837
  }
2558
- for (let i = 0; i < file.lines.length; i++) {
2559
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
2560
- const trimmed = file.lines[i].trim();
2561
- if (!/\bcatch\s*\(/.test(trimmed) && !/\bcatch\s*\{/.test(trimmed)) continue;
2562
- let braceStart = -1;
2563
- for (let j = i; j < Math.min(i + 3, file.lines.length); j++) {
2564
- if (file.lines[j].includes("{")) {
2565
- braceStart = j;
2566
- break;
2567
- }
2568
- }
2569
- if (braceStart === -1) continue;
2570
- let depth = 0;
2571
- let bodyEnd = braceStart;
2572
- let inSingle = false;
2573
- let inDouble = false;
2574
- let inTemplate = false;
2575
- for (let j = braceStart; j < file.lines.length; j++) {
2576
- const line = file.lines[j];
2577
- const startPos = j === braceStart ? line.indexOf("{") : 0;
2578
- for (let k = startPos; k < line.length; k++) {
2579
- const ch = line[k];
2580
- const prev = k > 0 ? line[k - 1] : "";
2581
- const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
2582
- if (!escaped) {
2583
- if (ch === "'" && !inDouble && !inTemplate) {
2584
- inSingle = !inSingle;
2585
- continue;
2586
- }
2587
- if (ch === '"' && !inSingle && !inTemplate) {
2588
- inDouble = !inDouble;
2589
- continue;
2590
- }
2591
- if (ch === "`" && !inSingle && !inDouble) {
2592
- inTemplate = !inTemplate;
2593
- continue;
2594
- }
2595
- }
2596
- if (inSingle || inDouble || inTemplate) continue;
2597
- if (ch === "{") depth++;
2598
- if (ch === "}") {
2599
- depth--;
2600
- if (depth === 0) {
2601
- bodyEnd = j;
2602
- break;
2603
- }
2604
- }
2605
- }
2606
- if (depth === 0) break;
2607
- }
2608
- const bodyLines = file.lines.slice(braceStart + 1, bodyEnd);
2609
- const { score, label } = scoreCatchBody(bodyLines);
2610
- if (score <= 1) {
2611
- findings.push({
2612
- ruleId: "shallow-catch",
2613
- file: file.relativePath,
2614
- line: i + 1,
2615
- column: file.lines[i].indexOf("catch") + 1,
2616
- message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
2617
- severity: score === 0 ? "warning" : "info",
2618
- category: "reliability",
2619
- fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
2620
- });
2621
- }
2622
- i = bodyEnd;
2838
+ const source = file.lines.join("\n");
2839
+ const code = blankOut(source, true);
2840
+ const noComments = blankOut(source, false);
2841
+ const catchRe = /(?<![\w$.])catch\s*(?:\(([^)]*)\)\s*)?\{/g;
2842
+ let catchLine = 0;
2843
+ let scanned = 0;
2844
+ for (let m = catchRe.exec(code); m; m = catchRe.exec(code)) {
2845
+ for (; scanned < m.index; scanned++) if (code[scanned] === "\n") catchLine++;
2846
+ const open = m.index + m[0].length - 1;
2847
+ const close = closingBrace(code, open);
2848
+ if (close === -1) continue;
2849
+ const finding = catchFinding(file, catchLine, {
2850
+ text: source.slice(open + 1, close),
2851
+ empty: code.slice(open + 1, close).trim() === "",
2852
+ returned: returnedValues(code.slice(open + 1, close), noComments.slice(open + 1, close)),
2853
+ param: m[1]?.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1]
2854
+ });
2855
+ if (finding) findings.push(finding);
2623
2856
  }
2624
2857
  return findings;
2625
2858
  }
@@ -2726,47 +2959,46 @@ var comprehensionDebtRule = {
2726
2959
  };
2727
2960
 
2728
2961
  // src/rules/phantom-dependency.ts
2729
- var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
2730
- "huggingface-cli",
2731
- // hallucinated, real: huggingface_hub
2732
- "flask-hierarchical",
2733
- // hallucinated
2734
- "beautifulsoup",
2735
- // real is beautifulsoup4
2736
- "python-dotenv",
2737
- // real is python-dotenv (this one exists but confusable)
2738
- "openai-sdk",
2739
- // hallucinated, real: openai
2740
- "anthropic-sdk",
2741
- // hallucinated, real: @anthropic-ai/sdk
2742
- "langchain-core",
2743
- // confusable with @langchain/core
2744
- "react-native-utils",
2745
- // hallucinated generic
2746
- "next-middleware",
2747
- // hallucinated
2748
- "supabase-client",
2749
- // hallucinated, real: @supabase/supabase-js
2750
- "stripe-sdk",
2751
- // hallucinated, real: stripe
2752
- "prisma-client",
2753
- // hallucinated, real: @prisma/client
2754
- "tailwind-utils",
2755
- // hallucinated
2756
- "express-validator-v2",
2757
- // hallucinated
2758
- "node-postgres-pool",
2759
- // hallucinated, real: pg
2760
- "mongo-client",
2761
- // hallucinated, real: mongodb
2762
- "redis-client",
2763
- // hallucinated, real: redis or ioredis
2764
- "aws-s3-upload",
2765
- // hallucinated
2766
- "gpt-tokenizer"
2767
- // exists but often confused
2962
+ var PHANTOM_PACKAGES = /* @__PURE__ */ new Map([
2963
+ ["huggingface-cli", ["@huggingface/hub"]],
2964
+ // a Python CLI; an npm placeholder
2965
+ ["flask-hierarchical", []],
2966
+ // not on npm
2967
+ ["beautifulsoup", ["cheerio"]],
2968
+ // a Python library (beautifulsoup4); not on npm
2969
+ ["python-dotenv", ["dotenv"]],
2970
+ // a Python library; an npm placeholder
2971
+ ["openai-sdk", ["openai"]],
2972
+ // unpublished
2973
+ ["anthropic-sdk", ["@anthropic-ai/sdk"]],
2974
+ // an npm placeholder
2975
+ ["langchain-core", ["@langchain/core"]],
2976
+ // deprecated
2977
+ ["react-native-utils", []],
2978
+ // a 2015 native module
2979
+ ["next-middleware", []],
2980
+ // a 2017 0.0.1; Next.js has middleware built in
2981
+ ["supabase-client", ["@supabase/supabase-js"]],
2982
+ // unpublished
2983
+ ["stripe-sdk", ["stripe"]],
2984
+ // not on npm
2985
+ ["prisma-client", ["@prisma/client"]],
2986
+ // an empty 0.0.0 placeholder
2987
+ ["tailwind-utils", []],
2988
+ // a 2021 alpha
2989
+ ["express-validator-v2", ["express-validator"]],
2990
+ // not on npm
2991
+ ["node-postgres-pool", ["pg"]],
2992
+ // not on npm
2993
+ ["mongo-client", ["mongodb"]],
2994
+ // a 2013 wrapper
2995
+ ["redis-client", ["redis", "ioredis"]],
2996
+ // a 2015 client
2997
+ ["aws-s3-upload", ["@aws-sdk/client-s3"]]
2998
+ // a 2018 0.0.2
2768
2999
  ]);
2769
- var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
3000
+ var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
3001
+ // 2-char names
2770
3002
  "pg",
2771
3003
  "ws",
2772
3004
  "ms",
@@ -2780,7 +3012,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2780
3012
  "io",
2781
3013
  "db",
2782
3014
  "fp",
2783
- "rx"
3015
+ "rx",
3016
+ // -js suffix
3017
+ "crypto-js",
3018
+ "chroma-js",
3019
+ "libphonenumber-js",
3020
+ "ua-parser-js",
3021
+ "keycloak-js",
3022
+ "pusher-js",
3023
+ "matter-js",
3024
+ "amplitude-js",
3025
+ // Vanity prefixes
3026
+ "fast-glob",
3027
+ "fast-deep-equal",
3028
+ "fast-json-stable-stringify",
3029
+ "fast-xml-parser",
3030
+ "fast-levenshtein",
3031
+ "fast-safe-stringify",
3032
+ "fast-uri",
3033
+ "fast-equals",
3034
+ "fast-csv",
3035
+ "fast-check",
3036
+ "fast-diff",
3037
+ "simple-git",
3038
+ "simple-peer",
3039
+ "simple-icons"
2784
3040
  ]);
2785
3041
  var SUSPICIOUS_PATTERNS = [
2786
3042
  /^[a-z]{1,2}$/,
@@ -2790,6 +3046,9 @@ var SUSPICIOUS_PATTERNS = [
2790
3046
  /^(the|my|simple|easy|fast|super|mega|ultra)-/
2791
3047
  // vanity prefixes
2792
3048
  ];
3049
+ function quoteList(names) {
3050
+ return names.map((n) => `"${n}"`).join(" or ");
3051
+ }
2793
3052
  var phantomDependencyRule = {
2794
3053
  id: "phantom-dependency",
2795
3054
  name: "Phantom Dependency",
@@ -2808,20 +3067,21 @@ var phantomDependencyRule = {
2808
3067
  ...project.packageJson.devDependencies ?? {}
2809
3068
  };
2810
3069
  for (const [name, _version] of Object.entries(deps)) {
2811
- if (PHANTOM_PACKAGES.has(name)) {
3070
+ const meant = PHANTOM_PACKAGES.get(name);
3071
+ if (meant) {
2812
3072
  findings.push({
2813
3073
  ruleId: "phantom-dependency",
2814
3074
  file: "package.json",
2815
3075
  line: 1,
2816
3076
  column: 1,
2817
- message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
3077
+ message: meant.length > 0 ? `"${name}" is a commonly hallucinated package name \u2014 did you mean ${quoteList(meant)}?` : `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
2818
3078
  severity: "warning",
2819
3079
  category: "security",
2820
- fix: "Add the missing package to dependencies in package.json, or remove the import if the package is hallucinated"
3080
+ fix: meant.length > 0 ? `Check "${name}" on npm before relying on it. If you meant ${quoteList(meant)}, install that instead and update the code that imports "${name}"` : `Check "${name}" on npm before relying on it \u2014 it may not exist, or may not be the package you meant`
2821
3081
  });
2822
3082
  }
2823
3083
  for (const pattern of SUSPICIOUS_PATTERNS) {
2824
- if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
3084
+ if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
2825
3085
  findings.push({
2826
3086
  ruleId: "phantom-dependency",
2827
3087
  file: "package.json",
@@ -3007,10 +3267,11 @@ var insecureRandomRule = {
3007
3267
  };
3008
3268
 
3009
3269
  // src/rules/next-server-action-validation.ts
3010
- var USE_SERVER = /['"]use server['"]/;
3270
+ var USE_SERVER = /^\s*['"]use server['"]/m;
3011
3271
  var FORM_DATA_GET = /formData\.get\s*\(/;
3012
3272
  var VALIDATION_PATTERNS2 = [
3013
- /\.parse\s*\(/,
3273
+ // A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
3274
+ /(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
3014
3275
  /\.safeParse\s*\(/,
3015
3276
  /\bvalidate\s*\(/,
3016
3277
  /\.parseAsync\s*\(/,
@@ -3161,6 +3422,30 @@ function findEnclosingFunction(node, parentMap) {
3161
3422
  }
3162
3423
 
3163
3424
  // src/rules/redirect-in-try-catch.ts
3425
+ var RETHROW_HELPERS = /^(?:unstable_rethrow|isRedirectError|isNextRouterError)$/;
3426
+ function rethrowsRedirect(handler) {
3427
+ const param = handler.param?.type === "Identifier" ? handler.param.name : void 0;
3428
+ if (param && handler.body.body.some((statement) => statement.type === "ThrowStatement" && statement.argument.type === "Identifier" && statement.argument.name === param)) {
3429
+ return true;
3430
+ }
3431
+ return subtreeContains(handler.body, (n) => n.type === "CallExpression" && n.callee.type === "Identifier" && RETHROW_HELPERS.test(n.callee.name) || n.type === "StringLiteral" && n.value.includes("NEXT_REDIRECT"));
3432
+ }
3433
+ function bodyRethrowsRedirect(body, param) {
3434
+ if (/\b(?:unstable_rethrow|isRedirectError|isNextRouterError)\s*\(|NEXT_REDIRECT/.test(body)) return true;
3435
+ return param !== void 0 && new RegExp(`\\bthrow\\s+${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
3436
+ }
3437
+ function redirectFinding(file, line, column) {
3438
+ return {
3439
+ ruleId: "redirect-in-try-catch",
3440
+ file: file.relativePath,
3441
+ line,
3442
+ column,
3443
+ message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
3444
+ severity: "critical",
3445
+ category: "reliability",
3446
+ fix: 'Move redirect() outside the try/catch block, or re-throw redirect errors in the catch: if (e instanceof Error && e.message === "NEXT_REDIRECT") throw e'
3447
+ };
3448
+ }
3164
3449
  var redirectInTryCatchRule = {
3165
3450
  id: "redirect-in-try-catch",
3166
3451
  name: "Redirect Inside Try/Catch",
@@ -3172,44 +3457,42 @@ var redirectInTryCatchRule = {
3172
3457
  if (isTestFile(file.relativePath)) return [];
3173
3458
  if (!/redirect\s*\(/.test(file.content)) return [];
3174
3459
  const findings = [];
3175
- let tryDepth = 0;
3176
- for (let i = 0; i < file.lines.length; i++) {
3177
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
3178
- const line = file.lines[i];
3179
- const trimmed = line.trim();
3180
- if (/\btry\s*\{/.test(trimmed) || trimmed === "try {") {
3181
- tryDepth++;
3182
- }
3183
- if (/\}\s*catch\s*[\s(]/.test(trimmed)) {
3184
- }
3185
- if (tryDepth > 0) {
3186
- const match = /\bredirect\s*\(/.exec(line);
3187
- if (match && !/\/\//.test(line.slice(0, match.index))) {
3188
- findings.push({
3189
- ruleId: "redirect-in-try-catch",
3190
- file: file.relativePath,
3191
- line: i + 1,
3192
- column: match.index + 1,
3193
- message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
3194
- severity: "critical",
3195
- category: "reliability",
3196
- fix: 'Move redirect() outside the try/catch block, or re-throw redirect errors in the catch: if (e instanceof Error && e.message === "NEXT_REDIRECT") throw e'
3460
+ const seen = /* @__PURE__ */ new Set();
3461
+ if (file.ast) {
3462
+ try {
3463
+ walkAST(file.ast.program, (node) => {
3464
+ if (node.type !== "TryStatement" || !node.handler || rethrowsRedirect(node.handler)) return;
3465
+ walkAST(node.block, (n) => {
3466
+ if (n.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "redirect") return;
3467
+ if (!n.loc || n.start == null || seen.has(n.start)) return;
3468
+ seen.add(n.start);
3469
+ findings.push(redirectFinding(file, n.loc.start.line, n.loc.start.column + 1));
3197
3470
  });
3198
- }
3199
- }
3200
- for (const ch of trimmed) {
3201
- if (ch === "{" && tryDepth > 0) {
3202
- }
3471
+ });
3472
+ return findings;
3473
+ } catch {
3474
+ findings.length = 0;
3475
+ seen.clear();
3203
3476
  }
3204
- if (tryDepth > 0 && /^\}\s*$/.test(trimmed)) {
3205
- let nextLine = "";
3206
- for (let j = i + 1; j < file.lines.length; j++) {
3207
- nextLine = file.lines[j].trim();
3208
- if (nextLine) break;
3209
- }
3210
- if (!/^catch\b/.test(nextLine) && !/^finally\b/.test(nextLine)) {
3211
- tryDepth--;
3212
- }
3477
+ }
3478
+ const source = file.lines.join("\n");
3479
+ const code = blankOut(source, true);
3480
+ const noComments = blankOut(source, false);
3481
+ for (const tryMatch of code.matchAll(/(?<![\w$.])try\s*\{/g)) {
3482
+ const open = tryMatch.index + tryMatch[0].length - 1;
3483
+ const close = closingBrace(code, open);
3484
+ if (close === -1) continue;
3485
+ const catchMatch = /^\s*catch\s*(?:\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*)?\{/.exec(code.slice(close + 1));
3486
+ if (!catchMatch) continue;
3487
+ const catchOpen = close + 1 + catchMatch[0].length - 1;
3488
+ const catchClose = closingBrace(code, catchOpen);
3489
+ if (bodyRethrowsRedirect(noComments.slice(catchOpen + 1, catchClose === -1 ? code.length : catchClose), catchMatch[1])) continue;
3490
+ for (const call of code.slice(open + 1, close).matchAll(/(?<![\w$.])redirect\s*\(/g)) {
3491
+ const offset = open + 1 + call.index;
3492
+ if (seen.has(offset)) continue;
3493
+ seen.add(offset);
3494
+ const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
3495
+ findings.push(redirectFinding(file, source.slice(0, offset).split("\n").length, offset - lineStart + 1));
3213
3496
  }
3214
3497
  }
3215
3498
  return findings;
@@ -3502,7 +3785,7 @@ var missingWebhookVerificationRule = {
3502
3785
  };
3503
3786
 
3504
3787
  // src/rules/server-action-auth.ts
3505
- var USE_SERVER3 = /['"]use server['"]/;
3788
+ var USE_SERVER3 = /^\s*['"]use server['"]/m;
3506
3789
  var AUTH_PATTERNS2 = [
3507
3790
  /getServerSession\s*\(/,
3508
3791
  /getSession\s*\(/,
@@ -3519,7 +3802,13 @@ var AUTH_PATTERNS2 = [
3519
3802
  /currentUser\s*\(/,
3520
3803
  /getAuth\s*\(/,
3521
3804
  /cookies\(\).*auth/s,
3522
- /session/i
3805
+ // Session lookups, not just any "session" text (a `sessionId` form field isn't auth):
3806
+ // verifySession(), getIronSession(), lucia.validateSession(), getServerAuthSession()...
3807
+ /\w*session\w*\s*\(/i,
3808
+ // ...reading a session cookie, or using and guarding a session object
3809
+ /cookie\w*\(?\)?\)?\s*\.get\(\s*['"`][\w-]*(?:session|auth|token)/i,
3810
+ /\bsession\??\.user(?:Id)?\b/,
3811
+ /!\s*session\b/
3523
3812
  ];
3524
3813
  var PUBLIC_ACTION_NAMES = [
3525
3814
  /contact/i,
@@ -3693,13 +3982,15 @@ var nextPublicSensitiveRule = {
3693
3982
  description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
3694
3983
  category: "security",
3695
3984
  severity: "critical",
3696
- fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env", "env.local", "env.production"],
3985
+ // The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
3986
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
3697
3987
  check(file, _project) {
3698
3988
  if (isTestFile(file.relativePath)) return [];
3699
3989
  const findings = [];
3990
+ const isEnvFile = file.ext === "env";
3700
3991
  for (let i = 0; i < file.lines.length; i++) {
3701
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
3702
3992
  const line = file.lines[i];
3993
+ if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
3703
3994
  const match = SENSITIVE_PATTERN.exec(line);
3704
3995
  if (match) {
3705
3996
  findings.push({
@@ -4162,7 +4453,17 @@ var unsafeFileUploadRule = {
4162
4453
 
4163
4454
  // src/rules/supabase-missing-rls.ts
4164
4455
  var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
4165
- var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
4456
+ var ENABLE_RLS = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:(?:public|"public")\.)?"?(\w+)"?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
4457
+ function isMigrationFile(file) {
4458
+ return file.ext === "sql" && !isTestFile(file.relativePath) && /migration|supabase|schema/i.test(file.relativePath);
4459
+ }
4460
+ function sqlProject(relativePath) {
4461
+ const dirs = relativePath.split("/").slice(0, -1);
4462
+ const supabase = dirs.map((d) => d.toLowerCase() === "supabase").lastIndexOf(true);
4463
+ const migrations = dirs.map((d) => /^migrations?$/i.test(d)).lastIndexOf(true);
4464
+ const end = supabase !== -1 ? supabase : migrations !== -1 ? migrations : dirs.length;
4465
+ return dirs.slice(0, end).join("/");
4466
+ }
4166
4467
  var supabaseMissingRlsRule = {
4167
4468
  id: "supabase-missing-rls",
4168
4469
  name: "Missing Row-Level Security",
@@ -4170,42 +4471,36 @@ var supabaseMissingRlsRule = {
4170
4471
  category: "security",
4171
4472
  severity: "critical",
4172
4473
  fileExtensions: ["sql"],
4173
- check(file, project) {
4174
- if (isTestFile(file.relativePath)) return [];
4175
- if (!/migration|supabase|schema/i.test(file.relativePath)) return [];
4176
- const content = file.content;
4474
+ check() {
4475
+ return [];
4476
+ },
4477
+ checkProject(files, _project) {
4177
4478
  const findings = [];
4178
- const tables = [];
4179
- CREATE_TABLE.lastIndex = 0;
4180
- let match;
4181
- while ((match = CREATE_TABLE.exec(content)) !== null) {
4182
- const name = match[1];
4183
- if (name.startsWith("_") || name === "schema_migrations") continue;
4184
- const beforeMatch = content.slice(0, match.index);
4185
- const line = beforeMatch.split("\n").length;
4186
- tables.push({ name, line });
4187
- }
4188
- if (tables.length === 0) return [];
4189
4479
  const rlsTables = /* @__PURE__ */ new Set();
4190
- ENABLE_RLS.lastIndex = 0;
4191
- while ((match = ENABLE_RLS.exec(content)) !== null) {
4192
- rlsTables.add(match[1].toLowerCase());
4193
- }
4194
- for (const filePath of project.allFiles) {
4195
- if (!filePath.endsWith(".sql")) continue;
4196
- if (filePath === file.relativePath) continue;
4480
+ for (const file of files) {
4481
+ if (file.ext !== "sql" || isTestFile(file.relativePath)) continue;
4482
+ const project = sqlProject(file.relativePath);
4483
+ for (const match of file.content.matchAll(ENABLE_RLS)) {
4484
+ rlsTables.add(`${project}::${match[1].toLowerCase()}`);
4485
+ }
4197
4486
  }
4198
- for (const table of tables) {
4199
- if (!rlsTables.has(table.name.toLowerCase())) {
4487
+ for (const file of files) {
4488
+ if (!isMigrationFile(file)) continue;
4489
+ const content = file.content;
4490
+ const project = sqlProject(file.relativePath);
4491
+ for (const match of content.matchAll(CREATE_TABLE)) {
4492
+ const name = match[1];
4493
+ if (name.startsWith("_") || name === "schema_migrations") continue;
4494
+ if (rlsTables.has(`${project}::${name.toLowerCase()}`)) continue;
4200
4495
  findings.push({
4201
4496
  ruleId: "supabase-missing-rls",
4202
4497
  file: file.relativePath,
4203
- line: table.line,
4498
+ line: content.slice(0, match.index).split("\n").length,
4204
4499
  column: 1,
4205
- message: `Table "${table.name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
4500
+ message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
4206
4501
  severity: "critical",
4207
4502
  category: "security",
4208
- fix: `Add: ALTER TABLE ${table.name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
4503
+ fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
4209
4504
  });
4210
4505
  }
4211
4506
  }
@@ -4536,10 +4831,15 @@ async function scan(options) {
4536
4831
  }
4537
4832
  }
4538
4833
  }
4834
+ const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
4539
4835
  for (const rule of rules) {
4540
4836
  if (rule.checkProject) {
4541
- const projectFindings = rule.checkProject(allFiles, project);
4542
- findings.push(...projectFindings);
4837
+ for (const finding of rule.checkProject(allFiles, project)) {
4838
+ const file = filesByPath.get(finding.file);
4839
+ if (!file || !isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
4840
+ findings.push(finding);
4841
+ }
4842
+ }
4543
4843
  }
4544
4844
  }
4545
4845
  const { overallScore, categoryScores } = calculateScores(findings);
@@ -4897,18 +5197,61 @@ async function runWebScan(targetUrl) {
4897
5197
  }
4898
5198
 
4899
5199
  // src/mcp.ts
5200
+ var SEVERITIES = ["critical", "warning", "info"];
5201
+ var CATEGORIES2 = ["security", "reliability", "performance", "ai-quality"];
5202
+ var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
5203
+ var DEFAULT_MAX_FINDINGS = 30;
5204
+ function bySeverity(a, b) {
5205
+ return SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line || a.column - b.column;
5206
+ }
4900
5207
  var server = new McpServer({
4901
5208
  name: "prodlint",
4902
5209
  version: getVersion()
4903
5210
  });
4904
- server.tool(
5211
+ server.registerTool(
4905
5212
  "scan",
4906
- "Check a vibe-coded project's production readiness. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
4907
5213
  {
4908
- path: z.string().describe("Absolute path to the project directory to scan"),
4909
- ignore: z.array(z.string()).optional().describe("Glob patterns to ignore")
5214
+ title: "Scan a project for production readiness",
5215
+ description: "Check a vibe-coded JS/TS project's production readiness. Returns a 0-100 score and findings across security, reliability, performance, and AI quality, most severe first. Every critical finding is always included; use minSeverity, categories, and maxFindings to narrow or widen the rest.",
5216
+ inputSchema: {
5217
+ path: z.string().describe("Absolute path to the project directory to scan; it must be inside the directory the server runs in"),
5218
+ ignore: z.array(z.string()).optional().describe("Glob patterns to ignore"),
5219
+ minSeverity: z.enum(SEVERITIES).optional().describe("Only list findings at this severity or higher (default: info, i.e. all)"),
5220
+ categories: z.array(z.enum(CATEGORIES2)).optional().describe("Only list findings in these categories (default: all)"),
5221
+ maxFindings: z.number().int().min(1).max(500).optional().describe(`Maximum number of findings to list (default: ${DEFAULT_MAX_FINDINGS}). Critical findings are always listed, even past this limit.`)
5222
+ },
5223
+ outputSchema: {
5224
+ version: z.string().describe("prodlint version that ran the scan"),
5225
+ path: z.string().describe("Directory that was scanned"),
5226
+ score: z.number().describe("Overall production-readiness score, 0-100"),
5227
+ filesScanned: z.number(),
5228
+ durationMs: z.number(),
5229
+ categoryScores: z.array(z.object({
5230
+ category: z.enum(CATEGORIES2),
5231
+ score: z.number(),
5232
+ findingCount: z.number()
5233
+ })),
5234
+ summary: z.object({
5235
+ critical: z.number(),
5236
+ warning: z.number(),
5237
+ info: z.number()
5238
+ }).describe("Counts of all findings, before minSeverity and categories are applied"),
5239
+ matched: z.number().describe("Findings that pass the minSeverity and categories filters"),
5240
+ omitted: z.number().describe("Matching findings left out because of maxFindings"),
5241
+ findings: z.array(z.object({
5242
+ ruleId: z.string(),
5243
+ severity: z.enum(SEVERITIES),
5244
+ category: z.enum(CATEGORIES2),
5245
+ file: z.string(),
5246
+ line: z.number(),
5247
+ column: z.number(),
5248
+ message: z.string(),
5249
+ fix: z.string().optional()
5250
+ })).describe("Matching findings, most severe first")
5251
+ },
5252
+ annotations: { readOnlyHint: true, openWorldHint: false }
4910
5253
  },
4911
- async ({ path, ignore }) => {
5254
+ async ({ path, ignore, minSeverity = "info", categories, maxFindings = DEFAULT_MAX_FINDINGS }) => {
4912
5255
  const resolved = resolve4(path);
4913
5256
  const cwd = process.cwd();
4914
5257
  const normalizedResolved = process.platform === "win32" ? resolved.toLowerCase() : resolved;
@@ -4934,6 +5277,11 @@ server.tool(
4934
5277
  };
4935
5278
  }
4936
5279
  const result = await scan({ path: resolved, ignore });
5280
+ const minRank = SEVERITY_RANK[minSeverity];
5281
+ const matching = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank && (!categories?.length || categories.includes(f.category))).sort(bySeverity);
5282
+ const criticalCount = matching.filter((f) => f.severity === "critical").length;
5283
+ const listed = matching.slice(0, Math.max(maxFindings, criticalCount));
5284
+ const omitted = matching.length - listed.length;
4937
5285
  const summary = [
4938
5286
  `## Production Readiness: ${result.overallScore}/100`,
4939
5287
  "",
@@ -4946,26 +5294,80 @@ server.tool(
4946
5294
  "",
4947
5295
  `### Summary: ${result.summary.critical} critical, ${result.summary.warning} warnings, ${result.summary.info} info`
4948
5296
  ];
4949
- if (result.findings.length > 0) {
4950
- summary.push("", "### Findings");
4951
- for (const f of result.findings.slice(0, 30)) {
5297
+ if (listed.length > 0) {
5298
+ const scope = [
5299
+ minSeverity !== "info" ? `${minSeverity} and above` : "",
5300
+ categories?.length ? categories.join(", ") : ""
5301
+ ].filter(Boolean).join("; ");
5302
+ summary.push("", `### Findings${scope ? ` (${scope})` : ""}, most severe first`);
5303
+ for (const f of listed) {
4952
5304
  const line = `- **[${f.severity}]** \`${f.ruleId}\` ${f.file}:${f.line} \u2014 ${f.message}`;
4953
5305
  summary.push(f.fix ? `${line}
4954
5306
  - _Fix: ${f.fix}_` : line);
4955
5307
  }
4956
- if (result.findings.length > 30) {
4957
- summary.push(`- ...and ${result.findings.length - 30} more findings`);
5308
+ if (omitted > 0) {
5309
+ summary.push(`- ...and ${omitted} more. Raise maxFindings, or narrow with minSeverity or categories.`);
4958
5310
  }
5311
+ } else if (result.findings.length > 0) {
5312
+ summary.push("", "No findings match the minSeverity and categories filters.");
4959
5313
  }
4960
5314
  return {
4961
- content: [{ type: "text", text: summary.join("\n") }]
5315
+ content: [{ type: "text", text: summary.join("\n") }],
5316
+ structuredContent: {
5317
+ version: result.version,
5318
+ path: result.scannedPath,
5319
+ score: result.overallScore,
5320
+ filesScanned: result.filesScanned,
5321
+ durationMs: result.scanDurationMs,
5322
+ categoryScores: result.categoryScores.map((c) => ({ category: c.category, score: c.score, findingCount: c.findingCount })),
5323
+ summary: result.summary,
5324
+ matched: matching.length,
5325
+ omitted,
5326
+ findings: listed.map((f) => ({
5327
+ ruleId: f.ruleId,
5328
+ severity: f.severity,
5329
+ category: f.category,
5330
+ file: f.file,
5331
+ line: f.line,
5332
+ column: f.column,
5333
+ message: f.message,
5334
+ fix: f.fix
5335
+ }))
5336
+ }
4962
5337
  };
4963
5338
  }
4964
5339
  );
4965
- server.tool(
5340
+ server.registerTool(
4966
5341
  "scan-web",
4967
- "Check a deployed website's AI agent-readiness. Returns a 0-100 score across 14 checks including robots.txt AI directives, llms.txt, AgentCard, WebMCP, and more.",
4968
- { url: z.string().describe("URL of the website to scan (e.g. https://example.com)") },
5342
+ {
5343
+ title: "Score a website's AI-agent readiness",
5344
+ description: "Check a deployed website's AI agent-readiness. Returns a 0-100 score across 14 checks including robots.txt AI directives, llms.txt, AgentCard, WebMCP, and more.",
5345
+ inputSchema: {
5346
+ url: z.string().describe("URL of the website to scan (e.g. https://example.com)")
5347
+ },
5348
+ outputSchema: {
5349
+ url: z.string(),
5350
+ domain: z.string(),
5351
+ score: z.number().describe("Agent-readiness score, 0-100"),
5352
+ grade: z.string(),
5353
+ summary: z.object({
5354
+ passed: z.number(),
5355
+ failed: z.number(),
5356
+ warnings: z.number(),
5357
+ totalChecks: z.number()
5358
+ }),
5359
+ checks: z.array(z.object({
5360
+ id: z.string(),
5361
+ name: z.string(),
5362
+ status: z.enum(["pass", "fail", "warn", "info"]),
5363
+ points: z.number(),
5364
+ maxPoints: z.number(),
5365
+ details: z.string().optional()
5366
+ }))
5367
+ },
5368
+ // Read-only, but it fetches the given site over the network
5369
+ annotations: { readOnlyHint: true, openWorldHint: true }
5370
+ },
4969
5371
  async ({ url }) => {
4970
5372
  let normalizedUrl;
4971
5373
  try {
@@ -4999,7 +5401,22 @@ server.tool(
4999
5401
  `### Summary: ${result.summary.passed} passed \xB7 ${result.summary.failed} failed \xB7 ${result.summary.warnings} warnings`
5000
5402
  ];
5001
5403
  return {
5002
- content: [{ type: "text", text: summary.join("\n") }]
5404
+ content: [{ type: "text", text: summary.join("\n") }],
5405
+ structuredContent: {
5406
+ url: result.url,
5407
+ domain: result.domain,
5408
+ score: result.overallScore,
5409
+ grade: result.grade,
5410
+ summary: result.summary,
5411
+ checks: result.checks.map((c) => ({
5412
+ id: c.id,
5413
+ name: c.name,
5414
+ status: c.status,
5415
+ points: c.points,
5416
+ maxPoints: c.maxPoints,
5417
+ details: c.details
5418
+ }))
5419
+ }
5003
5420
  };
5004
5421
  }
5005
5422
  );