Skip to content

Commit 09bfb3e

Browse files
committed
feat: add a codemod to migrate from the deprecated "next lint" command
1 parent f0edde9 commit 09bfb3e

File tree

10 files changed

+816
-9
lines changed

10 files changed

+816
-9
lines changed

docs/01-app/02-guides/upgrading/codemods.mdx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,78 @@ Replacing `<transform>` and `<path>` with appropriate values.
2424

2525
## Codemods
2626

27+
### 16.0
28+
29+
#### Migrate from `next lint` to ESLint CLI
30+
31+
##### `next-lint-to-eslint-cli`
32+
33+
```bash filename="Terminal"
34+
npx @next/codemod@latest next-lint-to-eslint-cli .
35+
```
36+
37+
This codemod migrates projects from using `next lint` to using the ESLint CLI using your local ESLint config. It:
38+
39+
- Creates an `eslint.config.mjs` file with Next.js recommended configurations
40+
- Updates package.json scripts to use `eslint .` instead of `next lint`
41+
- Adds necessary ESLint dependencies to package.json
42+
- Preserves existing ESLint configurations when found
43+
44+
For example:
45+
46+
```json filename="package.json"
47+
{
48+
"scripts": {
49+
"lint": "next lint"
50+
}
51+
}
52+
```
53+
54+
Becomes:
55+
56+
```json filename="package.json"
57+
{
58+
"scripts": {
59+
"lint": "eslint ."
60+
},
61+
"devDependencies": {
62+
"eslint": "^9",
63+
"eslint-config-next": "latest",
64+
"@eslint/eslintrc": "^3"
65+
}
66+
}
67+
```
68+
69+
And creates:
70+
71+
```js filename="eslint.config.mjs"
72+
import { dirname } from 'path'
73+
import { fileURLToPath } from 'url'
74+
import { FlatCompat } from '@eslint/eslintrc'
75+
76+
const __filename = fileURLToPath(import.meta.url)
77+
const __dirname = dirname(__filename)
78+
79+
const compat = new FlatCompat({
80+
baseDirectory: __dirname,
81+
})
82+
83+
const eslintConfig = [
84+
...compat.extends('next/core-web-vitals', 'next/typescript'),
85+
{
86+
ignores: [
87+
'node_modules/**',
88+
'.next/**',
89+
'out/**',
90+
'build/**',
91+
'next-env.d.ts',
92+
],
93+
},
94+
]
95+
96+
export default eslintConfig
97+
```
98+
2799
### 15.0
28100
29101
#### Transform App Router Route Segment Config `runtime` value from `experimental-edge` to `edge`

packages/next-codemod/bin/transform.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ export async function runTransform(
113113
return require(transformerPath).default(filesExpanded, options)
114114
}
115115

116+
if (transformer === 'next-lint-to-eslint-cli') {
117+
// next-lint-to-eslint-cli transform doesn't use jscodeshift directly
118+
return require(transformerPath).default(filesExpanded, options)
119+
}
120+
116121
let args = []
117122

118123
const { dry, print, runInBand, jscodeshift, verbose } = options

packages/next-codemod/lib/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,9 @@ export const TRANSFORMER_INQUIRER_CHOICES = [
121121
value: 'next-experimental-turbo-to-turbopack',
122122
version: '10.0.0',
123123
},
124+
{
125+
title: 'Migrate from `next lint` to the ESLint CLI',
126+
value: 'next-lint-to-eslint-cli',
127+
version: '16.0.0',
128+
},
124129
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "my-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "next lint"
10+
},
11+
"dependencies": {
12+
"react": "^18.3.0",
13+
"react-dom": "^18.3.0",
14+
"next": "15.0.0"
15+
},
16+
"devDependencies": {
17+
"typescript": "^5",
18+
"@types/node": "^20",
19+
"@types/react": "^19",
20+
"@types/react-dom": "^19"
21+
}
22+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "my-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "eslint ."
10+
},
11+
"dependencies": {
12+
"react": "^18.3.0",
13+
"react-dom": "^18.3.0",
14+
"next": "15.0.0"
15+
},
16+
"devDependencies": {
17+
"typescript": "^5",
18+
"@types/node": "^20",
19+
"@types/react": "^19",
20+
"@types/react-dom": "^19",
21+
"eslint": "^9",
22+
"eslint-config-next": "15.0.0",
23+
"@eslint/eslintrc": "^3"
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "my-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "next lint"
10+
},
11+
"dependencies": {
12+
"react": "^18.3.0",
13+
"react-dom": "^18.3.0",
14+
"next": "15.0.0"
15+
},
16+
"devDependencies": {
17+
"eslint": "^8.0.0",
18+
"eslint-config-next": "15.0.0"
19+
}
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "my-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "eslint ."
10+
},
11+
"dependencies": {
12+
"react": "^18.3.0",
13+
"react-dom": "^18.3.0",
14+
"next": "15.0.0"
15+
},
16+
"devDependencies": {
17+
"eslint": "^8.0.0",
18+
"eslint-config-next": "15.0.0",
19+
"@eslint/eslintrc": "^3"
20+
}
21+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/* global jest */
2+
jest.autoMockOff()
3+
const fs = require('fs')
4+
const path = require('path')
5+
const { tmpdir } = require('os')
6+
const transformer = require('../next-lint-to-eslint-cli').default
7+
8+
describe('next-lint-to-eslint-cli', () => {
9+
let tempDir
10+
11+
beforeEach(() => {
12+
// Create a unique temp directory for each test
13+
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'codemod-test-'))
14+
})
15+
16+
afterEach(() => {
17+
// Clean up temp directory
18+
if (tempDir && fs.existsSync(tempDir)) {
19+
fs.rmSync(tempDir, { recursive: true, force: true })
20+
}
21+
})
22+
23+
test('transforms correctly using basic data', () => {
24+
// Read input fixture
25+
const inputPath = path.join(__dirname, '../__testfixtures__/next-lint-to-eslint-cli/basic.input.json')
26+
const expectedOutputPath = path.join(__dirname, '../__testfixtures__/next-lint-to-eslint-cli/basic.output.json')
27+
28+
const inputContent = fs.readFileSync(inputPath, 'utf8')
29+
const expectedOutput = fs.readFileSync(expectedOutputPath, 'utf8')
30+
31+
// Set up test project
32+
const packageJsonPath = path.join(tempDir, 'package.json')
33+
const tsConfigPath = path.join(tempDir, 'tsconfig.json')
34+
35+
fs.writeFileSync(packageJsonPath, inputContent)
36+
fs.writeFileSync(tsConfigPath, '{}') // Create tsconfig.json to indicate TypeScript project
37+
38+
// Run transformer
39+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
40+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
41+
42+
transformer([tempDir], {})
43+
44+
// Check package.json was updated correctly
45+
const actualPackageJson = fs.readFileSync(packageJsonPath, 'utf8')
46+
expect(JSON.parse(actualPackageJson)).toEqual(JSON.parse(expectedOutput))
47+
48+
// Check eslint.config.mjs was created
49+
const eslintConfigPath = path.join(tempDir, 'eslint.config.mjs')
50+
expect(fs.existsSync(eslintConfigPath)).toBe(true)
51+
52+
const eslintConfig = fs.readFileSync(eslintConfigPath, 'utf8')
53+
expect(eslintConfig).toContain('next/core-web-vitals')
54+
expect(eslintConfig).toContain('next/typescript')
55+
expect(eslintConfig).toContain('ignores:')
56+
57+
consoleSpy.mockRestore()
58+
consoleErrorSpy.mockRestore()
59+
})
60+
61+
test('transforms correctly using existing-eslint data', () => {
62+
// Read input fixture
63+
const inputPath = path.join(__dirname, '../__testfixtures__/next-lint-to-eslint-cli/existing-eslint.input.json')
64+
const expectedOutputPath = path.join(__dirname, '../__testfixtures__/next-lint-to-eslint-cli/existing-eslint.output.json')
65+
66+
const inputContent = fs.readFileSync(inputPath, 'utf8')
67+
const expectedOutput = fs.readFileSync(expectedOutputPath, 'utf8')
68+
69+
// Set up test project
70+
const packageJsonPath = path.join(tempDir, 'package.json')
71+
const existingEslintPath = path.join(tempDir, '.eslintrc.json')
72+
73+
fs.writeFileSync(packageJsonPath, inputContent)
74+
fs.writeFileSync(existingEslintPath, '{"extends": ["next"]}') // Create existing ESLint config
75+
76+
// Run transformer
77+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
78+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
79+
80+
transformer([tempDir], {})
81+
82+
// Check package.json was updated correctly
83+
const actualPackageJson = fs.readFileSync(packageJsonPath, 'utf8')
84+
expect(JSON.parse(actualPackageJson)).toEqual(JSON.parse(expectedOutput))
85+
86+
// Check that no new eslint.config.mjs was created (existing config should be preserved)
87+
const eslintConfigPath = path.join(tempDir, 'eslint.config.mjs')
88+
expect(fs.existsSync(eslintConfigPath)).toBe(false)
89+
90+
// Check that existing config still exists
91+
expect(fs.existsSync(existingEslintPath)).toBe(true)
92+
93+
consoleSpy.mockRestore()
94+
consoleErrorSpy.mockRestore()
95+
})
96+
})

0 commit comments

Comments
 (0)