next.js/packages/next-codemod/transforms/next-lint-to-eslint-cli.ts
next-lint-to-eslint-cli.ts1209 lines38.2 KB
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
import path from 'node:path'
import { execSync } from 'node:child_process'
import semver from 'semver'
import { getPkgManager, installPackages } from '../lib/handle-package'
import { createParserFromPath } from '../lib/parser'
import { white, bold, red, yellow, green, magenta } from 'picocolors'

export const prefixes = {
  wait: white(bold('○')),
  error: red(bold('⨯')),
  warn: yellow(bold('⚠')),
  ready: '▲', // no color
  info: white(bold(' ')),
  event: green(bold('✓')),
  trace: magenta(bold('»')),
} as const

interface TransformerOptions {
  skipInstall?: boolean
  [key: string]: unknown
}

const ESLINT_CONFIG_TEMPLATE_TYPESCRIPT = `import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";

const eslintConfig = [
  ...nextCoreWebVitals,
  ...nextTypescript,
  {
    ignores: [
      "node_modules/**",
      ".next/**",
      "out/**",
      "build/**",
      "next-env.d.ts",
    ],
  },
];

export default eslintConfig;
`

const ESLINT_CONFIG_TEMPLATE_JAVASCRIPT = `import nextCoreWebVitals from "eslint-config-next/core-web-vitals";

const eslintConfig = [
  ...nextCoreWebVitals,
  {
    ignores: [
      "node_modules/**",
      ".next/**",
      "out/**",
      "build/**",
      "next-env.d.ts",
    ],
  },
];

export default eslintConfig;
`

function detectTypeScript(projectRoot: string): boolean {
  return existsSync(path.join(projectRoot, 'tsconfig.json'))
}

function findExistingEslintConfig(projectRoot: string): {
  exists: boolean
  path: string | null
  isLegacy: boolean | null
} {
  const flatConfigs = [
    'eslint.config.js',
    'eslint.config.mjs',
    'eslint.config.cjs',
    'eslint.config.ts',
    'eslint.config.mts',
    'eslint.config.cts',
  ]

  const legacyConfigs = [
    '.eslintrc.js',
    '.eslintrc.cjs',
    '.eslintrc.yaml',
    '.eslintrc.yml',
    '.eslintrc.json',
    '.eslintrc',
  ]

  // Check for flat configs first (preferred for v9+)
  for (const config of flatConfigs) {
    const configPath = path.join(projectRoot, config)
    if (existsSync(configPath)) {
      return { exists: true, path: configPath, isLegacy: false }
    }
  }

  // Check for legacy configs
  for (const config of legacyConfigs) {
    const configPath = path.join(projectRoot, config)
    if (existsSync(configPath)) {
      return { exists: true, path: configPath, isLegacy: true }
    }
  }

  return { exists: false, path: null, isLegacy: null }
}

function replaceFlatCompatInConfig(configPath: string): boolean {
  let configContent: string
  try {
    configContent = readFileSync(configPath, 'utf8')
  } catch (error) {
    console.error(`   Error reading config file: ${error}`)
    return false
  }

  // Check if FlatCompat is used
  const hasFlatCompat =
    configContent.includes('FlatCompat') ||
    configContent.includes('@eslint/eslintrc')

  if (!hasFlatCompat) {
    console.log('   No FlatCompat usage found, no changes needed')
    return false
  }

  // Parse the file using jscodeshift
  const j = createParserFromPath(configPath)
  const root = j(configContent)

  // Track if we need to add imports and preserve other configs
  let needsNext = false
  let needsNextVitals = false
  let needsNextTs = false
  let otherConfigs: string[] = []

  // Look for FlatCompat extends usage and identify which configs are being used
  root.find(j.CallExpression).forEach((astPath) => {
    const node = astPath.value

    // Detect compat.extends() calls and identify which configs are being used
    if (
      node.callee.type === 'MemberExpression' &&
      (node.callee as any).object.type === 'Identifier' &&
      (node.callee as any).object.name === 'compat' &&
      (node.callee as any).property.type === 'Identifier' &&
      (node.callee as any).property.name === 'extends'
    ) {
      // Check arguments for all configs
      node.arguments.forEach((arg: any) => {
        if (arg.type === 'Literal' || arg.type === 'StringLiteral') {
          if (arg.value === 'next') {
            needsNext = true
          } else if (arg.value === 'next/core-web-vitals') {
            needsNextVitals = true
          } else if (arg.value === 'next/typescript') {
            needsNextTs = true
          } else if (typeof arg.value === 'string') {
            // Preserve other configs (non-Next.js or other Next.js variants)
            otherConfigs.push(arg.value)
          }
        }
      })
    }

    // Detect compat.config({ extends: [...] }) calls and identify which configs are being used
    if (
      node.callee.type === 'MemberExpression' &&
      (node.callee as any).object.type === 'Identifier' &&
      (node.callee as any).object.name === 'compat' &&
      (node.callee as any).property.type === 'Identifier' &&
      (node.callee as any).property.name === 'config'
    ) {
      // Look for extends property in the object argument
      node.arguments.forEach((arg: any) => {
        if (arg.type === 'ObjectExpression') {
          arg.properties?.forEach((prop: any) => {
            if (
              prop.type === 'ObjectProperty' &&
              prop.key.type === 'Identifier' &&
              prop.key.name === 'extends' &&
              prop.value.type === 'ArrayExpression'
            ) {
              // Process the extends array
              prop.value.elements?.forEach((element: any) => {
                if (
                  element.type === 'Literal' ||
                  element.type === 'StringLiteral'
                ) {
                  if (element.value === 'next') {
                    needsNext = true
                  } else if (element.value === 'next/core-web-vitals') {
                    needsNextVitals = true
                  } else if (element.value === 'next/typescript') {
                    needsNextTs = true
                  } else if (typeof element.value === 'string') {
                    // Preserve other configs (non-Next.js or other Next.js variants)
                    otherConfigs.push(element.value)
                  }
                }
              })
            }
          })
        }
      })
    }
  })

  if (
    !needsNext &&
    !needsNextVitals &&
    !needsNextTs &&
    otherConfigs.length === 0
  ) {
    console.warn(
      prefixes.warn,
      '   No ESLint configs found in FlatCompat usage'
    )
    return false
  }

  if (!needsNext && !needsNextVitals && !needsNextTs) {
    console.log('   No Next.js configs found, but preserving other configs')
  }

  // Only remove FlatCompat setup if no other configs need it
  if (otherConfigs.length === 0) {
    // Remove FlatCompat imports and setup
    root.find(j.ImportDeclaration).forEach((astPath) => {
      const node = astPath.value
      if (
        node.source.value === '@eslint/eslintrc' ||
        node.source.value === '@eslint/js'
      ) {
        // Only remove FlatCompat-specific imports
        j(astPath).remove()
      }
      // Leave path/url imports alone - they might be used elsewhere
    })

    // Remove only the compat variable - keep __dirname and __filename
    root.find(j.VariableDeclaration).forEach((astPath) => {
      const node = astPath.value
      if (node.declarations) {
        // Filter out only the compat variable
        const filteredDeclarations = node.declarations.filter((decl: any) => {
          if (decl && decl.id && decl.id.type === 'Identifier') {
            return decl.id.name !== 'compat'
          }
          return true
        })

        if (filteredDeclarations.length === 0) {
          // Remove entire declaration if no declarations left
          j(astPath).remove()
        } else if (filteredDeclarations.length < node.declarations.length) {
          // Update declaration with filtered declarations
          node.declarations = filteredDeclarations
        }
      }
    })
  } else {
    console.log('   Preserving FlatCompat setup for other ESLint configs')
  }

  // Add new imports after the eslint/config import
  const imports = []

  // Add imports in correct order: next first, then core-web-vitals, then typescript
  if (needsNext) {
    imports.push(
      j.importDeclaration(
        [j.importDefaultSpecifier(j.identifier('next'))],
        j.literal('eslint-config-next')
      )
    )
  }

  if (needsNextVitals) {
    imports.push(
      j.importDeclaration(
        [j.importDefaultSpecifier(j.identifier('nextCoreWebVitals'))],
        j.literal('eslint-config-next/core-web-vitals')
      )
    )
  }

  if (needsNextTs) {
    imports.push(
      j.importDeclaration(
        [j.importDefaultSpecifier(j.identifier('nextTypescript'))],
        j.literal('eslint-config-next/typescript')
      )
    )
  }

  // Find the eslint/config import and insert our imports after it
  let eslintConfigImportPath = null
  root.find(j.ImportDeclaration).forEach((astPath) => {
    if (astPath.value.source.value === 'eslint/config') {
      eslintConfigImportPath = astPath
    }
  })

  // Insert imports after eslint/config import (or at beginning if not found)
  if (eslintConfigImportPath) {
    // Insert after the eslint/config import in correct order
    for (let i = imports.length - 1; i >= 0; i--) {
      eslintConfigImportPath.insertAfter(imports[i])
    }
  } else {
    // Fallback: insert at the beginning in correct order
    const program = root.find(j.Program)
    for (let i = imports.length - 1; i >= 0; i--) {
      program.get('body', 0).insertBefore(imports[i])
    }
  }

  // Replace FlatCompat extends with spread imports
  root.find(j.SpreadElement).forEach((astPath) => {
    const node = astPath.value

    // Replace spread of compat.extends(...) calls with direct imports
    if (
      node.argument.type === 'CallExpression' &&
      node.argument.callee.type === 'MemberExpression' &&
      node.argument.callee.object.type === 'Identifier' &&
      node.argument.callee.object.name === 'compat' &&
      node.argument.callee.property.type === 'Identifier' &&
      node.argument.callee.property.name === 'extends'
    ) {
      // Replace with spread of direct imports and preserve other configs
      const replacements = []
      node.argument.arguments.forEach((arg: any) => {
        if (arg.type === 'Literal' || arg.type === 'StringLiteral') {
          if (arg.value === 'next') {
            replacements.push(j.spreadElement(j.identifier('next')))
          } else if (arg.value === 'next/core-web-vitals') {
            replacements.push(
              j.spreadElement(j.identifier('nextCoreWebVitals'))
            )
          } else if (arg.value === 'next/typescript') {
            replacements.push(j.spreadElement(j.identifier('nextTypescript')))
          } else if (typeof arg.value === 'string') {
            // Preserve other configs as compat.extends() calls
            replacements.push(
              j.spreadElement(
                j.callExpression(
                  j.memberExpression(
                    j.identifier('compat'),
                    j.identifier('extends')
                  ),
                  [j.literal(arg.value)]
                )
              )
            )
          }
        }
      })

      if (replacements.length > 0) {
        // Replace the current spread element with multiple spread elements
        const parent = astPath.parent
        if (parent.value.type === 'ArrayExpression') {
          const index = parent.value.elements.indexOf(node)
          if (index !== -1) {
            parent.value.elements.splice(index, 1, ...replacements)
          }
        }
      }
    }

    // Replace spread of compat.config({ extends: [...] }) calls with direct imports
    if (
      node.argument.type === 'CallExpression' &&
      node.argument.callee.type === 'MemberExpression' &&
      node.argument.callee.object.type === 'Identifier' &&
      node.argument.callee.object.name === 'compat' &&
      node.argument.callee.property.type === 'Identifier' &&
      node.argument.callee.property.name === 'config'
    ) {
      const replacements = []
      const preservedConfigs = []

      // Process each argument to compat.config
      node.argument.arguments.forEach((arg: any) => {
        if (arg.type === 'ObjectExpression') {
          const updatedProperties = []

          arg.properties?.forEach((prop: any) => {
            if (
              prop.type === 'ObjectProperty' &&
              prop.key.type === 'Identifier' &&
              prop.key.name === 'extends' &&
              prop.value.type === 'ArrayExpression'
            ) {
              const nonNextConfigs = []

              // Process extends array
              prop.value.elements?.forEach((element: any) => {
                if (
                  element.type === 'Literal' ||
                  element.type === 'StringLiteral'
                ) {
                  if (element.value === 'next') {
                    replacements.push(j.spreadElement(j.identifier('next')))
                  } else if (element.value === 'next/core-web-vitals') {
                    replacements.push(
                      j.spreadElement(j.identifier('nextCoreWebVitals'))
                    )
                  } else if (element.value === 'next/typescript') {
                    replacements.push(
                      j.spreadElement(j.identifier('nextTypescript'))
                    )
                  } else if (typeof element.value === 'string') {
                    // Keep non-Next.js configs
                    nonNextConfigs.push(element)
                  }
                }
              })

              // If there are non-Next.js configs, preserve the extends property with them
              if (nonNextConfigs.length > 0) {
                updatedProperties.push(
                  j.property(
                    'init',
                    j.identifier('extends'),
                    j.arrayExpression(nonNextConfigs)
                  )
                )
              }
            } else {
              // Preserve other properties (not extends)
              updatedProperties.push(prop)
            }
          })

          // If we still have properties to preserve, keep the compat.config call
          if (updatedProperties.length > 0) {
            preservedConfigs.push(
              j.spreadElement(
                j.callExpression(
                  j.memberExpression(
                    j.identifier('compat'),
                    j.identifier('config')
                  ),
                  [j.objectExpression(updatedProperties)]
                )
              )
            )
          }
        }
      })

      // Add all replacements
      const allReplacements = [...replacements, ...preservedConfigs]

      if (allReplacements.length > 0) {
        // Replace the current spread element with multiple spread elements
        const parent = astPath.parent
        if (parent.value.type === 'ArrayExpression') {
          const index = parent.value.elements.indexOf(node)
          if (index !== -1) {
            parent.value.elements.splice(index, 1, ...allReplacements)
          }
        }
      }
    }
  })

  // Also handle the case where extends is used as a property value (not spread)
  root.find(j.ObjectExpression).forEach((astPath) => {
    const objectNode = astPath.value
    objectNode.properties?.forEach((prop: any) => {
      if (
        prop.type === 'ObjectProperty' &&
        prop.key.type === 'Identifier' &&
        prop.key.name === 'extends' &&
        prop.value.type === 'CallExpression' &&
        prop.value.callee.type === 'MemberExpression' &&
        prop.value.callee.object.type === 'Identifier' &&
        prop.value.callee.object.name === 'compat' &&
        prop.value.callee.property.type === 'Identifier' &&
        prop.value.callee.property.name === 'extends'
      ) {
        // Replace with array of spread imports and preserve other configs
        const replacements = []
        prop.value.arguments.forEach((arg: any) => {
          if (arg.type === 'Literal' || arg.type === 'StringLiteral') {
            if (arg.value === 'next') {
              replacements.push(j.spreadElement(j.identifier('next')))
            } else if (arg.value === 'next/core-web-vitals') {
              replacements.push(
                j.spreadElement(j.identifier('nextCoreWebVitals'))
              )
            } else if (arg.value === 'next/typescript') {
              replacements.push(j.spreadElement(j.identifier('nextTypescript')))
            } else if (typeof arg.value === 'string') {
              // Preserve other configs as compat.extends() calls
              replacements.push(
                j.spreadElement(
                  j.callExpression(
                    j.memberExpression(
                      j.identifier('compat'),
                      j.identifier('extends')
                    ),
                    [j.literal(arg.value)]
                  )
                )
              )
            }
          }
        })

        if (replacements.length > 0) {
          // Replace the property value with an array of spreads
          prop.value = j.arrayExpression(replacements)
        }
      }
    })
  })

  // Generate the updated code
  const updatedContent = root.toSource()

  if (updatedContent !== configContent) {
    // Validate the generated code by parsing it
    try {
      const validateJ = createParserFromPath(configPath)
      validateJ(updatedContent) // This will throw if the syntax is invalid
    } catch (parseError) {
      console.error(
        `   Generated code has invalid syntax: ${parseError instanceof Error ? parseError.message : parseError}`
      )
      console.error('   Skipping update to prevent breaking the config file')
      return false
    }

    // Create backup of original file
    const backupPath = `${configPath}.backup-${Date.now()}`
    try {
      writeFileSync(backupPath, configContent)
    } catch (backupError) {
      console.warn(`   Warning: Could not create backup file: ${backupError}`)
    }

    try {
      writeFileSync(configPath, updatedContent)
      console.log(
        `   Updated ${path.basename(configPath)} to use direct eslint-config-next imports`
      )

      // Remove backup on success
      try {
        if (existsSync(backupPath)) {
          unlinkSync(backupPath)
        }
      } catch (cleanupError) {
        console.warn(
          `   Warning: Could not remove backup file ${backupPath}: ${cleanupError}`
        )
      }

      return true
    } catch (error) {
      console.error(`   Error writing config file: ${error}`)

      // Restore from backup on failure
      try {
        if (existsSync(backupPath)) {
          writeFileSync(configPath, readFileSync(backupPath, 'utf8'))
          console.log('   Restored original config from backup')
        }
      } catch (restoreError) {
        console.error(`   Error restoring backup: ${restoreError}`)
      }

      return false
    }
  }

  return true
}

function updateExistingFlatConfig(
  configPath: string,
  isTypeScript: boolean = false
): boolean {
  let configContent: string
  try {
    configContent = readFileSync(configPath, 'utf8')
  } catch (error) {
    console.error(`   Error reading config file: ${error}`)
    return false
  }

  // Check if Next.js configs are already imported directly
  const hasNext = configContent.includes('eslint-config-next')
  const hasNextVitals = configContent.includes(
    'eslint-config-next/core-web-vitals'
  )
  const hasNextTs = configContent.includes('eslint-config-next/typescript')
  const hasNextConfigs = hasNextVitals || hasNextTs

  // Parse the file using jscodeshift
  const j = createParserFromPath(configPath)
  const root = j(configContent)

  // Find the exported array - support different export patterns
  let exportedArray = null

  // Pattern 1: export default [...]
  const directArrayExports = root.find(j.ExportDefaultDeclaration, {
    declaration: { type: 'ArrayExpression' },
  })

  if (directArrayExports.size() > 0) {
    exportedArray = directArrayExports.at(0).get('declaration')
  } else {
    // Pattern 2: const config = [...]; export default config
    const defaultExportIdentifier = root.find(j.ExportDefaultDeclaration, {
      declaration: { type: 'Identifier' },
    })

    if (defaultExportIdentifier.size() > 0) {
      const declarationNode = defaultExportIdentifier.at(0).get('declaration')
      if (declarationNode.value) {
        const varName = declarationNode.value.name
        const varDeclaration = root.find(j.VariableDeclarator, {
          id: { name: varName },
          init: { type: 'ArrayExpression' },
        })

        if (varDeclaration.size() > 0) {
          exportedArray = varDeclaration.at(0).get('init')
        } else {
          // Pattern 3: defineConfig([...]) or similar wrapper function
          const callDeclaration = root.find(j.VariableDeclarator, {
            id: { name: varName },
            init: { type: 'CallExpression' },
          })

          if (callDeclaration.size() > 0) {
            const callExpression = callDeclaration.at(0).get('init')
            if (
              callExpression.value.arguments.length > 0 &&
              callExpression.value.arguments[0].type === 'ArrayExpression'
            ) {
              exportedArray = callExpression.get('arguments', 0)
            } else {
              console.warn(
                prefixes.warn,
                '   Wrapper function does not have an array parameter. Manual migration required.'
              )
              return false
            }
          }
        }
      }
    }
  }

  if (!exportedArray) {
    console.warn(
      prefixes.warn,
      '   Config does not export an array or supported pattern. Manual migration required.'
    )
    return false
  }

  // Add Next.js imports if not present
  const program = root.find(j.Program)
  const imports = []

  if (!hasNext) {
    imports.push(
      j.importDeclaration(
        [j.importDefaultSpecifier(j.identifier('next'))],
        j.literal('eslint-config-next')
      )
    )
  }

  if (!hasNextVitals) {
    imports.push(
      j.importDeclaration(
        [j.importDefaultSpecifier(j.identifier('nextCoreWebVitals'))],
        j.literal('eslint-config-next/core-web-vitals')
      )
    )
  }

  if (!hasNextTs && isTypeScript) {
    imports.push(
      j.importDeclaration(
        [j.importDefaultSpecifier(j.identifier('nextTypescript'))],
        j.literal('eslint-config-next/typescript')
      )
    )
  }

  // Insert imports at the beginning in correct order
  for (let i = imports.length - 1; i >= 0; i--) {
    program.get('body', 0).insertBefore(imports[i])
  }

  // Add spread elements to config array if not already present
  if (!exportedArray.value.elements) {
    exportedArray.value.elements = []
  }

  const spreadsToAdd = []
  if (!hasNext) {
    spreadsToAdd.push(j.spreadElement(j.identifier('next')))
  }
  if (!hasNextVitals) {
    spreadsToAdd.push(j.spreadElement(j.identifier('nextCoreWebVitals')))
  }
  if (!hasNextTs && isTypeScript) {
    spreadsToAdd.push(j.spreadElement(j.identifier('nextTypescript')))
  }

  // Insert at the beginning of array in correct order
  for (let i = spreadsToAdd.length - 1; i >= 0; i--) {
    exportedArray.value.elements.unshift(spreadsToAdd[i])
  }

  // Add ignores config if not already present
  const hasIgnores = exportedArray.value.elements.some(
    (element) =>
      element &&
      element.type === 'ObjectExpression' &&
      element.properties &&
      element.properties.some(
        (prop) =>
          prop.type === 'ObjectProperty' &&
          prop.key &&
          prop.key.type === 'Identifier' &&
          prop.key.name === 'ignores'
      )
  )

  if (!hasIgnores) {
    const ignoresConfig = j.objectExpression([
      j.property(
        'init',
        j.identifier('ignores'),
        j.arrayExpression([
          j.literal('node_modules/**'),
          j.literal('.next/**'),
          j.literal('out/**'),
          j.literal('build/**'),
          j.literal('next-env.d.ts'),
        ])
      ),
    ])
    exportedArray.value.elements.push(ignoresConfig)
  }

  // Generate the updated code
  const updatedContent = root.toSource()

  if (updatedContent !== configContent) {
    // Validate the generated code by parsing it
    try {
      const validateJ = createParserFromPath(configPath)
      validateJ(updatedContent) // This will throw if the syntax is invalid
    } catch (parseError) {
      console.error(
        `   Generated code has invalid syntax: ${parseError instanceof Error ? parseError.message : parseError}`
      )
      console.error('   Skipping update to prevent breaking the config file')
      return false
    }

    // Create backup of original file
    const backupPath = `${configPath}.backup-${Date.now()}`
    try {
      writeFileSync(backupPath, configContent)
    } catch (backupError) {
      console.warn(`   Warning: Could not create backup file: ${backupError}`)
    }

    try {
      writeFileSync(configPath, updatedContent)
      console.log(
        `   Updated ${path.basename(configPath)} with Next.js configurations`
      )

      // Remove backup on success
      try {
        if (existsSync(backupPath)) {
          unlinkSync(backupPath)
        }
      } catch (cleanupError) {
        console.warn(
          `   Warning: Could not remove backup file ${backupPath}: ${cleanupError}`
        )
      }

      return true
    } catch (error) {
      console.error(`   Error writing config file: ${error}`)

      // Restore from backup on failure
      try {
        if (existsSync(backupPath)) {
          writeFileSync(configPath, readFileSync(backupPath, 'utf8'))
          console.log('   Restored original config from backup')
        }
      } catch (restoreError) {
        console.error(`   Error restoring backup: ${restoreError}`)
      }

      return false
    }
  }

  // If nothing changed but configs are present, that's still success
  if (hasNextConfigs) {
    console.log('   Next.js ESLint configs already present in flat config')
    return true
  }

  return true
}

function updatePackageJsonScripts(packageJsonContent: string): {
  updated: boolean
  content: string
} {
  try {
    const packageJson = JSON.parse(packageJsonContent)
    let needsUpdate = false

    if (!packageJson.scripts) {
      packageJson.scripts = {}
    }

    // Process all scripts that contain "next lint"
    for (const scriptName in packageJson.scripts) {
      const scriptValue = packageJson.scripts[scriptName]
      if (
        typeof scriptValue === 'string' &&
        scriptValue.includes('next lint')
      ) {
        // Replace "next lint" with "eslint" and handle special arguments
        const updatedScript = scriptValue.replace(
          /\bnext\s+lint\b([^&|;]*)/gi,
          (_match, args = '') => {
            // Track whether we need a trailing space before operators
            let trailingSpace = ''
            if (args.endsWith(' ')) {
              trailingSpace = ' '
              args = args.trimEnd()
            }

            // Check for redirects (2>, 1>, etc.) and preserve them
            let redirect = ''
            const redirectMatch = args.match(/\s+(\d*>[>&]?.*)$/)
            if (redirectMatch) {
              redirect = ` ${redirectMatch[1]}`
              args = args.substring(0, redirectMatch.index)
            }

            // Parse arguments - handle quoted strings properly
            const argTokens = []
            let current = ''
            let inQuotes = false
            let quoteChar = ''

            for (let j = 0; j < args.length; j++) {
              const char = args[j]
              if (
                (char === '"' || char === "'") &&
                (j === 0 || args[j - 1] !== '\\')
              ) {
                if (!inQuotes) {
                  inQuotes = true
                  quoteChar = char
                  current += char
                } else if (char === quoteChar) {
                  inQuotes = false
                  quoteChar = ''
                  current += char
                } else {
                  current += char
                }
              } else if (char === ' ' && !inQuotes) {
                if (current) {
                  argTokens.push(current)
                  current = ''
                }
              } else {
                current += char
              }
            }
            if (current) {
              argTokens.push(current)
            }

            const eslintArgs = []
            const paths = []

            for (let i = 0; i < argTokens.length; i++) {
              const token = argTokens[i]

              if (token === '--strict') {
                eslintArgs.push('--max-warnings', '0')
              } else if (token === '--dir' && i + 1 < argTokens.length) {
                paths.push(argTokens[++i])
              } else if (token === '--file' && i + 1 < argTokens.length) {
                paths.push(argTokens[++i])
              } else if (token === '--rulesdir' && i + 1 < argTokens.length) {
                // Skip rulesdir and its value
                i++
              } else if (token === '--ext' && i + 1 < argTokens.length) {
                // Skip ext and its value
                i++
              } else if (token.startsWith('--')) {
                // Keep other flags and their values
                eslintArgs.push(token)
                if (
                  i + 1 < argTokens.length &&
                  !argTokens[i + 1].startsWith('--')
                ) {
                  eslintArgs.push(argTokens[++i])
                }
              } else {
                // Positional arguments (paths)
                paths.push(token)
              }
            }

            // Build the result
            let result = 'eslint'
            if (eslintArgs.length > 0) {
              result += ` ${eslintArgs.join(' ')}`
            }

            // Add paths or default to .
            if (paths.length > 0) {
              result += ` ${paths.join(' ')}`
            } else {
              result += ' .'
            }

            // Add redirect if present
            result += redirect

            // Add back trailing space if we had one
            result += trailingSpace

            return result
          }
        )

        if (updatedScript !== scriptValue) {
          packageJson.scripts[scriptName] = updatedScript
          needsUpdate = true
          console.log(
            `   Updated script "${scriptName}": "${scriptValue}" → "${updatedScript}"`
          )

          // Note about unsupported flags
          if (scriptValue.includes('--rulesdir')) {
            console.log(`   Note: --rulesdir is not supported in ESLint v9`)
          }
          if (scriptValue.includes('--ext')) {
            console.log(`   Note: --ext is not needed in ESLint v9 flat config`)
          }
        }
      }
    }

    // Ensure required devDependencies exist
    if (!packageJson.devDependencies) {
      packageJson.devDependencies = {}
    }

    // Check if eslint exists in either dependencies or devDependencies
    if (
      !packageJson.devDependencies.eslint &&
      !packageJson.dependencies?.eslint
    ) {
      packageJson.devDependencies.eslint = '^9'
      needsUpdate = true
    }

    // Check if eslint-config-next exists in either dependencies or devDependencies
    if (
      !packageJson.devDependencies['eslint-config-next'] &&
      !packageJson.dependencies?.['eslint-config-next']
    ) {
      // Use the same version as next if available
      const nextVersion =
        packageJson.dependencies?.next || packageJson.devDependencies?.next
      packageJson.devDependencies['eslint-config-next'] =
        nextVersion || 'latest'
      needsUpdate = true
    }

    // Bump eslint to v9 for full Flat config support
    if (
      packageJson.dependencies?.['eslint'] &&
      semver.lt(
        semver.minVersion(packageJson.dependencies['eslint'])?.version ??
          '0.0.0',
        '9.0.0'
      )
    ) {
      packageJson.dependencies['eslint'] = '^9'
      needsUpdate = true
    }
    if (
      packageJson.devDependencies?.['eslint'] &&
      semver.lt(
        semver.minVersion(packageJson.devDependencies['eslint'])?.version ??
          '0.0.0',
        '9.0.0'
      )
    ) {
      packageJson.devDependencies['eslint'] = '^9'
      needsUpdate = true
    }

    // Remove @eslint/eslintrc if it exists since we no longer use FlatCompat
    if (packageJson.devDependencies?.['@eslint/eslintrc']) {
      delete packageJson.devDependencies['@eslint/eslintrc']
      needsUpdate = true
    }
    if (packageJson.dependencies?.['@eslint/eslintrc']) {
      delete packageJson.dependencies['@eslint/eslintrc']
      needsUpdate = true
    }

    const updatedContent = `${JSON.stringify(packageJson, null, 2)}\n`
    return { updated: needsUpdate, content: updatedContent }
  } catch (error) {
    console.error('Error updating package.json:', error)
    return { updated: false, content: packageJsonContent }
  }
}

export default function transformer(
  files: string[],
  options: TransformerOptions = {}
): void {
  // The codemod CLI passes arguments as an array for consistency with file-based transforms,
  // but project-level transforms like this one only process a single directory.
  // Usage: npx @next/codemod next-lint-to-eslint-cli <project-directory>
  const dir = files[0]
  if (!dir) {
    console.error('Error: Please specify a directory path')
    return
  }

  // Allow skipping installation via option
  const skipInstall = options.skipInstall === true

  const projectRoot = path.resolve(dir)
  const packageJsonPath = path.join(projectRoot, 'package.json')

  if (!existsSync(packageJsonPath)) {
    console.error('Error: package.json not found in the specified directory')
    return
  }

  const isTypeScript = detectTypeScript(projectRoot)

  console.log('Migrating from next lint to the ESLint CLI...')

  // Check for existing ESLint config
  const existingConfig = findExistingEslintConfig(projectRoot)

  // If no existing ESLint config found, create a new one.
  if (existingConfig.exists === false) {
    // Create new ESLint flat config
    const eslintConfigPath = path.join(projectRoot, 'eslint.config.mjs')
    const template = isTypeScript
      ? ESLINT_CONFIG_TEMPLATE_TYPESCRIPT
      : ESLINT_CONFIG_TEMPLATE_JAVASCRIPT

    try {
      writeFileSync(eslintConfigPath, template)
      console.log(`   Created ${path.basename(eslintConfigPath)}`)
    } catch (error) {
      console.error('   Error creating ESLint config:', error)
    }
  } else {
    let eslintConfigFilename = path.basename(existingConfig.path)
    let eslintConfigPath = existingConfig.path

    // If legacy config found, run ESLint migration tool first. It will
    // use FlatCompat, so will continue to migrate using Flat config format.
    if (existingConfig.isLegacy && existingConfig.path) {
      console.log(`   Found legacy ESLint config: ${eslintConfigFilename}`)

      // Run npx @eslint/migrate-config
      const command = `npx @eslint/migrate-config ${existingConfig.path}`
      console.log(`   Running "${command}" to convert legacy config...`)
      try {
        execSync(command, {
          cwd: projectRoot,
          stdio: 'pipe',
        })

        // The migration tool creates eslint.config.mjs by default
        const outputPath = path.join(projectRoot, 'eslint.config.mjs')
        if (!existsSync(outputPath)) {
          throw new Error(
            `Failed to find the expected output file "${outputPath}" generated by the migration tool.`
          )
        }

        // Use generated config will have FlatCompat, so continue to apply
        // the next steps to it.
        eslintConfigPath = outputPath
        eslintConfigFilename = path.basename(eslintConfigPath)
      } catch (cause) {
        throw new Error(
          `Failed to run "${command}" to migrate the legacy ESLint config "${eslintConfigFilename}".\n` +
            `Please try the migration to Flat config manually.\n` +
            `Learn more: https://eslint.org/docs/latest/use/configure/migration-guide`,
          { cause }
        )
      }
    }

    console.log(`   Found existing ESLint Flat config: ${eslintConfigFilename}`)

    // First try to replace FlatCompat usage if present
    replaceFlatCompatInConfig(eslintConfigPath)

    // Always try to update flat config with Next.js configurations
    // regardless of whether FlatCompat was found
    const updated = updateExistingFlatConfig(eslintConfigPath, isTypeScript)

    if (!updated) {
      console.log('   Could not automatically update the existing flat config.')
      console.log(
        '   Please manually ensure your ESLint config includes the Next.js configurations'
      )
    }
  }

  const packageJsonContent = readFileSync(packageJsonPath, 'utf8')
  const result = updatePackageJsonScripts(packageJsonContent)

  if (result.updated) {
    try {
      writeFileSync(packageJsonPath, result.content)
      console.log('Updated package.json scripts and dependencies')

      // Parse the updated package.json to find new dependencies
      const updatedPackageJson = JSON.parse(result.content)
      const originalPackageJson = JSON.parse(packageJsonContent)

      const newDependencies: string[] = []

      // Check for new devDependencies
      if (updatedPackageJson.devDependencies) {
        for (const [pkg, version] of Object.entries(
          updatedPackageJson.devDependencies
        )) {
          if (
            !originalPackageJson.devDependencies?.[pkg] &&
            !originalPackageJson.dependencies?.[pkg]
          ) {
            newDependencies.push(`${pkg}@${version}`)
          }
        }
      }

      // Install new dependencies if any were added
      if (newDependencies.length > 0) {
        if (skipInstall) {
          console.log('\nNew dependencies added to package.json:')
          newDependencies.forEach((dep) => console.log(`   - ${dep}`))
          console.log(`Please run: ${getPkgManager(projectRoot)} install`)
        } else {
          console.log('\nInstalling new dependencies...')
          try {
            const packageManager = getPkgManager(projectRoot)
            console.log(`   Using ${packageManager}...`)

            installPackages(newDependencies, {
              packageManager,
              dev: true,
              silent: false,
            })

            console.log('   Dependencies installed successfully!')
          } catch (_error) {
            console.error('   Failed to install dependencies automatically.')
            console.error(
              `   Please run: ${getPkgManager(projectRoot)} install`
            )
          }
        }
      }
    } catch (error) {
      console.error('Error writing package.json:', error)
    }
  }

  console.log('\nMigration complete! Your project now uses the ESLint CLI.')
}
Quest for Codev2.0.0
/
SIGN IN