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/index.js CHANGED
@@ -158,6 +158,133 @@ var NODE_BUILTINS = /* @__PURE__ */ new Set([
158
158
  // node: prefixed are handled separately
159
159
  ]);
160
160
 
161
+ // src/utils/ast.ts
162
+ import { parse } from "@babel/parser";
163
+ function parseFile(content, fileName) {
164
+ const plugins = ["decorators"];
165
+ if (/\.tsx?$/.test(fileName)) {
166
+ plugins.push("typescript");
167
+ }
168
+ if (/\.[jt]sx$/.test(fileName)) {
169
+ plugins.push("jsx");
170
+ }
171
+ if (/\.(js|mjs|cjs)$/.test(fileName)) {
172
+ plugins.push("jsx");
173
+ }
174
+ try {
175
+ return parse(content, {
176
+ sourceType: "module",
177
+ allowImportExportEverywhere: true,
178
+ allowReturnOutsideFunction: true,
179
+ plugins
180
+ });
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+ function walkAST(node, visitor, parent = null) {
186
+ if (!node || typeof node !== "object") return;
187
+ visitor(node, parent);
188
+ for (const key of Object.keys(node)) {
189
+ if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
190
+ const val = node[key];
191
+ if (Array.isArray(val)) {
192
+ for (const item of val) {
193
+ if (item && typeof item === "object" && item.type) {
194
+ walkAST(item, visitor, node);
195
+ }
196
+ }
197
+ } else if (val && typeof val === "object" && val.type) {
198
+ walkAST(val, visitor, node);
199
+ }
200
+ }
201
+ }
202
+ function isTaggedTemplateSql(node) {
203
+ const tag = node.tag;
204
+ if (tag.type === "Identifier" && tag.name === "sql") return true;
205
+ if (tag.type === "MemberExpression") {
206
+ const prop = tag.property;
207
+ if (prop.type === "Identifier" && (prop.name === "sql" || prop.name === "query" || prop.name === "raw")) {
208
+ return true;
209
+ }
210
+ }
211
+ return false;
212
+ }
213
+ function findLoopsAST(ast) {
214
+ const loops = [];
215
+ walkAST(ast.program, (node) => {
216
+ if (node.type === "ForStatement" || node.type === "ForInStatement" || node.type === "ForOfStatement" || node.type === "WhileStatement" || node.type === "DoWhileStatement") {
217
+ const loop = node;
218
+ const body = loop.body;
219
+ if (body.loc && node.loc) {
220
+ loops.push({
221
+ loopLine: node.loc.start.line - 1,
222
+ bodyStart: body.loc.start.line - 1,
223
+ bodyEnd: body.loc.end.line - 1
224
+ });
225
+ }
226
+ }
227
+ if (node.type === "CallExpression") {
228
+ const call = node;
229
+ if (call.callee.type === "MemberExpression" && call.callee.property.type === "Identifier" && (call.callee.property.name === "forEach" || call.callee.property.name === "map")) {
230
+ const callback = call.arguments[0];
231
+ if (callback && callback.loc && node.loc) {
232
+ loops.push({
233
+ loopLine: node.loc.start.line - 1,
234
+ bodyStart: callback.loc.start.line - 1,
235
+ bodyEnd: callback.loc.end.line - 1
236
+ });
237
+ }
238
+ }
239
+ }
240
+ });
241
+ return loops;
242
+ }
243
+
244
+ // src/utils/frameworks.ts
245
+ var FRAMEWORK_SAFE_METHODS = {
246
+ prisma: ["contains", "startsWith", "endsWith", "has", "hasEvery", "hasSome", "isEmpty"],
247
+ supabase: ["contains", "containedBy", "overlaps", "eq", "neq", "gt", "gte", "lt", "lte"],
248
+ drizzle: ["arrayContains", "arrayContainedIn", "arrayOverlaps"],
249
+ lodash: ["flatten", "flattenDeep", "contains", "includes", "has"],
250
+ mongoose: ["contains"]
251
+ };
252
+ function isFrameworkSafeMethod(methodName, frameworks) {
253
+ for (const framework of frameworks) {
254
+ const safeMethods = FRAMEWORK_SAFE_METHODS[framework];
255
+ if (safeMethods && safeMethods.includes(methodName)) {
256
+ return true;
257
+ }
258
+ }
259
+ return false;
260
+ }
261
+ var DEPENDENCY_TO_FRAMEWORK = {
262
+ "@prisma/client": "prisma",
263
+ "prisma": "prisma",
264
+ "@supabase/supabase-js": "supabase",
265
+ "@supabase/ssr": "supabase",
266
+ "drizzle-orm": "drizzle",
267
+ "@trpc/server": "trpc",
268
+ "next-auth": "next-auth",
269
+ "@auth/nextjs": "next-auth",
270
+ "@auth/core": "next-auth",
271
+ "express": "express",
272
+ "fastify": "fastify",
273
+ "hono": "hono",
274
+ "lodash": "lodash",
275
+ "lodash-es": "lodash",
276
+ "underscore": "lodash",
277
+ "mongoose": "mongoose",
278
+ "typeorm": "typeorm",
279
+ "sequelize": "sequelize",
280
+ "knex": "knex",
281
+ "@upstash/ratelimit": "upstash-ratelimit",
282
+ "express-rate-limit": "express-rate-limit",
283
+ "rate-limiter-flexible": "rate-limiter-flexible"
284
+ };
285
+ var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
286
+ var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
287
+
161
288
  // src/utils/file-walker.ts
162
289
  var DEFAULT_IGNORES = [
163
290
  "**/node_modules/**",
@@ -184,6 +311,7 @@ var SCAN_EXTENSIONS = [
184
311
  "cjs",
185
312
  "json"
186
313
  ];
314
+ var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
187
315
  var MAX_FILE_SIZE = 1024 * 1024;
188
316
  async function walkFiles(root, extraIgnores = []) {
189
317
  const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
@@ -208,14 +336,23 @@ async function readFileContext(root, relativePath) {
208
336
  if (fileStats.size > MAX_FILE_SIZE) return null;
209
337
  const content = await readFile(absolutePath, "utf-8");
210
338
  const lines = content.split(/\r?\n|\r/);
339
+ const ext = extname(relativePath).slice(1);
340
+ let ast = void 0;
341
+ if (AST_EXTENSIONS.has(ext)) {
342
+ try {
343
+ ast = parseFile(content, relativePath);
344
+ } catch {
345
+ ast = null;
346
+ }
347
+ }
211
348
  return {
212
349
  absolutePath,
213
350
  relativePath,
214
351
  content,
215
352
  lines,
216
- ext: extname(relativePath).slice(1),
217
- // remove leading dot
218
- commentMap: buildCommentMap(lines)
353
+ ext,
354
+ commentMap: buildCommentMap(lines),
355
+ ast
219
356
  };
220
357
  } catch {
221
358
  return null;
@@ -226,6 +363,8 @@ async function buildProjectContext(root, files) {
226
363
  let declaredDependencies = /* @__PURE__ */ new Set();
227
364
  let tsconfigPaths = /* @__PURE__ */ new Set();
228
365
  let hasAuthMiddleware = false;
366
+ let hasRateLimiting = false;
367
+ const detectedFrameworks = /* @__PURE__ */ new Set();
229
368
  let gitignoreContent = null;
230
369
  let envInGitignore = false;
231
370
  try {
@@ -237,6 +376,18 @@ async function buildProjectContext(root, files) {
237
376
  ...packageJson?.peerDependencies ?? {}
238
377
  };
239
378
  declaredDependencies = new Set(Object.keys(deps));
379
+ for (const dep of declaredDependencies) {
380
+ const framework = DEPENDENCY_TO_FRAMEWORK[dep];
381
+ if (framework) {
382
+ detectedFrameworks.add(framework);
383
+ }
384
+ }
385
+ for (const framework of detectedFrameworks) {
386
+ if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
387
+ hasRateLimiting = true;
388
+ break;
389
+ }
390
+ }
240
391
  } catch {
241
392
  }
242
393
  try {
@@ -280,6 +431,18 @@ async function buildProjectContext(root, files) {
280
431
  }
281
432
  } catch {
282
433
  }
434
+ if (!hasRateLimiting) {
435
+ for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
436
+ try {
437
+ const content = await readFile(resolve(root, name), "utf-8");
438
+ if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
439
+ hasRateLimiting = true;
440
+ break;
441
+ }
442
+ } catch {
443
+ }
444
+ }
445
+ }
283
446
  try {
284
447
  gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
285
448
  envInGitignore = /^\.env$/m.test(gitignoreContent) || /^\.env\*$/m.test(gitignoreContent) || /^\.env\.\*$/m.test(gitignoreContent) || /^\.env\.local$/m.test(gitignoreContent);
@@ -291,6 +454,8 @@ async function buildProjectContext(root, files) {
291
454
  declaredDependencies,
292
455
  tsconfigPaths,
293
456
  hasAuthMiddleware,
457
+ hasRateLimiting,
458
+ detectedFrameworks,
294
459
  gitignoreContent,
295
460
  envInGitignore,
296
461
  allFiles: files
@@ -315,26 +480,58 @@ function getVersion() {
315
480
 
316
481
  // src/scorer.ts
317
482
  var CATEGORIES = ["security", "reliability", "performance", "ai-quality"];
483
+ var CATEGORY_WEIGHTS = {
484
+ "security": 0.4,
485
+ "reliability": 0.3,
486
+ "performance": 0.15,
487
+ "ai-quality": 0.15
488
+ };
318
489
  var DEDUCTIONS = {
319
- critical: 10,
320
- warning: 3,
321
- info: 1
490
+ critical: 8,
491
+ warning: 2,
492
+ info: 0.5
493
+ };
494
+ var PER_RULE_CAP = {
495
+ critical: 1,
496
+ warning: 2,
497
+ info: 3
322
498
  };
323
499
  function calculateScores(findings) {
324
500
  const categoryScores = CATEGORIES.map((category) => {
325
501
  const categoryFindings = findings.filter((f) => f.category === category);
326
- let score = 100;
502
+ const byRule = /* @__PURE__ */ new Map();
327
503
  for (const f of categoryFindings) {
328
- score -= DEDUCTIONS[f.severity] ?? 0;
504
+ const arr = byRule.get(f.ruleId) ?? [];
505
+ arr.push(f);
506
+ byRule.set(f.ruleId, arr);
507
+ }
508
+ let totalDeduction = 0;
509
+ for (const [, ruleFindings] of byRule) {
510
+ const bySeverity = { critical: 0, warning: 0, info: 0 };
511
+ for (const f of ruleFindings) {
512
+ bySeverity[f.severity]++;
513
+ }
514
+ for (const sev of ["critical", "warning", "info"]) {
515
+ const count = Math.min(bySeverity[sev], PER_RULE_CAP[sev]);
516
+ totalDeduction += count * DEDUCTIONS[sev];
517
+ }
518
+ }
519
+ let effectiveDeduction;
520
+ if (totalDeduction <= 30) {
521
+ effectiveDeduction = totalDeduction;
522
+ } else if (totalDeduction <= 50) {
523
+ effectiveDeduction = 30 + (totalDeduction - 30) * 0.5;
524
+ } else {
525
+ effectiveDeduction = 30 + (50 - 30) * 0.5 + (totalDeduction - 50) * 0.25;
329
526
  }
330
527
  return {
331
528
  category,
332
- score: Math.max(0, score),
529
+ score: Math.max(0, Math.round(100 - effectiveDeduction)),
333
530
  findingCount: categoryFindings.length
334
531
  };
335
532
  });
336
533
  const overallScore = Math.round(
337
- categoryScores.reduce((sum, c) => sum + c.score, 0) / CATEGORIES.length
534
+ categoryScores.reduce((sum, c) => sum + c.score * CATEGORY_WEIGHTS[c.category], 0)
338
535
  );
339
536
  return { overallScore, categoryScores };
340
537
  }
@@ -505,25 +702,36 @@ var AUTH_PATTERNS = [
505
702
  /jwt\.verify\s*\(/,
506
703
  /createRouteHandlerClient/,
507
704
  /createServerComponentClient/,
705
+ /createMiddlewareClient/,
508
706
  /authorization/i,
509
- /bearer/i
707
+ /getAuth\s*\(/,
708
+ /withPageAuth/,
709
+ /cookies\(\).*auth/s
510
710
  ];
711
+ var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
511
712
  var authChecksRule = {
512
713
  id: "auth-checks",
513
714
  name: "Missing Auth Checks",
514
715
  description: "Detects API routes that lack authentication checks",
515
716
  category: "security",
516
- severity: "critical",
717
+ severity: "warning",
517
718
  fileExtensions: ["ts", "tsx", "js", "jsx"],
518
719
  check(file, project) {
519
720
  if (!isApiRoute(file.relativePath)) return [];
520
721
  for (const pattern of AUTH_EXEMPT_PATTERNS) {
521
722
  if (pattern.test(file.relativePath)) return [];
522
723
  }
523
- const severity = project.hasAuthMiddleware ? "info" : "critical";
524
724
  for (const pattern of AUTH_PATTERNS) {
525
725
  if (pattern.test(file.content)) return [];
526
726
  }
727
+ let severity;
728
+ if (project.hasAuthMiddleware) {
729
+ severity = "info";
730
+ } else if (MUTATION_EXPORT.test(file.content)) {
731
+ severity = "critical";
732
+ } else {
733
+ severity = "info";
734
+ }
527
735
  let handlerLine = 1;
528
736
  for (let i = 0; i < file.lines.length; i++) {
529
737
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -620,7 +828,10 @@ var errorHandlingRule = {
620
828
  if (!isApiRoute(file.relativePath)) return [];
621
829
  const hasFrameworkServe = /\bserve\s*\(/.test(file.content) || /createTRPCHandle/.test(file.content) || /fetchRequestHandler/.test(file.content);
622
830
  const hasTryCatch = /try\s*\{/.test(file.content);
623
- if (hasTryCatch || hasFrameworkServe) return [];
831
+ const hasCatchChain = /\.catch\s*\(/.test(file.content);
832
+ const hasOnError = /onError\s*[:(]/.test(file.content);
833
+ const hasNextResponseError = /NextResponse\.json\s*\([^)]*(?:error|status:\s*[45]\d{2})/.test(file.content);
834
+ if (hasTryCatch || hasFrameworkServe || hasCatchChain || hasOnError || hasNextResponseError) return [];
624
835
  let handlerLine = 1;
625
836
  for (let i = 0; i < file.lines.length; i++) {
626
837
  if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
@@ -725,10 +936,11 @@ var rateLimitingRule = {
725
936
  name: "Missing Rate Limiting",
726
937
  description: "Detects API routes without rate limiting",
727
938
  category: "security",
728
- severity: "warning",
939
+ severity: "info",
729
940
  fileExtensions: ["ts", "tsx", "js", "jsx"],
730
- check(file, _project) {
941
+ check(file, project) {
731
942
  if (!isApiRoute(file.relativePath)) return [];
943
+ if (project.hasRateLimiting) return [];
732
944
  for (const pattern of EXEMPT_PATTERNS) {
733
945
  if (pattern.test(file.relativePath)) return [];
734
946
  }
@@ -748,7 +960,7 @@ var rateLimitingRule = {
748
960
  line: handlerLine,
749
961
  column: 1,
750
962
  message: "No rate limiting \u2014 anyone could spam this endpoint and run up your API costs",
751
- severity: "warning",
963
+ severity: "info",
752
964
  category: "security"
753
965
  }];
754
966
  }
@@ -976,6 +1188,8 @@ var SQL_INJECTION_PATTERNS = [
976
1188
  // .query() or .execute() with template literal
977
1189
  { pattern: /\.(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/, message: "Database query with template literal interpolation \u2014 use parameterized queries" }
978
1190
  ];
1191
+ var TAGGED_TEMPLATE_PREFIX = /\b(?:sql|Prisma\.sql|db\.sql|db\.query)\s*`/;
1192
+ var PARAMETERIZED_QUERY = /\.(?:query|execute)\s*\([^,]+,\s*\[/;
979
1193
  var sqlInjectionRule = {
980
1194
  id: "sql-injection",
981
1195
  name: "SQL Injection Risk",
@@ -983,20 +1197,42 @@ var sqlInjectionRule = {
983
1197
  category: "security",
984
1198
  severity: "critical",
985
1199
  fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
986
- check(file, _project) {
1200
+ check(file, project) {
987
1201
  const findings = [];
1202
+ const safeTaggedLines = /* @__PURE__ */ new Set();
1203
+ if (file.ast) {
1204
+ try {
1205
+ walkAST(file.ast.program, (node) => {
1206
+ if (node.type === "TaggedTemplateExpression") {
1207
+ const tagged = node;
1208
+ if (isTaggedTemplateSql(tagged) && tagged.loc) {
1209
+ for (let l = tagged.loc.start.line; l <= tagged.loc.end.line; l++) {
1210
+ safeTaggedLines.add(l);
1211
+ }
1212
+ }
1213
+ }
1214
+ });
1215
+ } catch {
1216
+ }
1217
+ }
1218
+ const usesORM = [...project.detectedFrameworks].some((f) => SQL_SAFE_ORMS.has(f));
988
1219
  for (let i = 0; i < file.lines.length; i++) {
989
1220
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
990
1221
  const line = file.lines[i];
1222
+ const lineNum = i + 1;
1223
+ if (safeTaggedLines.has(lineNum)) continue;
1224
+ if (!file.ast && TAGGED_TEMPLATE_PREFIX.test(line)) continue;
1225
+ if (PARAMETERIZED_QUERY.test(line)) continue;
991
1226
  for (const { pattern, message } of SQL_INJECTION_PATTERNS) {
992
1227
  if (pattern.test(line)) {
1228
+ const severity = usesORM ? "warning" : "critical";
993
1229
  findings.push({
994
1230
  ruleId: "sql-injection",
995
1231
  file: file.relativePath,
996
- line: i + 1,
1232
+ line: lineNum,
997
1233
  column: 1,
998
1234
  message,
999
- severity: "critical",
1235
+ severity,
1000
1236
  category: "security"
1001
1237
  });
1002
1238
  break;
@@ -1018,7 +1254,7 @@ var PLACEHOLDERS = [
1018
1254
  { pattern: /['"]password123['"]/, label: 'Placeholder password "password123"' },
1019
1255
  { pattern: /['"]changeme['"]/, label: 'Placeholder value "changeme"' },
1020
1256
  { pattern: /['"]your-api-key-here['"]/, label: "Placeholder API key" },
1021
- { pattern: /['"]replace-with-['"]/, label: 'Placeholder "replace-with-" value' },
1257
+ { pattern: /['"]replace-with-[^'"]*['"]/, label: 'Placeholder "replace-with-" value' },
1022
1258
  { pattern: /['"]xxx+['"]/, label: 'Placeholder "xxx" value' },
1023
1259
  { pattern: /['"]TODO:?\s*replace['"/]/i, label: "TODO replace placeholder" }
1024
1260
  ];
@@ -1058,13 +1294,13 @@ var placeholderContentRule = {
1058
1294
  // src/rules/stale-fallback.ts
1059
1295
  var STALE_PATTERNS = [
1060
1296
  { pattern: /['"]http:\/\/localhost:\d+['"]/, label: "Hardcoded localhost URL" },
1061
- { pattern: /['"]https?:\/\/127\.0\.0\.1[:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1062
- { pattern: /['"]redis:\/\/localhost['"]/, label: "Hardcoded Redis localhost URL" },
1063
- { pattern: /['"]mongodb:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1064
- { pattern: /['"]mongodb\+srv:\/\/localhost['"]/, label: "Hardcoded MongoDB localhost URL" },
1065
- { pattern: /['"]postgres:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1066
- { pattern: /['"]postgresql:\/\/localhost['"]/, label: "Hardcoded Postgres localhost URL" },
1067
- { pattern: /['"]amqp:\/\/localhost['"]/, label: "Hardcoded AMQP localhost URL" }
1297
+ { pattern: /['"]https?:\/\/127\.0\.0\.1[/:'"]/, label: "Hardcoded 127.0.0.1 URL" },
1298
+ { pattern: /['"]redis:\/\/localhost[/'"]/, label: "Hardcoded Redis localhost URL" },
1299
+ { pattern: /['"]mongodb:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1300
+ { pattern: /['"]mongodb\+srv:\/\/localhost[/'"]/, label: "Hardcoded MongoDB localhost URL" },
1301
+ { pattern: /['"]postgres:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1302
+ { pattern: /['"]postgresql:\/\/localhost[/'"]/, label: "Hardcoded Postgres localhost URL" },
1303
+ { pattern: /['"]amqp:\/\/localhost[/'"]/, label: "Hardcoded AMQP localhost URL" }
1068
1304
  ];
1069
1305
  var staleFallbackRule = {
1070
1306
  id: "stale-fallback",
@@ -1103,15 +1339,15 @@ var staleFallbackRule = {
1103
1339
 
1104
1340
  // src/rules/hallucinated-api.ts
1105
1341
  var HALLUCINATED_APIS = [
1106
- { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()" },
1107
- { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()" },
1108
- { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()" },
1109
- { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()" },
1110
- { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()" },
1111
- { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()" },
1112
- { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()" },
1113
- { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)" },
1114
- { pattern: /\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()" }
1342
+ { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
1343
+ { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
1344
+ { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
1345
+ { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
1346
+ { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
1347
+ { pattern: /response\.body\.json\s*\(/, fix: "Use response.json() instead of response.body.json()", methodName: "json" },
1348
+ { pattern: /fetch\.abort\s*\(/, fix: "Use AbortController instead of fetch.abort()", methodName: "abort" },
1349
+ { pattern: /\.isArray\s*\(\s*\)/, fix: "Array.isArray() is static \u2014 use Array.isArray(value)", methodName: "isArray" },
1350
+ { pattern: /(?<!Object\.prototype)\.hasOwnProperty\s*\(/, fix: "Use Object.hasOwn() instead of .hasOwnProperty()", methodName: "hasOwnProperty" }
1115
1351
  ];
1116
1352
  var hallucinatedApiRule = {
1117
1353
  id: "hallucinated-api",
@@ -1120,14 +1356,16 @@ var hallucinatedApiRule = {
1120
1356
  category: "ai-quality",
1121
1357
  severity: "warning",
1122
1358
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1123
- check(file, _project) {
1359
+ check(file, project) {
1124
1360
  const findings = [];
1361
+ const frameworks = project.detectedFrameworks;
1125
1362
  for (let i = 0; i < file.lines.length; i++) {
1126
1363
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1127
1364
  const line = file.lines[i];
1128
- for (const { pattern, fix } of HALLUCINATED_APIS) {
1365
+ for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
1129
1366
  const match = pattern.exec(line);
1130
1367
  if (match) {
1368
+ if (isFrameworkSafeMethod(methodName, frameworks)) continue;
1131
1369
  findings.push({
1132
1370
  ruleId: "hallucinated-api",
1133
1371
  file: file.relativePath,
@@ -1145,7 +1383,7 @@ var hallucinatedApiRule = {
1145
1383
  };
1146
1384
 
1147
1385
  // src/rules/open-redirect.ts
1148
- var CRITICAL_PATTERNS = [
1386
+ var DIRECT_INPUT_PATTERNS = [
1149
1387
  // redirect(searchParams.get(...)) or redirect(searchParams.get('x')!)
1150
1388
  /redirect\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/,
1151
1389
  // redirect(req.query.x) or redirect(request.nextUrl.searchParams.get(...))
@@ -1154,21 +1392,21 @@ var CRITICAL_PATTERNS = [
1154
1392
  /NextResponse\.redirect\s*\(\s*new\s+URL\s*\(\s*(?:searchParams|query|params)\s*\.get\s*\(/
1155
1393
  ];
1156
1394
  var WARNING_PATTERNS = [
1157
- /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination)\s*[,)]/
1395
+ /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
1158
1396
  ];
1159
1397
  var openRedirectRule = {
1160
1398
  id: "open-redirect",
1161
1399
  name: "Open Redirect",
1162
1400
  description: "Detects user-controlled input passed directly to redirect functions",
1163
1401
  category: "security",
1164
- severity: "critical",
1402
+ severity: "warning",
1165
1403
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1166
1404
  check(file, _project) {
1167
1405
  const findings = [];
1168
1406
  for (let i = 0; i < file.lines.length; i++) {
1169
1407
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1170
1408
  const line = file.lines[i];
1171
- for (const pattern of CRITICAL_PATTERNS) {
1409
+ for (const pattern of DIRECT_INPUT_PATTERNS) {
1172
1410
  const match = pattern.exec(line);
1173
1411
  if (match) {
1174
1412
  findings.push({
@@ -1177,7 +1415,7 @@ var openRedirectRule = {
1177
1415
  line: i + 1,
1178
1416
  column: match.index + 1,
1179
1417
  message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1180
- severity: "critical",
1418
+ severity: "warning",
1181
1419
  category: "security"
1182
1420
  });
1183
1421
  break;
@@ -1242,6 +1480,7 @@ var noSyncFsRule = {
1242
1480
 
1243
1481
  // src/rules/no-n-plus-one.ts
1244
1482
  var DB_CALL_PATTERN = /(?:prisma\.\w+\.\w+\(|\.findUnique\s*\(|\.findFirst\s*\(|\.findMany\s*\(|\.insert\s*\(|\.upsert\s*\(|fetch\s*\()/;
1483
+ var PROMISE_ALL_MAP = /Promise\.(?:all|allSettled)\s*\(\s*\w+\.map\s*\(/;
1245
1484
  var noNPlusOneRule = {
1246
1485
  id: "no-n-plus-one",
1247
1486
  name: "No N+1 Queries",
@@ -1252,14 +1491,33 @@ var noNPlusOneRule = {
1252
1491
  check(file, _project) {
1253
1492
  if (isTestFile(file.relativePath)) return [];
1254
1493
  if (isScriptFile(file.relativePath)) return [];
1494
+ const promiseAllMapLines = /* @__PURE__ */ new Set();
1495
+ for (let i = 0; i < file.lines.length; i++) {
1496
+ if (PROMISE_ALL_MAP.test(file.lines[i])) {
1497
+ for (let j = Math.max(0, i - 1); j < Math.min(file.lines.length, i + 20); j++) {
1498
+ promiseAllMapLines.add(j);
1499
+ }
1500
+ }
1501
+ }
1255
1502
  const findings = [];
1256
- const loops = findLoopBodies(file.lines, file.commentMap);
1503
+ let loops;
1504
+ if (file.ast) {
1505
+ try {
1506
+ loops = findLoopsAST(file.ast);
1507
+ } catch {
1508
+ loops = findLoopBodies(file.lines, file.commentMap);
1509
+ }
1510
+ } else {
1511
+ loops = findLoopBodies(file.lines, file.commentMap);
1512
+ }
1257
1513
  const reported = /* @__PURE__ */ new Set();
1258
1514
  for (const loop of loops) {
1259
1515
  if (reported.has(loop.loopLine)) continue;
1516
+ if (promiseAllMapLines.has(loop.loopLine)) continue;
1260
1517
  for (let i = loop.bodyStart; i <= loop.bodyEnd; i++) {
1261
1518
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1262
1519
  const line = file.lines[i];
1520
+ if (promiseAllMapLines.has(i)) continue;
1263
1521
  const match = DB_CALL_PATTERN.exec(line);
1264
1522
  if (match) {
1265
1523
  reported.add(loop.loopLine);
@@ -1393,6 +1651,10 @@ var HANDLED_PATTERNS = [
1393
1651
  /Promise\.allSettled/,
1394
1652
  /Promise\.race/
1395
1653
  ];
1654
+ var CHAIN_START_PATTERNS = [
1655
+ /\.from\s*\(/,
1656
+ /\.rpc\s*\(/
1657
+ ];
1396
1658
  var unhandledPromiseRule = {
1397
1659
  id: "unhandled-promise",
1398
1660
  name: "Unhandled Promise",
@@ -1412,6 +1674,19 @@ var unhandledPromiseRule = {
1412
1674
  if (!asyncMatch) continue;
1413
1675
  const isHandled = HANDLED_PATTERNS.some((p) => p.test(trimmed));
1414
1676
  if (isHandled) continue;
1677
+ const isChainContinuation = CHAIN_START_PATTERNS.some((p) => p.test(trimmed));
1678
+ if (isChainContinuation) {
1679
+ let chainHandled = false;
1680
+ for (let j = i - 1; j >= Math.max(0, i - 10); j--) {
1681
+ const prevTrimmed = file.lines[j].trim();
1682
+ if (prevTrimmed === "" || prevTrimmed.endsWith(";")) break;
1683
+ if (/\bawait\b/.test(prevTrimmed) || /\bconst\s+\w/.test(prevTrimmed) || /\blet\s+\w/.test(prevTrimmed) || /=\s*await\b/.test(prevTrimmed) || /\breturn\b/.test(prevTrimmed)) {
1684
+ chainHandled = true;
1685
+ break;
1686
+ }
1687
+ }
1688
+ if (chainHandled) continue;
1689
+ }
1415
1690
  let handledAbove = false;
1416
1691
  for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
1417
1692
  const prevTrimmed = file.lines[j].trim();
@@ -1479,9 +1754,9 @@ var missingErrorBoundaryRule = {
1479
1754
  severity: "info",
1480
1755
  fileExtensions: ["tsx", "jsx", "ts", "js"],
1481
1756
  check(file, project) {
1482
- const match = file.relativePath.match(/^app\/(.+\/)layout\.(tsx?|jsx?)$/);
1757
+ const match = file.relativePath.match(/^((?:src\/)?app\/)(.+\/)layout\.(tsx?|jsx?)$/);
1483
1758
  if (!match) return [];
1484
- const dir = "app/" + match[1];
1759
+ const dir = match[1] + match[2];
1485
1760
  const hasErrorBoundary = project.allFiles.some(
1486
1761
  (f) => f.startsWith(dir) && /^error\.(tsx?|jsx?)$/.test(f.slice(dir.length))
1487
1762
  );
@@ -1659,7 +1934,8 @@ var deadExportsRule = {
1659
1934
  (f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
1660
1935
  );
1661
1936
  const exports = /* @__PURE__ */ new Map();
1662
- const imports = /* @__PURE__ */ new Set();
1937
+ const imports = /* @__PURE__ */ new Map();
1938
+ const allImportedSymbols = /* @__PURE__ */ new Set();
1663
1939
  const importedFiles = /* @__PURE__ */ new Set();
1664
1940
  for (const file of sourceFiles) {
1665
1941
  if (isEntryPoint(file.relativePath)) continue;
@@ -1683,14 +1959,28 @@ var deadExportsRule = {
1683
1959
  for (const file of files) {
1684
1960
  for (const line of file.lines) {
1685
1961
  let match;
1962
+ const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
1963
+ const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
1686
1964
  const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
1687
1965
  while ((match = bracesRe.exec(line)) !== null) {
1688
1966
  const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
1689
- for (const sym of symbols) imports.add(sym);
1967
+ for (const sym of symbols) {
1968
+ allImportedSymbols.add(sym);
1969
+ if (fromBasename) {
1970
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1971
+ set.add(sym);
1972
+ imports.set(fromBasename, set);
1973
+ }
1974
+ }
1690
1975
  }
1691
1976
  const defaultRe = /import\s+(\w+)\s+from/g;
1692
1977
  while ((match = defaultRe.exec(line)) !== null) {
1693
- imports.add(match[1]);
1978
+ allImportedSymbols.add(match[1]);
1979
+ if (fromBasename) {
1980
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
1981
+ set.add(match[1]);
1982
+ imports.set(fromBasename, set);
1983
+ }
1694
1984
  }
1695
1985
  const fromRe = /from\s+['"]([^'"]+)['"]/g;
1696
1986
  while ((match = fromRe.exec(line)) !== null) {
@@ -1701,7 +1991,10 @@ var deadExportsRule = {
1701
1991
  const deadByFile = /* @__PURE__ */ new Map();
1702
1992
  for (const [key, loc] of exports) {
1703
1993
  const symbolName = key.split("::")[1];
1704
- if (!imports.has(symbolName)) {
1994
+ const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
1995
+ const importSet = imports.get(exportFileBasename);
1996
+ const isImported = importSet?.has(symbolName) ?? false;
1997
+ if (!isImported && !allImportedSymbols.has(symbolName)) {
1705
1998
  deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
1706
1999
  }
1707
2000
  }
@@ -1771,12 +2064,33 @@ var shallowCatchRule = {
1771
2064
  if (braceStart === -1) continue;
1772
2065
  let depth = 0;
1773
2066
  let bodyEnd = braceStart;
2067
+ let inSingle = false;
2068
+ let inDouble = false;
2069
+ let inTemplate = false;
1774
2070
  for (let j = braceStart; j < file.lines.length; j++) {
1775
2071
  const line = file.lines[j];
1776
2072
  const startPos = j === braceStart ? line.indexOf("{") : 0;
1777
2073
  for (let k = startPos; k < line.length; k++) {
1778
- if (line[k] === "{") depth++;
1779
- if (line[k] === "}") {
2074
+ const ch = line[k];
2075
+ const prev = k > 0 ? line[k - 1] : "";
2076
+ const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
2077
+ if (!escaped) {
2078
+ if (ch === "'" && !inDouble && !inTemplate) {
2079
+ inSingle = !inSingle;
2080
+ continue;
2081
+ }
2082
+ if (ch === '"' && !inSingle && !inTemplate) {
2083
+ inDouble = !inDouble;
2084
+ continue;
2085
+ }
2086
+ if (ch === "`" && !inSingle && !inDouble) {
2087
+ inTemplate = !inTemplate;
2088
+ continue;
2089
+ }
2090
+ }
2091
+ if (inSingle || inDouble || inTemplate) continue;
2092
+ if (ch === "{") depth++;
2093
+ if (ch === "}") {
1780
2094
  depth--;
1781
2095
  if (depth === 0) {
1782
2096
  bodyEnd = j;
@@ -1943,6 +2257,22 @@ var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
1943
2257
  "gpt-tokenizer"
1944
2258
  // exists but often confused
1945
2259
  ]);
2260
+ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2261
+ "pg",
2262
+ "ws",
2263
+ "ms",
2264
+ "qs",
2265
+ "ip",
2266
+ "is",
2267
+ "he",
2268
+ "ky",
2269
+ "bl",
2270
+ "rc",
2271
+ "io",
2272
+ "db",
2273
+ "fp",
2274
+ "rx"
2275
+ ]);
1946
2276
  var SUSPICIOUS_PATTERNS = [
1947
2277
  /^[a-z]{1,2}$/,
1948
2278
  // 1-2 char names
@@ -1981,7 +2311,7 @@ var phantomDependencyRule = {
1981
2311
  });
1982
2312
  }
1983
2313
  for (const pattern of SUSPICIOUS_PATTERNS) {
1984
- if (pattern.test(name) && !name.startsWith("@")) {
2314
+ if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
1985
2315
  findings.push({
1986
2316
  ruleId: "phantom-dependency",
1987
2317
  file: "package.json",
@@ -1999,6 +2329,210 @@ var phantomDependencyRule = {
1999
2329
  }
2000
2330
  };
2001
2331
 
2332
+ // src/rules/insecure-cookie.ts
2333
+ var SENSITIVE_COOKIE_NAMES = /['"](?:session|token|auth|sid|jwt)['"]|\.set\s*\(\s*['"](?:session|token|auth|sid|jwt)['"]/i;
2334
+ var COOKIE_SET_PATTERNS = [
2335
+ /cookies\(\)\s*\.set\s*\(/,
2336
+ /res\.cookie\s*\(/,
2337
+ /response\.cookies\.set\s*\(/
2338
+ ];
2339
+ var SECURE_OPTIONS = [
2340
+ /httpOnly\s*:\s*true/,
2341
+ /secure\s*:\s*true/,
2342
+ /sameSite\s*:/
2343
+ ];
2344
+ var insecureCookieRule = {
2345
+ id: "insecure-cookie",
2346
+ name: "Insecure Cookie",
2347
+ description: "Detects sensitive cookies set without httpOnly, secure, or sameSite options",
2348
+ category: "security",
2349
+ severity: "warning",
2350
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2351
+ check(file, _project) {
2352
+ if (isTestFile(file.relativePath)) return [];
2353
+ if (isScriptFile(file.relativePath)) return [];
2354
+ const findings = [];
2355
+ for (let i = 0; i < file.lines.length; i++) {
2356
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2357
+ const line = file.lines[i];
2358
+ const isCookieSet = COOKIE_SET_PATTERNS.some((p) => p.test(line));
2359
+ if (!isCookieSet) continue;
2360
+ if (!SENSITIVE_COOKIE_NAMES.test(line)) continue;
2361
+ const block = file.lines.slice(i, Math.min(i + 8, file.lines.length)).join("\n");
2362
+ const missingOptions = SECURE_OPTIONS.filter((p) => !p.test(block));
2363
+ if (missingOptions.length > 0) {
2364
+ const missing = [];
2365
+ if (!/httpOnly\s*:\s*true/.test(block)) missing.push("httpOnly");
2366
+ if (!/secure\s*:\s*true/.test(block)) missing.push("secure");
2367
+ if (!/sameSite\s*:/.test(block)) missing.push("sameSite");
2368
+ findings.push({
2369
+ ruleId: "insecure-cookie",
2370
+ file: file.relativePath,
2371
+ line: i + 1,
2372
+ column: 1,
2373
+ message: `Sensitive cookie missing security options: ${missing.join(", ")}`,
2374
+ severity: "warning",
2375
+ category: "security",
2376
+ fix: "Add { httpOnly: true, secure: true, sameSite: 'lax' } to cookie options"
2377
+ });
2378
+ }
2379
+ }
2380
+ return findings;
2381
+ }
2382
+ };
2383
+
2384
+ // src/rules/leaked-env-in-logs.ts
2385
+ var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
2386
+ var leakedEnvInLogsRule = {
2387
+ id: "leaked-env-in-logs",
2388
+ name: "Leaked Env in Logs",
2389
+ description: "Detects process.env values logged to console \u2014 potential secret exposure",
2390
+ category: "security",
2391
+ severity: "warning",
2392
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2393
+ check(file, _project) {
2394
+ if (isTestFile(file.relativePath)) return [];
2395
+ if (isScriptFile(file.relativePath)) return [];
2396
+ const findings = [];
2397
+ for (let i = 0; i < file.lines.length; i++) {
2398
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2399
+ const line = file.lines[i];
2400
+ const match = CONSOLE_WITH_ENV.exec(line);
2401
+ if (match) {
2402
+ findings.push({
2403
+ ruleId: "leaked-env-in-logs",
2404
+ file: file.relativePath,
2405
+ line: i + 1,
2406
+ column: match.index + 1,
2407
+ message: "process.env value in console output \u2014 may leak secrets in production logs",
2408
+ severity: "warning",
2409
+ category: "security",
2410
+ fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
2411
+ });
2412
+ }
2413
+ }
2414
+ return findings;
2415
+ }
2416
+ };
2417
+
2418
+ // src/rules/insecure-random.ts
2419
+ var SECURITY_VAR_NAMES = /(?:token|secret|nonce|key|password|salt|session|csrf|otp|pin|code)/i;
2420
+ var MATH_RANDOM = /Math\.random\s*\(\)/;
2421
+ var insecureRandomRule = {
2422
+ id: "insecure-random",
2423
+ name: "Insecure Random",
2424
+ description: "Detects Math.random() used near security-sensitive variable names",
2425
+ category: "security",
2426
+ severity: "warning",
2427
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2428
+ check(file, _project) {
2429
+ if (isTestFile(file.relativePath)) return [];
2430
+ if (isScriptFile(file.relativePath)) return [];
2431
+ const findings = [];
2432
+ for (let i = 0; i < file.lines.length; i++) {
2433
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2434
+ const line = file.lines[i];
2435
+ const match = MATH_RANDOM.exec(line);
2436
+ if (!match) continue;
2437
+ const context = file.lines.slice(Math.max(0, i - 2), i + 1).join("\n");
2438
+ if (SECURITY_VAR_NAMES.test(context)) {
2439
+ findings.push({
2440
+ ruleId: "insecure-random",
2441
+ file: file.relativePath,
2442
+ line: i + 1,
2443
+ column: match.index + 1,
2444
+ message: "Math.random() used in security-sensitive context \u2014 not cryptographically secure",
2445
+ severity: "warning",
2446
+ category: "security",
2447
+ fix: "Use crypto.randomUUID() or crypto.getRandomValues() for security-sensitive values"
2448
+ });
2449
+ }
2450
+ }
2451
+ return findings;
2452
+ }
2453
+ };
2454
+
2455
+ // src/rules/next-server-action-validation.ts
2456
+ var USE_SERVER = /['"]use server['"]/;
2457
+ var FORM_DATA_GET = /formData\.get\s*\(/;
2458
+ var VALIDATION_PATTERNS2 = [
2459
+ /\.parse\s*\(/,
2460
+ /\.safeParse\s*\(/,
2461
+ /\bvalidate\s*\(/,
2462
+ /\.parseAsync\s*\(/,
2463
+ /\.safeParseAsync\s*\(/
2464
+ ];
2465
+ var nextServerActionValidationRule = {
2466
+ id: "next-server-action-validation",
2467
+ name: "Next.js Server Action Validation",
2468
+ description: "Detects server actions using formData without schema validation \u2014 unvalidated user input",
2469
+ category: "security",
2470
+ severity: "critical",
2471
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2472
+ check(file, _project) {
2473
+ if (isTestFile(file.relativePath)) return [];
2474
+ if (!USE_SERVER.test(file.content)) return [];
2475
+ if (!FORM_DATA_GET.test(file.content)) return [];
2476
+ const hasValidation = VALIDATION_PATTERNS2.some((p) => p.test(file.content));
2477
+ if (hasValidation) return [];
2478
+ let reportLine = 1;
2479
+ for (let i = 0; i < file.lines.length; i++) {
2480
+ if (FORM_DATA_GET.test(file.lines[i])) {
2481
+ reportLine = i + 1;
2482
+ break;
2483
+ }
2484
+ }
2485
+ return [{
2486
+ ruleId: "next-server-action-validation",
2487
+ file: file.relativePath,
2488
+ line: reportLine,
2489
+ column: 1,
2490
+ message: "Server action reads formData without schema validation \u2014 unvalidated user input",
2491
+ severity: "critical",
2492
+ category: "security",
2493
+ fix: "Validate with Zod: const data = schema.safeParse(Object.fromEntries(formData))"
2494
+ }];
2495
+ }
2496
+ };
2497
+
2498
+ // src/rules/missing-transaction.ts
2499
+ var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
2500
+ var PRISMA_TRANSACTION = /\$transaction\s*\(/;
2501
+ var missingTransactionRule = {
2502
+ id: "missing-transaction",
2503
+ name: "Missing Transaction",
2504
+ description: "Detects multiple Prisma write operations without $transaction \u2014 atomicity risk",
2505
+ category: "reliability",
2506
+ severity: "warning",
2507
+ fileExtensions: ["ts", "tsx", "js", "jsx"],
2508
+ check(file, project) {
2509
+ if (isTestFile(file.relativePath)) return [];
2510
+ if (isScriptFile(file.relativePath)) return [];
2511
+ if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
2512
+ let writeCount = 0;
2513
+ let firstWriteLine = -1;
2514
+ const hasTransaction = PRISMA_TRANSACTION.test(file.content);
2515
+ for (let i = 0; i < file.lines.length; i++) {
2516
+ if (isCommentLine(file.lines, i, file.commentMap)) continue;
2517
+ if (PRISMA_WRITE_OPS.test(file.lines[i])) {
2518
+ writeCount++;
2519
+ if (firstWriteLine === -1) firstWriteLine = i;
2520
+ }
2521
+ }
2522
+ if (writeCount < 2 || hasTransaction) return [];
2523
+ return [{
2524
+ ruleId: "missing-transaction",
2525
+ file: file.relativePath,
2526
+ line: firstWriteLine + 1,
2527
+ column: 1,
2528
+ message: `${writeCount} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
2529
+ severity: "warning",
2530
+ category: "reliability",
2531
+ fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
2532
+ }];
2533
+ }
2534
+ };
2535
+
2002
2536
  // src/rules/index.ts
2003
2537
  var rules = [
2004
2538
  // Security
@@ -2012,6 +2546,10 @@ var rules = [
2012
2546
  openRedirectRule,
2013
2547
  rateLimitingRule,
2014
2548
  phantomDependencyRule,
2549
+ insecureCookieRule,
2550
+ leakedEnvInLogsRule,
2551
+ insecureRandomRule,
2552
+ nextServerActionValidationRule,
2015
2553
  // Reliability
2016
2554
  hallucinatedImportsRule,
2017
2555
  errorHandlingRule,
@@ -2019,6 +2557,7 @@ var rules = [
2019
2557
  shallowCatchRule,
2020
2558
  missingLoadingStateRule,
2021
2559
  missingErrorBoundaryRule,
2560
+ missingTransactionRule,
2022
2561
  // Performance
2023
2562
  noSyncFsRule,
2024
2563
  noNPlusOneRule,