{"version":3,"file":"fix-auth-protection.mjs","names":[],"sources":["../../src/next/fix-auth-protection.ts"],"sourcesContent":["/**\n * Programmatic auto-fixer for the `require-auth-protection` rule.\n *\n * The rule exposes its `await auth.protect()` insertion as an opt-in\n * *suggestion* rather than an autofix, so `eslint --fix` deliberately leaves it\n * alone (adding a protection check changes runtime behavior). This runner lets\n * you apply those suggestions in bulk on demand — from a script via\n * `fixAuthProtection()` or from the terminal via the\n * `clerk-next-fix-auth-protection` command.\n *\n * It works by linting with the consumer's own ESLint config (so the\n * protected/public folder globs are honored) and applying the rule's\n * `addAuthProtect` suggestion to each flagged resource. Resources the rule\n * cannot safely fix (imported/wrapped exports, unacknowledged mixed-scope\n * layouts) are reported back as `unresolved` for manual follow-up.\n */\n\nimport { readFile, writeFile } from 'node:fs/promises';\n\nimport { ESLint, type Linter } from 'eslint';\n\nconst RULE_NAME = 'require-auth-protection';\nconst SUGGESTION_MESSAGE_ID = 'addAuthProtect';\nconst UNFIXABLE_MESSAGE_IDS = new Set(['exportImported', 'unverifiableExport', 'unlistedMixedScopeLayout']);\n\nexport interface FixAuthProtectionOptions {\n  /** File, directory, or glob patterns to scan. Defaults to `['.']`. */\n  patterns?: string[];\n  /** Working directory ESLint resolves config and files against. Defaults to `process.cwd()`. */\n  cwd?: string;\n  /** Compute the changes without writing them to disk. */\n  dryRun?: boolean;\n  /**\n   * Advanced/escape hatch: a pre-configured ESLint instance to lint with. When\n   * omitted, a default `new ESLint({ cwd })` is used, which discovers the\n   * consumer's flat config. Mainly useful for tests.\n   */\n  eslint?: ESLint;\n  /**\n   * Called before scanning with the path to the ESLint config file that will be\n   * used (or `undefined` when none is found / an instance was injected).\n   */\n  onConfigResolved?: (configFilePath: string | undefined) => void;\n  /**\n   * Called once linting finishes and per-file fixing begins, with the number of\n   * files that have flagged resources. Useful for reporting progress, since the\n   * initial lint can be slow on large projects.\n   */\n  onScanComplete?: (fileCount: number) => void;\n  /** Called after each file is fixed (or, in `dryRun`, would be fixed). */\n  onFileFixed?: (file: FixedFile) => void;\n}\n\nexport interface FixedFile {\n  filePath: string;\n  /** Number of resources that had `await auth.protect()` applied. */\n  protections: number;\n}\n\nexport interface UnresolvedIssue {\n  line: number;\n  column: number;\n  message: string;\n}\n\nexport interface UnresolvedFile {\n  filePath: string;\n  issues: UnresolvedIssue[];\n}\n\nexport interface FixAuthProtectionResult {\n  /** Files that were (or, in `dryRun`, would be) modified. */\n  fixed: FixedFile[];\n  /** Files with flagged resources that have no safe automatic fix. */\n  unresolved: UnresolvedFile[];\n}\n\ntype Fix = { range: [number, number]; text: string };\n\nfunction isAuthProtectionRule(ruleId: string | null): boolean {\n  // The plugin can be registered under any namespace (e.g. `@clerk/next/...`),\n  // so match on the rule name rather than a fixed, fully-qualified id.\n  return ruleId === RULE_NAME || (ruleId?.endsWith(`/${RULE_NAME}`) ?? false);\n}\n\nfunction collectSuggestionFixes(messages: Linter.LintMessage[]): Fix[] {\n  const fixes: Fix[] = [];\n  for (const message of messages) {\n    if (!isAuthProtectionRule(message.ruleId)) {\n      continue;\n    }\n    const suggestion = message.suggestions?.find(s => s.messageId === SUGGESTION_MESSAGE_ID);\n    if (suggestion?.fix) {\n      fixes.push({ range: [suggestion.fix.range[0], suggestion.fix.range[1]], text: suggestion.fix.text });\n    }\n  }\n  return fixes;\n}\n\n/**\n * Apply as many non-overlapping fixes as possible in a single pass, mirroring\n * ESLint's own `SourceCodeFixer`: sort by position and skip any fix that starts\n * before the previous one ended. Overlapping fixes are left for a later pass.\n */\nfunction applyFixes(source: string, fixes: Fix[]): { output: string; applied: number } {\n  const sorted = [...fixes].sort((a, b) => a.range[0] - b.range[0] || a.range[1] - b.range[1]);\n  let output = '';\n  let lastPos = 0;\n  let applied = 0;\n  for (const fix of sorted) {\n    const [start, end] = fix.range;\n    if (start < lastPos) {\n      continue;\n    }\n    output += source.slice(lastPos, start) + fix.text;\n    lastPos = end;\n    applied++;\n  }\n  output += source.slice(lastPos);\n  return { output, applied };\n}\n\nasync function applyFileFixes(\n  eslint: ESLint,\n  filePath: string,\n  source: string,\n): Promise<{ output: string; applied: number }> {\n  // Applying a file's suggestions should converge in at most two passes: the first pass\n  // fixes one resource and adds the shared top-of-file `auth` import, after which\n  // every remaining resource is independent and applied in the second pass.\n  // We allow up to 10 passes to allow for unaccounted for edge cases, or future\n  // changes to the fixer, but throw an error if it fails to converge.\n  const MAX_FIX_PASSES = 10;\n\n  let current = source;\n  let total = 0;\n  let passes = 0;\n  // Run one extra time so we can throw an error if the fixes don't converge.\n  for (let i = 0; i < MAX_FIX_PASSES + 1; i += 1) {\n    const [result] = await eslint.lintText(current, { filePath });\n    if (!result) {\n      break;\n    }\n    const fixes = collectSuggestionFixes(result.messages);\n    if (fixes.length === 0) {\n      break;\n    }\n    if (passes >= MAX_FIX_PASSES) {\n      throw new Error(\n        `Auth-protect fixes for ${filePath} did not converge after ${MAX_FIX_PASSES} passes. ` +\n          'This is unexpected; please report it at https://github.com/clerk/javascript/issues.',\n      );\n    }\n    const { output, applied } = applyFixes(current, fixes);\n    if (applied === 0) {\n      break;\n    }\n    current = output;\n    total += applied;\n    passes += 1;\n  }\n  return { output: current, applied: total };\n}\n\n/**\n * Lint the given patterns with the consumer's ESLint config and apply the\n * `require-auth-protection` rule's `await auth.protect()` suggestions to every\n * resource it can safely fix.\n */\nexport async function fixAuthProtection(options: FixAuthProtectionOptions = {}): Promise<FixAuthProtectionResult> {\n  const cwd = options.cwd ?? process.cwd();\n  const patterns = options.patterns && options.patterns.length > 0 ? options.patterns : ['.'];\n  const dryRun = options.dryRun ?? false;\n\n  // Only run our rule. The consumer's config (and its protected/public globs)\n  // still applies, but skipping every other rule avoids the cost of linting the\n  // whole project with the full ruleset on each pass.\n  const eslint = options.eslint ?? new ESLint({ cwd, ruleFilter: ({ ruleId }) => isAuthProtectionRule(ruleId) });\n\n  if (options.onConfigResolved) {\n    let configFile: string | undefined;\n    try {\n      configFile = await eslint.findConfigFile();\n    } catch {\n      configFile = undefined;\n    }\n    options.onConfigResolved(configFile);\n  }\n\n  const results = await eslint.lintFiles(patterns);\n\n  const fixed: FixedFile[] = [];\n  const unresolved: UnresolvedFile[] = [];\n\n  const flaggedResults = results.filter(result =>\n    result.messages.some(message => isAuthProtectionRule(message.ruleId)),\n  );\n  options.onScanComplete?.(flaggedResults.length);\n\n  for (const result of flaggedResults) {\n    const ruleMessages = result.messages.filter(message => isAuthProtectionRule(message.ruleId));\n\n    const hasFixable = ruleMessages.some(\n      message => message.suggestions?.some(s => s.messageId === SUGGESTION_MESSAGE_ID) ?? false,\n    );\n    if (hasFixable) {\n      const source = await readFile(result.filePath, 'utf8');\n      const { output, applied } = await applyFileFixes(eslint, result.filePath, source);\n      if (applied > 0) {\n        if (!dryRun) {\n          await writeFile(result.filePath, output, 'utf8');\n        }\n        const fixedFile = { filePath: result.filePath, protections: applied };\n        fixed.push(fixedFile);\n        options.onFileFixed?.(fixedFile);\n      }\n    }\n\n    // Messages without a suggestion (imported/wrapped exports, mixed-scope\n    // layouts) need a human; surface them so they aren't silently skipped.\n    const issues = ruleMessages\n      .filter(message => UNFIXABLE_MESSAGE_IDS.has(message.messageId ?? ''))\n      .map(message => ({ line: message.line, column: message.column, message: message.message }));\n    if (issues.length > 0) {\n      unresolved.push({ filePath: result.filePath, issues });\n    }\n  }\n\n  return { fixed, unresolved };\n}\n"],"mappings":"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"}