Repository: vercel/next.js
Pull request: Update next-lint-to-eslint-cli to support `FlatCompat.config`

ESLint `FlatCompat` can be used in two ways:

```js
compat.extends('next/core-web-vitals', 'next/typescript')
```

```js
compat.config({
  extends: ['next/core-web-vitals', 'next/typescript'],
})
```

Although using `.extends()` is recommended for migration, and the ESLint migration tool also uses that, it is possible to use `.config()`, so handle them as well for the migration.

Confirmed on local apps as well.

Closes NEXT-4742

Changed files:
filename: packages/next-codemod/transforms/__testfixtures__/next-lint-to-eslint-cli/flat-config-flat-compat-with-other-compat/eslint.config.mjs
patch: @@ -0,0 +1,30 @@
+import { dirname } from 'path'
+import { fileURLToPath } from 'url'
+import { FlatCompat } from '@eslint/eslintrc'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+const compat = new FlatCompat({
+  baseDirectory: __dirname,
+})
+
+const eslintConfig = [
+  ...compat.config({
+    extends: ['next/core-web-vitals', 'next/typescript'],
+  }),
+  ...compat.config({
+    extends: ['foo', 'bar'],
+  }),
+  {
+    ignores: [
+      'node_modules/**',
+      '.next/**',
+      'out/**',
+      'build/**',
+      'next-env.d.ts',
+    ],
+  },
+]
+
+export default eslintConfig

filename: packages/next-codemod/transforms/__testfixtures__/next-lint-to-eslint-cli/flat-config-flat-compat-with-other-compat/package.json
patch: @@ -0,0 +1,26 @@
+{
+  "scripts": {
+    "lint": "next lint --fix --dir src --dir pages",
+    "lint:strict": "next lint --strict",
+    "lint:ci": "next lint --quiet --output-file lint-results.json",
+    "precommit": "next lint --fix && npm test",
+    "test": "jest && next lint",
+    "complex": "npm run build && next lint --dir . --ext .js,.jsx,.ts,.tsx 2>/dev/null",
+    "pipe": "next lint | tee lint.log",
+    "redirect": "next lint > output.txt 2>&1",
+    "multi": "next lint; next build; next lint --fix"
+  },
+  "dependencies": {
+    "react": "^19",
+    "react-dom": "^19",
+    "next": "^16"
+  },
+  "devDependencies": {
+    "typescript": "^5",
+    "@types/node": "^20",
+    "@types/react": "^19",
+    "@types/react-dom": "^19",
+    "eslint": "^9",
+    "eslint-config-next": "^16"
+  }
+}

filename: packages/next-codemod/transforms/__tests__/next-lint-to-eslint-cli.test.js
patch: @@ -307,6 +307,89 @@ 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')
+      // Check BEFORE state
+      const beforeConfig = fs.readFileSync(
+        path.join(testDir, 'eslint.config.mjs'),
+        'utf8'
+      )
+
+      expect(beforeConfig).toMatchInlineSnapshot(`
+       "import { dirname } from 'path'
+       import { fileURLToPath } from 'url'
+       import { FlatCompat } from '@eslint/eslintrc'
+
+       const __filename = fileURLToPath(import.meta.url)
+       const __dirname = dirname(__filename)
+
+       const compat = new FlatCompat({
+         baseDirectory: __dirname,
+       })
+
+       const eslintConfig = [
+         ...compat.config({
+           extends: ['next/core-web-vitals', 'next/typescript'],
+         }),
+         ...compat.config({
+           extends: ['foo', 'bar'],
+         }),
+         {
+           ignores: [
+             'node_modules/**',
+             '.next/**',
+             'out/**',
+             'build/**',
+             'next-env.d.ts',
+           ],
+         },
+       ]
+
+       export default eslintConfig
+       "
+      `)
+
+      // Run transformer
+      transformer([testDir], { skipInstall: true })
+
+      // Check AFTER state
+      const actualConfig = fs.readFileSync(
+        path.join(testDir, 'eslint.config.mjs'),
+        'utf8'
+      )
+      expect(actualConfig).toMatchInlineSnapshot(`
+       "import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
+       import nextTypescript from "eslint-config-next/typescript";
+       import { dirname } from 'path'
+       import { fileURLToPath } from 'url'
+       import { FlatCompat } from '@eslint/eslintrc'
+
+       const __filename = fileURLToPath(import.meta.url)
+       const __dirname = dirname(__filename)
+
+       const compat = new FlatCompat({
+         baseDirectory: __dirname,
+       })
+
+       const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, ...compat.config({
+         extends: ['foo', 'bar']
+       }), {
+         ignores: [
+           'node_modules/**',
+           '.next/**',
+           'out/**',
+           'build/**',
+           'next-env.d.ts',
+         ],
+       }]
+
+       export default eslintConfig
+       "
+      `)
+    })
+  })
+
   describe('legacy-config', () => {
     it('should migrate legacy config to flat config and transform package.json', async () => {
       const testDir = path.join(fixturesDir, 'legacy-config')

filename: packages/next-codemod/transforms/next-lint-to-eslint-cli.ts
patch: @@ -136,6 +136,7 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
   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' &&
@@ -157,6 +158,46 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
         }
       })
     }
+
+    // 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/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 (!needsNextVitals && !needsNextTs && otherConfigs.length === 0) {
@@ -258,6 +299,8 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
   // 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' &&
@@ -304,6 +347,101 @@ function replaceFlatCompatInConfig(configPath: string): boolean {
         }
       }
     }
+
+    // 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/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)