prodlint 0.10.4 → 0.10.5

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
@@ -87,7 +87,8 @@ function isLineSuppressed(lines, lineIndex, ruleId) {
87
87
  return false;
88
88
  }
89
89
  function isTestFile(relativePath) {
90
- return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || /(?:^|\/)__tests__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
90
+ return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || // Jest's __tests__ and __mocks__, jscodeshift's __testfixtures__
91
+ /(?:^|\/)__(?:tests|testfixtures|fixtures|mocks)__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
91
92
  }
92
93
  function isScriptFile(relativePath) {
93
94
  if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
@@ -401,6 +402,263 @@ var DEPENDENCY_TO_FRAMEWORK = {
401
402
  var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
402
403
  var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
403
404
 
405
+ // src/utils/middleware-matcher.ts
406
+ function matcherExcludesApi(content, fileName) {
407
+ const sources = matcherSources(content, fileName);
408
+ if (!sources || sources.length === 0) return false;
409
+ return sources.every((source) => {
410
+ const matcher = compile(source);
411
+ return matcher !== null && !matcher.patterns.some(namesApi) && !apiPaths(matcher.literals).some((path) => matcher.regexp.test(path));
412
+ });
413
+ }
414
+ function matcherExcludesPages(content, fileName) {
415
+ const sources = matcherSources(content, fileName);
416
+ if (!sources || sources.length === 0) return false;
417
+ return sources.every((source) => {
418
+ const matcher = compile(source);
419
+ return matcher !== null && underApi(matcher.tokens);
420
+ });
421
+ }
422
+ function underApi(tokens) {
423
+ const [head, ...rest] = tokens;
424
+ if (typeof head !== "string") return false;
425
+ if (head.startsWith("/api/")) return true;
426
+ if (head !== "/api") return false;
427
+ for (const token of rest) {
428
+ const start = typeof token === "string" ? token : token.pattern ? prefixOf(token) : token.prefix + token.suffix;
429
+ if (!start.startsWith("/")) return false;
430
+ if (typeof token === "string" || token.modifier !== "?" && token.modifier !== "*") return true;
431
+ }
432
+ return true;
433
+ }
434
+ function apiPaths(literals) {
435
+ const paths = ["/api/x", "/api/x/y"];
436
+ for (const literal2 of literals) {
437
+ for (const head of ["", "/api", "/api/x"]) {
438
+ for (const tail of ["", "/x"]) paths.push(head + literal2 + tail);
439
+ }
440
+ }
441
+ return paths.filter((path) => path.startsWith("/api/"));
442
+ }
443
+ function matcherSources(content, fileName) {
444
+ const ast = parseFile(content, fileName);
445
+ if (!ast) return null;
446
+ for (const st of ast.program.body) {
447
+ if (st.type !== "ExportNamedDeclaration" || st.declaration?.type !== "VariableDeclaration") continue;
448
+ if (st.declaration.kind !== "const") continue;
449
+ for (const d of st.declaration.declarations) {
450
+ if (d.id.type !== "Identifier" || d.id.name !== "config" || !d.init) continue;
451
+ const config = unwrap(d.init);
452
+ if (config.type !== "ObjectExpression") return null;
453
+ if (config.properties.some((p) => p.type !== "ObjectProperty")) return null;
454
+ const matcher = property(config, "matcher");
455
+ if (!matcher) return void 0;
456
+ const values = matcher.type === "ArrayExpression" ? matcher.elements : [matcher];
457
+ const sources = [];
458
+ for (const value of values) {
459
+ const element = value ? unwrap(value) : null;
460
+ const source = element?.type === "ObjectExpression" ? property(element, "source") : element;
461
+ const text = source ? literal(source) : null;
462
+ if (text === null) return null;
463
+ sources.push(text);
464
+ }
465
+ return sources;
466
+ }
467
+ }
468
+ return void 0;
469
+ }
470
+ function unwrap(node) {
471
+ while (node.type === "TSSatisfiesExpression" || node.type === "TSAsExpression") node = node.expression;
472
+ return node;
473
+ }
474
+ function property(object, name) {
475
+ if (object.type !== "ObjectExpression") return void 0;
476
+ let value;
477
+ for (const p of object.properties) {
478
+ if (p.type !== "ObjectProperty" || p.computed) continue;
479
+ const key = p.key.type === "Identifier" ? p.key.name : p.key.type === "StringLiteral" ? p.key.value : null;
480
+ if (key === name) value = unwrap(p.value);
481
+ }
482
+ return value;
483
+ }
484
+ function literal(node) {
485
+ if (node.type === "StringLiteral") return node.value;
486
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked ?? null;
487
+ return null;
488
+ }
489
+ function namesApi(pattern) {
490
+ let kept = "";
491
+ const negative = [];
492
+ for (let i = 0; i < pattern.length; i++) {
493
+ const c = pattern[i];
494
+ const skipping = negative.includes(true);
495
+ if (c === "\\") {
496
+ if (!skipping) kept += pattern.slice(i, i + 2);
497
+ i++;
498
+ } else if (c === "(") {
499
+ negative.push(pattern.startsWith("(?!", i) || pattern.startsWith("(?<!", i));
500
+ if (!skipping) kept += c;
501
+ } else if (c === ")") {
502
+ negative.pop();
503
+ if (!negative.includes(true)) kept += c;
504
+ } else if (!skipping) {
505
+ kept += c;
506
+ }
507
+ }
508
+ return /\bapi\b/i.test(kept);
509
+ }
510
+ var DELIMITER = "/#?";
511
+ var PREFIXES = "./";
512
+ var escape = (s) => s.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
513
+ var prefixOf = (token) => (token.modifier === "*" || token.modifier === "+") && !token.prefix && !token.suffix ? "/" : token.prefix;
514
+ function lex(source) {
515
+ const tokens = [];
516
+ let i = 0;
517
+ while (i < source.length) {
518
+ const c = source[i];
519
+ if (c === "*" || c === "+" || c === "?") {
520
+ tokens.push({ type: "MODIFIER", value: source[i++] });
521
+ } else if (c === "\\") {
522
+ tokens.push({ type: "ESCAPED_CHAR", value: source[i + 1] });
523
+ i += 2;
524
+ } else if (c === "{" || c === "}") {
525
+ tokens.push({ type: c === "{" ? "OPEN" : "CLOSE", value: source[i++] });
526
+ } else if (c === ":") {
527
+ const name = /^\w+/.exec(source.slice(i + 1))?.[0];
528
+ if (!name) throw new TypeError(`Missing parameter name at ${i}`);
529
+ tokens.push({ type: "NAME", value: name });
530
+ i += 1 + name.length;
531
+ } else if (c === "(") {
532
+ let depth = 1;
533
+ let pattern = "";
534
+ let j = i + 1;
535
+ if (source[j] === "?") throw new TypeError(`Pattern cannot start with "?" at ${j}`);
536
+ while (j < source.length) {
537
+ if (source[j] === "\\") {
538
+ pattern += source[j++] + source[j++];
539
+ continue;
540
+ }
541
+ if (source[j] === ")") {
542
+ if (--depth === 0) {
543
+ j++;
544
+ break;
545
+ }
546
+ } else if (source[j] === "(") {
547
+ depth++;
548
+ if (source[j + 1] !== "?") throw new TypeError(`Capturing groups are not allowed at ${j}`);
549
+ }
550
+ pattern += source[j++];
551
+ }
552
+ if (depth) throw new TypeError(`Unbalanced pattern at ${i}`);
553
+ if (!pattern) throw new TypeError(`Missing pattern at ${i}`);
554
+ tokens.push({ type: "PATTERN", value: pattern });
555
+ i = j;
556
+ } else {
557
+ tokens.push({ type: "CHAR", value: source[i++] });
558
+ }
559
+ }
560
+ tokens.push({ type: "END", value: "" });
561
+ return tokens;
562
+ }
563
+ function parse2(source) {
564
+ const tokens = lex(source);
565
+ const result = [];
566
+ let i = 0;
567
+ let path = "";
568
+ const tryConsume = (type) => i < tokens.length && tokens[i].type === type ? tokens[i++].value : void 0;
569
+ const mustConsume = (type) => {
570
+ const value = tryConsume(type);
571
+ if (value === void 0) throw new TypeError(`Unexpected ${tokens[i].type}, expected ${type}`);
572
+ return value;
573
+ };
574
+ const consumeText = () => {
575
+ let text = "";
576
+ let value;
577
+ while (value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR")) text += value;
578
+ return text;
579
+ };
580
+ const safePattern = (prefix) => {
581
+ const prev = result[result.length - 1];
582
+ const prevText = prefix || (typeof prev === "string" ? prev : "");
583
+ if (prev && !prevText) throw new TypeError("Must have text between two parameters");
584
+ if (!prevText || [...DELIMITER].some((c) => prevText.includes(c))) return `[^${escape(DELIMITER)}]+?`;
585
+ return `(?:(?!${escape(prevText)})[^${escape(DELIMITER)}])+?`;
586
+ };
587
+ while (i < tokens.length) {
588
+ const char = tryConsume("CHAR");
589
+ const name = tryConsume("NAME");
590
+ const pattern = tryConsume("PATTERN");
591
+ if (name || pattern) {
592
+ let prefix = char || "";
593
+ if (!PREFIXES.includes(prefix)) {
594
+ path += prefix;
595
+ prefix = "";
596
+ }
597
+ if (path) {
598
+ result.push(path);
599
+ path = "";
600
+ }
601
+ result.push({ prefix, suffix: "", pattern: pattern || safePattern(prefix), modifier: tryConsume("MODIFIER") || "" });
602
+ continue;
603
+ }
604
+ const value = char || tryConsume("ESCAPED_CHAR");
605
+ if (value) {
606
+ path += value;
607
+ continue;
608
+ }
609
+ if (path) {
610
+ result.push(path);
611
+ path = "";
612
+ }
613
+ if (tryConsume("OPEN")) {
614
+ const prefix = consumeText();
615
+ const name2 = tryConsume("NAME") || "";
616
+ const pattern2 = tryConsume("PATTERN") || "";
617
+ const suffix = consumeText();
618
+ mustConsume("CLOSE");
619
+ result.push({ prefix, suffix, pattern: name2 && !pattern2 ? safePattern(prefix) : pattern2, modifier: tryConsume("MODIFIER") || "" });
620
+ continue;
621
+ }
622
+ mustConsume("END");
623
+ }
624
+ return result;
625
+ }
626
+ function compile(source) {
627
+ if (!source.startsWith("/")) return null;
628
+ try {
629
+ const tokens = parse2(source);
630
+ let route = "^";
631
+ for (const token of tokens) {
632
+ if (typeof token === "string") {
633
+ route += escape(token);
634
+ continue;
635
+ }
636
+ if (!token.pattern) {
637
+ route += `(?:${escape(token.prefix)}${escape(token.suffix)})${token.modifier}`;
638
+ continue;
639
+ }
640
+ const repeat = token.modifier === "*" || token.modifier === "+";
641
+ const prefix = escape(prefixOf(token));
642
+ const suffix = escape(token.suffix);
643
+ if (!prefix && !suffix) {
644
+ route += `(${token.pattern})${token.modifier}`;
645
+ } else if (repeat) {
646
+ route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${token.modifier === "*" ? "?" : ""}`;
647
+ } else {
648
+ route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`;
649
+ }
650
+ }
651
+ return {
652
+ regexp: new RegExp(`${route}[${escape(DELIMITER)}]?$`),
653
+ tokens,
654
+ literals: tokens.filter((token) => typeof token === "string"),
655
+ patterns: tokens.flatMap((token) => typeof token === "string" ? [] : [token.pattern])
656
+ };
657
+ } catch {
658
+ return null;
659
+ }
660
+ }
661
+
404
662
  // src/utils/file-walker.ts
405
663
  var DEFAULT_IGNORES = [
406
664
  "**/node_modules/**",
@@ -430,6 +688,37 @@ var SCAN_EXTENSIONS = [
430
688
  ];
431
689
  var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
432
690
  var MAX_FILE_SIZE = 1024 * 1024;
691
+ var MIDDLEWARE_FILES = [
692
+ "middleware.ts",
693
+ "middleware.js",
694
+ "src/middleware.ts",
695
+ "src/middleware.js",
696
+ "proxy.ts",
697
+ "proxy.js",
698
+ "src/proxy.ts",
699
+ "src/proxy.js"
700
+ ];
701
+ var MIDDLEWARE_AUTH_PATTERNS = [
702
+ /getSession/i,
703
+ /getUser/i,
704
+ /auth\(\)/,
705
+ /withAuth/i,
706
+ /clerkMiddleware/i,
707
+ /authMiddleware/i,
708
+ /NextAuth/i,
709
+ /supabase.*auth/i,
710
+ /createMiddlewareClient/i,
711
+ /getToken/i,
712
+ /verifyToken/i,
713
+ /jwt/i,
714
+ /updateSession/i,
715
+ // Auth.js: export { auth as proxy } from '@/auth', export default auth((req) => …)
716
+ /\bauth\s+as\s+(?:proxy|middleware|default)\b/,
717
+ /\bauth\(/,
718
+ // NextAuth v4: export { default } from 'next-auth/middleware'
719
+ /['"]next-auth\/middleware['"]/
720
+ ];
721
+ var MIDDLEWARE_RATE_LIMIT_PATTERNS = [/rateLimit/i, /throttle/i];
433
722
  async function walkFiles(root, extraIgnores = []) {
434
723
  const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
435
724
  patterns.push("**/.env", "**/.env.*");
@@ -568,6 +857,8 @@ async function buildProjectContext(root, files) {
568
857
  let declaredDependencies = /* @__PURE__ */ new Set();
569
858
  let tsconfigPaths = /* @__PURE__ */ new Set();
570
859
  let hasAuthMiddleware = false;
860
+ let authMiddlewareExcludesApi = false;
861
+ let authMiddlewareExcludesPages = false;
571
862
  let hasRateLimiting = false;
572
863
  const detectedFrameworks = /* @__PURE__ */ new Set();
573
864
  let gitignoreContent = null;
@@ -619,45 +910,19 @@ async function buildProjectContext(root, files) {
619
910
  }
620
911
  } catch {
621
912
  }
622
- try {
623
- for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
624
- try {
625
- const content = await readFile(resolve(root, name), "utf-8");
626
- const authPatterns = [
627
- /getSession/i,
628
- /getUser/i,
629
- /auth\(\)/,
630
- /withAuth/i,
631
- /clerkMiddleware/i,
632
- /authMiddleware/i,
633
- /NextAuth/i,
634
- /supabase.*auth/i,
635
- /createMiddlewareClient/i,
636
- /getToken/i,
637
- /verifyToken/i,
638
- /jwt/i,
639
- /updateSession/i
640
- ];
641
- if (authPatterns.some((p) => p.test(content))) {
642
- hasAuthMiddleware = true;
643
- break;
644
- }
645
- } catch {
646
- }
913
+ for (const name of MIDDLEWARE_FILES) {
914
+ let content;
915
+ try {
916
+ content = await readFile(resolve(root, name), "utf-8");
917
+ } catch {
918
+ continue;
647
919
  }
648
- } catch {
649
- }
650
- if (!hasRateLimiting) {
651
- for (const name of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js"]) {
652
- try {
653
- const content = await readFile(resolve(root, name), "utf-8");
654
- if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
655
- hasRateLimiting = true;
656
- break;
657
- }
658
- } catch {
659
- }
920
+ if (MIDDLEWARE_AUTH_PATTERNS.some((p) => p.test(content))) {
921
+ authMiddlewareExcludesApi = (!hasAuthMiddleware || authMiddlewareExcludesApi) && matcherExcludesApi(content, name);
922
+ authMiddlewareExcludesPages = (!hasAuthMiddleware || authMiddlewareExcludesPages) && matcherExcludesPages(content, name);
923
+ hasAuthMiddleware = true;
660
924
  }
925
+ if (MIDDLEWARE_RATE_LIMIT_PATTERNS.some((p) => p.test(content))) hasRateLimiting = true;
661
926
  }
662
927
  try {
663
928
  gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
@@ -670,6 +935,8 @@ async function buildProjectContext(root, files) {
670
935
  declaredDependencies,
671
936
  tsconfigPaths,
672
937
  hasAuthMiddleware,
938
+ authMiddlewareExcludesApi,
939
+ authMiddlewareExcludesPages,
673
940
  hasRateLimiting,
674
941
  detectedFrameworks,
675
942
  gitignoreContent,
@@ -987,6 +1254,15 @@ var AUTH_PATTERNS = [
987
1254
  var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
988
1255
  var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
989
1256
  var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
1257
+ function servedUnderApi(relativePath) {
1258
+ const pages = /(?:^|\/)pages\/api\/(.+)$/.exec(relativePath);
1259
+ if (pages) return !/^index\.\w+$/.test(pages[1]);
1260
+ const segments = relativePath.split("/");
1261
+ const app = segments.indexOf("app");
1262
+ if (app === -1) return false;
1263
+ const url = segments.slice(app + 1, -1).filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith("@"));
1264
+ return url[0] === "api" && url.length > 1;
1265
+ }
990
1266
  var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
991
1267
  var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
992
1268
  function words(name) {
@@ -1195,8 +1471,10 @@ var authChecksRule = {
1195
1471
  const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
1196
1472
  const exposed = handlers.filter((h) => !h.authed);
1197
1473
  if (handlers.length > 0 && exposed.length === 0) return [];
1474
+ const middlewareSkipsRoute = project.hasAuthMiddleware && project.authMiddlewareExcludesApi === true && servedUnderApi(file.relativePath);
1475
+ const middlewareAuth = project.hasAuthMiddleware && !middlewareSkipsRoute;
1198
1476
  let severity;
1199
- if (project.hasAuthMiddleware) {
1477
+ if (middlewareAuth) {
1200
1478
  severity = "info";
1201
1479
  } else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
1202
1480
  severity = "critical";
@@ -1204,7 +1482,7 @@ var authChecksRule = {
1204
1482
  severity = "info";
1205
1483
  }
1206
1484
  const handlerLine = exposed[0]?.line ?? 1;
1207
- const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
1485
+ const message = middlewareAuth ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : middlewareSkipsRoute ? "API route has no authentication check (middleware auth detected, but its matcher excludes /api)" : "API route has no authentication check";
1208
1486
  return [{
1209
1487
  ruleId: "auth-checks",
1210
1488
  file: file.relativePath,
@@ -2608,16 +2886,190 @@ var codebaseConsistencyRule = {
2608
2886
  };
2609
2887
 
2610
2888
  // src/rules/dead-exports.ts
2611
- function isEntryPoint(relativePath) {
2889
+ function isEntryPoint(relativePath, appRoots, routeModules) {
2612
2890
  const name = relativePath.split("/").pop() ?? "";
2613
- return /^(page|layout|loading|error|not-found|route|middleware|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest)\.(tsx?|jsx?)$/.test(name) || /^index\.(tsx?|jsx?)$/.test(name) || name === "global-error.tsx" || name === "global-error.jsx";
2891
+ return /^(page|layout|loading|error|not-found|route|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest)\.(tsx?|jsx?)$/.test(name) || isBarrel(relativePath) || name === "global-error.tsx" || name === "global-error.jsx" || // SvelteKit's route files: +page.ts, +page.server.ts, +layout.ts, +server.ts
2892
+ /^\+(page|layout|server)(\.server)?\.(ts|js)$/.test(name) || // Storybook's stories: it reads a CSF file's default export as the meta, and each named
2893
+ // export as a story
2894
+ /\.stories\.(tsx?|jsx?|mjs)$/.test(name) || isNextRootFile(relativePath, appRoots) || isPagesRoute(relativePath, appRoots) || routeModules.has(relativePath);
2895
+ }
2896
+ function isBarrel(relativePath) {
2897
+ return /(?:^|\/)index\.(tsx?|jsx?)$/.test(relativePath);
2898
+ }
2899
+ function isAppFolder(dir, appRoots) {
2900
+ return appRoots.has(dir) || appRoots.has(dir.replace(/(?:^|\/)src$/, ""));
2901
+ }
2902
+ function isNextRootFile(relativePath, appRoots) {
2903
+ const match = /^(?:(.*)\/)?(?:middleware|proxy|instrumentation|instrumentation-client)\.(tsx?|jsx?)$/.exec(relativePath);
2904
+ return match !== null && isAppFolder(match[1] ?? "", appRoots);
2905
+ }
2906
+ function isPagesRoute(relativePath, appRoots) {
2907
+ if (!/\.(tsx?|jsx?)$/.test(relativePath)) return false;
2908
+ const parts = relativePath.split("/");
2909
+ return parts.some((part, i) => part === "pages" && isAppFolder(parts.slice(0, i).join("/"), appRoots));
2910
+ }
2911
+ function findAppRoots(paths) {
2912
+ const roots = /* @__PURE__ */ new Set([""]);
2913
+ for (const path of paths) {
2914
+ const match = /^(?:(.*)\/)?package\.json$/.exec(path);
2915
+ if (match) roots.add(match[1] ?? "");
2916
+ }
2917
+ return roots;
2918
+ }
2919
+ function findRouteModules(files, paths, appRoots) {
2920
+ const appFolders = [];
2921
+ for (const path of paths) {
2922
+ const match = /^(?:(.*)\/)?app\/root\.(tsx?|jsx?)$/.exec(path);
2923
+ if (match && appRoots.has(match[1] ?? "")) appFolders.push(path.slice(0, path.lastIndexOf("/")));
2924
+ }
2925
+ const modules = /* @__PURE__ */ new Set();
2926
+ for (const { relativePath, content } of files) {
2927
+ const folder = appFolders.find((f) => relativePath.startsWith(`${f}/`));
2928
+ if (folder === void 0) continue;
2929
+ const path = relativePath.slice(folder.length + 1);
2930
+ if (/^(?:root|entry\.server|routes\/.+)\.(tsx?|jsx?)$/.test(path)) {
2931
+ modules.add(relativePath);
2932
+ } else if (/^routes\.(?:[cm]?[jt]s|[jt]sx)$/.test(path)) {
2933
+ for (const match of content.matchAll(/['"`]([^'"`\n]+\.(?:tsx?|jsx?))['"`]/g)) {
2934
+ const module = normalizePath(`${folder}/${match[1]}`);
2935
+ if (module !== null) modules.add(module);
2936
+ }
2937
+ }
2938
+ }
2939
+ return modules;
2614
2940
  }
2615
2941
  var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
2616
2942
  var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
2617
2943
  var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
2618
- var FROM_SOURCE = /from\s+['"]([^'"]+)['"]/g;
2619
- function sourceBasename(source) {
2620
- return source.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2944
+ var DYNAMIC_IMPORT = /(\btypeof\s+)?(?:\{([^{}]*)\}\s*=\s*await\s+)?\bimport\s*\(\s*['"`]([^'"`]+)['"`]\s*\)(?:\s*\.\s*([\w$]+))?/g;
2945
+ var REQUIRE = /(?:\{([^{}]*)\}\s*=\s*)?\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([\w$]+))?/g;
2946
+ var REEXPORT_NAMED = /\bexport\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
2947
+ var REEXPORT_ALL = /\bexport\s*\*\s*(?:as\s+([\w$]+)\s*)?from\s*['"]([^'"]+)['"]/g;
2948
+ var MODULE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
2949
+ var MODULE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
2950
+ function parseSpecifiers(list) {
2951
+ return list.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((s) => s.trim().replace(/^type\s+/, "")).filter(Boolean).map((s) => {
2952
+ const [name, alias] = s.split(/\s+as\s+/).map((part) => part.trim());
2953
+ return [name, alias ?? name];
2954
+ });
2955
+ }
2956
+ function normalizePath(path) {
2957
+ const parts = [];
2958
+ for (const part of path.split("/")) {
2959
+ if (part === "..") {
2960
+ if (parts.length === 0) return null;
2961
+ parts.pop();
2962
+ } else if (part && part !== ".") {
2963
+ parts.push(part);
2964
+ }
2965
+ }
2966
+ return parts.join("/");
2967
+ }
2968
+ var EXTERNAL = { files: [], folders: [] };
2969
+ var UNKNOWN = { files: [], folders: [""] };
2970
+ function isAsset(source) {
2971
+ const name = source.slice(source.lastIndexOf("/") + 1);
2972
+ return /^[^.].*\.\w+$/.test(name) && !MODULE_EXTENSION.test(name);
2973
+ }
2974
+ function stringsIn(value) {
2975
+ if (typeof value === "string") return [value];
2976
+ if (Array.isArray(value)) return value.flatMap(stringsIn);
2977
+ if (value && typeof value === "object") return Object.values(value).flatMap(stringsIn);
2978
+ return [];
2979
+ }
2980
+ function createResolver(files, project) {
2981
+ const modules = new Set(files.filter((f) => MODULE_EXTENSIONS.includes(f.ext)).map((f) => f.relativePath));
2982
+ const byName = /* @__PURE__ */ new Map();
2983
+ for (const path of modules) {
2984
+ const key = path.replace(/\.\w+$/, "").replace(/(?:^|\/)index$/, "");
2985
+ const name = key.slice(key.lastIndexOf("/") + 1);
2986
+ const list = byName.get(name);
2987
+ if (list) list.push({ path, key });
2988
+ else byName.set(name, [{ path, key }]);
2989
+ }
2990
+ const manifests = [];
2991
+ for (const file of files) {
2992
+ if (!/(?:^|\/)package\.json$/.test(file.relativePath)) continue;
2993
+ try {
2994
+ const json = JSON.parse(file.content);
2995
+ if (json && typeof json === "object") manifests.push({ folder: file.relativePath.replace(/\/?package\.json$/, ""), json });
2996
+ } catch {
2997
+ }
2998
+ }
2999
+ const packages = /* @__PURE__ */ new Map();
3000
+ for (const { folder, json } of manifests) {
3001
+ if (typeof json.name === "string" && json.name) packages.set(json.name, [...packages.get(json.name) ?? [], folder]);
3002
+ }
3003
+ const findModule = (path) => {
3004
+ const stem = path.replace(/\.(?:[cm]?js|jsx)$/, "");
3005
+ const index = path ? `${path}/index` : "index";
3006
+ return [
3007
+ path,
3008
+ // TypeScript's ESM style names the compiled file: ./format.js for format.ts
3009
+ ...stem === path ? [] : [`${stem}.ts`, `${stem}.tsx`],
3010
+ ...MODULE_EXTENSIONS.map((ext) => `${path}.${ext}`),
3011
+ ...MODULE_EXTENSIONS.map((ext) => `${index}.${ext}`)
3012
+ ].find((candidate) => modules.has(candidate));
3013
+ };
3014
+ const packageEntries = /* @__PURE__ */ new Set();
3015
+ for (const { folder, json } of manifests) {
3016
+ if (json.private === true) continue;
3017
+ for (const target of stringsIn([json.main, json.module, json.types, json.typings, json.exports, json.bin])) {
3018
+ const isSource = /\.(?:[cm]?ts|tsx)$/.test(target) && !/\.d\.[cm]?ts$/.test(target);
3019
+ const path = isSource || target.includes("*") ? null : normalizePath(`${folder}/${target}`);
3020
+ if (path === null) continue;
3021
+ const stem = path.replace(/\.d\.ts$/, "").replace(MODULE_EXTENSION, "");
3022
+ const entry = findModule(path) ?? findModule(stem) ?? findModule(stem.replace(/(^|\/)(?:dist|build|lib|out|esm|cjs)\//, "$1src/"));
3023
+ if (entry) packageEntries.add(entry);
3024
+ }
3025
+ }
3026
+ const resolveRelative = (path) => {
3027
+ const normalized = normalizePath(path);
3028
+ if (normalized?.includes("${")) {
3029
+ const parts = normalized.split(/\$\{[^}]*\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
3030
+ const pattern = new RegExp(`^${parts.join(".*")}(?:\\.\\w+)?$`);
3031
+ return { files: [...modules].filter((m) => pattern.test(m)), folders: [] };
3032
+ }
3033
+ const module = normalized === null ? void 0 : findModule(normalized);
3034
+ if (module) return { files: [module], folders: [] };
3035
+ return isAsset(path) ? EXTERNAL : UNKNOWN;
3036
+ };
3037
+ const resolveBare = (source) => {
3038
+ if (/^[a-z][\w+.-]*:/i.test(source)) return EXTERNAL;
3039
+ const name = source.startsWith("@") ? source.split("/").slice(0, 2).join("/") : source.split("/")[0];
3040
+ const folders = packages.get(name);
3041
+ if (folders) {
3042
+ const subpath = source.slice(name.length + 1);
3043
+ const found = folders.map((folder) => {
3044
+ const base = folder ? `${folder}/` : "";
3045
+ return subpath ? findModule(base + subpath) ?? findModule(`${base}src/${subpath}`) : findModule(folder) ?? findModule(`${base}src`);
3046
+ });
3047
+ return { files: found.filter((f) => f !== void 0), folders };
3048
+ }
3049
+ if (NODE_BUILTINS.has(name) || project.declaredDependencies.has(name)) return EXTERNAL;
3050
+ const segments = source.replace(MODULE_EXTENSION, "").split("/").filter(Boolean);
3051
+ const named = byName.get(segments[segments.length - 1] ?? "") ?? [];
3052
+ for (let i = 0; i < segments.length; i++) {
3053
+ const path = segments.slice(i).join("/");
3054
+ const matches = named.filter((m) => m.key === path || m.key.endsWith(`/${path}`));
3055
+ if (matches.length > 0) return i <= 1 ? { files: matches.map((m) => m.path), folders: [] } : UNKNOWN;
3056
+ }
3057
+ return isAsset(source) ? EXTERNAL : UNKNOWN;
3058
+ };
3059
+ const cache = /* @__PURE__ */ new Map();
3060
+ const resolve4 = (source, from) => {
3061
+ const clean = source.replace(/\?.*$/, "");
3062
+ const relative = /^\.\.?(?:\/|$)/.test(clean);
3063
+ const dir = relative ? from.slice(0, from.lastIndexOf("/") + 1) : "";
3064
+ const key = `${dir}\0${clean}`;
3065
+ let target = cache.get(key);
3066
+ if (!target) {
3067
+ target = relative ? resolveRelative(dir + clean) : resolveBare(clean);
3068
+ cache.set(key, target);
3069
+ }
3070
+ return target;
3071
+ };
3072
+ return { resolve: resolve4, packageEntries };
2621
3073
  }
2622
3074
  var THRESHOLD = 5;
2623
3075
  var deadExportsRule = {
@@ -2630,17 +3082,16 @@ var deadExportsRule = {
2630
3082
  check() {
2631
3083
  return [];
2632
3084
  },
2633
- checkProject(files, _project) {
3085
+ checkProject(files, project) {
2634
3086
  const sourceFiles = files.filter(
2635
3087
  (f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
2636
3088
  );
3089
+ const appRoots = findAppRoots(project.allFiles);
3090
+ const routeModules = findRouteModules(files, project.allFiles, appRoots);
3091
+ const { resolve: resolve4, packageEntries } = createResolver(files, project);
2637
3092
  const exports = /* @__PURE__ */ new Map();
2638
- const imports = /* @__PURE__ */ new Map();
2639
- const allImportedSymbols = /* @__PURE__ */ new Set();
2640
- const importedFiles = /* @__PURE__ */ new Set();
2641
- const namespaceImported = /* @__PURE__ */ new Set();
2642
3093
  for (const file of sourceFiles) {
2643
- if (isEntryPoint(file.relativePath)) continue;
3094
+ if (isEntryPoint(file.relativePath, appRoots, routeModules) || packageEntries.has(file.relativePath)) continue;
2644
3095
  let inTemplateLiteral = false;
2645
3096
  for (let i = 0; i < file.lines.length; i++) {
2646
3097
  const line = file.lines[i];
@@ -2681,38 +3132,92 @@ var deadExportsRule = {
2681
3132
  }
2682
3133
  }
2683
3134
  }
2684
- const addImport = (sym, source) => {
2685
- allImportedSymbols.add(sym);
2686
- const fromBasename = sourceBasename(source);
2687
- if (fromBasename) {
2688
- const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2689
- set.add(sym);
2690
- imports.set(fromBasename, set);
3135
+ const contents = new Map(files.map((f) => [f.relativePath, f.content]));
3136
+ const used = /* @__PURE__ */ new Set();
3137
+ const usedInFolder = /* @__PURE__ */ new Map();
3138
+ const reexports = /* @__PURE__ */ new Map();
3139
+ const reexportsOf = (file) => {
3140
+ let list = reexports.get(file);
3141
+ if (!list) {
3142
+ list = [];
3143
+ const content = contents.get(file) ?? "";
3144
+ for (const match of content.matchAll(REEXPORT_NAMED)) {
3145
+ for (const [imported, exported] of parseSpecifiers(match[1])) list.push({ source: match[2], imported, exported });
3146
+ }
3147
+ for (const match of content.matchAll(REEXPORT_ALL)) {
3148
+ list.push(match[1] ? { source: match[2], imported: "*", exported: match[1] } : { source: match[2], imported: "", exported: "*" });
3149
+ }
3150
+ reexports.set(file, list);
3151
+ }
3152
+ return list;
3153
+ };
3154
+ const use = (target, name) => {
3155
+ if (name !== "*" && name !== "default") {
3156
+ for (const folder of target.folders) {
3157
+ const names = usedInFolder.get(folder) ?? /* @__PURE__ */ new Set();
3158
+ names.add(name);
3159
+ usedInFolder.set(folder, names);
3160
+ }
3161
+ }
3162
+ for (const file of target.files) {
3163
+ const key = `${file}::${name}`;
3164
+ if (used.has(key)) continue;
3165
+ used.add(key);
3166
+ for (const { source, imported, exported } of reexportsOf(file)) {
3167
+ if (exported === "*") {
3168
+ if (name !== "default") use(resolve4(source, file), name);
3169
+ } else if (name === "*" || name === exported) {
3170
+ use(resolve4(source, file), imported);
3171
+ }
3172
+ }
3173
+ }
3174
+ };
3175
+ const useLoaded = (target, destructured, member, typeOnly) => {
3176
+ if (member && !["then", "catch", "finally"].includes(member)) {
3177
+ use(target, member);
3178
+ } else if (typeOnly) {
3179
+ return;
3180
+ } else if (destructured !== void 0 && !destructured.includes("...")) {
3181
+ for (const name of destructured.split(",").map((s) => s.split(/[:=]/)[0].trim()).filter(Boolean)) use(target, name);
3182
+ } else {
3183
+ use(target, "*");
2691
3184
  }
2692
3185
  };
2693
3186
  for (const file of files) {
3187
+ const from = file.relativePath;
2694
3188
  for (const match of file.content.matchAll(IMPORT_NAMED)) {
2695
- const symbols = match[1].replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()).filter(Boolean);
2696
- for (const sym of symbols) addImport(sym, match[2]);
3189
+ for (const [name] of parseSpecifiers(match[1])) use(resolve4(match[2], from), name);
2697
3190
  }
2698
3191
  for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
2699
- addImport(match[1], match[2]);
3192
+ const target = resolve4(match[2], from);
3193
+ use(target, "default");
3194
+ use(target, match[1]);
2700
3195
  }
2701
- for (const match of file.content.matchAll(IMPORT_NAMESPACE)) {
2702
- namespaceImported.add(sourceBasename(match[1]));
3196
+ for (const match of file.content.matchAll(IMPORT_NAMESPACE)) use(resolve4(match[1], from), "*");
3197
+ for (const match of file.content.matchAll(DYNAMIC_IMPORT)) {
3198
+ const [, typeOnly, destructured, source, member] = match;
3199
+ useLoaded(resolve4(source, from), destructured, member, Boolean(typeOnly));
2703
3200
  }
2704
- for (const match of file.content.matchAll(FROM_SOURCE)) {
2705
- importedFiles.add(match[1]);
3201
+ for (const match of file.content.matchAll(REQUIRE)) {
3202
+ const [, destructured, source, member] = match;
3203
+ useLoaded(resolve4(source, from), destructured, member, false);
2706
3204
  }
2707
- }
3205
+ if (isEntryPoint(from, appRoots, routeModules) && !isBarrel(from)) use({ files: [from], folders: [] }, "*");
3206
+ }
3207
+ for (const entry of packageEntries) use({ files: [entry], folders: [] }, "*");
3208
+ const usedInAFolderOf = (file, name) => {
3209
+ let folder = file;
3210
+ while (folder) {
3211
+ folder = folder.slice(0, Math.max(folder.lastIndexOf("/"), 0));
3212
+ if (usedInFolder.get(folder)?.has(name)) return true;
3213
+ }
3214
+ return false;
3215
+ };
2708
3216
  const deadByFile = /* @__PURE__ */ new Map();
2709
3217
  for (const [key, loc] of exports) {
2710
3218
  const symbolName = key.split("::")[1];
2711
- const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2712
- if (namespaceImported.has(exportFileBasename)) continue;
2713
- const importSet = imports.get(exportFileBasename);
2714
- const isImported = importSet?.has(symbolName) ?? false;
2715
- if (!isImported && !allImportedSymbols.has(symbolName)) {
3219
+ const isUsed = used.has(key) || used.has(`${loc.file}::*`) || usedInAFolderOf(loc.file, symbolName);
3220
+ if (!isUsed) {
2716
3221
  deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
2717
3222
  }
2718
3223
  }
@@ -3826,7 +4331,6 @@ var serverActionAuthRule = {
3826
4331
  check(file, project) {
3827
4332
  if (isTestFile(file.relativePath)) return [];
3828
4333
  if (!USE_SERVER3.test(file.content)) return [];
3829
- if (project.hasAuthMiddleware) return [];
3830
4334
  for (const p of PUBLIC_ACTION_NAMES) {
3831
4335
  if (p.test(file.relativePath)) return [];
3832
4336
  }
@@ -3841,15 +4345,17 @@ var serverActionAuthRule = {
3841
4345
  break;
3842
4346
  }
3843
4347
  }
4348
+ const middlewareSkipsPages = project.hasAuthMiddleware && project.authMiddlewareExcludesPages === true;
4349
+ const middleware = project.hasAuthMiddleware && !middlewareSkipsPages;
3844
4350
  return [{
3845
4351
  ruleId: "server-action-auth",
3846
4352
  file: file.relativePath,
3847
4353
  line: reportLine,
3848
4354
  column: 1,
3849
- message: "Server action performs mutations without any authentication check \u2014 anyone can call this action",
3850
- severity: "warning",
4355
+ message: middleware ? "Server action performs mutations without an inline auth check (middleware auth detected \u2014 verify coverage)" : middlewareSkipsPages ? "Server action performs mutations without any authentication check (middleware auth detected, but its matcher doesn't cover pages)" : "Server action performs mutations without any authentication check \u2014 anyone can call this action",
4356
+ severity: middleware ? "info" : "warning",
3851
4357
  category: "security",
3852
- fix: 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
4358
+ fix: middleware ? 'Check the user in the action too, not only in middleware: const session = await auth(); if (!session) throw new Error("Unauthorized")' : 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
3853
4359
  }];
3854
4360
  }
3855
4361
  };
@@ -5250,9 +5756,9 @@ function ipv6Groups(address) {
5250
5756
  const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
5251
5757
  const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
5252
5758
  const [head, tail] = hex.split("::");
5253
- const parse2 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
5254
- const front = parse2(head);
5255
- const back = parse2(tail);
5759
+ const parse3 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
5760
+ const front = parse3(head);
5761
+ const back = parse3(tail);
5256
5762
  return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
5257
5763
  }
5258
5764
  function embeddedIPv4(address) {