Repository: vercel/next.js
Pull request: [codemod] fix: next-lint-to-eslint-cli did not handle `'next'` plugin

Codemod `next-lint-to-eslint-cli` did not handle `extends: ['next']` in legacy ESLint config.

x-ref: https://github.com/vercel/next.js/issues/85244#issuecomment-3482558412

Fixes #85244

Changed files:
filename: packages/next-codemod/transforms/__testfixtures__/next-lint-to-eslint-cli/legacy-config/.eslintrc.json
patch: @@ -3,6 +3,8 @@
   "root": true,
   "extends": [
     "next/core-web-vitals",
+    "next",
+    "next/typescript",
     "turbo",
     "prettier",
     "plugin:tailwindcss/recommended"

filename: packages/next-codemod/transforms/__tests__/next-lint-to-eslint-cli.test.js
patch: @@ -110,13 +110,14 @@ describe('next-lint-to-eslint-cli', () => {
         'utf8'
       )
       expect(actualConfig).toMatchInlineSnapshot(`
-       "import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
+       "import next from "eslint-config-next";
+       import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
        import nextTypescript from "eslint-config-next/typescript";
        import { defineConfig } from 'eslint/config'
        import foo from 'foo'
        import bar from 'bar'
 
-       const eslintConfig = defineConfig([...nextCoreWebVitals, ...nextTypescript, foo, bar, {
+       const eslintConfig = defineConfig([...next, ...nextCoreWebVitals, ...nextTypescript, foo, bar, {
          ignores: [
            'node_modules/**',
            '.next/**',
@@ -309,7 +310,10 @@ describe('next-lint-to-eslint-cli', () => {
 
   describe('flat-config-flat-compat-with-other-compat', () => {
     it('should replace FlatCompat config with direct imports while preserving other configs', () => {
-      const testDir = path.join(fixturesDir, 'flat-config-flat-compat-with-other-compat')
+      const testDir = path.join(
+        fixturesDir,
+        'flat-config-flat-compat-with-other-compat'
+      )
       // Check BEFORE state
       const beforeConfig = fs.readFileSync(
         path.join(testDir, 'eslint.config.mjs'),
@@ -413,6 +417,8 @@ describe('next-lint-to-eslint-cli', () => {
          "root": true,
          "extends": [
            "next/core-web-vitals",
+           "next",
+           "next/typescript",
            "turbo",
            "prettier",
            "plugin:tailwindcss/recommended"
@@ -491,7 +497,9 @@ describe('next-lint-to-eslint-cli', () => {
       )
       expect(actualConfig).toMatchInlineSnapshot(`
        "import { defineConfig, globalIgnores } from "eslint/config";
+       import next from "eslint-config-next";
        import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
+       import nextTypescript from "eslint-config-next/typescript";
        import tailwindcss from "eslint-plugin-tailwindcss";
        import tsParser from "@typescript-eslint/parser";
        import path from "node:path";
@@ -518,6 +526,8 @@ describe('next-lint-to-eslint-cli', () => {
        ]), {
            extends: [
                ...nextCoreWebVitals,
+               ...next,
+               ...nextTypescript,
                ...compat.extends("turbo"),
                ...compat.extends("prettier"),
                ...compat.extends("plugin:tailwindcss/recommended")

filename: packages/next-codemod/transforms/next-lint-to-eslint-cli.ts
patch: @@ -129,6 +129,7 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
   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[] = []
@@ -148,7 +149,9 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
       // Check arguments for all configs
       node.arguments.forEach((arg: any) => {
         if (arg.type === 'Literal' || arg.type === 'StringLiteral') {
-          if (arg.value === 'next/core-web-vitals') {
+          if (arg.value === 'next') {
+            needsNext = true
+          } else if (arg.value === 'next/core-web-vitals') {
             needsNextVitals = true
           } else if (arg.value === 'next/typescript') {
             needsNextTs = true
@@ -184,7 +187,9 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
                   element.type === 'Literal' ||
                   element.type === 'StringLiteral'
                 ) {
-                  if (element.value === 'next/core-web-vitals') {
+                  if (element.value === 'next') {
+                    needsNext = true
+                  } else if (element.value === 'next/core-web-vitals') {
                     needsNextVitals = true
                   } else if (element.value === 'next/typescript') {
                     needsNextTs = true
@@ -201,15 +206,20 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
     }
   })
 
-  if (!needsNextVitals && !needsNextTs && otherConfigs.length === 0) {
+  if (
+    !needsNext &&
+    !needsNextVitals &&
+    !needsNextTs &&
+    otherConfigs.length === 0
+  ) {
     console.warn(
       prefixes.warn,
       '   No ESLint configs found in FlatCompat usage'
     )
     return false
   }
 
-  if (!needsNextVitals && !needsNextTs) {
+  if (!needsNext && !needsNextVitals && !needsNextTs) {
     console.log('   No Next.js configs found, but preserving other configs')
   }
 
@@ -256,7 +266,16 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
   // Add new imports after the eslint/config import
   const imports = []
 
-  // Add imports in correct order: core-web-vitals first, then typescript
+  // 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(
@@ -314,7 +333,9 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
       const replacements = []
       node.argument.arguments.forEach((arg: any) => {
         if (arg.type === 'Literal' || arg.type === 'StringLiteral') {
-          if (arg.value === 'next/core-web-vitals') {
+          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'))
             )
@@ -381,7 +402,9 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
                   element.type === 'Literal' ||
                   element.type === 'StringLiteral'
                 ) {
-                  if (element.value === 'next/core-web-vitals') {
+                  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'))
                     )
@@ -464,7 +487,9 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
         const replacements = []
         prop.value.arguments.forEach((arg: any) => {
           if (arg.type === 'Literal' || arg.type === 'StringLiteral') {
-            if (arg.value === 'next/core-web-vitals') {
+            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'))
               )
@@ -570,6 +595,7 @@ function updateExistingFlatConfig(
   }
 
   // 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'
   )
@@ -646,6 +672,15 @@ function updateExistingFlatConfig(
   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(
@@ -675,6 +710,9 @@ function updateExistingFlatConfig(
   }
 
   const spreadsToAdd = []
+  if (!hasNext) {
+    spreadsToAdd.push(j.spreadElement(j.identifier('next')))
+  }
   if (!hasNextVitals) {
     spreadsToAdd.push(j.spreadElement(j.identifier('nextCoreWebVitals')))
   }