Skip to content

Commit 506bd74

Browse files
committed
feat(migrate): skip ESLint migration when @nuxt/eslint is present
The @nuxt/eslint module wires ESLint into a Nuxt-specific flow that Vite+ can't migrate cleanly today: the generated vite.config.ts ends up referencing @nuxt/eslint-plugin (no longer installed), the user's nuxt.config.ts still loads the removed module via `modules: [...]`, and `nuxt dev` fails to boot until the user untangles it by hand. Detect @nuxt/eslint in the project's package.json (root or any workspace package) and skip ESLint migration entirely with a clear warning that tells the user how to migrate manually. Their ESLint setup — eslint itself, eslint.config.mjs, plugins, the `eslint` script — is preserved verbatim. vite-plus is still added so the rest of the toolchain adoption proceeds. Verified end-to-end against https://github.com/why-reproductions-are-required/vp-migrate-nuxt-eslint (a minimal Nuxt 4 + @nuxt/eslint reproduction).
1 parent f6f956a commit 506bd74

7 files changed

Lines changed: 188 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Stand-in for the auto-generated `.nuxt/eslint.config.mjs` flow.
2+
// We don't actually re-export from `.nuxt/` here so the snap-test
3+
// sandbox can load this file without running `nuxt prepare` first.
4+
export default [
5+
{
6+
rules: {
7+
'no-unused-vars': 'error',
8+
},
9+
},
10+
];
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "migration-eslint-nuxt-skip",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"build": "nuxt build",
7+
"dev": "nuxt dev",
8+
"lint": "eslint ."
9+
},
10+
"dependencies": {
11+
"nuxt": "^4.0.0"
12+
},
13+
"devDependencies": {
14+
"@nuxt/eslint": "^1.0.0",
15+
"eslint": "^9.0.0",
16+
"vite": "^7.0.0"
17+
}
18+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
> vp migrate --no-interactive # @nuxt/eslint detected — ESLint migration is skipped with a warning
2+
3+
@nuxt/eslint detected — automatic ESLint migration is skipped. @nuxt/eslint wires ESLint into a framework-specific flow that Vite+ cannot migrate cleanly yet. Your ESLint setup is preserved. To migrate manually, remove @nuxt/eslint from package.json and re-run `vp migrate`.
4+
◇ Migrated . to Vite+
5+
• Node <semver> pnpm <semver>
6+
• 2 config updates applied
7+
8+
> cat package.json # eslint, @nuxt/eslint, and eslint.config.mjs are preserved
9+
{
10+
"name": "migration-eslint-nuxt-skip",
11+
"private": true,
12+
"type": "module",
13+
"scripts": {
14+
"build": "nuxt build",
15+
"dev": "nuxt dev",
16+
"lint": "eslint .",
17+
"prepare": "vp config"
18+
},
19+
"dependencies": {
20+
"nuxt": "^4.0.0"
21+
},
22+
"devDependencies": {
23+
"@nuxt/eslint": "^1.0.0",
24+
"eslint": "^9.0.0",
25+
"vite": "catalog:",
26+
"vite-plus": "catalog:"
27+
},
28+
"packageManager": "pnpm@<semver>"
29+
}
30+
31+
> test -f eslint.config.mjs # eslint config file is NOT deleted
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"commands": [
3+
"vp migrate --no-interactive # @nuxt/eslint detected — ESLint migration is skipped with a warning",
4+
"cat package.json # eslint, @nuxt/eslint, and eslint.config.mjs are preserved",
5+
"test -f eslint.config.mjs # eslint config file is NOT deleted"
6+
]
7+
}

packages/cli/src/migration/__tests__/migrator.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const {
2828
addFrameworkShim,
2929
injectCreateDefaultTemplate,
3030
rewriteEslintPackageJson,
31+
detectIncompatibleEslintIntegration,
3132
} = await import('../migrator.js');
3233

3334
describe('rewritePackageJson', () => {
@@ -552,6 +553,57 @@ describe('rewriteEslintPackageJson', () => {
552553
});
553554
});
554555

556+
function writePkgAt(dir: string, pkg: object): void {
557+
fs.mkdirSync(dir, { recursive: true });
558+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg));
559+
}
560+
561+
describe('detectIncompatibleEslintIntegration', () => {
562+
let tmpDir: string;
563+
564+
beforeEach(() => {
565+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-incompat-eslint-'));
566+
});
567+
568+
afterEach(() => {
569+
fs.rmSync(tmpDir, { recursive: true, force: true });
570+
});
571+
572+
it('returns "@nuxt/eslint" when listed in devDependencies', () => {
573+
writePkgAt(tmpDir, { devDependencies: { '@nuxt/eslint': '^1.0.0' } });
574+
expect(detectIncompatibleEslintIntegration(tmpDir)).toBe('@nuxt/eslint');
575+
});
576+
577+
it('returns "@nuxt/eslint" when listed in dependencies', () => {
578+
writePkgAt(tmpDir, { dependencies: { '@nuxt/eslint': '^1.0.0' } });
579+
expect(detectIncompatibleEslintIntegration(tmpDir)).toBe('@nuxt/eslint');
580+
});
581+
582+
it('detects when @nuxt/eslint lives in a workspace package, not the root', () => {
583+
writePkgAt(tmpDir, { name: 'root' });
584+
writePkgAt(path.join(tmpDir, 'packages/app'), {
585+
name: 'app',
586+
devDependencies: { '@nuxt/eslint': '^1.0.0' },
587+
});
588+
expect(
589+
detectIncompatibleEslintIntegration(tmpDir, [
590+
{ name: 'app', path: 'packages/app', isTemplatePackage: false },
591+
]),
592+
).toBe('@nuxt/eslint');
593+
});
594+
595+
it('returns undefined when @nuxt/eslint is absent', () => {
596+
writePkgAt(tmpDir, {
597+
devDependencies: { eslint: '^9.0.0', '@nuxt/kit': '^3.0.0' },
598+
});
599+
expect(detectIncompatibleEslintIntegration(tmpDir)).toBeUndefined();
600+
});
601+
602+
it('returns undefined when package.json is missing', () => {
603+
expect(detectIncompatibleEslintIntegration(tmpDir)).toBeUndefined();
604+
});
605+
});
606+
555607
describe('parseNvmrcVersion', () => {
556608
it('strips v prefix', () => {
557609
expect(parseNvmrcVersion('v20.5.0')).toBe('20.5.0');

packages/cli/src/migration/bin.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
confirmPrettierMigration,
4848
detectEslintProject,
4949
detectFramework,
50+
detectIncompatibleEslintIntegration,
5051
detectNodeVersionManagerFile,
5152
detectPrettierProject,
5253
hasFrameworkShim,
@@ -60,6 +61,7 @@ import {
6061
promptPrettierMigration,
6162
rewriteMonorepo,
6263
rewriteStandaloneProject,
64+
warnIncompatibleEslintIntegration,
6365
warnLegacyEslintConfig,
6466
warnPackageLevelEslint,
6567
warnPackageLevelPrettier,
@@ -374,8 +376,17 @@ async function collectMigrationPlan(
374376

375377
// 7. ESLint detection + prompt
376378
const eslintProject = detectEslintProject(rootDir, packages);
379+
const incompatibleEslintIntegration = detectIncompatibleEslintIntegration(rootDir, packages);
377380
let migrateEslint = false;
378-
if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) {
381+
if (incompatibleEslintIntegration) {
382+
// e.g. `@nuxt/eslint` — skip the entire ESLint migration; preserve
383+
// the user's current ESLint setup and let them migrate by hand.
384+
warnIncompatibleEslintIntegration(incompatibleEslintIntegration);
385+
} else if (
386+
eslintProject.hasDependency &&
387+
!eslintProject.configFile &&
388+
eslintProject.legacyConfigFile
389+
) {
379390
warnLegacyEslintConfig(eslintProject.legacyConfigFile);
380391
} else if (eslintProject.hasDependency && eslintProject.configFile) {
381392
migrateEslint = await confirmEslintMigration(options.interactive);

packages/cli/src/migration/migrator.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2951,6 +2951,59 @@ export function warnPackageLevelEslint() {
29512951
);
29522952
}
29532953

2954+
// Framework-ESLint integration packages we can't migrate cleanly today.
2955+
// When any of these is present, the ESLint migration is skipped entirely
2956+
// — the user's ESLint setup stays intact and they get told how to proceed
2957+
// manually.
2958+
//
2959+
// `@nuxt/eslint` is a Nuxt module that loads ESLint at runtime via the
2960+
// dev server and writes a generated config to `.nuxt/eslint.config.mjs`,
2961+
// which the user's `eslint.config.mjs` re-exports. Migrating it
2962+
// produces a broken state: `vite.config.ts` references `@nuxt/eslint-plugin`
2963+
// (no longer installed) and `nuxt.config.ts` still tries to load the
2964+
// removed module. Track at https://github.com/voidzero-dev/vite-plus/issues
2965+
// once an issue exists.
2966+
const INCOMPATIBLE_ESLINT_INTEGRATIONS = ['@nuxt/eslint'] as const;
2967+
2968+
/**
2969+
* Detect framework-ESLint integration packages whose ESLint migration is
2970+
* known to be incompatible. Returns the offending package name, or
2971+
* `undefined` if none is present.
2972+
*/
2973+
export function detectIncompatibleEslintIntegration(
2974+
projectPath: string,
2975+
packages?: WorkspacePackage[],
2976+
): string | undefined {
2977+
const candidates = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))];
2978+
for (const candidate of candidates) {
2979+
const pkgJsonPath = path.join(candidate, 'package.json');
2980+
if (!fs.existsSync(pkgJsonPath)) {
2981+
continue;
2982+
}
2983+
let pkg: { devDependencies?: Record<string, string>; dependencies?: Record<string, string> };
2984+
try {
2985+
pkg = readJsonFile(pkgJsonPath) as typeof pkg;
2986+
} catch {
2987+
continue;
2988+
}
2989+
for (const name of INCOMPATIBLE_ESLINT_INTEGRATIONS) {
2990+
if (pkg.devDependencies?.[name] || pkg.dependencies?.[name]) {
2991+
return name;
2992+
}
2993+
}
2994+
}
2995+
return undefined;
2996+
}
2997+
2998+
export function warnIncompatibleEslintIntegration(name: string): void {
2999+
prompts.log.warn(
3000+
`${name} detected — automatic ESLint migration is skipped. ` +
3001+
`${name} wires ESLint into a framework-specific flow that Vite+ cannot migrate cleanly yet. ` +
3002+
'Your ESLint setup is preserved. ' +
3003+
`To migrate manually, remove ${name} from package.json and re-run \`vp migrate\`.`,
3004+
);
3005+
}
3006+
29543007
export function warnLegacyEslintConfig(legacyConfigFile: string) {
29553008
prompts.log.warn(
29563009
`Legacy ESLint configuration detected (${legacyConfigFile}). ` +
@@ -2983,6 +3036,11 @@ export async function promptEslintMigration(
29833036
interactive: boolean,
29843037
packages?: WorkspacePackage[],
29853038
): Promise<boolean> {
3039+
const incompatible = detectIncompatibleEslintIntegration(projectPath, packages);
3040+
if (incompatible) {
3041+
warnIncompatibleEslintIntegration(incompatible);
3042+
return false;
3043+
}
29863044
const eslintProject = detectEslintProject(projectPath, packages);
29873045
if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) {
29883046
warnLegacyEslintConfig(eslintProject.legacyConfigFile);

0 commit comments

Comments
 (0)