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/mcp.js CHANGED
@@ -167,6 +167,133 @@ var NODE_BUILTINS = /* @__PURE__ */ new Set([
167
167
  // node: prefixed are handled separately
168
168
  ]);
169
169
 
170
+ // src/utils/ast.ts
171
+ import { parse } from "@babel/parser";
172
+ function parseFile(content, fileName) {
173
+ const plugins = ["decorators"];
174
+ if (/\.tsx?$/.test(fileName)) {
175
+ plugins.push("typescript");
176
+ }
177
+ if (/\.[jt]sx$/.test(fileName)) {
178
+ plugins.push("jsx");
179
+ }
180
+ if (/\.(js|mjs|cjs)$/.test(fileName)) {
181
+ plugins.push("jsx");
182
+ }
183
+ try {
184
+ return parse(content, {
185
+ sourceType: "module",
186
+ allowImportExportEverywhere: true,
187
+ allowReturnOutsideFunction: true,
188
+ plugins
189
+ });
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+ function walkAST(node, visitor, parent = null) {
195
+ if (!node || typeof node !== "object") return;
196
+ visitor(node, parent);
197
+ for (const key of Object.keys(node)) {
198
+ if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
199
+ const val = node[key];
200
+ if (Array.isArray(val)) {
201
+ for (const item of val) {
202
+ if (item && typeof item === "object" && item.type) {
203
+ walkAST(item, visitor, node);
204
+ }
205
+ }
206
+ } else if (val && typeof val === "object" && val.type) {
207
+ walkAST(val, visitor, node);
208
+ }
209
+ }
210
+ }
211
+ function isTaggedTemplateSql(node) {
212
+ const tag = node.tag;
213
+ if (tag.type === "Identifier" && tag.name === "sql") return true;
214
+ if (tag.type === "MemberExpression") {
215
+ const prop = tag.property;
216
+ if (prop.type === "Identifier" && (prop.name === "sql" || prop.name === "query" || prop.name === "raw")) {
217
+ return true;
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+ function findLoopsAST(ast) {
223
+ const loops = [];
224
+ walkAST(ast.program, (node) => {
225
+ if (node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement" || node.type === "WhileStatement" || node.type === "DoWhileStatement") {
226
+ const loop = node;
227
+ const body = loop.body;
228
+ if (body.loc && node.loc) {
229
+ loops.push({
230
+ loopLine: node.loc.start.line - 1,
231
+ bodyStart: body.loc.start.line - 1,
232
+ bodyEnd: body.loc.end.line - 1
233
+ });
234
+ }
235
+ }
236
+ if (node.type === "CallExpression") {
237
+ const call = node;
238
+ if (call.callee.type === "MemberExpression" && call.callee.property.type === "Identifier" && (call.callee.property.name === "forEach" || call.callee.property.name === "map")) {
239
+ const callback = call.arguments[0];
240
+ if (callback && callback.loc && node.loc) {
241
+ loops.push({
242
+ loopLine: node.loc.start.line - 1,
243
+ bodyStart: callback.loc.start.line - 1,
244
+ bodyEnd: callback.loc.end.line - 1
245
+ });
246
+ }
247
+ }
248
+ }
249
+ });
250
+ return loops;
251
+ }
252
+
253
+ // src/utils/frameworks.ts
254
+ var FRAMEWORK_SAFE_METHODS = {
255
+ prisma: ["contains", "startsWith", "endsWith", "has", "hasEvery", "hasSome", "isEmpty"],
256
+ supabase: ["contains", "containedBy", "overlaps", "eq", "neq", "gt", "gte", "lt", "lte"],
257
+ drizzle: ["arrayContains", "arrayContainedIn", "arrayOverlaps"],
258
+ lodash: ["flatten", "flattenDeep", "contains", "includes", "has"],
259
+ mongoose: ["contains"]
260
+ };
261
+ function isFrameworkSafeMethod(methodName, frameworks) {
262
+ for (const framework of frameworks) {
263
+ const safeMethods = FRAMEWORK_SAFE_METHODS[framework];
264
+ if (safeMethods && safeMethods.includes(methodName)) {
265
+ return true;
266
+ }
267
+ }
268
+ return false;
269
+ }
270
+ var DEPENDENCY_TO_FRAMEWORK = {
271
+ "@prisma/client": "prisma",
272
+ "prisma": "prisma",
273
+ "@supabase/supabase-js": "supabase",
274
+ "@supabase/ssr": "supabase",
275
+ "drizzle-orm": "drizzle",
276
+ "@trpc/server": "trpc",
277
+ "next-auth": "next-auth",
278
+ "@auth/nextjs": "next-auth",
279
+ "@auth/core": "next-auth",
280
+ "express": "express",
281
+ "fastify": "fastify",
282
+ "hono": "hono",
283
+ "lodash": "lodash",
284
+ "lodash-es": "lodash",
285
+ "underscore": "lodash",
286
+ "mongoose": "mongoose",
287
+ "typeorm": "typeorm",
288
+ "sequelize": "sequelize",
289
+ "knex": "knex",
290
+ "@upstash/ratelimit": "upstash-ratelimit",
291
+ "express-rate-limit": "express-rate-limit",
292
+ "rate-limiter-flexible": "rate-limiter-flexible"
293
+ };
294
+ var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
295
+ var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
296
+
170
297
  // src/utils/file-walker.ts
171
298
  var DEFAULT_IGNORES = [
172
299
  "**/node_modules/**",
@@ -193,6 +320,7 @@ var SCAN_EXTENSIONS = [
193
320
  "cjs",
194
321
  "json"
195
322
  ];
323
+ var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
196
324
  var MAX_FILE_SIZE = 1024 * 1024;
197
325
  async function walkFiles(root, extraIgnores = []) {
198
326
  const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
@@ -217,14 +345,23 @@ async function readFileContext(root, relativePath) {
217
345
  if (fileStats.size > MAX_FILE_SIZE) return null;
218
346
  const content = await readFile(absolutePath, "utf-8");
219
347
  const lines = content.split(/\r?\n|\r/);
348
+ const ext = extname(relativePath).slice(1);
349
+ let ast = void 0;
350
+ if (AST_EXTENSIONS.has(ext)) {
351
+ try {
352
+ ast = parseFile(content, relativePath);
353
+ } catch {
354
+ ast = null;
355
+ }
356
+ }
220
357
  return {
221
358
  absolutePath,
222
359
  relativePath,
223
360
  content,
224
361
  lines,
225
- ext: extname(relativePath).slice(1),
226
- // remove leading dot
227
- commentMap: buildCommentMap(lines)
362
+ ext,
363
+ commentMap: buildCommentMap(lines),
364
+ ast
228
365
  };
229
366
  } catch {
230
367
  return null;
@@ -235,6 +372,8 @@ async function buildProjectContext(root, files) {
235
372
  let declaredDependencies = /* @__PURE__ */ new Set();
236
373
  let tsconfigPaths = /* @__PURE__ */ new Set();
237
374
  let hasAuthMiddleware = false;
375
+ let hasRateLimiting = false;
376
+ const detectedFrameworks = /* @__PURE__ */ new Set();
238
377
  let gitignoreContent = null;
239
378
  let envInGitignore = false;
240
379
  try {
@@ -246,6 +385,18 @@ async function buildProjectContext(root, files) {
246
385
  ...packageJson?.peerDependencies ?? {}
247
386
  };
248
387
  declaredDependencies = new Set(Object.keys(deps));
388
+ for (const dep of declaredDependencies) {
389
+ const framework = DEPENDENCY_TO_FRAMEWORK[dep];
390
+ if (framework) {
391
+ detectedFrameworks.add(framework);
392
+ }
393
+ }
394
+ for (const framework of detectedFrameworks) {
395
+ if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
396
+ hasRateLimiting = true;
397
+ break;
398
+ }
399
+ }
249
400
  } catch {
250
401
  }
251
402
  try {
@@ -289,6 +440,18 @@ async function buildProjectContext(root, files) {
289
440
  }
290
441
  } catch {
291
442
  }
443
+ if (!hasRateLimiting) {
444
+ for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
445
+ try {
446
+ const content = await readFile(resolve(root, name), "utf-8");
447
+ if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
448
+ hasRateLimiting = true;
449
+ break;
450
+ }
451
+ } catch {
452
+ }
453
+ }
454
+ }
292
455
  try {
293
456
  gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
294
457
  envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
@@ -300,6 +463,8 @@ async function buildProjectContext(root, files) {
300
463
  declaredDependencies,
301
464
  tsconfigPaths,
302
465
  hasAuthMiddleware,
466
+ hasRateLimiting,
467
+ detectedFrameworks,
303
468
  gitignoreContent,
304
469
  envInGitignore,
305
470
  allFiles: files
@@ -324,26 +489,58 @@ function getVersion() {
324
489
 
325
490
  // src/scorer.ts
326
491
  var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
492
+ var CATEGORY_WEIGHTS = {
493
+ "security": 0.4,
494
+ "reliability": 0.3,
495
+ "performance": 0.15,
496
+ "ai-quality": 0.15
497
+ };
327
498
  var DEDUCTIONS = {
328
- critical: 10,
329
- warning: 3,
330
- info: 1
499
+ critical: 8,
500
+ warning: 2,
501
+ info: 0.5
502
+ };
503
+ var PER_RULE_CAP = {
504
+ critical: 1,
505
+ warning: 2,
506
+ info: 3
331
507
  };
332
508
  function calculateScores(findings) {
333
509
  const categoryScores = CATEGORIES.map((category) => {
334
510
  const categoryFindings = findings.filter((f) => f.category === category);
335
- let score = 100;
511
+ const byRule = /* @__PURE__ */ new Map();
336
512
  for (const f of categoryFindings) {
337
- score -= DEDUCTIONS[f.severity] ?? 0;
513
+ const arr = byRule.get(f.ruleId) ?? [];
514
+ arr.push(f);
515
+ byRule.set(f.ruleId, arr);
516
+ }
517
+ let totalDeduction = 0;
518
+ for (const [, ruleFindings] of byRule) {
519
+ const bySeverity = { critical: 0, warning: 0, info: 0 };
520
+ for (const f of ruleFindings) {
521
+ bySeverity[f.severity]++;
522
+ }
523
+ for (const sev of ["critical", "warning", "info"]) {
524
+ const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
525
+ totalDeduction += count * DEDUCTIONS[sev];
526
+ }
527
+ }
528
+ let effectiveDeduction;
529
+ if (totalDeduction <= 30) {
530
+ effectiveDeduction = totalDeduction;
531
+ } else if (totalDeduction <= 50) {
532
+ effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
533
+ } else {
534
+ effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
338
535
  }
339
536
  return {
340
537
  category,
341
- score: Math.max(0, score),
538
+ score: Math.max(0, Math.round(100 - effectiveDeduction)),
342
539
  findingCount: categoryFindings.length
343
540
  };
344
541
  });
345
542
  const overallScore = Math.round(
346
- categoryScores.reduce((sum, c) => sum + c.score, 0) / CATEGORIES.length
543
+ categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
347
544
  );
348
545
  return { overallScore, categoryScores };
349
546
  }
@@ -514,25 +711,36 @@ var AUTH_PATTERNS = [
514
711
  /jwt\.verify\s*\(/,
515
712
  /createRouteHandlerClient/,
516
713
  /createServerComponentClient/,
714
+ /createMiddlewareClient/,
517
715
  /authorization/i,
518
- /bearer/i
716
+ /getAuth\s*\(/,
717
+ /withPageAuth/,
718
+ /cookies\(\).*auth/s
519
719
  ];
720
+ var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
520
721
  var authChecksRule = {
521
722
  id: "auth-checks",
522
723
  name: "Missing Auth Checks",
523
724
  description: "Detects API routes that lack authentication checks",
524
725
  category: "security",
525
- severity: "critical",
726
+ severity: "warning",
526
727
  fileExtensions: ["ts", "tsx", "js", "jsx"],
527
728
  check(file, project) {
528
729
  if (!isApiRoute(file.relativePath)) return [];
529
730
  for (const pattern of AUTH_EXEMPT_PATTERNS) {
530
731
  if (pattern.test(file.relativePath)) return [];
531
732
  }
532
- const severity = project.hasAuthMiddleware ? "info" : "critical";
533
733
  for (const pattern of AUTH_PATTERNS) {
534
734
  if (pattern.test(file.content)) return [];
535
735
  }
736
+ let severity;
737
+ if (project.hasAuthMiddleware) {
738
+ severity = "info";
739
+ } else if (MUTATION_EXPORT.test(file.content)) {
740
+ severity = "critical";
741
+ } else {
742
+ severity = "info";
743
+ }
536
744
  let handlerLine = 1;
537
745
  for (let i = 0; i < file.lines.length; i++) {
538
746
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -629,7 +837,10 @@ var errorHandlingRule = {
629
837
  if (!isApiRoute(file.relativePath)) return [];
630
838
  const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
631
839
  const hasTryCatch = /try\s*\{/.test(file.content);
632
- if (hasTryCatch || hasFrameworkServe) return [];
840
+ const hasCatchChain = /\.catch\s*\(/.test(file.content);
841
+ const hasOnError = /onError\s*[:(]/.test(file.content);
842
+ const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
843
+ if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
633
844
  let handlerLine = 1;
634
845
  for (let i = 0; i < file.lines.length; i++) {
635
846
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -734,10 +945,11 @@ var rateLimitingRule = {
734
945
  name: "Missing Rate Limiting",
735
946
  description: "Detects API routes without rate limiting",
736
947
  category: "security",
737
- severity: "warning",
948
+ severity: "info",
738
949
  fileExtensions: ["ts", "tsx", "js", "jsx"],
739
- check(file, _project) {
950
+ check(file, project) {
740
951
  if (!isApiRoute(file.relativePath)) return [];
952
+ if (project.hasRateLimiting) return [];
741
953
  for (const pattern of EXEMPT_PATTERNS) {
742
954
  if (pattern.test(file.relativePath)) return [];
743
955
  }
@@ -757,7 +969,7 @@ var rateLimitingRule = {
757
969
  line: handlerLine,
758
970
  column: 1,
759
971
  message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
760
- severity: "warning",
972
+ severity: "info",
761
973
  category: "security"
762
974
  }];
763
975
  }
@@ -985,6 +1197,8 @@ var SQL_INJECTION_PATTERNS = [
985
1197
  // .query() or .execute() with template literal
986
1198
  { pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
987
1199
  ];
1200
+ var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
1201
+ var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
988
1202
  var sqlInjectionRule = {
989
1203
  id: "sql-injection",
990
1204
  name: "SQL Injection Risk",
@@ -992,20 +1206,42 @@ var sqlInjectionRule = {
992
1206
  category: "security",
993
1207
  severity: "critical",
994
1208
  fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
995
- check(file, _project) {
1209
+ check(file, project) {
996
1210
  const findings = [];
1211
+ const safeTaggedLines = /* @__PURE__ */ new Set();
1212
+ if (file.ast) {
1213
+ try {
1214
+ walkAST(file.ast.program, (node) => {
1215
+ if (node.type === "TaggedTemplateExpression") {
1216
+ const tagged = node;
1217
+ if (isTaggedTemplateSql(tagged) && tagged.loc) {
1218
+ for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
1219
+ safeTaggedLines.add(l);
1220
+ }
1221
+ }
1222
+ }
1223
+ });
1224
+ } catch {
1225
+ }
1226
+ }
1227
+ const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
997
1228
  for (let i = 0; i < file.lines.length; i++) {
998
1229
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
999
1230
  const line = file.lines[i];
1231
+ const lineNum = i + 1;
1232
+ if (safeTaggedLines.has(lineNum)) continue;
1233
+ if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
1234
+ if (PARAMETERIZED_QUERY.test(line)) continue;
1000
1235
  for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
1001
1236
  if (pattern.test(line)) {
1237
+ const severity = usesORM ? "warning" : "critical";
1002
1238
  findings.push({
1003
1239
  ruleId: "sql-injection",
1004
1240
  file: file.relativePath,
1005
- line: i + 1,
1241
+ line: lineNum,
1006
1242
  column: 1,
1007
1243
  message,
1008
- severity: "critical",
1244
+ severity,
1009
1245
  category: "security"
1010
1246
  });
1011
1247
  break;
@@ -1027,7 +1263,7 @@ var PLACEHOLDERS = [
1027
1263
  { pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
1028
1264
  { pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
1029
1265
  { pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
1030
- { pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
1266
+ { pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
1031
1267
  { pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
1032
1268
  { pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
1033
1269
  ];
@@ -1067,13 +1303,13 @@ var placeholderContentRule = {
1067
1303
  // src/rules/stale-fallback.ts
1068
1304
  var STALE_PATTERNS = [
1069
1305
  { pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
1070
- { pattern: /['"]https?:\/\/127\.0\.0\.1[:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1071
- { pattern: /['"]redis:\/\/localhost['"]/, label: "Hardcoded Redis localhost URL" },
1072
- { pattern: /['"]mongodb:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1073
- { pattern: /['"]mongodb\+srv:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1074
- { pattern: /['"]postgres:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1075
- { pattern: /['"]postgresql:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1076
- { pattern: /['"]amqp:\/\/localhost['"]/, label: "Hardcoded AMQP localhost URL" }
1306
+ { pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1307
+ { pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
1308
+ { pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1309
+ { pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1310
+ { pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1311
+ { pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1312
+ { pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
1077
1313
  ];
1078
1314
  var staleFallbackRule = {
1079
1315
  id: "stale-fallback",
@@ -1112,15 +1348,15 @@ var staleFallbackRule = {
1112
1348
 
1113
1349
  // src/rules/hallucinated-api.ts
1114
1350
  var HALLUCINATED_APIS = [
1115
- { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()" },
1116
- { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()" },
1117
- { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()" },
1118
- { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()" },
1119
- { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()" },
1120
- { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()" },
1121
- { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()" },
1122
- { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)" },
1123
- { pattern: /\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()" }
1351
+ { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
1352
+ { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
1353
+ { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
1354
+ { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
1355
+ { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
1356
+ { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
1357
+ { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
1358
+ { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
1359
+ { pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
1124
1360
  ];
1125
1361
  var hallucinatedApiRule = {
1126
1362
  id: "hallucinated-api",
@@ -1129,14 +1365,16 @@ var hallucinatedApiRule = {
1129
1365
  category: "ai-quality",
1130
1366
  severity: "warning",
1131
1367
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1132
- check(file, _project) {
1368
+ check(file, project) {
1133
1369
  const findings = [];
1370
+ const frameworks = project.detectedFrameworks;
1134
1371
  for (let i = 0; i < file.lines.length; i++) {
1135
1372
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1136
1373
  const line = file.lines[i];
1137
- for (const { pattern, fix } of HALLUCINATED_APIS) {
1374
+ for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
1138
1375
  const match = pattern.exec(line);
1139
1376
  if (match) {
1377
+ if (isFrameworkSafeMethod(methodName, frameworks)) continue;
1140
1378
  findings.push({
1141
1379
  ruleId: "hallucinated-api",
1142
1380
  file: file.relativePath,
@@ -1154,7 +1392,7 @@ var hallucinatedApiRule = {
1154
1392
  };
1155
1393
 
1156
1394
  // src/rules/open-redirect.ts
1157
- var CRITICAL_PATTERNS = [
1395
+ var DIRECT_INPUT_PATTERNS = [
1158
1396
  // redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
1159
1397
  /redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
1160
1398
  // redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
@@ -1163,21 +1401,21 @@ var CRITICAL_PATTERNS = [
1163
1401
  /NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
1164
1402
  ];
1165
1403
  var WARNING_PATTERNS = [
1166
- /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
1404
+ /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
1167
1405
  ];
1168
1406
  var openRedirectRule = {
1169
1407
  id: "open-redirect",
1170
1408
  name: "Open Redirect",
1171
1409
  description: "Detects user-controlled input passed directly to redirect functions",
1172
1410
  category: "security",
1173
- severity: "critical",
1411
+ severity: "warning",
1174
1412
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1175
1413
  check(file, _project) {
1176
1414
  const findings = [];
1177
1415
  for (let i = 0; i < file.lines.length; i++) {
1178
1416
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1179
1417
  const line = file.lines[i];
1180
- for (const pattern of CRITICAL_PATTERNS) {
1418
+ for (const pattern of DIRECT_INPUT_PATTERNS) {
1181
1419
  const match = pattern.exec(line);
1182
1420
  if (match) {
1183
1421
  findings.push({
@@ -1186,7 +1424,7 @@ var openRedirectRule = {
1186
1424
  line: i + 1,
1187
1425
  column: match.index + 1,
1188
1426
  message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1189
- severity: "critical",
1427
+ severity: "warning",
1190
1428
  category: "security"
1191
1429
  });
1192
1430
  break;
@@ -1251,6 +1489,7 @@ var noSyncFsRule = {
1251
1489
 
1252
1490
  // src/rules/no-n-plus-one.ts
1253
1491
  var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
1492
+ var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
1254
1493
  var noNPlusOneRule = {
1255
1494
  id: "no-n-plus-one",
1256
1495
  name: "No N+1 Queries",
@@ -1261,14 +1500,33 @@ var noNPlusOneRule = {
1261
1500
  check(file, _project) {
1262
1501
  if (isTestFile(file.relativePath)) return [];
1263
1502
  if (isScriptFile(file.relativePath)) return [];
1503
+ const promiseAllMapLines = /* @__PURE__ */ new Set();
1504
+ for (let i = 0; i < file.lines.length; i++) {
1505
+ if (PROMISE_ALL_MAP.test(file.lines[i])) {
1506
+ for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
1507
+ promiseAllMapLines.add(j);
1508
+ }
1509
+ }
1510
+ }
1264
1511
  const findings = [];
1265
- const loops = findLoopBodies(file.lines, file.commentMap);
1512
+ let loops;
1513
+ if (file.ast) {
1514
+ try {
1515
+ loops = findLoopsAST(file.ast);
1516
+ } catch {
1517
+ loops = findLoopBodies(file.lines, file.commentMap);
1518
+ }
1519
+ } else {
1520
+ loops = findLoopBodies(file.lines, file.commentMap);
1521
+ }
1266
1522
  const reported = /* @__PURE__ */ new Set();
1267
1523
  for (const loop of loops) {
1268
1524
  if (reported.has(loop.loopLine)) continue;
1525
+ if (promiseAllMapLines.has(loop.loopLine)) continue;
1269
1526
  for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
1270
1527
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1271
1528
  const line = file.lines[i];
1529
+ if (promiseAllMapLines.has(i)) continue;
1272
1530
  const match = DB_CALL_PATTERN.exec(line);
1273
1531
  if (match) {
1274
1532
  reported.add(loop.loopLine);
@@ -1402,6 +1660,10 @@ var HANDLED_PATTERNS = [
1402
1660
  /Promise\.allSettled/,
1403
1661
  /Promise\.race/
1404
1662
  ];
1663
+ var CHAIN_START_PATTERNS = [
1664
+ /\.from\s*\(/,
1665
+ /\.rpc\s*\(/
1666
+ ];
1405
1667
  var unhandledPromiseRule = {
1406
1668
  id: "unhandled-promise",
1407
1669
  name: "Unhandled Promise",
@@ -1421,6 +1683,19 @@ var unhandledPromiseRule = {
1421
1683
  if (!asyncMatch) continue;
1422
1684
  const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
1423
1685
  if (isHandled) continue;
1686
+ const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
1687
+ if (isChainContinuation) {
1688
+ let chainHandled = false;
1689
+ for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
1690
+ const prevTrimmed = file.lines[j].trim();
1691
+ if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
1692
+ if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
1693
+ chainHandled = true;
1694
+ break;
1695
+ }
1696
+ }
1697
+ if (chainHandled) continue;
1698
+ }
1424
1699
  let handledAbove = false;
1425
1700
  for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
1426
1701
  const prevTrimmed = file.lines[j].trim();
@@ -1488,9 +1763,9 @@ var missingErrorBoundaryRule = {
1488
1763
  severity: "info",
1489
1764
  fileExtensions: ["tsx", "jsx", "ts", "js"],
1490
1765
  check(file, project) {
1491
- const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
1766
+ const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
1492
1767
  if (!match) return [];
1493
- const dir = "app/" + match[1];
1768
+ const dir = match[1] + match[2];
1494
1769
  const hasErrorBoundary = project.allFiles.some(
1495
1770
  (f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
1496
1771
  );
@@ -1668,7 +1943,8 @@ var deadExportsRule = {
1668
1943
  (f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
1669
1944
  );
1670
1945
  const exports = /* @__PURE__ */ new Map();
1671
- const imports = /* @__PURE__ */ new Set();
1946
+ const imports = /* @__PURE__ */ new Map();
1947
+ const allImportedSymbols = /* @__PURE__ */ new Set();
1672
1948
  const importedFiles = /* @__PURE__ */ new Set();
1673
1949
  for (const file of sourceFiles) {
1674
1950
  if (isEntryPoint(file.relativePath)) continue;
@@ -1692,14 +1968,28 @@ var deadExportsRule = {
1692
1968
  for (const file of files) {
1693
1969
  for (const line of file.lines) {
1694
1970
  let match;
1971
+ const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
1972
+ const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
1695
1973
  const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
1696
1974
  while ((match = bracesRe.exec(line)) !== null) {
1697
1975
  const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
1698
- for (const sym of symbols) imports.add(sym);
1976
+ for (const sym of symbols) {
1977
+ allImportedSymbols.add(sym);
1978
+ if (fromBasename) {
1979
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1980
+ set.add(sym);
1981
+ imports.set(fromBasename, set);
1982
+ }
1983
+ }
1699
1984
  }
1700
1985
  const defaultRe = /import\s+(\w+)\s+from/g;
1701
1986
  while ((match = defaultRe.exec(line)) !== null) {
1702
- imports.add(match[1]);
1987
+ allImportedSymbols.add(match[1]);
1988
+ if (fromBasename) {
1989
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1990
+ set.add(match[1]);
1991
+ imports.set(fromBasename, set);
1992
+ }
1703
1993
  }
1704
1994
  const fromRe = /from\s+['"]([^'"]+)['"]/g;
1705
1995
  while ((match = fromRe.exec(line)) !== null) {
@@ -1710,7 +2000,10 @@ var deadExportsRule = {
1710
2000
  const deadByFile = /* @__PURE__ */ new Map();
1711
2001
  for (const [key, loc] of exports) {
1712
2002
  const symbolName = key.split("::")[1];
1713
- if (!imports.has(symbolName)) {
2003
+ const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2004
+ const importSet = imports.get(exportFileBasename);
2005
+ const isImported = importSet?.has(symbolName) ?? false;
2006
+ if (!isImported && !allImportedSymbols.has(symbolName)) {
1714
2007
  deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
1715
2008
  }
1716
2009
  }
@@ -1780,12 +2073,33 @@ var shallowCatchRule = {
1780
2073
  if (braceStart === -1) continue;
1781
2074
  let depth = 0;
1782
2075
  let bodyEnd = braceStart;
2076
+ let inSingle = false;
2077
+ let inDouble = false;
2078
+ let inTemplate = false;
1783
2079
  for (let j = braceStart; j < file.lines.length; j++) {
1784
2080
  const line = file.lines[j];
1785
2081
  const startPos = j === braceStart ? line.indexOf("{") : 0;
1786
2082
  for (let k = startPos; k < line.length; k++) {
1787
- if (line[k] === "{") depth++;
1788
- if (line[k] === "}") {
2083
+ const ch = line[k];
2084
+ const prev = k > 0 ? line[k - 1] : "";
2085
+ const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
2086
+ if (!escaped) {
2087
+ if (ch === "'" && !inDouble && !inTemplate) {
2088
+ inSingle = !inSingle;
2089
+ continue;
2090
+ }
2091
+ if (ch === '"' && !inSingle && !inTemplate) {
2092
+ inDouble = !inDouble;
2093
+ continue;
2094
+ }
2095
+ if (ch === "`" && !inSingle && !inDouble) {
2096
+ inTemplate = !inTemplate;
2097
+ continue;
2098
+ }
2099
+ }
2100
+ if (inSingle || inDouble || inTemplate) continue;
2101
+ if (ch === "{") depth++;
2102
+ if (ch === "}") {
1789
2103
  depth--;
1790
2104
  if (depth === 0) {
1791
2105
  bodyEnd = j;
@@ -1952,6 +2266,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
1952
2266
  "gpt-tokenizer"
1953
2267
  // exists but often confused
1954
2268
  ]);
2269
+ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2270
+ "pg",
2271
+ "ws",
2272
+ "ms",
2273
+ "qs",
2274
+ "ip",
2275
+ "is",
2276
+ "he",
2277
+ "ky",
2278
+ "bl",
2279
+ "rc",
2280
+ "io",
2281
+ "db",
2282
+ "fp",
2283
+ "rx"
2284
+ ]);
1955
2285
  var SUSPICIOUS_PATTERNS = [
1956
2286
  /^[a-z]{1,2}$/,
1957
2287
  // 1-2 char names
@@ -1990,7 +2320,7 @@ var phantomDependencyRule = {
1990
2320
  });
1991
2321
  }
1992
2322
  for (const pattern of SUSPICIOUS_PATTERNS) {
1993
- if (pattern.test(name) && !name.startsWith("@")) {
2323
+ if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
1994
2324
  findings.push({
1995
2325
  ruleId: "phantom-dependency",
1996
2326
  file: "package.json",
@@ -2008,6 +2338,210 @@ var phantomDependencyRule = {
2008
2338
  }
2009
2339
  };
2010
2340
 
2341
+ // src/rules/insecure-cookie.ts
2342
+ var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
2343
+ var COOKIE_SET_PATTERNS = [
2344
+ /cookies\(\)\s*\.set\s*\(/,
2345
+ /res\.cookie\s*\(/,
2346
+ /response\.cookies\.set\s*\(/
2347
+ ];
2348
+ var SECURE_OPTIONS = [
2349
+ /httpOnly\s*:\s*true/,
2350
+ /secure\s*:\s*true/,
2351
+ /sameSite\s*:/
2352
+ ];
2353
+ var insecureCookieRule = {
2354
+ id: "insecure-cookie",
2355
+ name: "Insecure Cookie",
2356
+ description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
2357
+ category: "security",
2358
+ severity: "warning",
2359
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2360
+ check(file, _project) {
2361
+ if (isTestFile(file.relativePath)) return [];
2362
+ if (isScriptFile(file.relativePath)) return [];
2363
+ const findings = [];
2364
+ for (let i = 0; i < file.lines.length; i++) {
2365
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2366
+ const line = file.lines[i];
2367
+ const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
2368
+ if (!isCookieSet) continue;
2369
+ if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
2370
+ const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
2371
+ const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
2372
+ if (missingOptions.length > 0) {
2373
+ const missing = [];
2374
+ if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
2375
+ if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
2376
+ if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
2377
+ findings.push({
2378
+ ruleId: "insecure-cookie",
2379
+ file: file.relativePath,
2380
+ line: i + 1,
2381
+ column: 1,
2382
+ message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
2383
+ severity: "warning",
2384
+ category: "security",
2385
+ fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
2386
+ });
2387
+ }
2388
+ }
2389
+ return findings;
2390
+ }
2391
+ };
2392
+
2393
+ // src/rules/leaked-env-in-logs.ts
2394
+ var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
2395
+ var leakedEnvInLogsRule = {
2396
+ id: "leaked-env-in-logs",
2397
+ name: "Leaked Env in Logs",
2398
+ description: "Detects process.env values logged to console \u2014 potential secret exposure",
2399
+ category: "security",
2400
+ severity: "warning",
2401
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2402
+ check(file, _project) {
2403
+ if (isTestFile(file.relativePath)) return [];
2404
+ if (isScriptFile(file.relativePath)) return [];
2405
+ const findings = [];
2406
+ for (let i = 0; i < file.lines.length; i++) {
2407
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2408
+ const line = file.lines[i];
2409
+ const match = CONSOLE_WITH_ENV.exec(line);
2410
+ if (match) {
2411
+ findings.push({
2412
+ ruleId: "leaked-env-in-logs",
2413
+ file: file.relativePath,
2414
+ line: i + 1,
2415
+ column: match.index + 1,
2416
+ message: "process.env value in console output \u2014 may leak secrets in production logs",
2417
+ severity: "warning",
2418
+ category: "security",
2419
+ fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
2420
+ });
2421
+ }
2422
+ }
2423
+ return findings;
2424
+ }
2425
+ };
2426
+
2427
+ // src/rules/insecure-random.ts
2428
+ var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
2429
+ var MATH_RANDOM = /Math\.random\s*\(\)/;
2430
+ var insecureRandomRule = {
2431
+ id: "insecure-random",
2432
+ name: "Insecure Random",
2433
+ description: "Detects Math.random() used near security-sensitive variable names",
2434
+ category: "security",
2435
+ severity: "warning",
2436
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2437
+ check(file, _project) {
2438
+ if (isTestFile(file.relativePath)) return [];
2439
+ if (isScriptFile(file.relativePath)) return [];
2440
+ const findings = [];
2441
+ for (let i = 0; i < file.lines.length; i++) {
2442
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2443
+ const line = file.lines[i];
2444
+ const match = MATH_RANDOM.exec(line);
2445
+ if (!match) continue;
2446
+ const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
2447
+ if (SECURITY_VAR_NAMES.test(context)) {
2448
+ findings.push({
2449
+ ruleId: "insecure-random",
2450
+ file: file.relativePath,
2451
+ line: i + 1,
2452
+ column: match.index + 1,
2453
+ message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
2454
+ severity: "warning",
2455
+ category: "security",
2456
+ fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
2457
+ });
2458
+ }
2459
+ }
2460
+ return findings;
2461
+ }
2462
+ };
2463
+
2464
+ // src/rules/next-server-action-validation.ts
2465
+ var USE_SERVER = /['"]use server['"]/;
2466
+ var FORM_DATA_GET = /formData\.get\s*\(/;
2467
+ var VALIDATION_PATTERNS2 = [
2468
+ /\.parse\s*\(/,
2469
+ /\.safeParse\s*\(/,
2470
+ /\bvalidate\s*\(/,
2471
+ /\.parseAsync\s*\(/,
2472
+ /\.safeParseAsync\s*\(/
2473
+ ];
2474
+ var nextServerActionValidationRule = {
2475
+ id: "next-server-action-validation",
2476
+ name: "Next.js Server Action Validation",
2477
+ description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
2478
+ category: "security",
2479
+ severity: "critical",
2480
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2481
+ check(file, _project) {
2482
+ if (isTestFile(file.relativePath)) return [];
2483
+ if (!USE_SERVER.test(file.content)) return [];
2484
+ if (!FORM_DATA_GET.test(file.content)) return [];
2485
+ const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
2486
+ if (hasValidation) return [];
2487
+ let reportLine = 1;
2488
+ for (let i = 0; i < file.lines.length; i++) {
2489
+ if (FORM_DATA_GET.test(file.lines[i])) {
2490
+ reportLine = i + 1;
2491
+ break;
2492
+ }
2493
+ }
2494
+ return [{
2495
+ ruleId: "next-server-action-validation",
2496
+ file: file.relativePath,
2497
+ line: reportLine,
2498
+ column: 1,
2499
+ message: "Server action reads formData without schema validation \u2014 unvalidated user input",
2500
+ severity: "critical",
2501
+ category: "security",
2502
+ fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
2503
+ }];
2504
+ }
2505
+ };
2506
+
2507
+ // src/rules/missing-transaction.ts
2508
+ var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
2509
+ var PRISMA_TRANSACTION = /\$transaction\s*\(/;
2510
+ var missingTransactionRule = {
2511
+ id: "missing-transaction",
2512
+ name: "Missing Transaction",
2513
+ description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
2514
+ category: "reliability",
2515
+ severity: "warning",
2516
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2517
+ check(file, project) {
2518
+ if (isTestFile(file.relativePath)) return [];
2519
+ if (isScriptFile(file.relativePath)) return [];
2520
+ if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
2521
+ let writeCount = 0;
2522
+ let firstWriteLine = -1;
2523
+ const hasTransaction = PRISMA_TRANSACTION.test(file.content);
2524
+ for (let i = 0; i < file.lines.length; i++) {
2525
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2526
+ if (PRISMA_WRITE_OPS.test(file.lines[i])) {
2527
+ writeCount++;
2528
+ if (firstWriteLine === -1) firstWriteLine = i;
2529
+ }
2530
+ }
2531
+ if (writeCount < 2 || hasTransaction) return [];
2532
+ return [{
2533
+ ruleId: "missing-transaction",
2534
+ file: file.relativePath,
2535
+ line: firstWriteLine + 1,
2536
+ column: 1,
2537
+ message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
2538
+ severity: "warning",
2539
+ category: "reliability",
2540
+ fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
2541
+ }];
2542
+ }
2543
+ };
2544
+
2011
2545
  // src/rules/index.ts
2012
2546
  var rules = [
2013
2547
  // Security
@@ -2021,6 +2555,10 @@ var rules = [
2021
2555
  openRedirectRule,
2022
2556
  rateLimitingRule,
2023
2557
  phantomDependencyRule,
2558
+ insecureCookieRule,
2559
+ leakedEnvInLogsRule,
2560
+ insecureRandomRule,
2561
+ nextServerActionValidationRule,
2024
2562
  // Reliability
2025
2563
  hallucinatedImportsRule,
2026
2564
  errorHandlingRule,
@@ -2028,6 +2566,7 @@ var rules = [
2028
2566
  shallowCatchRule,
2029
2567
  missingLoadingStateRule,
2030
2568
  missingErrorBoundaryRule,
2569
+ missingTransactionRule,
2031
2570
  // Performance
2032
2571
  noSyncFsRule,
2033
2572
  noNPlusOneRule,