diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/eslint.config.mjs b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/eslint.config.mjs new file mode 100644 index 0000000000..006dddfcd3 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/eslint.config.mjs @@ -0,0 +1,45 @@ +// Exercises the sanitizer: +// 1. base-level `fictional/*` rule via an inline plugin namespace that +// doesn't resolve to a native Oxlint plugin nor an installed +// package — translates into `jsPlugins: ['eslint-plugin-fictional']` +// + rule under `fictional/*` (the WeakAuras-style failure shape). +// 2. an OVERRIDE that introduces a second unresolvable plugin +// (`./*.test.js` files only) — verifies the per-override sanitize +// path strips both the override's `jsPlugins` entry and its rules. +export default [ + { + plugins: { + fictional: { + rules: { + 'no-fiction': { + meta: { type: 'problem' }, + create() { + return {}; + }, + }, + }, + }, + }, + rules: { + 'fictional/no-fiction': 'warn', + }, + }, + { + files: ['**/*.test.js'], + plugins: { + 'override-only': { + rules: { + 'no-skip': { + meta: { type: 'problem' }, + create() { + return {}; + }, + }, + }, + }, + }, + rules: { + 'override-only/no-skip': 'error', + }, + }, +]; diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/package.json b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/package.json new file mode 100644 index 0000000000..20ea03c180 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-eslint-jsplugins-orphan-strip", + "scripts": { + "lint": "eslint ." + }, + "devDependencies": { + "eslint": "^9.0.0", + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/snap.txt b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/snap.txt new file mode 100644 index 0000000000..1895202e86 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/snap.txt @@ -0,0 +1,53 @@ +> vp migrate --no-interactive # orphan jsPlugin / unknown plugin / dangling rule should be stripped, with warnings +◇ Migrated . to Vite+ +• Node pnpm +• 4 config updates applied +• ESLint rules migrated to Oxlint +! Warnings: + - Stripped JS plugin reference(s) from the generated lint config: eslint-plugin-fictional, eslint-plugin-override-only. No matching package is present in this workspace, so loading them at lint time would fail. If you want their Oxlint coverage back, install each package (e.g. `vp install `) and add its name back to `lint.jsPlugins` in vite.config.ts. + +> cat vite.config.ts # lint block should NOT contain `fictional` in plugins / jsPlugins / rules +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + fmt: {}, + lint: { + "plugins": [ + "oxc", + "typescript", + "unicorn", + "react" + ], + "jsPlugins": [ + { + "name": "vite-plus", + "specifier": "vite-plus/oxlint-plugin" + } + ], + "categories": { + "correctness": "warn" + }, + "env": { + "builtin": true + }, + "rules": { + "vite-plus/prefer-vite-plus-imports": "error" + }, + "overrides": [ + { + "files": [ + "**/*.test.js" + ], + "rules": {}, + "jsPlugins": [] + } + ], + "options": { + "typeAware": true, + "typeCheck": true + } + }, +}); diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/steps.json b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/steps.json new file mode 100644 index 0000000000..7c94cba8a4 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migrate --no-interactive # orphan jsPlugin / unknown plugin / dangling rule should be stripped, with warnings", + "cat vite.config.ts # lint block should NOT contain `fictional` in plugins / jsPlugins / rules" + ] +} diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/eslint.config.mjs b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/eslint.config.mjs new file mode 100644 index 0000000000..a05dfe0506 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/eslint.config.mjs @@ -0,0 +1,31 @@ +// Inline-defined `survives` plugin — @oxlint/migrate translates it into +// `lint.jsPlugins: ["eslint-plugin-survives"]`. The package is listed +// in this fixture's package.json devDependencies, so: +// 1. The cleanup step should NOT delete `eslint-plugin-survives` +// from package.json (it's referenced by the generated jsPlugins +// array — removing it would invalidate the lint config we just +// generated). +// 2. The sanitizer should NOT strip the jsPlugins entry (the +// package is present in the workspace). +// 3. The `survives/no-fiction` rule should survive in the merged +// `lint.rules` (the `survives` namespace is backed by the kept +// jsPlugin). +export default [ + { + plugins: { + survives: { + rules: { + 'no-fiction': { + meta: { type: 'problem' }, + create() { + return {}; + }, + }, + }, + }, + }, + rules: { + 'survives/no-fiction': 'warn', + }, + }, +]; diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/package.json b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/package.json new file mode 100644 index 0000000000..3877d6ad9c --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-eslint-jsplugins-preserve", + "scripts": { + "lint": "eslint ." + }, + "devDependencies": { + "eslint": "^9.0.0", + "eslint-plugin-survives": "^1.0.0", + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt new file mode 100644 index 0000000000..b36a35cd0e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt @@ -0,0 +1,59 @@ +> vp migrate --no-interactive # plugin referenced via lint.jsPlugins must be preserved through cleanup AND sanitization +◇ Migrated . to Vite+ +• Node pnpm +• 4 config updates applied +• ESLint rules migrated to Oxlint + +> cat package.json # eslint-plugin-survives stays in devDependencies (eslint itself is removed) +{ + "name": "migration-eslint-jsplugins-preserve", + "scripts": { + "lint": "vp lint .", + "prepare": "vp config" + }, + "devDependencies": { + "eslint-plugin-survives": "^1.0.0", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@" +} + +> cat vite.config.ts # lint.jsPlugins keeps `eslint-plugin-survives`; lint.rules keeps `survives/no-fiction` +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + fmt: {}, + lint: { + "plugins": [ + "oxc", + "typescript", + "unicorn", + "react" + ], + "jsPlugins": [ + "eslint-plugin-survives", + { + "name": "vite-plus", + "specifier": "vite-plus/oxlint-plugin" + } + ], + "categories": { + "correctness": "warn" + }, + "env": { + "builtin": true + }, + "rules": { + "survives/no-fiction": "warn", + "vite-plus/prefer-vite-plus-imports": "error" + }, + "options": { + "typeAware": true, + "typeCheck": true + } + }, +}); diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/steps.json b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/steps.json new file mode 100644 index 0000000000..118f29955f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # plugin referenced via lint.jsPlugins must be preserved through cleanup AND sanitization", + "cat package.json # eslint-plugin-survives stays in devDependencies (eslint itself is removed)", + "cat vite.config.ts # lint.jsPlugins keeps `eslint-plugin-survives`; lint.rules keeps `survives/no-fiction`" + ] +} diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/eslint.config.mjs b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/eslint.config.mjs new file mode 100644 index 0000000000..55dc0b9266 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/eslint.config.mjs @@ -0,0 +1,7 @@ +export default [ + { + rules: { + 'no-unused-vars': 'error', + }, + }, +]; diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/package.json b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/package.json new file mode 100644 index 0000000000..23907b122b --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-eslint-monorepo-plugins-in-packages", + "scripts": { + "lint": "eslint ." + }, + "devDependencies": { + "eslint": "^9.0.0", + "eslint-config-airbnb": "^19.0.0", + "vite": "^7.0.0" + }, + "packageManager": "pnpm@10.18.0" +} diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/.lintstagedrc.json b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/.lintstagedrc.json new file mode 100644 index 0000000000..55ab52ae4a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.ts": "eslint --fix" +} diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/eslint.config.mjs b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/eslint.config.mjs new file mode 100644 index 0000000000..473e5c1724 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/eslint.config.mjs @@ -0,0 +1,9 @@ +// Per-workspace eslint config — should be deleted by the migration +// alongside the root config (covers the workspace-config-deletion path). +export default [ + { + rules: { + 'no-console': 'warn', + }, + }, +]; diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/package.json b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/package.json new file mode 100644 index 0000000000..8e37cdf8aa --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/app", + "scripts": { + "dev": "vite", + "lint": "eslint ." + }, + "devDependencies": { + "@typescript-eslint/parser": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0", + "eslint": "^9.0.0", + "eslint-plugin-vue": "^10.0.0", + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/lint-config/package.json b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/lint-config/package.json new file mode 100644 index 0000000000..3053b329e5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/lint-config/package.json @@ -0,0 +1,14 @@ +{ + "name": "@test/lint-config", + "scripts": { + "lint": "eslint ." + }, + "dependencies": { + "@stylistic/eslint-plugin": "^2.0.0", + "eslint-plugin-import": "^2.0.0", + "eslint-plugin-vue": "^10.0.0" + }, + "peerDependencies": { + "eslint": ">=9" + } +} diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/pnpm-workspace.yaml b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/snap.txt b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/snap.txt new file mode 100644 index 0000000000..9dc3e84469 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/snap.txt @@ -0,0 +1,51 @@ +> vp migrate --no-interactive # workspace packages get the SAME aggressive cleanup as the root (deps, configs, lint-staged) + +✔ Created vite.config.ts in vite.config.ts + +✔ Merged .oxlintrc.json into vite.config.ts +◇ Migrated . to Vite+ +• Node pnpm +• 3 config updates applied +• ESLint rules migrated to Oxlint + +> cat package.json # root: eslint + eslint-config-airbnb removed +{ + "name": "migration-eslint-monorepo-plugins-in-packages", + "scripts": { + "lint": "vp lint .", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@" +} + +> cat packages/app/package.json # workspace: eslint, eslint-plugin-vue, @typescript-eslint/parser removed; @typescript-eslint/utils preserved (reusable AST lib) +{ + "name": "@test/app", + "scripts": { + "dev": "vp dev", + "lint": "vp lint ." + }, + "devDependencies": { + "@typescript-eslint/utils": "^8.0.0", + "vite": "catalog:", + "vite-plus": "catalog:" + } +} + +> cat packages/lint-config/package.json # workspace: all eslint-plugin-* removed; peerDeps.eslint scrubbed (field deleted when empty) +{ + "name": "@test/lint-config", + "scripts": { + "lint": "vp lint ." + } +} + +> test ! -f packages/app/eslint.config.mjs # workspace eslint config is deleted +> cat packages/app/.lintstagedrc.json # workspace lint-staged rewritten (eslint --fix → vp lint --fix) +{ + "*.ts": "vp lint --fix" +} diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/steps.json b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/steps.json new file mode 100644 index 0000000000..bc65f3262f --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/steps.json @@ -0,0 +1,10 @@ +{ + "commands": [ + "vp migrate --no-interactive # workspace packages get the SAME aggressive cleanup as the root (deps, configs, lint-staged)", + "cat package.json # root: eslint + eslint-config-airbnb removed", + "cat packages/app/package.json # workspace: eslint, eslint-plugin-vue, @typescript-eslint/parser removed; @typescript-eslint/utils preserved (reusable AST lib)", + "cat packages/lint-config/package.json # workspace: all eslint-plugin-* removed; peerDeps.eslint scrubbed (field deleted when empty)", + "test ! -f packages/app/eslint.config.mjs # workspace eslint config is deleted", + "cat packages/app/.lintstagedrc.json # workspace lint-staged rewritten (eslint --fix → vp lint --fix)" + ] +} diff --git a/packages/cli/snap-tests-global/migration-eslint-monorepo/snap.txt b/packages/cli/snap-tests-global/migration-eslint-monorepo/snap.txt index 648071ca28..f119d62c7c 100644 --- a/packages/cli/snap-tests-global/migration-eslint-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo/snap.txt @@ -44,8 +44,7 @@ "name": "@test/utils", "scripts": { "lint": "vp lint ." - }, - "devDependencies": {} + } } > test ! -f eslint.config.mjs # check root eslint config is removed \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/eslint.config.mjs b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/eslint.config.mjs new file mode 100644 index 0000000000..e08ffd33f5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/eslint.config.mjs @@ -0,0 +1,10 @@ +// Stand-in for the auto-generated `.nuxt/eslint.config.mjs` flow. +// We don't actually re-export from `.nuxt/` here so the snap-test +// sandbox can load this file without running `nuxt prepare` first. +export default [ + { + rules: { + 'no-unused-vars': 'error', + }, + }, +]; diff --git a/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/package.json b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/package.json new file mode 100644 index 0000000000..8a6a67e769 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-eslint-nuxt-skip", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "lint": "eslint ." + }, + "dependencies": { + "nuxt": "^4.0.0" + }, + "devDependencies": { + "@nuxt/eslint": "^1.0.0", + "eslint": "^9.0.0", + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt new file mode 100644 index 0000000000..445a9ec158 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt @@ -0,0 +1,31 @@ +> vp migrate --no-interactive # @nuxt/eslint detected — ESLint migration is skipped with a warning + +@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`. +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied + +> cat package.json # eslint, @nuxt/eslint, and eslint.config.mjs are preserved +{ + "name": "migration-eslint-nuxt-skip", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "lint": "eslint .", + "prepare": "vp config" + }, + "dependencies": { + "nuxt": "^4.0.0" + }, + "devDependencies": { + "@nuxt/eslint": "^1.0.0", + "eslint": "^9.0.0", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@" +} + +> test -f eslint.config.mjs # eslint config file is NOT deleted \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/steps.json b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/steps.json new file mode 100644 index 0000000000..e2eaca2161 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # @nuxt/eslint detected — ESLint migration is skipped with a warning", + "cat package.json # eslint, @nuxt/eslint, and eslint.config.mjs are preserved", + "test -f eslint.config.mjs # eslint config file is NOT deleted" + ] +} diff --git a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/eslint.config.mjs b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/eslint.config.mjs new file mode 100644 index 0000000000..55dc0b9266 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/eslint.config.mjs @@ -0,0 +1,7 @@ +export default [ + { + rules: { + 'no-unused-vars': 'error', + }, + }, +]; diff --git a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json new file mode 100644 index 0000000000..5ec40adb40 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json @@ -0,0 +1,31 @@ +{ + "name": "migration-eslint-plugins-cleanup", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint ." + }, + "devDependencies": { + "@angular-eslint/template-parser": "^18.0.0", + "@eslint-community/eslint-utils": "^4.0.0", + "@eslint/js": "^9.0.0", + "@nuxt/kit": "^3.13.0", + "@types/eslint": "^9.0.0", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/eslint-config-typescript": "^14.0.0", + "eslint": "^9.0.0", + "eslint-config-airbnb": "^19.0.0", + "eslint-formatter-pretty": "^6.0.0", + "eslint-plugin-vue": "^10.0.0", + "typescript-eslint": "^8.0.0", + "vite": "^7.0.0", + "vue": "^3.5.0" + }, + "peerDependencies": { + "eslint": ">=9" + } +} diff --git a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt new file mode 100644 index 0000000000..0275e5301a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt @@ -0,0 +1,66 @@ +> vp migrate --no-interactive # migration should remove ESLint, plugins, configs, scopes, formatters, and peer eslint +◇ Migrated . to Vite+ +• Node pnpm +• 4 config updates applied +• ESLint rules migrated to Oxlint +• TypeScript shim added for framework component files + +> cat package.json # verify the comprehensive ESLint ecosystem cleanup +{ + "name": "migration-eslint-plugins-cleanup", + "scripts": { + "dev": "vp dev", + "build": "vp build", + "lint": "vp lint .", + "prepare": "vp config" + }, + "devDependencies": { + "@nuxt/kit": "^3.13.0", + "@types/node": "^22.0.0", + "@typescript-eslint/utils": "^8.0.0", + "@vitejs/plugin-vue": "^6.0.0", + "vite": "catalog:", + "vue": "^3.5.0", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@" +} + +> test ! -f eslint.config.mjs # check eslint config is removed +> cat vite.config.ts # verify the generated vite.config.ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + fmt: {}, + lint: { + "plugins": [ + "oxc", + "typescript", + "unicorn", + "react" + ], + "categories": { + "correctness": "warn" + }, + "env": { + "builtin": true + }, + "rules": { + "no-unused-vars": "error", + "vite-plus/prefer-vite-plus-imports": "error" + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "jsPlugins": [ + { + "name": "vite-plus", + "specifier": "vite-plus/oxlint-plugin" + } + ] + }, +}); diff --git a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json new file mode 100644 index 0000000000..7dc5a98d2a --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration should remove ESLint, plugins, configs, scopes, formatters, and peer eslint", + "cat package.json # verify the comprehensive ESLint ecosystem cleanup", + "test ! -f eslint.config.mjs # check eslint config is removed", + "cat vite.config.ts # verify the generated vite.config.ts" + ] +} diff --git a/packages/cli/snap-tests-global/migration-eslint-type-aware/eslint.config.mjs b/packages/cli/snap-tests-global/migration-eslint-type-aware/eslint.config.mjs new file mode 100644 index 0000000000..948cbedafb --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/eslint.config.mjs @@ -0,0 +1,15 @@ +// Flat config exercising the type-aware sniffer without importing +// typescript-eslint at runtime, so `@oxlint/migrate` can load the file +// in the snap-test sandbox where no node_modules are installed. +export default [ + { + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + rules: { + 'no-unused-vars': 'error', + }, + }, +]; diff --git a/packages/cli/snap-tests-global/migration-eslint-type-aware/package.json b/packages/cli/snap-tests-global/migration-eslint-type-aware/package.json new file mode 100644 index 0000000000..27ece53759 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/package.json @@ -0,0 +1,16 @@ +{ + "name": "migration-eslint-type-aware", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint ." + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.0.0", + "vite": "^7.0.0" + } +} diff --git a/packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt b/packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt new file mode 100644 index 0000000000..18c0ded362 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt @@ -0,0 +1,60 @@ +> vp migrate --no-interactive # migration should preserve type-aware coverage +◇ Migrated . to Vite+ +• Node pnpm +• 4 config updates applied +• ESLint rules migrated to Oxlint + +> cat package.json # check typescript-eslint and @typescript-eslint/* are removed; typescript is preserved +{ + "name": "migration-eslint-type-aware", + "scripts": { + "dev": "vp dev", + "build": "vp build", + "lint": "vp lint .", + "prepare": "vp config" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@" +} + +> cat vite.config.ts # check options.typeAware/typeCheck = true is set in the lint block +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + fmt: {}, + lint: { + "plugins": [ + "oxc", + "typescript", + "unicorn", + "react" + ], + "categories": { + "correctness": "warn" + }, + "env": { + "builtin": true + }, + "rules": { + "no-unused-vars": "error", + "vite-plus/prefer-vite-plus-imports": "error" + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "jsPlugins": [ + { + "name": "vite-plus", + "specifier": "vite-plus/oxlint-plugin" + } + ] + }, +}); diff --git a/packages/cli/snap-tests-global/migration-eslint-type-aware/steps.json b/packages/cli/snap-tests-global/migration-eslint-type-aware/steps.json new file mode 100644 index 0000000000..78abc211af --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration should preserve type-aware coverage", + "cat package.json # check typescript-eslint and @typescript-eslint/* are removed; typescript is preserved", + "cat vite.config.ts # check options.typeAware/typeCheck = true is set in the lint block" + ] +} diff --git a/packages/cli/snap-tests-global/migration-eslint-type-aware/tsconfig.json b/packages/cli/snap-tests-global/migration-eslint-type-aware/tsconfig.json new file mode 100644 index 0000000000..8dca15760d --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true + } +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c710d0b1d3..9ad9bf0551 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -27,6 +27,8 @@ const { hasFrameworkShim, addFrameworkShim, injectCreateDefaultTemplate, + rewriteEslintPackageJson, + detectIncompatibleEslintIntegration, } = await import('../migrator.js'); describe('rewritePackageJson', () => { @@ -327,6 +329,351 @@ describe('rewritePackageJson', () => { }); }); +describe('rewriteEslintPackageJson', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-eslint-cleanup-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writePkg(pkg: object): string { + const pkgPath = path.join(tmpDir, 'package.json'); + fs.writeFileSync(pkgPath, JSON.stringify(pkg)); + return pkgPath; + } + + it('removes eslint, eslint-plugin-*, eslint-config-*, typescript-eslint, @typescript-eslint/*', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + 'eslint-plugin-vue': '^10.0.0', + 'eslint-plugin-react': '^7.0.0', + 'eslint-config-airbnb': '^19.0.0', + 'typescript-eslint': '^8.0.0', + '@typescript-eslint/parser': '^8.0.0', + '@typescript-eslint/eslint-plugin': '^8.0.0', + vite: '^7.0.0', + }, + dependencies: { + 'eslint-plugin-import': '^2.0.0', + vue: '^3.5.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ vite: '^7.0.0' }); + expect(pkg.dependencies).toEqual({ vue: '^3.5.0' }); + }); + + it('removes scoped ESLint plugin/config packages (e.g. @vue/eslint-config-typescript)', () => { + const pkgPath = writePkg({ + devDependencies: { + '@vue/eslint-config-typescript': '^13.0.0', + '@nuxt/eslint-config': '^0.5.0', + '@stylistic/eslint-plugin': '^2.0.0', + '@stylistic/eslint-plugin-ts': '^2.0.0', + '@vitest/eslint-plugin': '^1.0.0', + keepme: '^1.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ keepme: '^1.0.0' }); + }); + + it('removes @eslint/*, @eslint-community/*, and @angular-eslint/* scope packages', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + '@eslint/js': '^9.0.0', + '@eslint/eslintrc': '^3.0.0', + '@eslint/compat': '^1.0.0', + '@eslint-community/eslint-utils': '^4.0.0', + '@eslint-community/regexpp': '^4.0.0', + '@angular-eslint/template-parser': '^18.0.0', + '@angular-eslint/builder': '^18.0.0', + keepme: '^1.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ keepme: '^1.0.0' }); + }); + + it('removes ESLint formatter and helper packages', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + 'eslint-formatter-pretty': '^6.0.0', + 'eslint-formatter-gitlab': '^5.0.0', + eslintrc: '^2.0.0', + 'eslint-utils': '^3.0.0', + 'eslint-visitor-keys': '^4.0.0', + 'eslint-scope': '^8.0.0', + 'eslint-define-config': '^2.0.0', + 'eslint-doc-generator': '^2.0.0', + keepme: '^1.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ keepme: '^1.0.0' }); + }); + + it('does NOT remove framework-ESLint integrations (e.g. @nuxt/eslint) — those short-circuit migration upstream', () => { + // The skip path in `bin.ts` prevents `rewriteEslintPackageJson` from + // being called when `@nuxt/eslint` is present, so this function + // doesn't need to (and shouldn't) know about it. + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + '@nuxt/eslint': '^1.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ '@nuxt/eslint': '^1.0.0' }); + }); + + it('preserves reusable @typescript-eslint/* AST libraries (utils, typescript-estree, etc.)', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + '@typescript-eslint/parser': '^8.0.0', + '@typescript-eslint/eslint-plugin': '^8.0.0', + '@typescript-eslint/rule-tester': '^8.0.0', + '@typescript-eslint/utils': '^8.0.0', + '@typescript-eslint/typescript-estree': '^8.0.0', + '@typescript-eslint/scope-manager': '^8.0.0', + '@typescript-eslint/types': '^8.0.0', + vite: '^7.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ + '@typescript-eslint/utils': '^8.0.0', + '@typescript-eslint/typescript-estree': '^8.0.0', + '@typescript-eslint/scope-manager': '^8.0.0', + '@typescript-eslint/types': '^8.0.0', + vite: '^7.0.0', + }); + }); + + it('removes @types/ packages symmetrically with their runtime counterparts', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + '@types/eslint': '^9.0.0', + '@types/eslint-plugin-foo': '^1.0.0', + '@types/eslint-config-bar': '^1.0.0', + // Type-only counterpart of an ESLint plugin should also go. + '@types/eslint-scope': '^3.0.0', + // Unrelated @types should stay. + '@types/node': '^22.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ '@types/node': '^22.0.0' }); + }); + + it('scrubs peerDependencies and optionalDependencies', () => { + const pkgPath = writePkg({ + peerDependencies: { + eslint: '>=9', + 'eslint-plugin-vue': '^10.0.0', + }, + optionalDependencies: { + '@typescript-eslint/parser': '^8.0.0', + }, + devDependencies: { vite: '^7.0.0' }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.peerDependencies).toBeUndefined(); + expect(pkg.optionalDependencies).toBeUndefined(); + expect(pkg.devDependencies).toEqual({ vite: '^7.0.0' }); + }); + + it('deletes the dependency field entirely when our cleanup emptied it', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + 'eslint-plugin-import': '^2.0.0', + }, + dependencies: { 'eslint-config-airbnb': '^19.0.0' }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toBeUndefined(); + expect(pkg.dependencies).toBeUndefined(); + }); + + it('preserves unrelated dependencies (e.g. @vitejs/plugin-vue, vue, vite, @nuxt/kit)', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + '@vitejs/plugin-vue': '^6.0.0', + '@vue/runtime-core': '^3.5.0', + '@nuxt/kit': '^3.13.0', + vite: '^7.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ + '@vitejs/plugin-vue': '^6.0.0', + '@vue/runtime-core': '^3.5.0', + '@nuxt/kit': '^3.13.0', + vite: '^7.0.0', + }); + }); + + it('no-ops when package.json has no eslint-ecosystem deps', () => { + const pkgPath = writePkg({ + devDependencies: { vite: '^7.0.0' }, + }); + const before = fs.readFileSync(pkgPath, 'utf8'); + rewriteEslintPackageJson(pkgPath); + const after = fs.readFileSync(pkgPath, 'utf8'); + expect(after).toBe(before); + }); + + it('preserves packages referenced in lint.jsPlugins (so the generated config still loads)', () => { + // When @oxlint/migrate translates a real ESLint plugin into a + // lint.jsPlugins reference, Oxlint will `import()` the package at + // lint time. If we strip it from package.json the lint config we + // just generated is invalidated. The preserveJsPlugins set guards + // against that. + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + 'eslint-plugin-vue': '^10.0.0', + 'eslint-plugin-import-x': '^4.0.0', + 'eslint-plugin-react': '^7.37.0', + '@stylistic/eslint-plugin': '^2.0.0', + '@typescript-eslint/parser': '^8.0.0', + vite: '^7.0.0', + }, + }); + rewriteEslintPackageJson( + pkgPath, + new Set(['eslint-plugin-vue', 'eslint-plugin-import-x', '@stylistic/eslint-plugin']), + ); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ + // Preserved (in jsPlugins set, so Oxlint will load them): + 'eslint-plugin-vue': '^10.0.0', + 'eslint-plugin-import-x': '^4.0.0', + '@stylistic/eslint-plugin': '^2.0.0', + // Removed (no jsPlugins reference, normal cleanup): + // 'eslint': stripped + // 'eslint-plugin-react': stripped + // '@typescript-eslint/parser': stripped + vite: '^7.0.0', + }); + }); + + it('preserveJsPlugins overrides every cleanup pattern (named, prefix, scope, regex)', () => { + // Stress-test each branch of isEslintEcosystemDep against the + // preserve set so a future contributor adding a new cleanup branch + // can't accidentally bypass the carve-out. + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', // named match in ESLINT_ECOSYSTEM_NAMES + 'eslint-plugin-foo': '^1.0.0', // prefix match + '@eslint/js': '^9.0.0', // scope match + '@scope/eslint-plugin-bar': '^1.0.0', // scoped regex match + keepme: '^1.0.0', + }, + }); + rewriteEslintPackageJson( + pkgPath, + new Set(['eslint', 'eslint-plugin-foo', '@eslint/js', '@scope/eslint-plugin-bar']), + ); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ + eslint: '^9.0.0', + 'eslint-plugin-foo': '^1.0.0', + '@eslint/js': '^9.0.0', + '@scope/eslint-plugin-bar': '^1.0.0', + keepme: '^1.0.0', + }); + }); + + it('does not invent preserveJsPlugins entries — only what the caller asked for', () => { + // Sanity: an empty preserve set behaves identically to the default + // (no carve-out), so the new parameter can't accidentally weaken + // the cleanup for existing callers. + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + 'eslint-plugin-foo': '^1.0.0', + vite: '^7.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath, new Set()); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ vite: '^7.0.0' }); + }); +}); + +function writePkgAt(dir: string, pkg: object): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); +} + +describe('detectIncompatibleEslintIntegration', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-incompat-eslint-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns "@nuxt/eslint" when listed in devDependencies', () => { + writePkgAt(tmpDir, { devDependencies: { '@nuxt/eslint': '^1.0.0' } }); + expect(detectIncompatibleEslintIntegration(tmpDir)).toBe('@nuxt/eslint'); + }); + + it('returns "@nuxt/eslint" when listed in dependencies', () => { + writePkgAt(tmpDir, { dependencies: { '@nuxt/eslint': '^1.0.0' } }); + expect(detectIncompatibleEslintIntegration(tmpDir)).toBe('@nuxt/eslint'); + }); + + it('detects when @nuxt/eslint lives in a workspace package, not the root', () => { + writePkgAt(tmpDir, { name: 'root' }); + writePkgAt(path.join(tmpDir, 'packages/app'), { + name: 'app', + devDependencies: { '@nuxt/eslint': '^1.0.0' }, + }); + expect( + detectIncompatibleEslintIntegration(tmpDir, [ + { name: 'app', path: 'packages/app', isTemplatePackage: false }, + ]), + ).toBe('@nuxt/eslint'); + }); + + it('returns undefined when @nuxt/eslint is absent', () => { + writePkgAt(tmpDir, { + devDependencies: { eslint: '^9.0.0', '@nuxt/kit': '^3.0.0' }, + }); + expect(detectIncompatibleEslintIntegration(tmpDir)).toBeUndefined(); + }); + + it('returns undefined when package.json is missing', () => { + expect(detectIncompatibleEslintIntegration(tmpDir)).toBeUndefined(); + }); +}); + describe('parseNvmrcVersion', () => { it('strips v prefix', () => { expect(parseNvmrcVersion('v20.5.0')).toBe('20.5.0'); @@ -431,23 +778,7 @@ describe('migrateNodeVersionManagerFile', () => { it('adds volta manual step when voltaPresent is set', () => { fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'v20.5.0\n'); - const report = { - createdViteConfigCount: 0, - mergedConfigCount: 0, - mergedStagedConfigCount: 0, - inlinedLintStagedConfigCount: 0, - removedConfigCount: 0, - tsdownImportCount: 0, - rewrittenImportFileCount: 0, - rewrittenImportErrors: [], - eslintMigrated: false, - prettierMigrated: false, - nodeVersionFileMigrated: false, - gitHooksConfigured: false, - frameworkShimAdded: false, - warnings: [], - manualSteps: [], - }; + const report = createMigrationReport(); migrateNodeVersionManagerFile(tmpDir, { file: '.nvmrc', voltaPresent: true }, report); expect(report.manualSteps).toContain('Remove the "volta" field from package.json'); }); @@ -462,23 +793,7 @@ describe('migrateNodeVersionManagerFile', () => { it('returns false and warns for unsupported alias', () => { fs.writeFileSync(path.join(tmpDir, '.nvmrc'), 'system\n'); - const report = { - createdViteConfigCount: 0, - mergedConfigCount: 0, - mergedStagedConfigCount: 0, - inlinedLintStagedConfigCount: 0, - removedConfigCount: 0, - tsdownImportCount: 0, - rewrittenImportFileCount: 0, - rewrittenImportErrors: [], - eslintMigrated: false, - prettierMigrated: false, - nodeVersionFileMigrated: false, - gitHooksConfigured: false, - frameworkShimAdded: false, - warnings: [], - manualSteps: [], - }; + const report = createMigrationReport(); const ok = migrateNodeVersionManagerFile(tmpDir, { file: '.nvmrc' }, report); expect(ok).toBe(false); expect(report.warnings.length).toBe(1); @@ -495,23 +810,7 @@ describe('migrateNodeVersionManagerFile', () => { }); it('sets nodeVersionFileMigrated and manualSteps in report for volta migration', () => { - const report = { - createdViteConfigCount: 0, - mergedConfigCount: 0, - mergedStagedConfigCount: 0, - inlinedLintStagedConfigCount: 0, - removedConfigCount: 0, - tsdownImportCount: 0, - rewrittenImportFileCount: 0, - rewrittenImportErrors: [], - eslintMigrated: false, - prettierMigrated: false, - nodeVersionFileMigrated: false, - gitHooksConfigured: false, - frameworkShimAdded: false, - warnings: [], - manualSteps: [], - }; + const report = createMigrationReport(); migrateNodeVersionManagerFile( tmpDir, { file: 'package.json', voltaNodeVersion: '20.5.0' }, @@ -531,23 +830,7 @@ describe('migrateNodeVersionManagerFile', () => { }); it('returns false and warns when volta.node is a partial version', () => { - const report = { - createdViteConfigCount: 0, - mergedConfigCount: 0, - mergedStagedConfigCount: 0, - inlinedLintStagedConfigCount: 0, - removedConfigCount: 0, - tsdownImportCount: 0, - rewrittenImportFileCount: 0, - rewrittenImportErrors: [], - eslintMigrated: false, - prettierMigrated: false, - nodeVersionFileMigrated: false, - gitHooksConfigured: false, - frameworkShimAdded: false, - warnings: [], - manualSteps: [], - }; + const report = createMigrationReport(); const ok = migrateNodeVersionManagerFile( tmpDir, { file: 'package.json', voltaNodeVersion: '20' }, diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d9fc891b18..06d9ce7f08 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -52,6 +52,7 @@ import { confirmPrettierMigration, detectEslintProject, detectFramework, + detectIncompatibleEslintIntegration, detectNodeVersionManagerFile, detectPrettierProject, hasFrameworkShim, @@ -66,6 +67,7 @@ import { promptPrettierMigration, rewriteMonorepo, rewriteStandaloneProject, + warnIncompatibleEslintIntegration, warnLegacyEslintConfig, warnPackageLevelEslint, warnPackageLevelPrettier, @@ -445,8 +447,17 @@ async function collectMigrationPlan( // 7. ESLint detection + prompt const eslintProject = detectEslintProject(rootDir, packages); + const incompatibleEslintIntegration = detectIncompatibleEslintIntegration(rootDir, packages); let migrateEslint = false; - if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) { + if (incompatibleEslintIntegration) { + // e.g. `@nuxt/eslint` — skip the entire ESLint migration; preserve + // the user's current ESLint setup and let them migrate by hand. + warnIncompatibleEslintIntegration(incompatibleEslintIntegration); + } else if ( + eslintProject.hasDependency && + !eslintProject.configFile && + eslintProject.legacyConfigFile + ) { warnLegacyEslintConfig(eslintProject.legacyConfigFile); } else if (eslintProject.hasDependency && eslintProject.configFile) { migrateEslint = await confirmEslintMigration(options.interactive); @@ -970,7 +981,12 @@ async function main() { // Merge configs and reinstall once if any tool migration happened if (eslintMigrated || prettierMigrated) { updateMigrationProgress('Rewriting configs'); - mergeViteConfigFiles(workspaceInfoOptional.rootDir, true, report); + mergeViteConfigFiles( + workspaceInfoOptional.rootDir, + true, + report, + workspaceInfoOptional.packages, + ); updateMigrationProgress('Installing dependencies'); // Resolve the actual pnpm version that `vp install` will use so the // auto-install can opt into `--ignore-scripts` on pnpm v11 (which fails diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index ec3217f783..7852153a5b 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -99,6 +99,29 @@ const PUBLIC_PEER_DEPENDENCY_FALLBACKS: Record = { vitest: '*', }; +// Plugins Oxlint resolves natively (no JS import). Source: +// `LintPluginOptionsSchema` in `node_modules/oxlint/dist/index.d.ts`. +// Anything else in the merged `lint.plugins[]` after migration is a +// reference left over from `@oxlint/migrate` that won't resolve at lint +// time. +const OXLINT_NATIVE_PLUGINS = new Set([ + 'eslint', + 'react', + 'unicorn', + 'typescript', + 'oxc', + 'import', + 'jsdoc', + 'jest', + 'vitest', + 'jsx-a11y', + 'nextjs', + 'react-perf', + 'promise', + 'node', + 'vue', +]); + type PackageJsonDependencyField = | 'devDependencies' | 'dependencies' @@ -306,25 +329,78 @@ export async function migrateEslintToOxlint( options.report.eslintMigrated = true; } - // Step 3: Delete all eslint config files at root - deleteEslintConfigFiles(projectPath, options?.report, options?.silent); - - // Step 4: Remove eslint dependency and rewrite eslint scripts (root only) - rewriteEslintPackageJson(path.join(projectPath, 'package.json')); - - // Step 4b: Rewrite eslint scripts in workspace packages - if (packages) { - for (const pkg of packages) { - rewriteEslintPackageJson(path.join(projectPath, pkg.path, 'package.json')); + // Read the generated `.oxlintrc.json` to find any packages it references + // in `lint.jsPlugins`. Those packages need to stay in `package.json` so + // Oxlint can actually `import()` them at lint time — without this carve-out, + // the next step would strip them via `isEslintEcosystemDep` and we'd + // immediately invalidate the config we just generated. Local-path + // specifiers (`./X`, `../X`, `/X`) are skipped — they're paths, not + // package names, and have no `package.json` entry to preserve. + const preserveJsPlugins = collectJsPluginPackageNames(projectPath); + + // Step 3-5: Cleanup runs uniformly across the root and every workspace + // package — delete eslint config files, scrub ESLint-ecosystem deps from + // package.json, and rewrite eslint references in any local lint-staged + // config. A monorepo running `vp migrate` is treated as adopted as a + // whole; there's no per-package opt-out today. If a workspace package + // publishes a shared ESLint preset that you want to keep intact, exclude + // it from your `pnpm-workspace.yaml` / `workspaces` before running + // `vp migrate`, then add it back afterwards. + const cleanupTargets = [ + projectPath, + ...(packages ?? []).map((p) => path.join(projectPath, p.path)), + ]; + for (const target of cleanupTargets) { + if (!fs.existsSync(path.join(target, 'package.json'))) { + continue; } + deleteEslintConfigFiles(target, options?.report, options?.silent); + rewriteEslintPackageJson(path.join(target, 'package.json'), preserveJsPlugins); + rewriteEslintLintStagedConfigFiles(target, options?.report); } - // Step 5: Rewrite eslint references in lint-staged config files - rewriteEslintLintStagedConfigFiles(projectPath, options?.report); - return true; } +/** + * Read `/.oxlintrc.json` (if any) and collect the package + * names referenced via `lint.jsPlugins[]` string entries. Object-form + * entries (`{ name, specifier }`) and local-path specifiers (`./X`, + * `../X`, `/X`) are excluded — neither maps to a `package.json` entry + * we'd accidentally strip. + */ +function collectJsPluginPackageNames(projectPath: string): Set { + const out = new Set(); + const oxlintConfigPath = path.join(projectPath, '.oxlintrc.json'); + if (!fs.existsSync(oxlintConfigPath)) { + return out; + } + let config: OxlintConfig; + try { + config = readJsonFile(oxlintConfigPath, true) as OxlintConfig; + } catch { + return out; + } + const collectFrom = (jsPlugins: OxlintConfig['jsPlugins']): void => { + for (const entry of jsPlugins ?? []) { + if (typeof entry !== 'string') { + continue; + } + if (entry.startsWith('./') || entry.startsWith('../') || entry.startsWith('/')) { + continue; + } + out.add(entry); + } + }; + collectFrom(config.jsPlugins); + if (Array.isArray(config.overrides)) { + for (const override of config.overrides) { + collectFrom(override.jsPlugins); + } + } + return out; +} + function deleteEslintConfigFiles(basePath: string, report?: MigrationReport, silent = false): void { const configs = detectConfigs(basePath); for (const file of [configs.eslintConfig, configs.eslintLegacyConfig]) { @@ -343,21 +419,122 @@ function deleteEslintConfigFiles(basePath: string, report?: MigrationReport, sil } } -function rewriteEslintPackageJson(packageJsonPath: string): void { +// Bare names of packages whose sole purpose is to support ESLint. Removed +// at root cleanup. Reusable AST libraries published under +// `@typescript-eslint/*` (`utils`, `typescript-estree`, `scope-manager`, +// `types`) are deliberately absent so codemods and doc generators that +// import them directly keep working after migration. +const ESLINT_ECOSYSTEM_NAMES = new Set([ + 'eslint', + 'typescript-eslint', + 'eslintrc', + 'eslint-utils', + 'eslint-visitor-keys', + 'eslint-scope', + 'eslint-define-config', + 'eslint-doc-generator', + // ESLint-only typescript-eslint entry points: + '@typescript-eslint/eslint-plugin', + '@typescript-eslint/parser', + '@typescript-eslint/rule-tester', + // Note: framework-ESLint integration modules (e.g. `@nuxt/eslint`) + // are NOT listed here. They short-circuit the entire ESLint + // migration via `INCOMPATIBLE_ESLINT_INTEGRATIONS`, so this list is + // never consulted for them. Keeping them out avoids duplicating the + // "what to do about Nuxt" decision in two places. +]); + +// Flat name prefixes that mark an ESLint-only package. +const ESLINT_ECOSYSTEM_PREFIXES = ['eslint-plugin-', 'eslint-config-', 'eslint-formatter-']; + +// Scopes whose every package is part of the ESLint ecosystem. +// @eslint/* — official ESLint scope (e.g. @eslint/js, @eslint/eslintrc) +// @eslint-community/* — community-maintained ESLint dependencies +// @angular-eslint/* — Angular's ESLint integration family +const ESLINT_ECOSYSTEM_SCOPES = ['@eslint/', '@eslint-community/', '@angular-eslint/']; + +/** + * Decide whether a dependency entry should be removed alongside `eslint` + * itself. The set is intentionally broad: anything whose only purpose is + * to extend, configure, format, or wire ESLint becomes dead weight after + * migration. `@types/` packages are checked symmetrically with `` + * so type-only counterparts of removed runtime packages also go. + */ +function isEslintEcosystemDep(name: string): boolean { + const stripped = name.startsWith('@types/') ? name.slice('@types/'.length) : name; + if (ESLINT_ECOSYSTEM_NAMES.has(stripped)) { + return true; + } + if (ESLINT_ECOSYSTEM_PREFIXES.some((p) => stripped.startsWith(p))) { + return true; + } + if (ESLINT_ECOSYSTEM_SCOPES.some((s) => stripped.startsWith(s))) { + return true; + } + // Scoped plugins/configs/formatters, e.g.: + // @vue/eslint-config-typescript + // @stylistic/eslint-plugin-ts + // @vitest/eslint-plugin + if (/^@[^/]+\/eslint-(plugin|config|formatter)(-.+)?$/.test(stripped)) { + return true; + } + return false; +} + +/** + * Rewrite a project's `package.json` after ESLint has been migrated to + * Oxlint: drop every ESLint-ecosystem dependency (see + * `isEslintEcosystemDep`), strip empty containers, and rewrite eslint + * tokens in scripts / lint-staged. Applied uniformly to the root and to + * every workspace package — the migration treats the whole workspace as + * in scope for adoption, so a half-cleanup at the workspace level would + * be inconsistent with the rest of the flow (which already replaces + * vite-related overrides and adds vite-plus across all packages). + * + * `preserveJsPlugins` names packages that `@oxlint/migrate` referenced + * via `lint.jsPlugins` and that Oxlint will need to `import()` at lint + * time. They override `isEslintEcosystemDep` so the generated config + * isn't immediately invalidated by the cleanup step. + */ +export function rewriteEslintPackageJson( + packageJsonPath: string, + preserveJsPlugins: ReadonlySet = new Set(), +): void { editJsonFile<{ devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; scripts?: Record; 'lint-staged'?: Record; }>(packageJsonPath, (pkg) => { let changed = false; - if (pkg.devDependencies?.eslint) { - delete pkg.devDependencies.eslint; - changed = true; - } - if (pkg.dependencies?.eslint) { - delete pkg.dependencies.eslint; - changed = true; + for (const field of [ + 'devDependencies', + 'dependencies', + 'peerDependencies', + 'optionalDependencies', + ] as const) { + const deps = pkg[field]; + if (!deps) { + continue; + } + let removedAny = false; + for (const name of Object.keys(deps)) { + if (preserveJsPlugins.has(name)) { + continue; + } + if (isEslintEcosystemDep(name)) { + delete deps[name]; + changed = true; + removedAny = true; + } + } + // Drop the field entirely if our cleanup emptied it — avoid + // leaving `"devDependencies": {}` noise in the output. + if (removedAny && Object.keys(deps).length === 0) { + delete pkg[field]; + } } if (pkg.scripts) { const updated = rewriteEslint(JSON.stringify(pkg.scripts)); @@ -976,7 +1153,7 @@ export function rewriteStandaloneProject( } cleanupDeprecatedTsconfigOptions(projectPath, silent, report); rewriteTsconfigTypes(projectPath, silent, report); - mergeViteConfigFiles(projectPath, silent, report); + mergeViteConfigFiles(projectPath, silent, report, workspaceInfo.packages); injectLintTypeCheckDefaults(projectPath, silent, report); injectFmtDefaults(projectPath, silent, report); mergeTsdownConfigFile(projectPath, silent, report); @@ -1013,9 +1190,18 @@ export function rewriteMonorepo( workspaceInfo.packageManager, skipStagedMigration, catalogDependencyResolver, + workspaceInfo.packages, ); - - // rewrite packages + // (mergeViteConfigFiles below will sanitize the merged lint config + // against this workspace's full package set.) + + // rewrite packages — pass workspace context so the per-package + // sanitizer can see hoisted deps that live elsewhere in the + // workspace, not just this sub-package's own `package.json`. + const workspaceContext = { + rootDir: workspaceInfo.rootDir, + packages: workspaceInfo.packages, + }; for (const pkg of workspaceInfo.packages) { rewriteMonorepoProject( path.join(workspaceInfo.rootDir, pkg.path), @@ -1024,6 +1210,7 @@ export function rewriteMonorepo( silent, report, catalogDependencyResolver, + workspaceContext, ); } @@ -1032,7 +1219,7 @@ export function rewriteMonorepo( } cleanupDeprecatedTsconfigOptions(workspaceInfo.rootDir, silent, report); rewriteTsconfigTypes(workspaceInfo.rootDir, silent, report); - mergeViteConfigFiles(workspaceInfo.rootDir, silent, report); + mergeViteConfigFiles(workspaceInfo.rootDir, silent, report, workspaceInfo.packages); injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report); injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); @@ -1045,6 +1232,11 @@ export function rewriteMonorepo( /** * Rewrite monorepo project to add vite-plus dependencies * @param projectPath - The path to the project + * @param workspaceContext - Full workspace info, used so the lint-config + * sanitizer can see hoisted deps living elsewhere in the workspace, + * not just this sub-package's own `package.json`. `rootDir` is the + * workspace root (paths in `packages` are relative to it); `packages` + * is the workspace package list. */ export function rewriteMonorepoProject( projectPath: string, @@ -1053,10 +1245,17 @@ export function rewriteMonorepoProject( silent = false, report?: MigrationReport, catalogDependencyResolver?: CatalogDependencyResolver, + workspaceContext?: { rootDir: string; packages: WorkspacePackage[] }, ): void { cleanupDeprecatedTsconfigOptions(projectPath, silent, report); rewriteTsconfigTypes(projectPath, silent, report); - mergeViteConfigFiles(projectPath, silent, report); + mergeViteConfigFiles( + projectPath, + silent, + report, + workspaceContext?.packages, + workspaceContext?.rootDir, + ); mergeTsdownConfigFile(projectPath, silent, report); const packageJsonPath = path.join(projectPath, 'package.json'); @@ -1570,6 +1769,10 @@ function rewriteRootWorkspacePackageJson( packageManager: PackageManager, skipStagedMigration?: boolean, catalogDependencyResolver?: CatalogDependencyResolver, + // Forwarded to `rewriteMonorepoProject` so the per-root lint-config + // sanitizer can see hoisted deps in sibling workspace packages, not + // just the root's own `package.json`. + packages?: WorkspacePackage[], ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -1657,7 +1860,9 @@ function rewriteRootWorkspacePackageJson( migratePnpmOverridesToWorkspaceYaml(projectPath, remainingPnpmOverrides); } - // rewrite package.json + // rewrite package.json — `projectPath` IS the workspace root here, so + // `workspaceContext.rootDir` matches it; sanitizer resolves + // sibling-package paths against `projectPath`. rewriteMonorepoProject( projectPath, packageManager, @@ -1665,6 +1870,7 @@ function rewriteRootWorkspacePackageJson( undefined, undefined, catalogDependencyResolver, + packages ? { rootDir: projectPath, packages } : undefined, ); } @@ -1952,6 +2158,308 @@ function mergeTsdownConfigFile( ); } +/** + * Best-effort: derive the Oxlint rule-namespace a JS plugin package + * contributes. Mirrors the conventions @oxlint/migrate uses when + * translating ESLint configs, and the conventions Oxlint-native plugin + * authors use (`oxlint-plugin-` — see posva/pinia-colada in the + * wild): + * `eslint-plugin-unocss` → `unocss` (rules: `unocss/order`) + * `oxlint-plugin-posva` → `posva` (rules: `posva/foo`) + * `@stylistic/eslint-plugin` → `@stylistic` (rules: `@stylistic/indent`) + * `@stylistic/eslint-plugin-ts` → `@stylistic/ts` (rules: `@stylistic/ts/indent`) + * `@scope/oxlint-plugin-x` → `@scope/x` + * anything else → the package name verbatim + */ +function deriveJsPluginNamespace(packageName: string): string { + for (const prefix of ['eslint-plugin-', 'oxlint-plugin-']) { + if (packageName.startsWith(prefix)) { + const suffix = packageName.slice(prefix.length); + return suffix || packageName; + } + } + const scoped = packageName.match(/^(@[^/]+)\/(?:eslint|oxlint)-plugin(?:-(.+))?$/); + if (scoped) { + return scoped[2] ? `${scoped[1]}/${scoped[2]}` : scoped[1]; + } + return packageName; +} + +/** + * Collect every dependency name declared across the root + workspace + * `package.json` files after the ESLint cleanup has run. Used to verify + * that JS plugins referenced by the generated `.oxlintrc.json` are + * actually installable. + */ +function collectInstalledPackageNames( + projectPath: string, + packages?: WorkspacePackage[], +): Set { + const names = new Set(); + const paths = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; + for (const dir of paths) { + const pkgJsonPath = path.join(dir, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + let pkg: Record | undefined>; + try { + pkg = readJsonFile(pkgJsonPath) as typeof pkg; + } catch { + continue; + } + for (const field of [ + 'devDependencies', + 'dependencies', + 'peerDependencies', + 'optionalDependencies', + ] as const) { + const deps = pkg[field]; + if (deps) { + for (const name of Object.keys(deps)) { + names.add(name); + } + } + } + } + return names; +} + +/** + * Test whether a rule key (e.g. `@stylistic/ts/indent`) belongs to any + * namespace in `namespaces`. We can't just split on the first `/` — + * `@stylistic/eslint-plugin-ts` contributes the multi-segment namespace + * `@stylistic/ts`, so the lookup has to try progressively longer + * prefixes until one matches or we run out of slashes. + */ +function ruleKeyMatchesNamespace(key: string, namespaces: Set): boolean { + if (!key.includes('/')) { + return true; + } + let idx = key.indexOf('/'); + while (idx !== -1) { + if (namespaces.has(key.slice(0, idx))) { + return true; + } + idx = key.indexOf('/', idx + 1); + } + return false; +} + +/** Filter a rules object to only entries whose namespace is recognized. */ +function filterRulesAgainstNamespaces( + rules: Record, + namespaces: Set, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(rules)) { + if (ruleKeyMatchesNamespace(key, namespaces)) { + out[key] = value; + } + } + return out; +} + +/** + * Sort a jsPlugins array into installed entries (kept) and string + * entries for packages that aren't present in the workspace. Object-form + * entries (`{ name, specifier }`) and string entries that look like + * local paths (`./X`, `/X`, `../X`) are passed through — Oxlint resolves + * them itself. + */ +function partitionJsPlugins( + entries: NonNullable, + availablePackages: Set, +): { + kept: NonNullable; + dropped: string[]; +} { + const kept: NonNullable = []; + const dropped: string[] = []; + for (const entry of entries) { + if (typeof entry !== 'string') { + kept.push(entry); + continue; + } + // Local-path specifiers don't go through `package.json`; preserve + // them so users with hand-authored local plugin imports survive + // a `vp migrate` re-run. + if (entry.startsWith('./') || entry.startsWith('../') || entry.startsWith('/')) { + kept.push(entry); + continue; + } + if (availablePackages.has(entry)) { + kept.push(entry); + } else { + dropped.push(entry); + } + } + return { kept, dropped }; +} + +/** Build the set of rule-key namespaces backed by a given jsPlugins set. */ +function jsPluginsToNamespaces(entries: NonNullable): Set { + const ns = new Set(); + for (const entry of entries) { + if (typeof entry === 'string') { + ns.add(deriveJsPluginNamespace(entry)); + } else if (entry && typeof entry === 'object' && 'name' in entry && entry.name) { + ns.add(entry.name); + } + } + // Empty-string namespace (e.g. from `eslint-plugin-` with no suffix) + // would smuggle slash-prefixed rules through; drop it defensively. + ns.delete(''); + return ns; +} + +/** + * Sanitize the `.oxlintrc.json` produced by `@oxlint/migrate` (in-place) + * before it gets merged into `vite.config.ts`. Drop references that + * won't resolve at lint time and warn the user. + * + * Why: `@oxlint/migrate` can emit `jsPlugins[]` / `plugins[]` / `rules` + * entries referring to packages the user never installed (e.g. + * translating `@unocss/eslint-config` into `eslint-plugin-unocss`), + * to plugins outside Oxlint's native set, or under namespaces no + * surviving plugin contributes. Without sanitization, `vp lint` aborts + * with "Failed to load JS plugin" / "Plugin not found" before running + * any rule. This produces a degraded-but-functional config instead. + * + * Per-override entries (`overrides[].jsPlugins`, `.plugins`, `.rules`) + * are sanitized independently — an override can introduce its own + * jsPlugin, so namespace availability is computed per-override (base + * namespaces ∪ the override's own surviving jsPlugins' namespaces). + */ +function sanitizeMigratedOxlintConfig( + config: OxlintConfig, + availablePackages: Set, + report?: MigrationReport, +): void { + // Track everything we strip so we can warn the user. + const allDroppedJsPlugins = new Set(); + const allDroppedPlugins = new Set(); + + // 1. Sanitize base-level jsPlugins. + const baseSplit = partitionJsPlugins(config.jsPlugins ?? [], availablePackages); + for (const n of baseSplit.dropped) { + allDroppedJsPlugins.add(n); + } + if (config.jsPlugins && baseSplit.dropped.length > 0) { + config.jsPlugins = baseSplit.kept; + } + + // 2. Base namespaces = native plugins + surviving jsPlugins' namespaces. + const baseNamespaces = new Set(OXLINT_NATIVE_PLUGINS); + for (const ns of jsPluginsToNamespaces(baseSplit.kept)) { + baseNamespaces.add(ns); + } + + // 3. Sanitize base-level plugins[] against base namespaces. + if (config.plugins) { + type PluginEntry = NonNullable[number]; + const keptPlugins: PluginEntry[] = []; + for (const p of config.plugins) { + if (baseNamespaces.has(p)) { + keptPlugins.push(p); + } else { + allDroppedPlugins.add(p); + } + } + if (keptPlugins.length !== config.plugins.length) { + config.plugins = keptPlugins; + } + } + + // 4. Sanitize base rules. Guard the reassignment to avoid adding a + // `rules: undefined` property that would shift downstream key + // emission in the merged vite.config.ts. + if (config.rules) { + const filtered = filterRulesAgainstNamespaces(config.rules, baseNamespaces); + if (Object.keys(filtered).length !== Object.keys(config.rules).length) { + config.rules = filtered as typeof config.rules; + } + } + + // 5. Sanitize each override INDEPENDENTLY. An override can declare + // its own `jsPlugins` / `plugins`, so we compute a per-override + // namespace set: base namespaces ∪ the override's own surviving + // jsPlugins' namespaces. If `override.plugins` is present it + // replaces base.plugins per Oxlint's schema, but for namespace + // resolution we still include the base set (rules under a base + // namespace are still valid inside the override). + if (Array.isArray(config.overrides)) { + for (const override of config.overrides) { + // Override jsPlugins. + let overrideSurvivors: NonNullable = []; + if (override.jsPlugins) { + const split = partitionJsPlugins(override.jsPlugins, availablePackages); + for (const n of split.dropped) { + allDroppedJsPlugins.add(n); + } + if (split.dropped.length > 0) { + override.jsPlugins = split.kept; + } + overrideSurvivors = split.kept; + } + const overrideNamespaces = new Set(baseNamespaces); + for (const ns of jsPluginsToNamespaces(overrideSurvivors)) { + overrideNamespaces.add(ns); + } + + // Override plugins[]. + if (override.plugins) { + type OverridePluginEntry = NonNullable[number]; + const keptOverridePlugins: OverridePluginEntry[] = []; + for (const p of override.plugins) { + if (overrideNamespaces.has(p)) { + keptOverridePlugins.push(p); + } else { + allDroppedPlugins.add(p); + } + } + if (keptOverridePlugins.length !== override.plugins.length) { + override.plugins = keptOverridePlugins; + } + } + + // Override rules. + if (override.rules) { + const filtered = filterRulesAgainstNamespaces(override.rules, overrideNamespaces); + if (Object.keys(filtered).length !== Object.keys(override.rules).length) { + override.rules = filtered as typeof override.rules; + } + } + } + } + + // 6. Warn. + // + // We deliberately don't try to distinguish "we just removed this + // package as part of the ESLint-ecosystem cleanup" from "the user + // never had it installed" — the only honest signal we have is "not + // in any package.json after cleanup", and a name-based heuristic + // (matches `eslint-plugin-*`?) misclassifies the @oxlint/migrate + // phantom-reference case (e.g. `@unocss/eslint-config` translating + // into `eslint-plugin-unocss` even though the user never had it). + // A single accurate message covers both paths. + if (allDroppedJsPlugins.size > 0) { + warnMigration( + `Stripped JS plugin reference(s) from the generated lint config: ${[...allDroppedJsPlugins].join(', ')}. ` + + 'No matching package is present in this workspace, so loading them at lint time would fail. ' + + 'If you want their Oxlint coverage back, install each package (e.g. `vp install `) and add its name back to `lint.jsPlugins` in vite.config.ts.', + report, + ); + } + if (allDroppedPlugins.size > 0) { + warnMigration( + `Stripped unknown plugin reference(s) from the generated lint config: ${[...allDroppedPlugins].join(', ')}. ` + + "These aren't native Oxlint plugins and no surviving JS plugin contributes them.", + report, + ); + } +} + /** * Merge oxlint and oxfmt config into vite.config.ts */ @@ -1959,6 +2467,12 @@ export function mergeViteConfigFiles( projectPath: string, silent = false, report?: MigrationReport, + packages?: WorkspacePackage[], + // For per-sub-package callers: the workspace root that `packages[].path` + // is relative to. When undefined we resolve relative to `projectPath` + // (correct for the top-level standalone/monorepo callers, where + // projectPath IS the workspace root). + workspaceRoot?: string, ): void { const configs = detectConfigs(projectPath); if (!configs.oxfmtConfig && !configs.oxlintConfig) { @@ -1983,6 +2497,18 @@ export function mergeViteConfigFiles( } else { warnMigration(BASEURL_TSCONFIG_WARNING, report); } + // Drop references to plugins / jsPlugins / rules that won't resolve + // at lint time (e.g. `@oxlint/migrate` translating `@unocss/eslint-config` + // → `eslint-plugin-unocss` even when that package isn't installed). + // Resolve workspace package paths against `workspaceRoot` when the + // caller is processing a sub-package — otherwise the sanitizer would + // mistakenly look for `subPath/` and miss the + // hoisted deps it's supposed to see. + sanitizeMigratedOxlintConfig( + oxlintJson, + collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), + report, + ); const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); fs.writeFileSync(fullOxlintPath, JSON.stringify(normalizedOxlintConfig, null, 2)); // merge oxlint config into vite.config.ts @@ -2866,6 +3392,59 @@ export function warnPackageLevelEslint() { ); } +// Framework-ESLint integration packages we can't migrate cleanly today. +// When any of these is present, the ESLint migration is skipped entirely +// — the user's ESLint setup stays intact and they get told how to proceed +// manually. +// +// `@nuxt/eslint` is a Nuxt module that loads ESLint at runtime via the +// dev server and writes a generated config to `.nuxt/eslint.config.mjs`, +// which the user's `eslint.config.mjs` re-exports. Migrating it +// produces a broken state: `vite.config.ts` references `@nuxt/eslint-plugin` +// (no longer installed) and `nuxt.config.ts` still tries to load the +// removed module. Track at https://github.com/voidzero-dev/vite-plus/issues +// once an issue exists. +const INCOMPATIBLE_ESLINT_INTEGRATIONS = ['@nuxt/eslint'] as const; + +/** + * Detect framework-ESLint integration packages whose ESLint migration is + * known to be incompatible. Returns the offending package name, or + * `undefined` if none is present. + */ +export function detectIncompatibleEslintIntegration( + projectPath: string, + packages?: WorkspacePackage[], +): string | undefined { + const candidates = [projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path))]; + for (const candidate of candidates) { + const pkgJsonPath = path.join(candidate, 'package.json'); + if (!fs.existsSync(pkgJsonPath)) { + continue; + } + let pkg: { devDependencies?: Record; dependencies?: Record }; + try { + pkg = readJsonFile(pkgJsonPath) as typeof pkg; + } catch { + continue; + } + for (const name of INCOMPATIBLE_ESLINT_INTEGRATIONS) { + if (pkg.devDependencies?.[name] || pkg.dependencies?.[name]) { + return name; + } + } + } + return undefined; +} + +export function warnIncompatibleEslintIntegration(name: string): void { + prompts.log.warn( + `${name} detected — automatic ESLint migration is skipped. ` + + `${name} wires ESLint into a framework-specific flow that Vite+ cannot migrate cleanly yet. ` + + 'Your ESLint setup is preserved. ' + + `To migrate manually, remove ${name} from package.json and re-run \`vp migrate\`.`, + ); +} + export function warnLegacyEslintConfig(legacyConfigFile: string) { prompts.log.warn( `Legacy ESLint configuration detected (${legacyConfigFile}). ` + @@ -2898,6 +3477,11 @@ export async function promptEslintMigration( interactive: boolean, packages?: WorkspacePackage[], ): Promise { + const incompatible = detectIncompatibleEslintIntegration(projectPath, packages); + if (incompatible) { + warnIncompatibleEslintIntegration(incompatible); + return false; + } const eslintProject = detectEslintProject(projectPath, packages); if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) { warnLegacyEslintConfig(eslintProject.legacyConfigFile);