prodlint 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -163,6 +163,133 @@ var NODE_BUILTINS = /* @__PURE__ */ new Set([
163
163
  // node: prefixed are handled separately
164
164
  ]);
165
165
 
166
+ // src/utils/ast.ts
167
+ import { parse } from "@babel/parser";
168
+ function parseFile(content, fileName) {
169
+ const plugins = ["decorators"];
170
+ if (/\.tsx?$/.test(fileName)) {
171
+ plugins.push("typescript");
172
+ }
173
+ if (/\.[jt]sx$/.test(fileName)) {
174
+ plugins.push("jsx");
175
+ }
176
+ if (/\.(js|mjs|cjs)$/.test(fileName)) {
177
+ plugins.push("jsx");
178
+ }
179
+ try {
180
+ return parse(content, {
181
+ sourceType: "module",
182
+ allowImportExportEverywhere: true,
183
+ allowReturnOutsideFunction: true,
184
+ plugins
185
+ });
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+ function walkAST(node, visitor, parent = null) {
191
+ if (!node || typeof node !== "object") return;
192
+ visitor(node, parent);
193
+ for (const key of Object.keys(node)) {
194
+ if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
195
+ const val = node[key];
196
+ if (Array.isArray(val)) {
197
+ for (const item of val) {
198
+ if (item && typeof item === "object" && item.type) {
199
+ walkAST(item, visitor, node);
200
+ }
201
+ }
202
+ } else if (val && typeof val === "object" && val.type) {
203
+ walkAST(val, visitor, node);
204
+ }
205
+ }
206
+ }
207
+ function isTaggedTemplateSql(node) {
208
+ const tag = node.tag;
209
+ if (tag.type === "Identifier" && tag.name === "sql") return true;
210
+ if (tag.type === "MemberExpression") {
211
+ const prop = tag.property;
212
+ if (prop.type === "Identifier" && (prop.name === "sql" || prop.name === "query" || prop.name === "raw")) {
213
+ return true;
214
+ }
215
+ }
216
+ return false;
217
+ }
218
+ function findLoopsAST(ast) {
219
+ const loops = [];
220
+ walkAST(ast.program, (node) => {
221
+ if (node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement" || node.type === "WhileStatement" || node.type === "DoWhileStatement") {
222
+ const loop = node;
223
+ const body = loop.body;
224
+ if (body.loc && node.loc) {
225
+ loops.push({
226
+ loopLine: node.loc.start.line - 1,
227
+ bodyStart: body.loc.start.line - 1,
228
+ bodyEnd: body.loc.end.line - 1
229
+ });
230
+ }
231
+ }
232
+ if (node.type === "CallExpression") {
233
+ const call = node;
234
+ if (call.callee.type === "MemberExpression" && call.callee.property.type === "Identifier" && (call.callee.property.name === "forEach" || call.callee.property.name === "map")) {
235
+ const callback = call.arguments[0];
236
+ if (callback && callback.loc && node.loc) {
237
+ loops.push({
238
+ loopLine: node.loc.start.line - 1,
239
+ bodyStart: callback.loc.start.line - 1,
240
+ bodyEnd: callback.loc.end.line - 1
241
+ });
242
+ }
243
+ }
244
+ }
245
+ });
246
+ return loops;
247
+ }
248
+
249
+ // src/utils/frameworks.ts
250
+ var FRAMEWORK_SAFE_METHODS = {
251
+ prisma: ["contains", "startsWith", "endsWith", "has", "hasEvery", "hasSome", "isEmpty"],
252
+ supabase: ["contains", "containedBy", "overlaps", "eq", "neq", "gt", "gte", "lt", "lte"],
253
+ drizzle: ["arrayContains", "arrayContainedIn", "arrayOverlaps"],
254
+ lodash: ["flatten", "flattenDeep", "contains", "includes", "has"],
255
+ mongoose: ["contains"]
256
+ };
257
+ function isFrameworkSafeMethod(methodName, frameworks) {
258
+ for (const framework of frameworks) {
259
+ const safeMethods = FRAMEWORK_SAFE_METHODS[framework];
260
+ if (safeMethods && safeMethods.includes(methodName)) {
261
+ return true;
262
+ }
263
+ }
264
+ return false;
265
+ }
266
+ var DEPENDENCY_TO_FRAMEWORK = {
267
+ "@prisma/client": "prisma",
268
+ "prisma": "prisma",
269
+ "@supabase/supabase-js": "supabase",
270
+ "@supabase/ssr": "supabase",
271
+ "drizzle-orm": "drizzle",
272
+ "@trpc/server": "trpc",
273
+ "next-auth": "next-auth",
274
+ "@auth/nextjs": "next-auth",
275
+ "@auth/core": "next-auth",
276
+ "express": "express",
277
+ "fastify": "fastify",
278
+ "hono": "hono",
279
+ "lodash": "lodash",
280
+ "lodash-es": "lodash",
281
+ "underscore": "lodash",
282
+ "mongoose": "mongoose",
283
+ "typeorm": "typeorm",
284
+ "sequelize": "sequelize",
285
+ "knex": "knex",
286
+ "@upstash/ratelimit": "upstash-ratelimit",
287
+ "express-rate-limit": "express-rate-limit",
288
+ "rate-limiter-flexible": "rate-limiter-flexible"
289
+ };
290
+ var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
291
+ var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
292
+
166
293
  // src/utils/file-walker.ts
167
294
  var DEFAULT_IGNORES = [
168
295
  "**/node_modules/**",
@@ -189,6 +316,7 @@ var SCAN_EXTENSIONS = [
189
316
  "cjs",
190
317
  "json"
191
318
  ];
319
+ var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
192
320
  var MAX_FILE_SIZE = 1024 * 1024;
193
321
  async function walkFiles(root, extraIgnores = []) {
194
322
  const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
@@ -213,14 +341,23 @@ async function readFileContext(root, relativePath) {
213
341
  if (fileStats.size > MAX_FILE_SIZE) return null;
214
342
  const content = await readFile(absolutePath, "utf-8");
215
343
  const lines = content.split(/\r?\n|\r/);
344
+ const ext = extname(relativePath).slice(1);
345
+ let ast = void 0;
346
+ if (AST_EXTENSIONS.has(ext)) {
347
+ try {
348
+ ast = parseFile(content, relativePath);
349
+ } catch {
350
+ ast = null;
351
+ }
352
+ }
216
353
  return {
217
354
  absolutePath,
218
355
  relativePath,
219
356
  content,
220
357
  lines,
221
- ext: extname(relativePath).slice(1),
222
- // remove leading dot
223
- commentMap: buildCommentMap(lines)
358
+ ext,
359
+ commentMap: buildCommentMap(lines),
360
+ ast
224
361
  };
225
362
  } catch {
226
363
  return null;
@@ -231,6 +368,8 @@ async function buildProjectContext(root, files) {
231
368
  let declaredDependencies = /* @__PURE__ */ new Set();
232
369
  let tsconfigPaths = /* @__PURE__ */ new Set();
233
370
  let hasAuthMiddleware = false;
371
+ let hasRateLimiting = false;
372
+ const detectedFrameworks = /* @__PURE__ */ new Set();
234
373
  let gitignoreContent = null;
235
374
  let envInGitignore = false;
236
375
  try {
@@ -242,6 +381,18 @@ async function buildProjectContext(root, files) {
242
381
  ...packageJson?.peerDependencies ?? {}
243
382
  };
244
383
  declaredDependencies = new Set(Object.keys(deps));
384
+ for (const dep of declaredDependencies) {
385
+ const framework = DEPENDENCY_TO_FRAMEWORK[dep];
386
+ if (framework) {
387
+ detectedFrameworks.add(framework);
388
+ }
389
+ }
390
+ for (const framework of detectedFrameworks) {
391
+ if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
392
+ hasRateLimiting = true;
393
+ break;
394
+ }
395
+ }
245
396
  } catch {
246
397
  }
247
398
  try {
@@ -285,6 +436,18 @@ async function buildProjectContext(root, files) {
285
436
  }
286
437
  } catch {
287
438
  }
439
+ if (!hasRateLimiting) {
440
+ for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
441
+ try {
442
+ const content = await readFile(resolve(root, name), "utf-8");
443
+ if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
444
+ hasRateLimiting = true;
445
+ break;
446
+ }
447
+ } catch {
448
+ }
449
+ }
450
+ }
288
451
  try {
289
452
  gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
290
453
  envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
@@ -296,6 +459,8 @@ async function buildProjectContext(root, files) {
296
459
  declaredDependencies,
297
460
  tsconfigPaths,
298
461
  hasAuthMiddleware,
462
+ hasRateLimiting,
463
+ detectedFrameworks,
299
464
  gitignoreContent,
300
465
  envInGitignore,
301
466
  allFiles: files
@@ -320,26 +485,58 @@ function getVersion() {
320
485
 
321
486
  // src/scorer.ts
322
487
  var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
488
+ var CATEGORY_WEIGHTS = {
489
+ "security": 0.4,
490
+ "reliability": 0.3,
491
+ "performance": 0.15,
492
+ "ai-quality": 0.15
493
+ };
323
494
  var DEDUCTIONS = {
324
- critical: 10,
325
- warning: 3,
326
- info: 1
495
+ critical: 8,
496
+ warning: 2,
497
+ info: 0.5
498
+ };
499
+ var PER_RULE_CAP = {
500
+ critical: 1,
501
+ warning: 2,
502
+ info: 3
327
503
  };
328
504
  function calculateScores(findings) {
329
505
  const categoryScores = CATEGORIES.map((category) => {
330
506
  const categoryFindings = findings.filter((f) => f.category === category);
331
- let score = 100;
507
+ const byRule = /* @__PURE__ */ new Map();
332
508
  for (const f of categoryFindings) {
333
- score -= DEDUCTIONS[f.severity] ?? 0;
509
+ const arr = byRule.get(f.ruleId) ?? [];
510
+ arr.push(f);
511
+ byRule.set(f.ruleId, arr);
512
+ }
513
+ let totalDeduction = 0;
514
+ for (const [, ruleFindings] of byRule) {
515
+ const bySeverity = { critical: 0, warning: 0, info: 0 };
516
+ for (const f of ruleFindings) {
517
+ bySeverity[f.severity]++;
518
+ }
519
+ for (const sev of ["critical", "warning", "info"]) {
520
+ const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
521
+ totalDeduction += count * DEDUCTIONS[sev];
522
+ }
523
+ }
524
+ let effectiveDeduction;
525
+ if (totalDeduction <= 30) {
526
+ effectiveDeduction = totalDeduction;
527
+ } else if (totalDeduction <= 50) {
528
+ effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
529
+ } else {
530
+ effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
334
531
  }
335
532
  return {
336
533
  category,
337
- score: Math.max(0, score),
534
+ score: Math.max(0, Math.round(100 - effectiveDeduction)),
338
535
  findingCount: categoryFindings.length
339
536
  };
340
537
  });
341
538
  const overallScore = Math.round(
342
- categoryScores.reduce((sum, c) => sum + c.score, 0) / CATEGORIES.length
539
+ categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
343
540
  );
344
541
  return { overallScore, categoryScores };
345
542
  }
@@ -510,25 +707,36 @@ var AUTH_PATTERNS = [
510
707
  /jwt\.verify\s*\(/,
511
708
  /createRouteHandlerClient/,
512
709
  /createServerComponentClient/,
710
+ /createMiddlewareClient/,
513
711
  /authorization/i,
514
- /bearer/i
712
+ /getAuth\s*\(/,
713
+ /withPageAuth/,
714
+ /cookies\(\).*auth/s
515
715
  ];
716
+ var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
516
717
  var authChecksRule = {
517
718
  id: "auth-checks",
518
719
  name: "Missing Auth Checks",
519
720
  description: "Detects API routes that lack authentication checks",
520
721
  category: "security",
521
- severity: "critical",
722
+ severity: "warning",
522
723
  fileExtensions: ["ts", "tsx", "js", "jsx"],
523
724
  check(file, project) {
524
725
  if (!isApiRoute(file.relativePath)) return [];
525
726
  for (const pattern of AUTH_EXEMPT_PATTERNS) {
526
727
  if (pattern.test(file.relativePath)) return [];
527
728
  }
528
- const severity = project.hasAuthMiddleware ? "info" : "critical";
529
729
  for (const pattern of AUTH_PATTERNS) {
530
730
  if (pattern.test(file.content)) return [];
531
731
  }
732
+ let severity;
733
+ if (project.hasAuthMiddleware) {
734
+ severity = "info";
735
+ } else if (MUTATION_EXPORT.test(file.content)) {
736
+ severity = "critical";
737
+ } else {
738
+ severity = "info";
739
+ }
532
740
  let handlerLine = 1;
533
741
  for (let i = 0; i < file.lines.length; i++) {
534
742
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -625,7 +833,10 @@ var errorHandlingRule = {
625
833
  if (!isApiRoute(file.relativePath)) return [];
626
834
  const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
627
835
  const hasTryCatch = /try\s*\{/.test(file.content);
628
- if (hasTryCatch || hasFrameworkServe) return [];
836
+ const hasCatchChain = /\.catch\s*\(/.test(file.content);
837
+ const hasOnError = /onError\s*[:(]/.test(file.content);
838
+ const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
839
+ if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
629
840
  let handlerLine = 1;
630
841
  for (let i = 0; i < file.lines.length; i++) {
631
842
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -730,10 +941,11 @@ var rateLimitingRule = {
730
941
  name: "Missing Rate Limiting",
731
942
  description: "Detects API routes without rate limiting",
732
943
  category: "security",
733
- severity: "warning",
944
+ severity: "info",
734
945
  fileExtensions: ["ts", "tsx", "js", "jsx"],
735
- check(file, _project) {
946
+ check(file, project) {
736
947
  if (!isApiRoute(file.relativePath)) return [];
948
+ if (project.hasRateLimiting) return [];
737
949
  for (const pattern of EXEMPT_PATTERNS) {
738
950
  if (pattern.test(file.relativePath)) return [];
739
951
  }
@@ -753,7 +965,7 @@ var rateLimitingRule = {
753
965
  line: handlerLine,
754
966
  column: 1,
755
967
  message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
756
- severity: "warning",
968
+ severity: "info",
757
969
  category: "security"
758
970
  }];
759
971
  }
@@ -981,6 +1193,8 @@ var SQL_INJECTION_PATTERNS = [
981
1193
  // .query() or .execute() with template literal
982
1194
  { pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
983
1195
  ];
1196
+ var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
1197
+ var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
984
1198
  var sqlInjectionRule = {
985
1199
  id: "sql-injection",
986
1200
  name: "SQL Injection Risk",
@@ -988,20 +1202,42 @@ var sqlInjectionRule = {
988
1202
  category: "security",
989
1203
  severity: "critical",
990
1204
  fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
991
- check(file, _project) {
1205
+ check(file, project) {
992
1206
  const findings = [];
1207
+ const safeTaggedLines = /* @__PURE__ */ new Set();
1208
+ if (file.ast) {
1209
+ try {
1210
+ walkAST(file.ast.program, (node) => {
1211
+ if (node.type === "TaggedTemplateExpression") {
1212
+ const tagged = node;
1213
+ if (isTaggedTemplateSql(tagged) && tagged.loc) {
1214
+ for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
1215
+ safeTaggedLines.add(l);
1216
+ }
1217
+ }
1218
+ }
1219
+ });
1220
+ } catch {
1221
+ }
1222
+ }
1223
+ const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
993
1224
  for (let i = 0; i < file.lines.length; i++) {
994
1225
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
995
1226
  const line = file.lines[i];
1227
+ const lineNum = i + 1;
1228
+ if (safeTaggedLines.has(lineNum)) continue;
1229
+ if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
1230
+ if (PARAMETERIZED_QUERY.test(line)) continue;
996
1231
  for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
997
1232
  if (pattern.test(line)) {
1233
+ const severity = usesORM ? "warning" : "critical";
998
1234
  findings.push({
999
1235
  ruleId: "sql-injection",
1000
1236
  file: file.relativePath,
1001
- line: i + 1,
1237
+ line: lineNum,
1002
1238
  column: 1,
1003
1239
  message,
1004
- severity: "critical",
1240
+ severity,
1005
1241
  category: "security"
1006
1242
  });
1007
1243
  break;
@@ -1023,7 +1259,7 @@ var PLACEHOLDERS = [
1023
1259
  { pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
1024
1260
  { pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
1025
1261
  { pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
1026
- { pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
1262
+ { pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
1027
1263
  { pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
1028
1264
  { pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
1029
1265
  ];
@@ -1063,13 +1299,13 @@ var placeholderContentRule = {
1063
1299
  // src/rules/stale-fallback.ts
1064
1300
  var STALE_PATTERNS = [
1065
1301
  { pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
1066
- { pattern: /['"]https?:\/\/127\.0\.0\.1[:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1067
- { pattern: /['"]redis:\/\/localhost['"]/, label: "Hardcoded Redis localhost URL" },
1068
- { pattern: /['"]mongodb:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1069
- { pattern: /['"]mongodb\+srv:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1070
- { pattern: /['"]postgres:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1071
- { pattern: /['"]postgresql:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1072
- { pattern: /['"]amqp:\/\/localhost['"]/, label: "Hardcoded AMQP localhost URL" }
1302
+ { pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1303
+ { pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
1304
+ { pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1305
+ { pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1306
+ { pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1307
+ { pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1308
+ { pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
1073
1309
  ];
1074
1310
  var staleFallbackRule = {
1075
1311
  id: "stale-fallback",
@@ -1108,15 +1344,15 @@ var staleFallbackRule = {
1108
1344
 
1109
1345
  // src/rules/hallucinated-api.ts
1110
1346
  var HALLUCINATED_APIS = [
1111
- { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()" },
1112
- { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()" },
1113
- { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()" },
1114
- { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()" },
1115
- { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()" },
1116
- { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()" },
1117
- { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()" },
1118
- { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)" },
1119
- { pattern: /\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()" }
1347
+ { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
1348
+ { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
1349
+ { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
1350
+ { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
1351
+ { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
1352
+ { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
1353
+ { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
1354
+ { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
1355
+ { pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
1120
1356
  ];
1121
1357
  var hallucinatedApiRule = {
1122
1358
  id: "hallucinated-api",
@@ -1125,14 +1361,16 @@ var hallucinatedApiRule = {
1125
1361
  category: "ai-quality",
1126
1362
  severity: "warning",
1127
1363
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1128
- check(file, _project) {
1364
+ check(file, project) {
1129
1365
  const findings = [];
1366
+ const frameworks = project.detectedFrameworks;
1130
1367
  for (let i = 0; i < file.lines.length; i++) {
1131
1368
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1132
1369
  const line = file.lines[i];
1133
- for (const { pattern, fix } of HALLUCINATED_APIS) {
1370
+ for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
1134
1371
  const match = pattern.exec(line);
1135
1372
  if (match) {
1373
+ if (isFrameworkSafeMethod(methodName, frameworks)) continue;
1136
1374
  findings.push({
1137
1375
  ruleId: "hallucinated-api",
1138
1376
  file: file.relativePath,
@@ -1150,7 +1388,7 @@ var hallucinatedApiRule = {
1150
1388
  };
1151
1389
 
1152
1390
  // src/rules/open-redirect.ts
1153
- var CRITICAL_PATTERNS = [
1391
+ var DIRECT_INPUT_PATTERNS = [
1154
1392
  // redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
1155
1393
  /redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
1156
1394
  // redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
@@ -1159,21 +1397,21 @@ var CRITICAL_PATTERNS = [
1159
1397
  /NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
1160
1398
  ];
1161
1399
  var WARNING_PATTERNS = [
1162
- /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
1400
+ /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
1163
1401
  ];
1164
1402
  var openRedirectRule = {
1165
1403
  id: "open-redirect",
1166
1404
  name: "Open Redirect",
1167
1405
  description: "Detects user-controlled input passed directly to redirect functions",
1168
1406
  category: "security",
1169
- severity: "critical",
1407
+ severity: "warning",
1170
1408
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1171
1409
  check(file, _project) {
1172
1410
  const findings = [];
1173
1411
  for (let i = 0; i < file.lines.length; i++) {
1174
1412
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1175
1413
  const line = file.lines[i];
1176
- for (const pattern of CRITICAL_PATTERNS) {
1414
+ for (const pattern of DIRECT_INPUT_PATTERNS) {
1177
1415
  const match = pattern.exec(line);
1178
1416
  if (match) {
1179
1417
  findings.push({
@@ -1182,7 +1420,7 @@ var openRedirectRule = {
1182
1420
  line: i + 1,
1183
1421
  column: match.index + 1,
1184
1422
  message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1185
- severity: "critical",
1423
+ severity: "warning",
1186
1424
  category: "security"
1187
1425
  });
1188
1426
  break;
@@ -1247,6 +1485,7 @@ var noSyncFsRule = {
1247
1485
 
1248
1486
  // src/rules/no-n-plus-one.ts
1249
1487
  var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
1488
+ var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
1250
1489
  var noNPlusOneRule = {
1251
1490
  id: "no-n-plus-one",
1252
1491
  name: "No N+1 Queries",
@@ -1257,14 +1496,33 @@ var noNPlusOneRule = {
1257
1496
  check(file, _project) {
1258
1497
  if (isTestFile(file.relativePath)) return [];
1259
1498
  if (isScriptFile(file.relativePath)) return [];
1499
+ const promiseAllMapLines = /* @__PURE__ */ new Set();
1500
+ for (let i = 0; i < file.lines.length; i++) {
1501
+ if (PROMISE_ALL_MAP.test(file.lines[i])) {
1502
+ for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
1503
+ promiseAllMapLines.add(j);
1504
+ }
1505
+ }
1506
+ }
1260
1507
  const findings = [];
1261
- const loops = findLoopBodies(file.lines, file.commentMap);
1508
+ let loops;
1509
+ if (file.ast) {
1510
+ try {
1511
+ loops = findLoopsAST(file.ast);
1512
+ } catch {
1513
+ loops = findLoopBodies(file.lines, file.commentMap);
1514
+ }
1515
+ } else {
1516
+ loops = findLoopBodies(file.lines, file.commentMap);
1517
+ }
1262
1518
  const reported = /* @__PURE__ */ new Set();
1263
1519
  for (const loop of loops) {
1264
1520
  if (reported.has(loop.loopLine)) continue;
1521
+ if (promiseAllMapLines.has(loop.loopLine)) continue;
1265
1522
  for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
1266
1523
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1267
1524
  const line = file.lines[i];
1525
+ if (promiseAllMapLines.has(i)) continue;
1268
1526
  const match = DB_CALL_PATTERN.exec(line);
1269
1527
  if (match) {
1270
1528
  reported.add(loop.loopLine);
@@ -1398,6 +1656,10 @@ var HANDLED_PATTERNS = [
1398
1656
  /Promise\.allSettled/,
1399
1657
  /Promise\.race/
1400
1658
  ];
1659
+ var CHAIN_START_PATTERNS = [
1660
+ /\.from\s*\(/,
1661
+ /\.rpc\s*\(/
1662
+ ];
1401
1663
  var unhandledPromiseRule = {
1402
1664
  id: "unhandled-promise",
1403
1665
  name: "Unhandled Promise",
@@ -1417,6 +1679,19 @@ var unhandledPromiseRule = {
1417
1679
  if (!asyncMatch) continue;
1418
1680
  const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
1419
1681
  if (isHandled) continue;
1682
+ const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
1683
+ if (isChainContinuation) {
1684
+ let chainHandled = false;
1685
+ for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
1686
+ const prevTrimmed = file.lines[j].trim();
1687
+ if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
1688
+ if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
1689
+ chainHandled = true;
1690
+ break;
1691
+ }
1692
+ }
1693
+ if (chainHandled) continue;
1694
+ }
1420
1695
  let handledAbove = false;
1421
1696
  for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
1422
1697
  const prevTrimmed = file.lines[j].trim();
@@ -1484,9 +1759,9 @@ var missingErrorBoundaryRule = {
1484
1759
  severity: "info",
1485
1760
  fileExtensions: ["tsx", "jsx", "ts", "js"],
1486
1761
  check(file, project) {
1487
- const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
1762
+ const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
1488
1763
  if (!match) return [];
1489
- const dir = "app/" + match[1];
1764
+ const dir = match[1] + match[2];
1490
1765
  const hasErrorBoundary = project.allFiles.some(
1491
1766
  (f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
1492
1767
  );
@@ -1664,7 +1939,8 @@ var deadExportsRule = {
1664
1939
  (f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
1665
1940
  );
1666
1941
  const exports = /* @__PURE__ */ new Map();
1667
- const imports = /* @__PURE__ */ new Set();
1942
+ const imports = /* @__PURE__ */ new Map();
1943
+ const allImportedSymbols = /* @__PURE__ */ new Set();
1668
1944
  const importedFiles = /* @__PURE__ */ new Set();
1669
1945
  for (const file of sourceFiles) {
1670
1946
  if (isEntryPoint(file.relativePath)) continue;
@@ -1688,14 +1964,28 @@ var deadExportsRule = {
1688
1964
  for (const file of files) {
1689
1965
  for (const line of file.lines) {
1690
1966
  let match;
1967
+ const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
1968
+ const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
1691
1969
  const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
1692
1970
  while ((match = bracesRe.exec(line)) !== null) {
1693
1971
  const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
1694
- for (const sym of symbols) imports.add(sym);
1972
+ for (const sym of symbols) {
1973
+ allImportedSymbols.add(sym);
1974
+ if (fromBasename) {
1975
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1976
+ set.add(sym);
1977
+ imports.set(fromBasename, set);
1978
+ }
1979
+ }
1695
1980
  }
1696
1981
  const defaultRe = /import\s+(\w+)\s+from/g;
1697
1982
  while ((match = defaultRe.exec(line)) !== null) {
1698
- imports.add(match[1]);
1983
+ allImportedSymbols.add(match[1]);
1984
+ if (fromBasename) {
1985
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1986
+ set.add(match[1]);
1987
+ imports.set(fromBasename, set);
1988
+ }
1699
1989
  }
1700
1990
  const fromRe = /from\s+['"]([^'"]+)['"]/g;
1701
1991
  while ((match = fromRe.exec(line)) !== null) {
@@ -1706,7 +1996,10 @@ var deadExportsRule = {
1706
1996
  const deadByFile = /* @__PURE__ */ new Map();
1707
1997
  for (const [key, loc] of exports) {
1708
1998
  const symbolName = key.split("::")[1];
1709
- if (!imports.has(symbolName)) {
1999
+ const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2000
+ const importSet = imports.get(exportFileBasename);
2001
+ const isImported = importSet?.has(symbolName) ?? false;
2002
+ if (!isImported && !allImportedSymbols.has(symbolName)) {
1710
2003
  deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
1711
2004
  }
1712
2005
  }
@@ -1776,12 +2069,33 @@ var shallowCatchRule = {
1776
2069
  if (braceStart === -1) continue;
1777
2070
  let depth = 0;
1778
2071
  let bodyEnd = braceStart;
2072
+ let inSingle = false;
2073
+ let inDouble = false;
2074
+ let inTemplate = false;
1779
2075
  for (let j = braceStart; j < file.lines.length; j++) {
1780
2076
  const line = file.lines[j];
1781
2077
  const startPos = j === braceStart ? line.indexOf("{") : 0;
1782
2078
  for (let k = startPos; k < line.length; k++) {
1783
- if (line[k] === "{") depth++;
1784
- if (line[k] === "}") {
2079
+ const ch = line[k];
2080
+ const prev = k > 0 ? line[k - 1] : "";
2081
+ const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
2082
+ if (!escaped) {
2083
+ if (ch === "'" && !inDouble && !inTemplate) {
2084
+ inSingle = !inSingle;
2085
+ continue;
2086
+ }
2087
+ if (ch === '"' && !inSingle && !inTemplate) {
2088
+ inDouble = !inDouble;
2089
+ continue;
2090
+ }
2091
+ if (ch === "`" && !inSingle && !inDouble) {
2092
+ inTemplate = !inTemplate;
2093
+ continue;
2094
+ }
2095
+ }
2096
+ if (inSingle || inDouble || inTemplate) continue;
2097
+ if (ch === "{") depth++;
2098
+ if (ch === "}") {
1785
2099
  depth--;
1786
2100
  if (depth === 0) {
1787
2101
  bodyEnd = j;
@@ -1948,6 +2262,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
1948
2262
  "gpt-tokenizer"
1949
2263
  // exists but often confused
1950
2264
  ]);
2265
+ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2266
+ "pg",
2267
+ "ws",
2268
+ "ms",
2269
+ "qs",
2270
+ "ip",
2271
+ "is",
2272
+ "he",
2273
+ "ky",
2274
+ "bl",
2275
+ "rc",
2276
+ "io",
2277
+ "db",
2278
+ "fp",
2279
+ "rx"
2280
+ ]);
1951
2281
  var SUSPICIOUS_PATTERNS = [
1952
2282
  /^[a-z]{1,2}$/,
1953
2283
  // 1-2 char names
@@ -1986,7 +2316,7 @@ var phantomDependencyRule = {
1986
2316
  });
1987
2317
  }
1988
2318
  for (const pattern of SUSPICIOUS_PATTERNS) {
1989
- if (pattern.test(name) && !name.startsWith("@")) {
2319
+ if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
1990
2320
  findings.push({
1991
2321
  ruleId: "phantom-dependency",
1992
2322
  file: "package.json",
@@ -2004,6 +2334,210 @@ var phantomDependencyRule = {
2004
2334
  }
2005
2335
  };
2006
2336
 
2337
+ // src/rules/insecure-cookie.ts
2338
+ var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
2339
+ var COOKIE_SET_PATTERNS = [
2340
+ /cookies\(\)\s*\.set\s*\(/,
2341
+ /res\.cookie\s*\(/,
2342
+ /response\.cookies\.set\s*\(/
2343
+ ];
2344
+ var SECURE_OPTIONS = [
2345
+ /httpOnly\s*:\s*true/,
2346
+ /secure\s*:\s*true/,
2347
+ /sameSite\s*:/
2348
+ ];
2349
+ var insecureCookieRule = {
2350
+ id: "insecure-cookie",
2351
+ name: "Insecure Cookie",
2352
+ description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
2353
+ category: "security",
2354
+ severity: "warning",
2355
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2356
+ check(file, _project) {
2357
+ if (isTestFile(file.relativePath)) return [];
2358
+ if (isScriptFile(file.relativePath)) return [];
2359
+ const findings = [];
2360
+ for (let i = 0; i < file.lines.length; i++) {
2361
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2362
+ const line = file.lines[i];
2363
+ const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
2364
+ if (!isCookieSet) continue;
2365
+ if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
2366
+ const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
2367
+ const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
2368
+ if (missingOptions.length > 0) {
2369
+ const missing = [];
2370
+ if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
2371
+ if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
2372
+ if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
2373
+ findings.push({
2374
+ ruleId: "insecure-cookie",
2375
+ file: file.relativePath,
2376
+ line: i + 1,
2377
+ column: 1,
2378
+ message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
2379
+ severity: "warning",
2380
+ category: "security",
2381
+ fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
2382
+ });
2383
+ }
2384
+ }
2385
+ return findings;
2386
+ }
2387
+ };
2388
+
2389
+ // src/rules/leaked-env-in-logs.ts
2390
+ var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
2391
+ var leakedEnvInLogsRule = {
2392
+ id: "leaked-env-in-logs",
2393
+ name: "Leaked Env in Logs",
2394
+ description: "Detects process.env values logged to console \u2014 potential secret exposure",
2395
+ category: "security",
2396
+ severity: "warning",
2397
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2398
+ check(file, _project) {
2399
+ if (isTestFile(file.relativePath)) return [];
2400
+ if (isScriptFile(file.relativePath)) return [];
2401
+ const findings = [];
2402
+ for (let i = 0; i < file.lines.length; i++) {
2403
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2404
+ const line = file.lines[i];
2405
+ const match = CONSOLE_WITH_ENV.exec(line);
2406
+ if (match) {
2407
+ findings.push({
2408
+ ruleId: "leaked-env-in-logs",
2409
+ file: file.relativePath,
2410
+ line: i + 1,
2411
+ column: match.index + 1,
2412
+ message: "process.env value in console output \u2014 may leak secrets in production logs",
2413
+ severity: "warning",
2414
+ category: "security",
2415
+ fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
2416
+ });
2417
+ }
2418
+ }
2419
+ return findings;
2420
+ }
2421
+ };
2422
+
2423
+ // src/rules/insecure-random.ts
2424
+ var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
2425
+ var MATH_RANDOM = /Math\.random\s*\(\)/;
2426
+ var insecureRandomRule = {
2427
+ id: "insecure-random",
2428
+ name: "Insecure Random",
2429
+ description: "Detects Math.random() used near security-sensitive variable names",
2430
+ category: "security",
2431
+ severity: "warning",
2432
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2433
+ check(file, _project) {
2434
+ if (isTestFile(file.relativePath)) return [];
2435
+ if (isScriptFile(file.relativePath)) return [];
2436
+ const findings = [];
2437
+ for (let i = 0; i < file.lines.length; i++) {
2438
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2439
+ const line = file.lines[i];
2440
+ const match = MATH_RANDOM.exec(line);
2441
+ if (!match) continue;
2442
+ const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
2443
+ if (SECURITY_VAR_NAMES.test(context)) {
2444
+ findings.push({
2445
+ ruleId: "insecure-random",
2446
+ file: file.relativePath,
2447
+ line: i + 1,
2448
+ column: match.index + 1,
2449
+ message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
2450
+ severity: "warning",
2451
+ category: "security",
2452
+ fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
2453
+ });
2454
+ }
2455
+ }
2456
+ return findings;
2457
+ }
2458
+ };
2459
+
2460
+ // src/rules/next-server-action-validation.ts
2461
+ var USE_SERVER = /['"]use server['"]/;
2462
+ var FORM_DATA_GET = /formData\.get\s*\(/;
2463
+ var VALIDATION_PATTERNS2 = [
2464
+ /\.parse\s*\(/,
2465
+ /\.safeParse\s*\(/,
2466
+ /\bvalidate\s*\(/,
2467
+ /\.parseAsync\s*\(/,
2468
+ /\.safeParseAsync\s*\(/
2469
+ ];
2470
+ var nextServerActionValidationRule = {
2471
+ id: "next-server-action-validation",
2472
+ name: "Next.js Server Action Validation",
2473
+ description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
2474
+ category: "security",
2475
+ severity: "critical",
2476
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2477
+ check(file, _project) {
2478
+ if (isTestFile(file.relativePath)) return [];
2479
+ if (!USE_SERVER.test(file.content)) return [];
2480
+ if (!FORM_DATA_GET.test(file.content)) return [];
2481
+ const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
2482
+ if (hasValidation) return [];
2483
+ let reportLine = 1;
2484
+ for (let i = 0; i < file.lines.length; i++) {
2485
+ if (FORM_DATA_GET.test(file.lines[i])) {
2486
+ reportLine = i + 1;
2487
+ break;
2488
+ }
2489
+ }
2490
+ return [{
2491
+ ruleId: "next-server-action-validation",
2492
+ file: file.relativePath,
2493
+ line: reportLine,
2494
+ column: 1,
2495
+ message: "Server action reads formData without schema validation \u2014 unvalidated user input",
2496
+ severity: "critical",
2497
+ category: "security",
2498
+ fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
2499
+ }];
2500
+ }
2501
+ };
2502
+
2503
+ // src/rules/missing-transaction.ts
2504
+ var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
2505
+ var PRISMA_TRANSACTION = /\$transaction\s*\(/;
2506
+ var missingTransactionRule = {
2507
+ id: "missing-transaction",
2508
+ name: "Missing Transaction",
2509
+ description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
2510
+ category: "reliability",
2511
+ severity: "warning",
2512
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2513
+ check(file, project) {
2514
+ if (isTestFile(file.relativePath)) return [];
2515
+ if (isScriptFile(file.relativePath)) return [];
2516
+ if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
2517
+ let writeCount = 0;
2518
+ let firstWriteLine = -1;
2519
+ const hasTransaction = PRISMA_TRANSACTION.test(file.content);
2520
+ for (let i = 0; i < file.lines.length; i++) {
2521
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2522
+ if (PRISMA_WRITE_OPS.test(file.lines[i])) {
2523
+ writeCount++;
2524
+ if (firstWriteLine === -1) firstWriteLine = i;
2525
+ }
2526
+ }
2527
+ if (writeCount < 2 || hasTransaction) return [];
2528
+ return [{
2529
+ ruleId: "missing-transaction",
2530
+ file: file.relativePath,
2531
+ line: firstWriteLine + 1,
2532
+ column: 1,
2533
+ message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
2534
+ severity: "warning",
2535
+ category: "reliability",
2536
+ fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
2537
+ }];
2538
+ }
2539
+ };
2540
+
2007
2541
  // src/rules/index.ts
2008
2542
  var rules = [
2009
2543
  // Security
@@ -2017,6 +2551,10 @@ var rules = [
2017
2551
  openRedirectRule,
2018
2552
  rateLimitingRule,
2019
2553
  phantomDependencyRule,
2554
+ insecureCookieRule,
2555
+ leakedEnvInLogsRule,
2556
+ insecureRandomRule,
2557
+ nextServerActionValidationRule,
2020
2558
  // Reliability
2021
2559
  hallucinatedImportsRule,
2022
2560
  errorHandlingRule,
@@ -2024,6 +2562,7 @@ var rules = [
2024
2562
  shallowCatchRule,
2025
2563
  missingLoadingStateRule,
2026
2564
  missingErrorBoundaryRule,
2565
+ missingTransactionRule,
2027
2566
  // Performance
2028
2567
  noSyncFsRule,
2029
2568
  noNPlusOneRule,
@@ -2110,11 +2649,16 @@ function groupByFile(findings) {
2110
2649
  }
2111
2650
  return map;
2112
2651
  }
2113
- function reportPretty(result) {
2652
+ function reportPretty(result, opts = {}) {
2114
2653
  const lines = [];
2654
+ const { critical, warning, info } = result.summary;
2115
2655
  lines.push("");
2116
2656
  lines.push(pc.bold(" prodlint") + pc.dim(` v${result.version}`));
2117
- lines.push(pc.dim(` Scanned ${result.filesScanned} files in ${result.scanDurationMs}ms`));
2657
+ const headerParts = [`Scanned ${result.filesScanned} files`];
2658
+ if (critical > 0) headerParts.push(`${critical} critical`);
2659
+ if (warning > 0) headerParts.push(`${warning} warnings`);
2660
+ if (info > 0) headerParts.push(`${info} info`);
2661
+ lines.push(pc.dim(` ${headerParts.join(" \xB7 ")}`));
2118
2662
  lines.push("");
2119
2663
  if (result.findings.length > 0) {
2120
2664
  const grouped = groupByFile(result.findings);
@@ -2126,6 +2670,9 @@ function reportPretty(result) {
2126
2670
  lines.push(
2127
2671
  ` ${pc.dim(`${f.line}:${f.column}`)} ${color(label)} ${f.message} ${pc.dim(f.ruleId)}`
2128
2672
  );
2673
+ if (f.fix) {
2674
+ lines.push(` ${pc.dim(` \u21B3 ${f.fix}`)}`);
2675
+ }
2129
2676
  }
2130
2677
  lines.push("");
2131
2678
  }
@@ -2140,22 +2687,23 @@ function reportPretty(result) {
2140
2687
  const overallColor = scoreColor(result.overallScore);
2141
2688
  lines.push(pc.bold(` Overall: ${overallColor(String(result.overallScore))}/100`));
2142
2689
  lines.push("");
2143
- const { critical, warning, info } = result.summary;
2144
- const parts = [];
2145
- if (critical > 0) parts.push(pc.red(`${critical} critical`));
2146
- if (warning > 0) parts.push(pc.yellow(`${warning} warnings`));
2147
- if (info > 0) parts.push(pc.blue(`${info} info`));
2148
- if (parts.length === 0) {
2690
+ const summaryParts = [];
2691
+ if (critical > 0) summaryParts.push(pc.red(`${critical} critical`));
2692
+ if (warning > 0) summaryParts.push(pc.yellow(`${warning} warnings`));
2693
+ if (info > 0) summaryParts.push(pc.blue(`${info} info`));
2694
+ if (summaryParts.length === 0) {
2149
2695
  lines.push(pc.green(" No issues found!"));
2150
2696
  } else {
2151
- lines.push(` ${parts.join(pc.dim(" \xB7 "))}`);
2697
+ lines.push(` ${summaryParts.join(pc.dim(" \xB7 "))}`);
2152
2698
  }
2153
2699
  lines.push("");
2154
- const badgeColor = result.overallScore >= 80 ? "brightgreen" : result.overallScore >= 60 ? "yellow" : "red";
2155
- const badgeUrl = `https://img.shields.io/badge/prodlint-${result.overallScore}%2F100-${badgeColor}`;
2156
- lines.push(pc.dim(" Add to your README:"));
2157
- lines.push(pc.dim(` [![prodlint](${badgeUrl})](https://prodlint.com)`));
2158
- lines.push("");
2700
+ if (!opts.quiet) {
2701
+ const badgeColor = result.overallScore >= 80 ? "brightgreen" : result.overallScore >= 60 ? "yellow" : "red";
2702
+ const badgeUrl = `https://img.shields.io/badge/prodlint-${result.overallScore}%2F100-${badgeColor}`;
2703
+ lines.push(pc.dim(" Add to your README:"));
2704
+ lines.push(pc.dim(` [![prodlint](${badgeUrl})](https://prodlint.com)`));
2705
+ lines.push("");
2706
+ }
2159
2707
  return lines.join("\n");
2160
2708
  }
2161
2709
  function reportJson(result) {
@@ -2170,12 +2718,15 @@ function renderBar(score) {
2170
2718
  }
2171
2719
 
2172
2720
  // src/cli.ts
2721
+ var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
2173
2722
  async function main() {
2174
2723
  const { values, positionals } = parseArgs({
2175
2724
  allowPositionals: true,
2176
2725
  options: {
2177
2726
  json: { type: "boolean", default: false },
2178
2727
  ignore: { type: "string", multiple: true, default: [] },
2728
+ "min-severity": { type: "string", default: "info" },
2729
+ quiet: { type: "boolean", default: false },
2179
2730
  help: { type: "boolean", short: "h", default: false },
2180
2731
  version: { type: "boolean", short: "v", default: false }
2181
2732
  }
@@ -2189,14 +2740,17 @@ async function main() {
2189
2740
  process.exit(0);
2190
2741
  }
2191
2742
  const targetPath = positionals[0] ?? ".";
2743
+ const minSeverity = values["min-severity"] ?? "info";
2192
2744
  const result = await scan({
2193
2745
  path: targetPath,
2194
2746
  ignore: values.ignore
2195
2747
  });
2748
+ const minRank = SEVERITY_RANK[minSeverity] ?? 1;
2749
+ result.findings = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank);
2196
2750
  if (values.json) {
2197
2751
  console.log(reportJson(result));
2198
2752
  } else {
2199
- console.log(reportPretty(result));
2753
+ console.log(reportPretty(result, { quiet: values.quiet }));
2200
2754
  }
2201
2755
  if (result.summary.critical > 0) {
2202
2756
  process.exit(1);
@@ -2210,16 +2764,20 @@ function printHelp() {
2210
2764
  npx prodlint [path] [options]
2211
2765
 
2212
2766
  Options:
2213
- --json Output results as JSON
2214
- --ignore <pattern> Glob patterns to ignore (can be repeated)
2215
- -h, --help Show this help message
2216
- -v, --version Show version
2767
+ --json Output results as JSON
2768
+ --ignore <pattern> Glob patterns to ignore (can be repeated)
2769
+ --min-severity <level> Minimum severity to show: critical, warning, info (default: info)
2770
+ --quiet Suppress badge and summary
2771
+ -h, --help Show this help message
2772
+ -v, --version Show version
2217
2773
 
2218
2774
  Examples:
2219
- npx prodlint Scan current directory
2220
- npx prodlint ./my-app Scan specific path
2221
- npx prodlint --json JSON output
2222
- npx prodlint --ignore "*.test" Ignore test files
2775
+ npx prodlint Scan current directory
2776
+ npx prodlint ./my-app Scan specific path
2777
+ npx prodlint --json JSON output
2778
+ npx prodlint --ignore "*.test" Ignore test files
2779
+ npx prodlint --min-severity warning Only warnings and criticals
2780
+ npx prodlint --quiet No badge output
2223
2781
  `);
2224
2782
  }
2225
2783
  main().catch((err) => {