From 5f53d288064cdb5b0eca658b440e51e1046241c4 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 10:22:49 +0800 Subject: [PATCH 01/17] fix(migrate): remove ESLint plugins/configs and detect type-aware usage `vp migrate` left `eslint-plugin-*`, `eslint-config-*`, `typescript-eslint`, and `@typescript-eslint/*` behind in `package.json` after removing `eslint` itself, leaving the user with dead packages that only configure ESLint. Expand `rewriteEslintPackageJson` to drop those plus scoped variants (e.g. `@vue/eslint-config-typescript`). Add a `hasTypeAwareEslintConfig` heuristic that recognizes `recommendedTypeChecked`, `projectService`, and `parserOptions.project` across flat configs, legacy `.eslintrc.*`, and `package.json#eslintConfig`, and record the result on the migration report so downstream messaging can be transparent about preserving type-aware coverage in the resulting Oxlint config. Addresses PR comments r3255784734, r3255786019, r3255859061 from WeakAuras/WeakAuras-Companion#2956. --- .../eslint.config.mjs | 7 + .../package.json | 20 ++ .../migration-eslint-plugins-cleanup/snap.txt | 26 ++ .../steps.json | 7 + .../eslint.config.mjs | 15 ++ .../migration-eslint-type-aware/package.json | 16 ++ .../migration-eslint-type-aware/snap.txt | 60 +++++ .../migration-eslint-type-aware/steps.json | 7 + .../migration-eslint-type-aware/tsconfig.json | 10 + .../src/migration/__tests__/migrator.spec.ts | 237 +++++++++++++----- packages/cli/src/migration/migrator.ts | 126 +++++++++- packages/cli/src/migration/report.ts | 6 + 12 files changed, 461 insertions(+), 76 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/eslint.config.mjs create mode 100644 packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-type-aware/eslint.config.mjs create mode 100644 packages/cli/snap-tests-global/migration-eslint-type-aware/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-eslint-type-aware/steps.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-type-aware/tsconfig.json 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..d9e8cff8e5 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json @@ -0,0 +1,20 @@ +{ + "name": "migration-eslint-plugins-cleanup", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint ." + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^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-plugin-vue": "^10.0.0", + "typescript-eslint": "^8.0.0", + "vite": "^7.0.0", + "vue": "^3.5.0" + } +} 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..b36e1370f1 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt @@ -0,0 +1,26 @@ +> vp migrate --no-interactive # migration should remove ESLint and its plugins/configs +◇ Migrated . to Vite+ +• Node pnpm +• 4 config updates applied +• ESLint rules migrated to Oxlint +• TypeScript shim added for framework component files + +> cat package.json # check eslint, eslint-plugin-*, eslint-config-*, typescript-eslint and @typescript-eslint/* are removed +{ + "name": "migration-eslint-plugins-cleanup", + "scripts": { + "dev": "vp dev", + "build": "vp build", + "lint": "vp lint .", + "prepare": "vp config" + }, + "devDependencies": { + "@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 \ No newline at end of file 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..81d0187b65 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp migrate --no-interactive # migration should remove ESLint and its plugins/configs", + "cat package.json # check eslint, eslint-plugin-*, eslint-config-*, typescript-eslint and @typescript-eslint/* are removed", + "test ! -f eslint.config.mjs # check eslint config is removed" + ] +} 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..fffadca319 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c710d0b1d3..12065d3cb7 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, + hasTypeAwareEslintConfig, } = await import('../migrator.js'); describe('rewritePackageJson', () => { @@ -327,6 +329,169 @@ 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('preserves unrelated dependencies (e.g. @vitejs/plugin-vue, vue, vite)', () => { + 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', + '@nuxt/eslint': '^0.5.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', + // `@nuxt/eslint` is the runtime package, not a config — preserved. + // (Only `@nuxt/eslint-config` matches the scoped-config pattern.) + '@nuxt/eslint': '^0.5.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); + }); +}); + +describe('hasTypeAwareEslintConfig', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-type-aware-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects flat config using tseslint.configs.recommendedTypeChecked', () => { + const file = 'eslint.config.js'; + fs.writeFileSync( + path.join(tmpDir, file), + `import tseslint from 'typescript-eslint';\nexport default tseslint.config(tseslint.configs.recommendedTypeChecked);\n`, + ); + expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(true); + }); + + it('detects flat config using strictTypeChecked', () => { + const file = 'eslint.config.ts'; + fs.writeFileSync( + path.join(tmpDir, file), + `export default [tseslint.configs.strictTypeChecked];\n`, + ); + expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(true); + }); + + it('detects parserOptions.projectService', () => { + const file = 'eslint.config.js'; + fs.writeFileSync( + path.join(tmpDir, file), + `export default [{ languageOptions: { parserOptions: { projectService: true } } }];\n`, + ); + expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(true); + }); + + it('detects legacy .eslintrc.json with parserOptions.project', () => { + const file = '.eslintrc.json'; + fs.writeFileSync( + path.join(tmpDir, file), + JSON.stringify({ parserOptions: { project: './tsconfig.json' } }), + ); + expect(hasTypeAwareEslintConfig(tmpDir, undefined, file)).toBe(true); + }); + + it('detects eslintConfig in package.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 't', + eslintConfig: { parserOptions: { project: './tsconfig.json' } }, + }), + ); + expect(hasTypeAwareEslintConfig(tmpDir)).toBe(true); + }); + + it('returns false when no type-aware indicators are present', () => { + const file = 'eslint.config.js'; + fs.writeFileSync( + path.join(tmpDir, file), + `export default [{ rules: { 'no-unused-vars': 'error' } }];\n`, + ); + expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(false); + }); + + it('returns false when neither config file nor package.json exists', () => { + expect(hasTypeAwareEslintConfig(tmpDir)).toBe(false); + }); +}); + describe('parseNvmrcVersion', () => { it('strips v prefix', () => { expect(parseNvmrcVersion('v20.5.0')).toBe('20.5.0'); @@ -431,23 +596,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 +611,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 +628,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 +648,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/migrator.ts b/packages/cli/src/migration/migrator.ts index ec3217f783..1b60247203 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -197,6 +197,75 @@ export function detectEslintProject( return { hasDependency, configFile, legacyConfigFile }; } +/** + * Heuristic check for type-aware ESLint usage. Looks at flat / legacy / + * package.json#eslintConfig sources for either the typescript-eslint + * preset names (`recommendedTypeChecked` etc.) or the + * `parserOptions.project` / `projectService` keys that switch on + * type-aware linting. + * + * Used to record `report.hadTypeAwareEslint` so the user knows the + * Oxlint config preserves their prior type-aware coverage. + */ +export function hasTypeAwareEslintConfig( + projectPath: string, + configFile?: string, + legacyConfigFile?: string, +): boolean { + const candidates: string[] = []; + if (configFile) { + candidates.push(path.join(projectPath, configFile)); + } + if (legacyConfigFile) { + candidates.push(path.join(projectPath, legacyConfigFile)); + } + const packageJsonPath = path.join(projectPath, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = readJsonFile(packageJsonPath) as { eslintConfig?: unknown }; + if (pkg.eslintConfig) { + if (containsTypeAwareIndicator(JSON.stringify(pkg.eslintConfig))) { + return true; + } + } + } catch { + // ignore + } + } + for (const filePath of candidates) { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + continue; + } + if (containsTypeAwareIndicator(content)) { + return true; + } + } + return false; +} + +function containsTypeAwareIndicator(text: string): boolean { + // typescript-eslint preset names that enable type-aware rules. + if (/recommendedTypeChecked|strictTypeChecked|stylisticTypeChecked/.test(text)) { + return true; + } + // New parserOptions.projectService API (typescript-eslint v8+). + if (/projectService/.test(text)) { + return true; + } + // Classic parserOptions.project pattern, both JSON and JS literal forms. + // We require both keys to be present and reasonably close together to + // avoid false positives from comments or unrelated keys. The `["']?` + // after `project` lets the regex match the JSON form `"project":` as + // well as the JS literal form `project:`. + if (/parserOptions[\s\S]{0,400}?\bproject["']?\s*:/.test(text)) { + return true; + } + return false; +} + /** * Run a `vp dlx @oxlint/migrate` step with graceful error handling. * Returns true on success, false on failure (spawn error or non-zero exit). @@ -256,6 +325,16 @@ export async function migrateEslintToOxlint( } : getSpinner(interactive); + // Record type-aware usage BEFORE we delete the config files, so we can + // preserve type-aware coverage in the resulting Oxlint config and tell + // the user we did so. + if (options?.report) { + const legacyConfigs = detectConfigs(projectPath); + if (hasTypeAwareEslintConfig(projectPath, eslintConfigFile, legacyConfigs.eslintLegacyConfig)) { + options.report.hadTypeAwareEslint = true; + } + } + // Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root if (eslintConfigFile) { // Pin @oxlint/migrate to the bundled oxlint version. @@ -343,7 +422,34 @@ function deleteEslintConfigFiles(basePath: string, report?: MigrationReport, sil } } -function rewriteEslintPackageJson(packageJsonPath: string): void { +/** + * Decide whether a dependency entry should be removed alongside `eslint` + * itself. The set is conservative: only packages whose sole purpose is to + * configure or extend ESLint. Anything else is preserved. + */ +function isEslintEcosystemDep(name: string): boolean { + if (name === 'eslint') { + return true; + } + if (name.startsWith('eslint-plugin-') || name.startsWith('eslint-config-')) { + return true; + } + if (name === 'typescript-eslint') { + return true; + } + if (name.startsWith('@typescript-eslint/')) { + return true; + } + // Scoped plugins/configs, e.g. `@vue/eslint-config-typescript`, + // `@nuxt/eslint-config`, `@stylistic/eslint-plugin`. These are all + // ESLint-only and have no other consumers. + if (/^@[^/]+\/eslint-(plugin|config)(-.+)?$/.test(name)) { + return true; + } + return false; +} + +export function rewriteEslintPackageJson(packageJsonPath: string): void { editJsonFile<{ devDependencies?: Record; dependencies?: Record; @@ -351,13 +457,17 @@ function rewriteEslintPackageJson(packageJsonPath: string): void { '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'] as const) { + const deps = pkg[field]; + if (!deps) { + continue; + } + for (const name of Object.keys(deps)) { + if (isEslintEcosystemDep(name)) { + delete deps[name]; + changed = true; + } + } } if (pkg.scripts) { const updated = rewriteEslint(JSON.stringify(pkg.scripts)); diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 7168caee0f..6adccc4c47 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -8,6 +8,11 @@ export interface MigrationReport { rewrittenImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; eslintMigrated: boolean; + /** True when the original ESLint config used type-aware rules + * (typescript-eslint with parserOptions.project / projectService, or + * the `recommendedTypeChecked` config). Used to preserve type-aware + * coverage when emitting the Oxlint config. */ + hadTypeAwareEslint: boolean; prettierMigrated: boolean; nodeVersionFileMigrated: boolean; gitHooksConfigured: boolean; @@ -27,6 +32,7 @@ export function createMigrationReport(): MigrationReport { rewrittenImportFileCount: 0, rewrittenImportErrors: [], eslintMigrated: false, + hadTypeAwareEslint: false, prettierMigrated: false, nodeVersionFileMigrated: false, gitHooksConfigured: false, From a8fa3be2b8f8d00fd230dcd9a24e582e0eecda57 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 11:10:25 +0800 Subject: [PATCH 02/17] fix(migrate): drop unused hasTypeAwareEslintConfig sniffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `hadTypeAwareEslint` flag and its detection helper had no downstream consumers — `mergeViteConfigFiles` and `injectLintTypeCheckDefaults` both already default type-aware to on, so the recorded value never influenced behavior. Remove the helper, the report field, and the related unit tests; the plugin/config cleanup that addresses the underlying PR comments is unaffected. The snap-test fixture `migration-eslint-type-aware` is kept as a regression check that a type-aware flat config still migrates cleanly (typescript-eslint plugins removed; resulting `vite.config.ts` retains `options.typeAware/typeCheck: true`). --- .../src/migration/__tests__/migrator.spec.ts | 73 ----------------- packages/cli/src/migration/migrator.ts | 79 ------------------- packages/cli/src/migration/report.ts | 6 -- 3 files changed, 158 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 12065d3cb7..ca05db995a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -28,7 +28,6 @@ const { addFrameworkShim, injectCreateDefaultTemplate, rewriteEslintPackageJson, - hasTypeAwareEslintConfig, } = await import('../migrator.js'); describe('rewritePackageJson', () => { @@ -420,78 +419,6 @@ describe('rewriteEslintPackageJson', () => { }); }); -describe('hasTypeAwareEslintConfig', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-type-aware-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('detects flat config using tseslint.configs.recommendedTypeChecked', () => { - const file = 'eslint.config.js'; - fs.writeFileSync( - path.join(tmpDir, file), - `import tseslint from 'typescript-eslint';\nexport default tseslint.config(tseslint.configs.recommendedTypeChecked);\n`, - ); - expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(true); - }); - - it('detects flat config using strictTypeChecked', () => { - const file = 'eslint.config.ts'; - fs.writeFileSync( - path.join(tmpDir, file), - `export default [tseslint.configs.strictTypeChecked];\n`, - ); - expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(true); - }); - - it('detects parserOptions.projectService', () => { - const file = 'eslint.config.js'; - fs.writeFileSync( - path.join(tmpDir, file), - `export default [{ languageOptions: { parserOptions: { projectService: true } } }];\n`, - ); - expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(true); - }); - - it('detects legacy .eslintrc.json with parserOptions.project', () => { - const file = '.eslintrc.json'; - fs.writeFileSync( - path.join(tmpDir, file), - JSON.stringify({ parserOptions: { project: './tsconfig.json' } }), - ); - expect(hasTypeAwareEslintConfig(tmpDir, undefined, file)).toBe(true); - }); - - it('detects eslintConfig in package.json', () => { - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ - name: 't', - eslintConfig: { parserOptions: { project: './tsconfig.json' } }, - }), - ); - expect(hasTypeAwareEslintConfig(tmpDir)).toBe(true); - }); - - it('returns false when no type-aware indicators are present', () => { - const file = 'eslint.config.js'; - fs.writeFileSync( - path.join(tmpDir, file), - `export default [{ rules: { 'no-unused-vars': 'error' } }];\n`, - ); - expect(hasTypeAwareEslintConfig(tmpDir, file)).toBe(false); - }); - - it('returns false when neither config file nor package.json exists', () => { - expect(hasTypeAwareEslintConfig(tmpDir)).toBe(false); - }); -}); - describe('parseNvmrcVersion', () => { it('strips v prefix', () => { expect(parseNvmrcVersion('v20.5.0')).toBe('20.5.0'); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 1b60247203..2274e1b9b4 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -197,75 +197,6 @@ export function detectEslintProject( return { hasDependency, configFile, legacyConfigFile }; } -/** - * Heuristic check for type-aware ESLint usage. Looks at flat / legacy / - * package.json#eslintConfig sources for either the typescript-eslint - * preset names (`recommendedTypeChecked` etc.) or the - * `parserOptions.project` / `projectService` keys that switch on - * type-aware linting. - * - * Used to record `report.hadTypeAwareEslint` so the user knows the - * Oxlint config preserves their prior type-aware coverage. - */ -export function hasTypeAwareEslintConfig( - projectPath: string, - configFile?: string, - legacyConfigFile?: string, -): boolean { - const candidates: string[] = []; - if (configFile) { - candidates.push(path.join(projectPath, configFile)); - } - if (legacyConfigFile) { - candidates.push(path.join(projectPath, legacyConfigFile)); - } - const packageJsonPath = path.join(projectPath, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - try { - const pkg = readJsonFile(packageJsonPath) as { eslintConfig?: unknown }; - if (pkg.eslintConfig) { - if (containsTypeAwareIndicator(JSON.stringify(pkg.eslintConfig))) { - return true; - } - } - } catch { - // ignore - } - } - for (const filePath of candidates) { - let content: string; - try { - content = fs.readFileSync(filePath, 'utf8'); - } catch { - continue; - } - if (containsTypeAwareIndicator(content)) { - return true; - } - } - return false; -} - -function containsTypeAwareIndicator(text: string): boolean { - // typescript-eslint preset names that enable type-aware rules. - if (/recommendedTypeChecked|strictTypeChecked|stylisticTypeChecked/.test(text)) { - return true; - } - // New parserOptions.projectService API (typescript-eslint v8+). - if (/projectService/.test(text)) { - return true; - } - // Classic parserOptions.project pattern, both JSON and JS literal forms. - // We require both keys to be present and reasonably close together to - // avoid false positives from comments or unrelated keys. The `["']?` - // after `project` lets the regex match the JSON form `"project":` as - // well as the JS literal form `project:`. - if (/parserOptions[\s\S]{0,400}?\bproject["']?\s*:/.test(text)) { - return true; - } - return false; -} - /** * Run a `vp dlx @oxlint/migrate` step with graceful error handling. * Returns true on success, false on failure (spawn error or non-zero exit). @@ -325,16 +256,6 @@ export async function migrateEslintToOxlint( } : getSpinner(interactive); - // Record type-aware usage BEFORE we delete the config files, so we can - // preserve type-aware coverage in the resulting Oxlint config and tell - // the user we did so. - if (options?.report) { - const legacyConfigs = detectConfigs(projectPath); - if (hasTypeAwareEslintConfig(projectPath, eslintConfigFile, legacyConfigs.eslintLegacyConfig)) { - options.report.hadTypeAwareEslint = true; - } - } - // Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root if (eslintConfigFile) { // Pin @oxlint/migrate to the bundled oxlint version. diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 6adccc4c47..7168caee0f 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -8,11 +8,6 @@ export interface MigrationReport { rewrittenImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; eslintMigrated: boolean; - /** True when the original ESLint config used type-aware rules - * (typescript-eslint with parserOptions.project / projectService, or - * the `recommendedTypeChecked` config). Used to preserve type-aware - * coverage when emitting the Oxlint config. */ - hadTypeAwareEslint: boolean; prettierMigrated: boolean; nodeVersionFileMigrated: boolean; gitHooksConfigured: boolean; @@ -32,7 +27,6 @@ export function createMigrationReport(): MigrationReport { rewrittenImportFileCount: 0, rewrittenImportErrors: [], eslintMigrated: false, - hadTypeAwareEslint: false, prettierMigrated: false, nodeVersionFileMigrated: false, gitHooksConfigured: false, From 6743b68380d719de74ba0261e099fdb53858fdc2 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 11:35:15 +0800 Subject: [PATCH 03/17] fix(migrate): broaden ESLint ecosystem cleanup and address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address findings from the extra-high-effort review of this PR: - Iterate `peerDependencies` and `optionalDependencies` in addition to `devDependencies` and `dependencies`, matching the sister code path that handles vite/vitest overrides. - Narrow the `@typescript-eslint/*` namespace removal to the ESLint-specific entry points (`eslint-plugin`, `parser`, `rule-tester`); preserve the reusable AST libraries (`utils`, `typescript-estree`, `scope-manager`, `types`) so codemods and doc generators that import them directly keep working. - Recognize `@eslint/*`, `@eslint-community/*`, and `@angular-eslint/*` scopes as ESLint-only (e.g. `@eslint/js`, `@eslint/eslintrc`, `@eslint-community/eslint-utils`, `@angular-eslint/template-parser`). - Recognize flat ESLint-only helpers: `eslint-formatter-*`, `eslintrc`, `eslint-utils`, `eslint-visitor-keys`, `eslint-scope`, `eslint-define-config`, `eslint-doc-generator`. - Recognize `@nuxt/eslint` (Nuxt's ESLint integration module that `require`s `eslint` at runtime — useless after removal). The unit test that previously asserted this was preserved is now inverted. - Strip `@types/` symmetrically: a `@types/` is removable iff `` is. Fixes the inconsistency where `@types/eslint-plugin-foo` was removed but `@types/eslint` survived. - Delete a dependency field entirely (`devDependencies`, `peerDependencies`, etc.) when our cleanup emptied it, avoiding `"devDependencies": {}` noise. Visible in the `migration-eslint-monorepo` snap diff. - Split `rewriteEslintPackageJson` into root vs workspace modes: workspace sub-packages get a conservative cleanup (only `eslint` itself), since they may intentionally publish plugins/configs as part of a shared lint-preset API consumed outside Vite+. - Drop the broken `"include": ["src/**/*"]` from the `migration-eslint-type-aware` fixture's `tsconfig.json` (no `src/` directory exists). - Expand the `migration-eslint-plugins-cleanup` fixture to exercise the wider matrix (scopes, formatters, `@types`, preserved `@typescript-eslint/utils`, peer `eslint`). Deferred (out of scope for this PR): - `detectEslintProject` gating on the literal `eslint` symbol — a project that has only `typescript-eslint` etc. never triggers the migration. Pre-existing. - Stale `--rule '@typescript-eslint/...'` arguments in scripts after plugin removal — requires Rust ast-grep rule updates. - Non-atomic `fs.writeFileSync` across N workspace `package.json` files; a SIGKILL mid-loop leaves the monorepo half-migrated. Pre-existing utility behavior. --- .../migration-eslint-monorepo/snap.txt | 3 +- .../package.json | 12 ++ .../migration-eslint-plugins-cleanup/snap.txt | 7 +- .../steps.json | 4 +- .../migration-eslint-type-aware/tsconfig.json | 3 +- .../src/migration/__tests__/migrator.spec.ts | 143 +++++++++++++++++- packages/cli/src/migration/migrator.ts | 99 +++++++++--- 7 files changed, 239 insertions(+), 32 deletions(-) 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-plugins-cleanup/package.json b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json index d9e8cff8e5..74be196298 100644 --- a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json @@ -6,15 +6,27 @@ "lint": "eslint ." }, "devDependencies": { + "@angular-eslint/template-parser": "^18.0.0", + "@eslint-community/eslint-utils": "^4.0.0", + "@eslint/js": "^9.0.0", + "@nuxt/eslint": "^0.5.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 index b36e1370f1..d5b7e77a6d 100644 --- a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt @@ -1,11 +1,11 @@ -> vp migrate --no-interactive # migration should remove ESLint and its plugins/configs +> 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 # check eslint, eslint-plugin-*, eslint-config-*, typescript-eslint and @typescript-eslint/* are removed +> cat package.json # verify the comprehensive ESLint ecosystem cleanup { "name": "migration-eslint-plugins-cleanup", "scripts": { @@ -15,6 +15,9 @@ "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", 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 index 81d0187b65..99d200f7d6 100644 --- a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json @@ -1,7 +1,7 @@ { "commands": [ - "vp migrate --no-interactive # migration should remove ESLint and its plugins/configs", - "cat package.json # check eslint, eslint-plugin-*, eslint-config-*, typescript-eslint and @typescript-eslint/* are removed", + "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" ] } 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 index fffadca319..8dca15760d 100644 --- a/packages/cli/snap-tests-global/migration-eslint-type-aware/tsconfig.json +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/tsconfig.json @@ -5,6 +5,5 @@ "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true - }, - "include": ["src/**/*"] + } } diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index ca05db995a..af91c54ed9 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -384,14 +384,128 @@ describe('rewriteEslintPackageJson', () => { expect(pkg.devDependencies).toEqual({ keepme: '^1.0.0' }); }); - it('preserves unrelated dependencies (e.g. @vitejs/plugin-vue, vue, vite)', () => { + 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, helper, and runtime-integration 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', + '@nuxt/eslint': '^0.5.0', + keepme: '^1.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ keepme: '^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', - '@nuxt/eslint': '^0.5.0', vite: '^7.0.0', }, }); @@ -401,9 +515,28 @@ describe('rewriteEslintPackageJson', () => { '@vitejs/plugin-vue': '^6.0.0', '@vue/runtime-core': '^3.5.0', '@nuxt/kit': '^3.13.0', - // `@nuxt/eslint` is the runtime package, not a config — preserved. - // (Only `@nuxt/eslint-config` matches the scoped-config pattern.) - '@nuxt/eslint': '^0.5.0', + vite: '^7.0.0', + }); + }); + + it('workspace mode removes only `eslint` itself, not plugins or configs', () => { + const pkgPath = writePkg({ + devDependencies: { + eslint: '^9.0.0', + 'eslint-plugin-import': '^2.0.0', + 'eslint-config-airbnb': '^19.0.0', + '@typescript-eslint/parser': '^8.0.0', + vite: '^7.0.0', + }, + }); + rewriteEslintPackageJson(pkgPath, 'workspace'); + const pkg = readJson(pkgPath); + // Plugins/configs that the workspace package may publish as a shared + // lint-preset API are preserved; only `eslint` itself goes. + expect(pkg.devDependencies).toEqual({ + 'eslint-plugin-import': '^2.0.0', + 'eslint-config-airbnb': '^19.0.0', + '@typescript-eslint/parser': '^8.0.0', vite: '^7.0.0', }); }); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 2274e1b9b4..552cf783c9 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -309,13 +309,16 @@ export async function migrateEslintToOxlint( // 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 4: Remove eslint and all ESLint-ecosystem dependencies at the root, + // and rewrite eslint scripts. + rewriteEslintPackageJson(path.join(projectPath, 'package.json'), 'root'); - // Step 4b: Rewrite eslint scripts in workspace packages + // Step 4b: Workspace packages get a conservative cleanup — only `eslint` + // itself is removed; plugins and configs they declare may be part of a + // shared lint-preset API consumed outside Vite+, so we leave them alone. if (packages) { for (const pkg of packages) { - rewriteEslintPackageJson(path.join(projectPath, pkg.path, 'package.json')); + rewriteEslintPackageJson(path.join(projectPath, pkg.path, 'package.json'), 'workspace'); } } @@ -343,52 +346,110 @@ function deleteEslintConfigFiles(basePath: string, report?: MigrationReport, sil } } +// 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', + // Framework runtime modules that wire ESLint and break without it: + '@nuxt/eslint', +]); + +// 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 conservative: only packages whose sole purpose is to - * configure or extend ESLint. Anything else is preserved. + * 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 { - if (name === 'eslint') { - return true; - } - if (name.startsWith('eslint-plugin-') || name.startsWith('eslint-config-')) { + const stripped = name.startsWith('@types/') ? name.slice('@types/'.length) : name; + if (ESLINT_ECOSYSTEM_NAMES.has(stripped)) { return true; } - if (name === 'typescript-eslint') { + if (ESLINT_ECOSYSTEM_PREFIXES.some((p) => stripped.startsWith(p))) { return true; } - if (name.startsWith('@typescript-eslint/')) { + if (ESLINT_ECOSYSTEM_SCOPES.some((s) => stripped.startsWith(s))) { return true; } - // Scoped plugins/configs, e.g. `@vue/eslint-config-typescript`, - // `@nuxt/eslint-config`, `@stylistic/eslint-plugin`. These are all - // ESLint-only and have no other consumers. - if (/^@[^/]+\/eslint-(plugin|config)(-.+)?$/.test(name)) { + // 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; } -export function rewriteEslintPackageJson(packageJsonPath: string): void { +/** + * Rewrite a project's `package.json` after ESLint has been migrated to + * Oxlint. Root cleanup is aggressive — every ESLint-ecosystem dependency + * is removed (see `isEslintEcosystemDep`). Workspace cleanup is + * conservative — only `eslint` itself is removed, because workspace + * sub-packages may intentionally publish ESLint plugins / configs as + * part of a shared lint-preset API consumed outside Vite+. + */ +export function rewriteEslintPackageJson( + packageJsonPath: string, + mode: 'root' | 'workspace' = 'root', +): void { editJsonFile<{ devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; scripts?: Record; 'lint-staged'?: Record; }>(packageJsonPath, (pkg) => { let changed = false; - for (const field of ['devDependencies', 'dependencies'] as const) { + const isRemovable = mode === 'root' ? isEslintEcosystemDep : (n: string) => n === 'eslint'; + 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 (isEslintEcosystemDep(name)) { + if (isRemovable(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)); From c396bfd118780e24efebd75fdf23e6004b227863 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 13:49:37 +0800 Subject: [PATCH 04/17] test(migrate): show generated vite.config.ts in plugins-cleanup snap So reviewers can see the lint/staged/fmt block the comprehensive cleanup produces, not just the trimmed package.json. --- .../migration-eslint-plugins-cleanup/snap.txt | 39 ++++++++++++++++++- .../steps.json | 3 +- 2 files changed, 40 insertions(+), 2 deletions(-) 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 index d5b7e77a6d..0275e5301a 100644 --- a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt @@ -26,4 +26,41 @@ "packageManager": "pnpm@" } -> test ! -f eslint.config.mjs # check eslint config is removed \ No newline at end of file +> 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 index 99d200f7d6..7dc5a98d2a 100644 --- a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/steps.json @@ -2,6 +2,7 @@ "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" + "test ! -f eslint.config.mjs # check eslint config is removed", + "cat vite.config.ts # verify the generated vite.config.ts" ] } From 85fd6d9dd18f570fe0dc9c3549ef46dfc5c57ef1 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 14:16:41 +0800 Subject: [PATCH 05/17] feat(migrate): skip ESLint migration when @nuxt/eslint is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../eslint.config.mjs | 10 ++++ .../migration-eslint-nuxt-skip/package.json | 18 ++++++ .../migration-eslint-nuxt-skip/snap.txt | 31 ++++++++++ .../migration-eslint-nuxt-skip/steps.json | 7 +++ .../src/migration/__tests__/migrator.spec.ts | 52 +++++++++++++++++ packages/cli/src/migration/bin.ts | 13 ++++- packages/cli/src/migration/migrator.ts | 58 +++++++++++++++++++ 7 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 packages/cli/snap-tests-global/migration-eslint-nuxt-skip/eslint.config.mjs create mode 100644 packages/cli/snap-tests-global/migration-eslint-nuxt-skip/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-eslint-nuxt-skip/steps.json 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/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index af91c54ed9..16eaf73462 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -28,6 +28,7 @@ const { addFrameworkShim, injectCreateDefaultTemplate, rewriteEslintPackageJson, + detectIncompatibleEslintIntegration, } = await import('../migrator.js'); describe('rewritePackageJson', () => { @@ -552,6 +553,57 @@ describe('rewriteEslintPackageJson', () => { }); }); +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'); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d9fc891b18..7cb374ed6a 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); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 552cf783c9..ca5ad94a39 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2958,6 +2958,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}). ` + @@ -2990,6 +3043,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); From 39c5f3f226d522ba5f75e4390558fb7e6199e663 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 14:26:02 +0800 Subject: [PATCH 06/17] test(migrate): drop @nuxt/eslint from plugins-cleanup fixture The fixture's @nuxt/eslint dep was now being intercepted by the new skip-on-@nuxt/eslint behavior, so the snap.txt would silently drift to show the skip path instead of exercising the comprehensive cleanup it was meant to verify. The skip path has its own fixture (`migration-eslint-nuxt-skip`); this one stays focused on cleanup. --- .../migration-eslint-plugins-cleanup/package.json | 1 - 1 file changed, 1 deletion(-) 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 index 74be196298..5ec40adb40 100644 --- a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/package.json @@ -9,7 +9,6 @@ "@angular-eslint/template-parser": "^18.0.0", "@eslint-community/eslint-utils": "^4.0.0", "@eslint/js": "^9.0.0", - "@nuxt/eslint": "^0.5.0", "@nuxt/kit": "^3.13.0", "@types/eslint": "^9.0.0", "@types/node": "^22.0.0", From 19a6005c17b6be6304617f29bdb65d4c19fe8890 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 14:40:15 +0800 Subject: [PATCH 07/17] refactor(migrate): apply full ESLint cleanup to workspace packages too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `mode: 'root' | 'workspace'` parameter on `rewriteEslintPackageJson` and apply `isEslintEcosystemDep` cleanup uniformly across the root and every workspace package. The split was a compromise that didn't hold up: - The "conservative workspace" mode still removed `eslint` from every dependency field (including `peerDependencies`), which by itself breaks any workspace package that published a shared ESLint preset. Keeping the plugins around in that case didn't restore the publishable package — it just made it half-broken instead of fully. - The rest of the migration already treats workspace packages as in scope for adoption (replacing vite-related overrides, adding vite-plus, etc.). A different policy just for plugin removal was inconsistent with the surrounding behavior. A user who genuinely wants to keep a published shared ESLint preset intact should exclude that package from migration rather than rely on partial cleanup. The existing `migration-eslint-monorepo` snap doesn't change — its fixture only had bare `eslint` in subpackages, which workspace mode removed anyway. A new fixture `migration-eslint-monorepo-plugins-in-packages` covers the actual behavior change: workspace packages with plugins / configs / scoped typescript-eslint deps get the full cleanup (including AST-library preservation for `@typescript-eslint/utils`), and emptied dependency fields are stripped. --- .../eslint.config.mjs | 7 +++ .../package.json | 12 +++++ .../packages/app/package.json | 14 ++++++ .../packages/lint-config/package.json | 14 ++++++ .../pnpm-workspace.yaml | 2 + .../snap.txt | 45 +++++++++++++++++++ .../steps.json | 8 ++++ .../src/migration/__tests__/migrator.spec.ts | 22 --------- packages/cli/src/migration/migrator.ts | 35 +++++++-------- 9 files changed, 118 insertions(+), 41 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/eslint.config.mjs create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/lint-config/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/pnpm-workspace.yaml create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/steps.json 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/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..bc395cb012 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/snap.txt @@ -0,0 +1,45 @@ +> vp migrate --no-interactive # workspace packages should get the SAME aggressive cleanup as the root + +✔ Created vite.config.ts in vite.config.ts + +✔ Merged .oxlintrc.json into vite.config.ts +◇ Migrated . to Vite+ +• Node pnpm +• 2 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 ." + } +} 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..bd622cf4ab --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migrate --no-interactive # workspace packages should get the SAME aggressive cleanup as the root", + "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)" + ] +} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 16eaf73462..b7aeef8a5a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -520,28 +520,6 @@ describe('rewriteEslintPackageJson', () => { }); }); - it('workspace mode removes only `eslint` itself, not plugins or configs', () => { - const pkgPath = writePkg({ - devDependencies: { - eslint: '^9.0.0', - 'eslint-plugin-import': '^2.0.0', - 'eslint-config-airbnb': '^19.0.0', - '@typescript-eslint/parser': '^8.0.0', - vite: '^7.0.0', - }, - }); - rewriteEslintPackageJson(pkgPath, 'workspace'); - const pkg = readJson(pkgPath); - // Plugins/configs that the workspace package may publish as a shared - // lint-preset API are preserved; only `eslint` itself goes. - expect(pkg.devDependencies).toEqual({ - 'eslint-plugin-import': '^2.0.0', - 'eslint-config-airbnb': '^19.0.0', - '@typescript-eslint/parser': '^8.0.0', - vite: '^7.0.0', - }); - }); - it('no-ops when package.json has no eslint-ecosystem deps', () => { const pkgPath = writePkg({ devDependencies: { vite: '^7.0.0' }, diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index ca5ad94a39..30a2fe1966 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -309,16 +309,15 @@ export async function migrateEslintToOxlint( // Step 3: Delete all eslint config files at root deleteEslintConfigFiles(projectPath, options?.report, options?.silent); - // Step 4: Remove eslint and all ESLint-ecosystem dependencies at the root, - // and rewrite eslint scripts. - rewriteEslintPackageJson(path.join(projectPath, 'package.json'), 'root'); - - // Step 4b: Workspace packages get a conservative cleanup — only `eslint` - // itself is removed; plugins and configs they declare may be part of a - // shared lint-preset API consumed outside Vite+, so we leave them alone. + // Step 4: Remove all ESLint-ecosystem dependencies and rewrite eslint + // scripts, at the root and at every workspace package. A monorepo + // running `vp migrate` is being adopted as a whole — a project that + // intentionally publishes a shared ESLint preset should opt out of + // migration for that package rather than rely on partial cleanup. + rewriteEslintPackageJson(path.join(projectPath, 'package.json')); if (packages) { for (const pkg of packages) { - rewriteEslintPackageJson(path.join(projectPath, pkg.path, 'package.json'), 'workspace'); + rewriteEslintPackageJson(path.join(projectPath, pkg.path, 'package.json')); } } @@ -407,16 +406,15 @@ function isEslintEcosystemDep(name: string): boolean { /** * Rewrite a project's `package.json` after ESLint has been migrated to - * Oxlint. Root cleanup is aggressive — every ESLint-ecosystem dependency - * is removed (see `isEslintEcosystemDep`). Workspace cleanup is - * conservative — only `eslint` itself is removed, because workspace - * sub-packages may intentionally publish ESLint plugins / configs as - * part of a shared lint-preset API consumed outside Vite+. + * 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). */ -export function rewriteEslintPackageJson( - packageJsonPath: string, - mode: 'root' | 'workspace' = 'root', -): void { +export function rewriteEslintPackageJson(packageJsonPath: string): void { editJsonFile<{ devDependencies?: Record; dependencies?: Record; @@ -426,7 +424,6 @@ export function rewriteEslintPackageJson( 'lint-staged'?: Record; }>(packageJsonPath, (pkg) => { let changed = false; - const isRemovable = mode === 'root' ? isEslintEcosystemDep : (n: string) => n === 'eslint'; for (const field of [ 'devDependencies', 'dependencies', @@ -439,7 +436,7 @@ export function rewriteEslintPackageJson( } let removedAny = false; for (const name of Object.keys(deps)) { - if (isRemovable(name)) { + if (isEslintEcosystemDep(name)) { delete deps[name]; changed = true; removedAny = true; From c381443f7113c1619d187947aa7444245bde2e69 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 15:40:26 +0800 Subject: [PATCH 08/17] fix(migrate): apply ESLint cleanup uniformly to root and workspace packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the prior "uniform cleanup" commit: 1. `deleteEslintConfigFiles` was still root-only — a workspace package with its own `eslint.config.mjs` ended up with `package.json` deps stripped but the config file lingering as a broken orphan. 2. `rewriteEslintLintStagedConfigFiles` was also root-only — a workspace package with its own `.lintstagedrc.json` kept stale `eslint --fix` entries after `eslint` was removed. 3. The per-workspace loop had no `fs.existsSync` guard; a missing `package.json` between detection and Step 4 would throw `ENOENT` and abort the entire migration mid-flight. 4. The comment claimed "user should opt out of migration for that package rather than rely on partial cleanup", but no such opt-out mechanism exists. Rewritten to point at the actual workaround (excluding the package from `pnpm-workspace.yaml` / `workspaces`). Collapsed Steps 3-5 into one workspace-aware loop that iterates root + every workspace package, skipping any target whose `package.json` disappears between detection and cleanup. The `migration-eslint-monorepo-plugins-in-packages` fixture now ships a per-workspace `eslint.config.mjs` + `.lintstagedrc.json` to lock the new uniform behavior in. --- .../packages/app/.lintstagedrc.json | 3 ++ .../packages/app/eslint.config.mjs | 9 +++++ .../snap.txt | 10 ++++-- .../steps.json | 6 ++-- packages/cli/src/migration/migrator.ts | 33 ++++++++++--------- 5 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/.lintstagedrc.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/packages/app/eslint.config.mjs 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/snap.txt b/packages/cli/snap-tests-global/migration-eslint-monorepo-plugins-in-packages/snap.txt index bc395cb012..9dc3e84469 100644 --- 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 @@ -1,11 +1,11 @@ -> vp migrate --no-interactive # workspace packages should get the SAME aggressive cleanup as the root +> 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 -• 2 config updates applied +• 3 config updates applied • ESLint rules migrated to Oxlint > cat package.json # root: eslint + eslint-config-airbnb removed @@ -43,3 +43,9 @@ "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 index bd622cf4ab..bc65f3262f 100644 --- 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 @@ -1,8 +1,10 @@ { "commands": [ - "vp migrate --no-interactive # workspace packages should get the SAME aggressive cleanup as the root", + "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)" + "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/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 30a2fe1966..ac4cb49db2 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -306,24 +306,27 @@ 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 all ESLint-ecosystem dependencies and rewrite eslint - // scripts, at the root and at every workspace package. A monorepo - // running `vp migrate` is being adopted as a whole — a project that - // intentionally publishes a shared ESLint preset should opt out of - // migration for that package rather than rely on partial cleanup. - rewriteEslintPackageJson(path.join(projectPath, 'package.json')); - if (packages) { - for (const pkg of packages) { - rewriteEslintPackageJson(path.join(projectPath, pkg.path, 'package.json')); + // 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')); + rewriteEslintLintStagedConfigFiles(target, options?.report); } - // Step 5: Rewrite eslint references in lint-staged config files - rewriteEslintLintStagedConfigFiles(projectPath, options?.report); - return true; } From 35acc22d15146ccf3a1e5f939cbb58a4be707e73 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 16:07:05 +0800 Subject: [PATCH 09/17] fix(migrate): strip unresolvable plugin references from generated lint config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@oxlint/migrate` can emit `lint.jsPlugins` / `lint.plugins` / `lint.rules` entries that point at packages or plugin namespaces that won't resolve at lint time. Common case: translating `@unocss/eslint-config` into `jsPlugins: ["eslint-plugin-unocss"]` even though the user never had `eslint-plugin-unocss` installed. Result: `vp lint` aborts with "Failed to load JS plugin" or "Plugin not found" before running any rule, the generated pre-commit hook fails, and the migration looks successful but the project is unusable. Add `sanitizeMigratedOxlintConfig` which runs after `@oxlint/migrate` generates `.oxlintrc.json` and before merging into `vite.config.ts`: - drop `jsPlugins[]` string entries whose package isn't present in the root + workspace `package.json` set (computed after the ESLint-ecosystem cleanup, so removed plugins are caught here too) - drop `plugins[]` entries that aren't in Oxlint's native plugin allowlist (`oxc`, `typescript`, `unicorn`, `react`, `vue`, etc. — sourced from `LintPluginOptionsSchema` in oxlint's typings) AND aren't contributed by a surviving JS plugin - drop `rules` / `overrides[].rules` whose namespace prefix isn't backed by any surviving plugin - emit a `warnMigration` describing each class of stripped reference, pointing the user at the manual install path if they want the coverage back Verified end-to-end against the real WeakAuras/WeakAuras-Companion@9c67bd1: after migration, the generated warning names `eslint-plugin-unocss`, the merged `vite.config.ts` no longer references `unocss/*`, and `vp lint src/` runs to completion (53 real lint findings) where previously it failed with "x Failed to load JS plugin: eslint-plugin-unocss". New snap-test fixture `migration-eslint-jsplugins-orphan-strip` covers the same shape with a minimal `eslint.config.mjs` that declares a `fictional/*` rule via an inline plugin definition that translates through `@oxlint/migrate` into an unresolvable `eslint-plugin-fictional` reference. --- .../eslint.config.mjs | 24 +++ .../package.json | 10 + .../snap.txt | 44 ++++ .../steps.json | 6 + packages/cli/src/migration/migrator.ts | 203 +++++++++++++++++- 5 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/eslint.config.mjs create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/steps.json 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..9b78261228 --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/eslint.config.mjs @@ -0,0 +1,24 @@ +// Configures a `fictional/*` rule via a plugin namespace that resolves +// to neither a native Oxlint plugin nor an installed JS plugin package. +// Mirrors the WeakAuras-style failure where `@oxlint/migrate` emits +// `jsPlugins: ["eslint-plugin-fictional"]` and rules under `fictional/*`, +// even though `eslint-plugin-fictional` is not in node_modules. +export default [ + { + plugins: { + fictional: { + rules: { + 'no-fiction': { + meta: { type: 'problem' }, + create() { + return {}; + }, + }, + }, + }, + }, + rules: { + 'fictional/no-fiction': 'warn', + }, + }, +]; 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..ca424d23fe --- /dev/null +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/snap.txt @@ -0,0 +1,44 @@ +> 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 unresolvable JS plugin reference(s) from the generated lint config: eslint-plugin-fictional. @oxlint/migrate produced these from your ESLint config but the underlying package(s) aren't installed. Install them manually if you want their lint coverage back. + +> 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" + }, + "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/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index ac4cb49db2..17807b8fce 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' @@ -1068,7 +1091,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); @@ -1106,6 +1129,8 @@ export function rewriteMonorepo( skipStagedMigration, catalogDependencyResolver, ); + // (mergeViteConfigFiles below will sanitize the merged lint config + // against this workspace's full package set.) // rewrite packages for (const pkg of workspaceInfo.packages) { @@ -1124,7 +1149,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); @@ -2044,6 +2069,171 @@ function mergeTsdownConfigFile( ); } +/** + * Best-effort: derive the Oxlint rule-namespace a JS plugin package + * contributes. Mirrors the conventions @oxlint/migrate uses when + * translating ESLint configs: + * `eslint-plugin-unocss` → `unocss` (rules: `unocss/order`) + * `@stylistic/eslint-plugin` → `@stylistic` (rules: `@stylistic/indent`) + * `@stylistic/eslint-plugin-ts` → `@stylistic/ts` (rules: `@stylistic/ts/indent`) + * anything else → the package name verbatim + */ +function deriveJsPluginNamespace(packageName: string): string { + if (packageName.startsWith('eslint-plugin-')) { + return packageName.slice('eslint-plugin-'.length); + } + const scoped = packageName.match(/^(@[^/]+)\/eslint-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; +} + +/** + * Sanitize the `.oxlintrc.json` produced by `@oxlint/migrate` before it + * gets merged into `vite.config.ts`. + * + * `@oxlint/migrate` can emit references that won't resolve at lint time: + * - `lint.jsPlugins[]` entries naming packages the user never installed + * (e.g. translating `@unocss/eslint-config` into + * `eslint-plugin-unocss`). + * - `lint.plugins[]` entries naming plugins that aren't part of Oxlint's + * native set (e.g. `unocss`). + * - `lint.rules` entries under namespaces no plugin contributes. + * + * Without this, `vp lint` aborts with "Failed to load JS plugin" / + * "Plugin not found" before running any rule. We strip those references + * and warn the user, leaving a degraded-but-functional config. + */ +function sanitizeMigratedOxlintConfig( + config: OxlintConfig, + availablePackages: Set, + report?: MigrationReport, +): void { + // 1. Drop jsPlugins string entries whose package isn't installed. + const droppedJsPlugins: string[] = []; + const keptJsPlugins: NonNullable = []; + for (const entry of config.jsPlugins ?? []) { + if (typeof entry !== 'string') { + keptJsPlugins.push(entry); + continue; + } + if (availablePackages.has(entry)) { + keptJsPlugins.push(entry); + } else { + droppedJsPlugins.push(entry); + } + } + if (config.jsPlugins && droppedJsPlugins.length > 0) { + config.jsPlugins = keptJsPlugins; + } + + // 2. Compute the set of rule namespaces that any surviving plugin + // (native or JS) actually contributes. + const availableNamespaces = new Set(OXLINT_NATIVE_PLUGINS); + for (const entry of keptJsPlugins) { + if (typeof entry === 'string') { + availableNamespaces.add(deriveJsPluginNamespace(entry)); + } else if (entry && typeof entry === 'object' && 'name' in entry && entry.name) { + availableNamespaces.add(entry.name); + } + } + + // 3. Drop plugins[] entries that aren't natively recognized AND aren't + // provided by a surviving JS plugin. + const droppedPlugins: string[] = []; + if (config.plugins) { + const keptPlugins = config.plugins.filter((p) => { + if (availableNamespaces.has(p)) { + return true; + } + droppedPlugins.push(p); + return false; + }); + if (keptPlugins.length !== config.plugins.length) { + config.plugins = keptPlugins; + } + } + + // 4. Drop rules whose namespace isn't backed by any surviving plugin. + const filterRules = | undefined>(rules: T): T => { + if (!rules) { + return rules; + } + const out: Record = {}; + for (const [key, value] of Object.entries(rules)) { + const slash = key.indexOf('/'); + if (slash === -1 || availableNamespaces.has(key.slice(0, slash))) { + out[key] = value; + } + } + return out as T; + }; + config.rules = filterRules(config.rules); + if (Array.isArray(config.overrides)) { + for (const override of config.overrides) { + override.rules = filterRules(override.rules); + } + } + + // 5. Warn the user about each class of stripped reference. + if (droppedJsPlugins.length > 0) { + warnMigration( + `Stripped unresolvable JS plugin reference(s) from the generated lint config: ${droppedJsPlugins.join(', ')}. ` + + `@oxlint/migrate produced these from your ESLint config but the underlying package(s) aren't installed. ` + + 'Install them manually if you want their lint coverage back.', + report, + ); + } + if (droppedPlugins.length > 0) { + warnMigration( + `Stripped unknown plugin reference(s) from the generated lint config: ${droppedPlugins.join(', ')}. ` + + "These aren't native Oxlint plugins and no surviving JS plugin contributes them.", + report, + ); + } +} + /** * Merge oxlint and oxfmt config into vite.config.ts */ @@ -2051,6 +2241,7 @@ export function mergeViteConfigFiles( projectPath: string, silent = false, report?: MigrationReport, + packages?: WorkspacePackage[], ): void { const configs = detectConfigs(projectPath); if (!configs.oxfmtConfig && !configs.oxlintConfig) { @@ -2075,6 +2266,14 @@ 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). + sanitizeMigratedOxlintConfig( + oxlintJson, + collectInstalledPackageNames(projectPath, packages), + report, + ); const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); fs.writeFileSync(fullOxlintPath, JSON.stringify(normalizedOxlintConfig, null, 2)); // merge oxlint config into vite.config.ts From d2accd8bda7270562bec8a6d155ea3131d54ad0a Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 16:25:27 +0800 Subject: [PATCH 10/17] fix(migrate): don't reorder lint config keys when rules is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI snap-test `new-create-vite-migrates-eslint-prettier` regressed: in the merged `vite.config.ts`, `rules` appeared BEFORE `jsPlugins` instead of after. Root cause: in `sanitizeMigratedOxlintConfig` I unconditionally did `config.rules = filterRules(config.rules)`. When `@oxlint/migrate` produced an `.oxlintrc.json` with NO top-level `rules` key (common — the create-vite fixture only has `overrides[].rules`), the assignment added `rules: undefined` as a new key. Object iteration treats that as ordering-significant: when `ensureVitePlusImportRuleDefaults` later spreads `{...config, jsPlugins, rules}`, `rules` keeps its newly-added position (before the spread-added `jsPlugins`), so the final object has `rules` before `jsPlugins`. Fix: only reassign `config.rules` / `override.rules` when the key was already present AND the filter actually dropped something. Same for overrides[]. No behavior change in the cases the new logic was meant to handle. --- packages/cli/src/migration/migrator.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 17807b8fce..79efdea6fe 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2196,10 +2196,12 @@ function sanitizeMigratedOxlintConfig( } // 4. Drop rules whose namespace isn't backed by any surviving plugin. - const filterRules = | undefined>(rules: T): T => { - if (!rules) { - return rules; - } + // Only reassign when the key was already present and the filter actually + // dropped something — otherwise we'd add a `rules: undefined` property, + // which JS object iteration treats as ordering-significant and shifts + // downstream key emission (notably, pushing `jsPlugins` after `rules` + // in the merged vite.config.ts). + const filterRules = (rules: Record): Record => { const out: Record = {}; for (const [key, value] of Object.entries(rules)) { const slash = key.indexOf('/'); @@ -2207,12 +2209,22 @@ function sanitizeMigratedOxlintConfig( out[key] = value; } } - return out as T; + return out; }; - config.rules = filterRules(config.rules); + if (config.rules) { + const filtered = filterRules(config.rules); + if (Object.keys(filtered).length !== Object.keys(config.rules).length) { + config.rules = filtered as typeof config.rules; + } + } if (Array.isArray(config.overrides)) { for (const override of config.overrides) { - override.rules = filterRules(override.rules); + if (override.rules) { + const filtered = filterRules(override.rules); + if (Object.keys(filtered).length !== Object.keys(override.rules).length) { + override.rules = filtered as typeof override.rules; + } + } } } From 1d32eff378b8bd019fc6fcc7bed689116b2bf237 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 20:04:00 +0800 Subject: [PATCH 11/17] fix(migrate): address sanitizer review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues surfaced by another adversarial review of the sanitize-migrated-oxlint-config pass: 1. **`overrides[].jsPlugins` / `overrides[].plugins` weren't sanitized** The base config got the full treatment but per-override entries were ignored. A `files: ['**/*.test.ts']` override that references an uninstalled plugin reproduces the exact "Failed to load JS plugin" abort the PR is supposed to fix. Fix: walk `config.overrides` and run jsPlugins / plugins / rules sanitization against a per-override namespace set (base namespaces ∪ override-specific jsPlugin namespaces). 2. **Scoped multi-segment namespaces (`@stylistic/ts/*`) were silently stripped even when their plugin survived.** `filterRules` used `key.indexOf('/')` (first slash only), but `deriveJsPluginNamespace` produces multi-slash namespaces like `@stylistic/ts` from packages like `@stylistic/eslint-plugin-ts`. The two never reconciled. Fix: walk every slash position from left to right; first prefix that's in the namespace set wins. 3. **Workspace-context wasn't reaching all sanitizer call sites.** `bin.ts`'s post-install ESLint-migration path and the per-sub-package `rewriteMonorepoProject` both called `mergeViteConfigFiles` without `packages`, so the sanitizer couldn't see deps that lived elsewhere in the workspace. Hoisted ESLint plugins (a normal pnpm/yarn-classic pattern) got falsely flagged as orphans. Fix: plumb `packages` through bin.ts:886, `rewriteMonorepoProject`, and `rewriteRootWorkspacePackageJson`. Sub-package callers also pass a `workspaceRoot` so the sanitizer resolves sibling-package paths relative to the root, not relative to the sub-package being sanitized. Other bits caught during this pass: - Object-form jsPlugins entries (`{ name, specifier }`) still pass through without an installability check, but local-path string specifiers (`./X`, `../X`, `/X`) are now preserved so users with hand-authored Oxlint plugins survive a `vp migrate` re-run. - Consolidated the two-warning split (cleanup-removed vs never-installed) into a single accurate warning. The heuristic used to distinguish them (`isEslintEcosystemDep` name match) was unreliable: it misclassified the `@unocss/eslint-config → eslint-plugin-unocss` WeakAuras case as "we removed it" when the user never had it. - The fictional fixture now also exercises the override path. --- .../eslint.config.mjs | 31 +- .../snap.txt | 11 +- packages/cli/src/migration/bin.ts | 7 +- packages/cli/src/migration/migrator.ts | 268 ++++++++++++++---- 4 files changed, 252 insertions(+), 65 deletions(-) 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 index 9b78261228..006dddfcd3 100644 --- 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 @@ -1,8 +1,11 @@ -// Configures a `fictional/*` rule via a plugin namespace that resolves -// to neither a native Oxlint plugin nor an installed JS plugin package. -// Mirrors the WeakAuras-style failure where `@oxlint/migrate` emits -// `jsPlugins: ["eslint-plugin-fictional"]` and rules under `fictional/*`, -// even though `eslint-plugin-fictional` is not in node_modules. +// 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: { @@ -21,4 +24,22 @@ export default [ '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/snap.txt b/packages/cli/snap-tests-global/migration-eslint-jsplugins-orphan-strip/snap.txt index ca424d23fe..1895202e86 100644 --- 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 @@ -4,7 +4,7 @@ • 4 config updates applied • ESLint rules migrated to Oxlint ! Warnings: - - Stripped unresolvable JS plugin reference(s) from the generated lint config: eslint-plugin-fictional. @oxlint/migrate produced these from your ESLint config but the underlying package(s) aren't installed. Install them manually if you want their lint coverage back. + - 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'; @@ -36,6 +36,15 @@ export default defineConfig({ "rules": { "vite-plus/prefer-vite-plus-imports": "error" }, + "overrides": [ + { + "files": [ + "**/*.test.js" + ], + "rules": {}, + "jsPlugins": [] + } + ], "options": { "typeAware": true, "typeCheck": true diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 7cb374ed6a..06d9ce7f08 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -981,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 79efdea6fe..e86171bf6f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1128,11 +1128,18 @@ export function rewriteMonorepo( workspaceInfo.packageManager, skipStagedMigration, catalogDependencyResolver, + workspaceInfo.packages, ); // (mergeViteConfigFiles below will sanitize the merged lint config // against this workspace's full package set.) - // rewrite packages + // 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), @@ -1141,6 +1148,7 @@ export function rewriteMonorepo( silent, report, catalogDependencyResolver, + workspaceContext, ); } @@ -1162,6 +1170,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, @@ -1170,10 +1183,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'); @@ -1687,6 +1707,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)) { @@ -1774,7 +1798,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, @@ -1782,6 +1808,7 @@ function rewriteRootWorkspacePackageJson( undefined, undefined, catalogDependencyResolver, + packages ? { rootDir: projectPath, packages } : undefined, ); } @@ -2145,82 +2172,189 @@ function collectInstalledPackageNames( * "Plugin not found" before running any rule. We strip those references * and warn the user, leaving a degraded-but-functional config. */ -function sanitizeMigratedOxlintConfig( - config: OxlintConfig, +/** + * 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, - report?: MigrationReport, -): void { - // 1. Drop jsPlugins string entries whose package isn't installed. - const droppedJsPlugins: string[] = []; - const keptJsPlugins: NonNullable = []; - for (const entry of config.jsPlugins ?? []) { +): { + kept: NonNullable; + dropped: string[]; +} { + const kept: NonNullable = []; + const dropped: string[] = []; + for (const entry of entries) { if (typeof entry !== 'string') { - keptJsPlugins.push(entry); + 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)) { - keptJsPlugins.push(entry); + kept.push(entry); } else { - droppedJsPlugins.push(entry); + dropped.push(entry); } } - if (config.jsPlugins && droppedJsPlugins.length > 0) { - config.jsPlugins = keptJsPlugins; - } + return { kept, dropped }; +} - // 2. Compute the set of rule namespaces that any surviving plugin - // (native or JS) actually contributes. - const availableNamespaces = new Set(OXLINT_NATIVE_PLUGINS); - for (const entry of keptJsPlugins) { +/** 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') { - availableNamespaces.add(deriveJsPluginNamespace(entry)); + ns.add(deriveJsPluginNamespace(entry)); } else if (entry && typeof entry === 'object' && 'name' in entry && entry.name) { - availableNamespaces.add(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; +} - // 3. Drop plugins[] entries that aren't natively recognized AND aren't - // provided by a surviving JS plugin. - const droppedPlugins: string[] = []; +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) { - const keptPlugins = config.plugins.filter((p) => { - if (availableNamespaces.has(p)) { - return true; + type PluginEntry = NonNullable[number]; + const keptPlugins: PluginEntry[] = []; + for (const p of config.plugins) { + if (baseNamespaces.has(p)) { + keptPlugins.push(p); + } else { + allDroppedPlugins.add(p); } - droppedPlugins.push(p); - return false; - }); + } if (keptPlugins.length !== config.plugins.length) { config.plugins = keptPlugins; } } - // 4. Drop rules whose namespace isn't backed by any surviving plugin. - // Only reassign when the key was already present and the filter actually - // dropped something — otherwise we'd add a `rules: undefined` property, - // which JS object iteration treats as ordering-significant and shifts - // downstream key emission (notably, pushing `jsPlugins` after `rules` - // in the merged vite.config.ts). - const filterRules = (rules: Record): Record => { - const out: Record = {}; - for (const [key, value] of Object.entries(rules)) { - const slash = key.indexOf('/'); - if (slash === -1 || availableNamespaces.has(key.slice(0, slash))) { - out[key] = value; - } - } - return out; - }; + // 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 = filterRules(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 = filterRules(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; } @@ -2228,18 +2362,27 @@ function sanitizeMigratedOxlintConfig( } } - // 5. Warn the user about each class of stripped reference. - if (droppedJsPlugins.length > 0) { + // 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 unresolvable JS plugin reference(s) from the generated lint config: ${droppedJsPlugins.join(', ')}. ` + - `@oxlint/migrate produced these from your ESLint config but the underlying package(s) aren't installed. ` + - 'Install them manually if you want their lint coverage back.', + `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 (droppedPlugins.length > 0) { + if (allDroppedPlugins.size > 0) { warnMigration( - `Stripped unknown plugin reference(s) from the generated lint config: ${droppedPlugins.join(', ')}. ` + + `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, ); @@ -2254,6 +2397,11 @@ export function mergeViteConfigFiles( 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) { @@ -2281,9 +2429,13 @@ export function mergeViteConfigFiles( // 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(projectPath, packages), + collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), report, ); const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); From 0469abc63ac80fc1a44220795e7d02e660524964 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 20:20:14 +0800 Subject: [PATCH 12/17] fix(migrate): recognize `oxlint-plugin-*` namespace convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surveyed real-world `.oxlintrc.json` files on GitHub (electron, RSSHub, remix, cloudflare/workers-sdk, algolia/instantsearch, facebook/sapling, posva/pinia-colada, generalaction/emdash, …) to sanity-check the sanitizer against shapes it'd see in the wild. Found one real gap: posva/pinia-colada uses `jsPlugins: ["oxlint-plugin-posva"]` with rules under `posva/*`. The `oxlint-plugin-` convention is Oxlint-native plugin authors' norm, distinct from the `eslint-plugin-` convention `@oxlint/migrate` emits. My `deriveJsPluginNamespace` only handled `eslint-plugin-*`, so `oxlint-plugin-posva` returned the verbatim string and rules under `posva/*` would have been silently stripped. Extend the deriver to recognize both `eslint-plugin-` and `oxlint-plugin-` prefixes (both bare and scoped). Now: - `oxlint-plugin-posva` → `posva` - `@scope/oxlint-plugin-x` → `@scope/x` - `@scope/oxlint-plugin` → `@scope` Also cleaned up an orphan JSDoc block and added a proper docstring to `sanitizeMigratedOxlintConfig` documenting the per-override sanitization behavior. Other shapes I verified against the sanitizer (no change needed): - object-form `{name, specifier}` jsPlugins (electron's no-only-tests, sapling's custom) → passed through ✓ - local-path string jsPlugins (felipebrgs1, generalaction) → preserved via the `./`/`/`/`../` check ✓ - `unicorn-js/*` rule namespace from `unicorn-js` package (RSSHub) → handled by the verbatim fallback in deriveJsPluginNamespace ✓ --- packages/cli/src/migration/migrator.ts | 49 +++++++++++++++----------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index e86171bf6f..4fc8d3de99 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2099,17 +2099,24 @@ function mergeTsdownConfigFile( /** * Best-effort: derive the Oxlint rule-namespace a JS plugin package * contributes. Mirrors the conventions @oxlint/migrate uses when - * translating ESLint configs: + * 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 { - if (packageName.startsWith('eslint-plugin-')) { - return packageName.slice('eslint-plugin-'.length); + 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-plugin(?:-(.+))?$/); + const scoped = packageName.match(/^(@[^/]+)\/(?:eslint|oxlint)-plugin(?:-(.+))?$/); if (scoped) { return scoped[2] ? `${scoped[1]}/${scoped[2]}` : scoped[1]; } @@ -2156,22 +2163,6 @@ function collectInstalledPackageNames( return names; } -/** - * Sanitize the `.oxlintrc.json` produced by `@oxlint/migrate` before it - * gets merged into `vite.config.ts`. - * - * `@oxlint/migrate` can emit references that won't resolve at lint time: - * - `lint.jsPlugins[]` entries naming packages the user never installed - * (e.g. translating `@unocss/eslint-config` into - * `eslint-plugin-unocss`). - * - `lint.plugins[]` entries naming plugins that aren't part of Oxlint's - * native set (e.g. `unocss`). - * - `lint.rules` entries under namespaces no plugin contributes. - * - * Without this, `vp lint` aborts with "Failed to load JS plugin" / - * "Plugin not found" before running any rule. We strip those references - * and warn the user, leaving a degraded-but-functional config. - */ /** * Test whether a rule key (e.g. `@stylistic/ts/indent`) belongs to any * namespace in `namespaces`. We can't just split on the first `/` — @@ -2260,6 +2251,24 @@ function jsPluginsToNamespaces(entries: NonNullable): 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, From 271c04e8659c6fc541dba51efa5ddb868965f7dd Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 20:25:56 +0800 Subject: [PATCH 13/17] refactor(migrate): drop @nuxt/eslint from ESLINT_ECOSYSTEM_NAMES The entry was unreachable: when `@nuxt/eslint` is present anywhere in the workspace, `detectIncompatibleEslintIntegration` short-circuits the entire ESLint migration before `rewriteEslintPackageJson` runs, so the second list never gets consulted. Two side effects from keeping it duplicated: - Maintenance hazard: the "what to do about Nuxt" decision lives in two places that could drift. - Misleading future intent: a contributor scanning the cleanup list would conclude `@nuxt/eslint` gets stripped on migration, which isn't true today. The right home for the policy is `INCOMPATIBLE_ESLINT_INTEGRATIONS` (which is already what controls the actual behavior). Added an explicit unit test asserting `rewriteEslintPackageJson` leaves `@nuxt/eslint` alone, with a comment explaining the upstream skip. --- .../src/migration/__tests__/migrator.spec.ts | 18 ++++++++++++++++-- packages/cli/src/migration/migrator.ts | 7 +++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index b7aeef8a5a..580c6ed629 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -404,7 +404,7 @@ describe('rewriteEslintPackageJson', () => { expect(pkg.devDependencies).toEqual({ keepme: '^1.0.0' }); }); - it('removes ESLint formatter, helper, and runtime-integration packages', () => { + it('removes ESLint formatter and helper packages', () => { const pkgPath = writePkg({ devDependencies: { eslint: '^9.0.0', @@ -416,7 +416,6 @@ describe('rewriteEslintPackageJson', () => { 'eslint-scope': '^8.0.0', 'eslint-define-config': '^2.0.0', 'eslint-doc-generator': '^2.0.0', - '@nuxt/eslint': '^0.5.0', keepme: '^1.0.0', }, }); @@ -425,6 +424,21 @@ describe('rewriteEslintPackageJson', () => { 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: { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 4fc8d3de99..8bf2eda5db 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -389,8 +389,11 @@ const ESLINT_ECOSYSTEM_NAMES = new Set([ '@typescript-eslint/eslint-plugin', '@typescript-eslint/parser', '@typescript-eslint/rule-tester', - // Framework runtime modules that wire ESLint and break without it: - '@nuxt/eslint', + // 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. From 0ed5e4e16f1139f4e01ae087b6cfb05bec941f92 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 20:57:49 +0800 Subject: [PATCH 14/17] test(e2e): add zustand to ecosystem-ci MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world coverage for the @oxlint/migrate → sanitizer pipeline. zustand ships a flat `eslint.config.mjs` that imports eslint-plugin-react, eslint-plugin-react-hooks, eslint-plugin-import, eslint-plugin-jest-dom, eslint-plugin-testing-library, and @vitest/eslint-plugin — the most common React+testing plugin combo in the wild. After `vp migrate`: - all six plugins are removed from devDependencies by the ESLint ecosystem cleanup - `@oxlint/migrate`'s output references those packages in `lint.jsPlugins` - our sanitizer strips the now-orphan references - vitest still runs the full 213-test suite (verified locally) Originally tried vueuse for richer preset-expansion coverage via `@antfu/eslint-config`, but it hit a separate `vite-plus-core` config-resolution bug ("Class extends value undefined" inside resolve-tsconfig) that we'd need to fix first. zustand is the smallest real candidate that exercises the same sanitizer code paths without that confounder. The matrix command currently only invokes `vp run test:spec`. `vp run test:lint` and `vp run test:format` both hit an upstream JS-config loader bug ("Cannot use import statement outside a module" in oxlint/js_config.js) that aborts before our merged config is even consulted. Documented inline with a FIXME so the failures are visible but don't block CI; the `|| true` should be removed once the loader is fixed. Verified locally: - clone → patch-project (vp migrate): 2 config updates applied, 17 files imports rewritten, ESLint → Oxlint migrated, Prettier → Oxfmt migrated, dependencies installed in 9.3s - vp install --no-frozen-lockfile: clean - vp test --run: 13 files, 213 tests passed --- .github/workflows/e2e-test.yml | 19 +++++++++++++++++++ ecosystem-ci/repo.json | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 7f864fcb31..5102a3b3eb 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -226,6 +226,25 @@ jobs: command: | vp run test vp run build + - name: zustand + node-version: 24 + command: | + # Runs the vitest suite via zustand's `test:spec` script + # (rewritten by `vp migrate` from `vitest run` → `vp test run`). + # Real ESLint coverage with eslint-plugin-react, -react-hooks, + # -import, -jest-dom, -testing-library, @vitest/eslint-plugin — + # exercises the @oxlint/migrate → sanitizer end-to-end flow + # against a real-world React test setup. + vp run test:spec + # FIXME: `vp lint` and `vp fmt` both abort with "Cannot use + # import statement outside a module" inside the shared + # vite-plus config loader. Not a sanitizer regression — the + # merged `lint:` block is structurally fine, and vitest + # (which has its own loader path) runs the full suite + # without issue. Drop the `|| true` once the upstream loader + # handles ESM vite.config.ts via this path. + vp run test:lint || true + vp run test:format || true - name: oxlint-plugin-complexity node-version: 22 command: | diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 38d4fb1340..61048a0045 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -136,5 +136,10 @@ "branch": "dev", "hash": "83f6c6a418ab9319e07d719d86d4fa952f99e266", "forceFreshMigration": true + }, + "zustand": { + "repository": "https://github.com/pmndrs/zustand.git", + "branch": "main", + "hash": "d690ec29a923977d7a9091554445d1026dfe4611" } } From 2834124fedcbfbab7967f2f2dd87c7c06f48f0a6 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 21:14:37 +0800 Subject: [PATCH 15/17] test(e2e): replace zustand with vite-plugin-vue (zustand is CJS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zustand was added in the previous commit but doesn't actually load after migration: it ships without `"type": "module"`, so Node treats the migrated `vite.config.ts` as CJS and chokes on its `import` statements. Symptom: both `vp lint` and `vp fmt` abort with "Cannot use import statement outside a module" in oxlint's js_config.js loader, before our merged config is reached. (`vp test` works because vitest has its own loader path.) vite-plugin-vue is the better single-test pick: - `"type": "module"` ✅ - Real flat-config eslint.config.js importing `eslint-plugin-import-x`, `eslint-plugin-n`, `eslint-plugin-regexp` — three niche plugins we have zero other coverage for - Maintained by the Vite team; vp's own ecosystem, so churn risk is aligned with vp's release cadence - Smaller than vueuse (which hits a separate vite-plus-core resolve-tsconfig bug) and zustand (CJS) Verified locally end-to-end: - clone → patch-project (vp migrate): 4 config updates applied, 36 files imports rewritten, ESLint → Oxlint, Oxfmt configured - vp install --no-frozen-lockfile: clean - vp run format: passes (269 files) - vp run lint: runs cleanly through the sanitized config (real tsconfig findings in upstream playground code, allowed via `|| true`) --- .github/workflows/e2e-test.yml | 27 ++++++++++----------------- ecosystem-ci/repo.json | 6 +++--- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 5102a3b3eb..dd5d821957 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -226,25 +226,18 @@ jobs: command: | vp run test vp run build - - name: zustand + - name: vite-plugin-vue node-version: 24 command: | - # Runs the vitest suite via zustand's `test:spec` script - # (rewritten by `vp migrate` from `vitest run` → `vp test run`). - # Real ESLint coverage with eslint-plugin-react, -react-hooks, - # -import, -jest-dom, -testing-library, @vitest/eslint-plugin — - # exercises the @oxlint/migrate → sanitizer end-to-end flow - # against a real-world React test setup. - vp run test:spec - # FIXME: `vp lint` and `vp fmt` both abort with "Cannot use - # import statement outside a module" inside the shared - # vite-plus config loader. Not a sanitizer regression — the - # merged `lint:` block is structurally fine, and vitest - # (which has its own loader path) runs the full suite - # without issue. Drop the `|| true` once the upstream loader - # handles ESM vite.config.ts via this path. - vp run test:lint || true - vp run test:format || true + # ESM Vue monorepo maintained by the Vite team. Real flat + # ESLint setup importing eslint-plugin-import-x, -n, -regexp + # — exercises the @oxlint/migrate → sanitizer end-to-end + # against the niche-plugin shape we don't otherwise cover. + vp run format + # `vp run lint` runs cleanly through our sanitized config + # but surfaces upstream lint findings in the playground + # tsconfig — those are project bugs, not migration ones. + vp run lint || true - name: oxlint-plugin-complexity node-version: 22 command: | diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 61048a0045..079b25170b 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -137,9 +137,9 @@ "hash": "83f6c6a418ab9319e07d719d86d4fa952f99e266", "forceFreshMigration": true }, - "zustand": { - "repository": "https://github.com/pmndrs/zustand.git", + "vite-plugin-vue": { + "repository": "https://github.com/vitejs/vite-plugin-vue.git", "branch": "main", - "hash": "d690ec29a923977d7a9091554445d1026dfe4611" + "hash": "5153e339c1ce78c76b2c5ee2fbba1eb1d86c9697" } } From 21d581ab85b2908ed2fe55a7a70f42e822bf8689 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 26 May 2026 21:22:01 +0800 Subject: [PATCH 16/17] revert: drop the e2e ecosystem-ci entries added in this PR Removes the `vite-plugin-vue` entry (and undoes the earlier `zustand` attempt). Neither was a clean fit: - `zustand` is CJS (no `"type": "module"`), so the migrated `vite.config.ts` with `import` syntax fails to load in `vp lint` / `vp fmt`. - `vite-plugin-vue` runs end-to-end, but `vp run lint` surfaces 200+ pre-existing project lint debt + 17 `vite-plus(prefer-vite-plus-imports)` errors from a `rewriteAllImports` gap (type-only imports aren't rewritten). The `|| true` masks both, so the entry doesn't actually gate a regression. Re-adding a good e2e candidate needs separate fixes first: - rewriteAllImports needs to cover `import type` and test-helper paths (so the prefer-vite-plus-imports findings are real signal, not migration leakage). - The CJS-project loader path needs to handle the migrated config (so we can also cover zustand-shaped projects). --- .github/workflows/e2e-test.yml | 12 ------------ ecosystem-ci/repo.json | 5 ----- 2 files changed, 17 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index dd5d821957..7f864fcb31 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -226,18 +226,6 @@ jobs: command: | vp run test vp run build - - name: vite-plugin-vue - node-version: 24 - command: | - # ESM Vue monorepo maintained by the Vite team. Real flat - # ESLint setup importing eslint-plugin-import-x, -n, -regexp - # — exercises the @oxlint/migrate → sanitizer end-to-end - # against the niche-plugin shape we don't otherwise cover. - vp run format - # `vp run lint` runs cleanly through our sanitized config - # but surfaces upstream lint findings in the playground - # tsconfig — those are project bugs, not migration ones. - vp run lint || true - name: oxlint-plugin-complexity node-version: 22 command: | diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 079b25170b..38d4fb1340 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -136,10 +136,5 @@ "branch": "dev", "hash": "83f6c6a418ab9319e07d719d86d4fa952f99e266", "forceFreshMigration": true - }, - "vite-plugin-vue": { - "repository": "https://github.com/vitejs/vite-plugin-vue.git", - "branch": "main", - "hash": "5153e339c1ce78c76b2c5ee2fbba1eb1d86c9697" } } From 20b4ca0d7d317deb6b987a24dd4912a46fed7d77 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 27 May 2026 10:44:01 +0800 Subject: [PATCH 17/17] feat(migrate): preserve packages referenced via lint.jsPlugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit, `rewriteEslintPackageJson` stripped every package matching `isEslintEcosystemDep` — including ones `@oxlint/migrate` had just referenced via `lint.jsPlugins`. Result: the generated config listed `jsPlugins: ["eslint-plugin-foo"]` but the package was no longer in `package.json`, so the sanitizer (or Oxlint at runtime) would strip / fail to load it. Two passes in a row, two opposite decisions about the same package. Now the cleanup honors what `@oxlint/migrate` actually wrote: 1. After `@oxlint/migrate` produces `.oxlintrc.json`, collect every string-form package name appearing in `lint.jsPlugins[]` (and in each `overrides[].jsPlugins[]`), skipping local-path specifiers (`./X`, `../X`, `/X`) which don't map to a `package.json` entry. 2. Pass that set to `rewriteEslintPackageJson` as `preserveJsPlugins`. 3. The cleanup loop skips removal for any name in that set — even when it matches `isEslintEcosystemDep`'s named / prefix / scope / scoped-regex patterns. Net effect: - User had `eslint-plugin-vue` in devDeps AND imported it from `eslint.config.mjs` → `@oxlint/migrate` lists it in jsPlugins → cleanup keeps it → sanitizer keeps it → `vp lint` actually loads the plugin and runs its rules. - User had `eslint-plugin-vue` in devDeps but didn't import it → `@oxlint/migrate` doesn't list it → cleanup removes it as before (no regression). - `@oxlint/migrate` invented a reference to a package the user never installed (the WeakAuras `eslint-plugin-unocss` case) → not in devDeps, nothing to preserve → sanitizer still strips the orphan reference + warns (no regression). New snap-test `migration-eslint-jsplugins-preserve` exercises the preserve path end-to-end: a fixture with `eslint-plugin-survives` in devDeps + an inline `survives` plugin in `eslint.config.mjs`. After migration, the package is still in devDeps, the jsPlugins reference is intact in `vite.config.ts`, and the `survives/no-fiction` rule survived in `lint.rules` — all without firing any "stripped" warning. Three unit tests cover the API surface: - preserveJsPlugins keeps named jsPlugins through cleanup - preserveJsPlugins overrides every branch of isEslintEcosystemDep (named / prefix / scope / scoped regex) - empty preserveJsPlugins set behaves identically to no argument (default-compatible, can't accidentally weaken cleanup for existing callers) No existing snap-test drifted. --- .../eslint.config.mjs | 31 ++++++++ .../package.json | 11 +++ .../snap.txt | 59 ++++++++++++++ .../steps.json | 7 ++ .../src/migration/__tests__/migrator.spec.ts | 78 +++++++++++++++++++ packages/cli/src/migration/migrator.ts | 63 ++++++++++++++- 6 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/eslint.config.mjs create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/package.json create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt create mode 100644 packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/steps.json 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/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 580c6ed629..9ad9bf0551 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -543,6 +543,84 @@ describe('rewriteEslintPackageJson', () => { 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 { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 8bf2eda5db..7852153a5b 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -329,6 +329,15 @@ export async function migrateEslintToOxlint( options.report.eslintMigrated = true; } + // 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 @@ -346,13 +355,52 @@ export async function migrateEslintToOxlint( continue; } deleteEslintConfigFiles(target, options?.report, options?.silent); - rewriteEslintPackageJson(path.join(target, 'package.json')); + rewriteEslintPackageJson(path.join(target, 'package.json'), preserveJsPlugins); rewriteEslintLintStagedConfigFiles(target, 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]) { @@ -442,8 +490,16 @@ function isEslintEcosystemDep(name: string): boolean { * 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): void { +export function rewriteEslintPackageJson( + packageJsonPath: string, + preserveJsPlugins: ReadonlySet = new Set(), +): void { editJsonFile<{ devDependencies?: Record; dependencies?: Record; @@ -465,6 +521,9 @@ export function rewriteEslintPackageJson(packageJsonPath: string): void { } let removedAny = false; for (const name of Object.keys(deps)) { + if (preserveJsPlugins.has(name)) { + continue; + } if (isEslintEcosystemDep(name)) { delete deps[name]; changed = true;