diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d75bbb879f..64bc30ba6e 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -49,6 +49,7 @@ const { ensureSvelteRuneGlobals, mergeViteConfigFiles, rewriteEslintPackageJson, + rewritePrettierPackageJson, collectInstalledPackageNames, sanitizeMigratedOxlintConfig, detectIncompatibleEslintIntegration, @@ -1136,6 +1137,123 @@ describe('rewriteEslintPackageJson', () => { }); }); +describe('rewritePrettierPackageJson', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-prettier-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 prettier and flat prettier-plugin-* / prettier-config-* packages', () => { + const pkgPath = writePkg({ + devDependencies: { + prettier: '^3.0.0', + 'prettier-plugin-tailwindcss': '^0.6.0', + 'prettier-plugin-astro': '^0.14.0', + 'prettier-config-standard': '^7.0.0', + vite: '^7.0.0', + }, + dependencies: { + 'prettier-plugin-svelte': '^3.0.0', + vue: '^3.5.0', + }, + }); + rewritePrettierPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ vite: '^7.0.0' }); + expect(pkg.dependencies).toEqual({ vue: '^3.5.0' }); + }); + + it('removes scoped Prettier plugin/config packages (e.g. @trivago/prettier-plugin-sort-imports)', () => { + const pkgPath = writePkg({ + devDependencies: { + '@trivago/prettier-plugin-sort-imports': '^5.0.0', + '@ianvs/prettier-plugin-sort-imports': '^4.0.0', + '@shopify/prettier-plugin-liquid': '^1.0.0', + '@company/prettier-config': '^1.0.0', + keepme: '^1.0.0', + }, + }); + rewritePrettierPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ keepme: '^1.0.0' }); + }); + + it('removes @prettier/* scope packages', () => { + const pkgPath = writePkg({ + devDependencies: { + '@prettier/plugin-php': '^0.22.0', + '@prettier/plugin-xml': '^3.0.0', + '@prettier/plugin-ruby': '^4.0.0', + '@prettier/sync': '^0.5.0', + keepme: '^1.0.0', + }, + }); + rewritePrettierPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ keepme: '^1.0.0' }); + }); + + it('removes @types/ packages symmetrically with their runtime counterparts', () => { + const pkgPath = writePkg({ + devDependencies: { + prettier: '^3.0.0', + '@types/prettier': '^3.0.0', + // Unrelated @types should stay. + '@types/node': '^22.0.0', + }, + }); + rewritePrettierPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ '@types/node': '^22.0.0' }); + }); + + it('preserves unrelated packages that merely contain "prettier" in their name', () => { + const pkgPath = writePkg({ + devDependencies: { + // Not Prettier-only: these are ESLint packages, handled by the + // ESLint migration when it runs. + 'eslint-config-prettier': '^9.0.0', + 'eslint-plugin-prettier': '^5.0.0', + // Not a Prettier plugin: a scope that merely starts with the same + // characters, and a flat name that isn't a plugin/config. + '@prettierx/core': '^1.0.0', + prettierx: '^0.19.0', + vite: '^7.0.0', + }, + }); + rewritePrettierPackageJson(pkgPath); + const pkg = readJson(pkgPath); + expect(pkg.devDependencies).toEqual({ + 'eslint-config-prettier': '^9.0.0', + 'eslint-plugin-prettier': '^5.0.0', + '@prettierx/core': '^1.0.0', + prettierx: '^0.19.0', + vite: '^7.0.0', + }); + }); + + it('no-ops when package.json has no prettier-ecosystem deps', () => { + const pkgPath = writePkg({ + devDependencies: { vite: '^7.0.0' }, + }); + const before = fs.readFileSync(pkgPath, 'utf8'); + rewritePrettierPackageJson(pkgPath); + const after = fs.readFileSync(pkgPath, 'utf8'); + expect(after).toBe(before); + }); +}); + describe('collectInstalledPackageNames', () => { let tmpDir: string; diff --git a/packages/cli/src/migration/migrator/prettier.ts b/packages/cli/src/migration/migrator/prettier.ts index 39b5866247..6e00a29586 100644 --- a/packages/cli/src/migration/migrator/prettier.ts +++ b/packages/cli/src/migration/migrator/prettier.ts @@ -219,7 +219,48 @@ function deletePrettierConfigFiles( }); } -function rewritePrettierPackageJson(packageJsonPath: string): void { +// Bare names of packages whose sole purpose is to support Prettier. +const PRETTIER_ECOSYSTEM_NAMES = new Set(['prettier']); + +// Flat name prefixes that mark a Prettier-only package. +const PRETTIER_ECOSYSTEM_PREFIXES = ['prettier-plugin-', 'prettier-config-']; + +// Scopes whose every package is part of the Prettier ecosystem. +// @prettier/* — official Prettier scope (@prettier/plugin-php, +// @prettier/plugin-xml, @prettier/plugin-ruby, @prettier/sync) +const PRETTIER_ECOSYSTEM_SCOPES = ['@prettier/']; + +/** + * Decide whether a dependency entry should be removed alongside `prettier` + * itself, mirroring `isEslintEcosystemDep` in `eslint.ts`. Plugins and + * shareable configs are dead weight once formatting moves to Oxfmt, and + * scoped names are as common as flat ones — `@trivago/prettier-plugin-sort-imports` + * is not less of a Prettier plugin than `prettier-plugin-tailwindcss`. + * `@types/` packages are checked symmetrically with ``. + */ +function isPrettierEcosystemDep(name: string): boolean { + const stripped = name.startsWith('@types/') ? name.slice('@types/'.length) : name; + if (PRETTIER_ECOSYSTEM_NAMES.has(stripped)) { + return true; + } + if (PRETTIER_ECOSYSTEM_PREFIXES.some((p) => stripped.startsWith(p))) { + return true; + } + if (PRETTIER_ECOSYSTEM_SCOPES.some((s) => stripped.startsWith(s))) { + return true; + } + // Scoped plugins/configs, e.g.: + // @trivago/prettier-plugin-sort-imports + // @ianvs/prettier-plugin-sort-imports + // @shopify/prettier-plugin-liquid + // @company/prettier-config + if (/^@[^/]+\/prettier-(plugin|config)(-.+)?$/.test(stripped)) { + return true; + } + return false; +} + +export function rewritePrettierPackageJson(packageJsonPath: string): void { if (!fs.existsSync(packageJsonPath)) { return; } @@ -230,10 +271,10 @@ function rewritePrettierPackageJson(packageJsonPath: string): void { 'lint-staged'?: Record; }>(packageJsonPath, (pkg) => { let changed = false; - // Remove prettier and prettier-plugin-* dependencies + // Remove prettier and its plugins / shareable configs if (pkg.devDependencies) { for (const dep of Object.keys(pkg.devDependencies)) { - if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) { + if (isPrettierEcosystemDep(dep)) { delete pkg.devDependencies[dep]; changed = true; } @@ -241,7 +282,7 @@ function rewritePrettierPackageJson(packageJsonPath: string): void { } if (pkg.dependencies) { for (const dep of Object.keys(pkg.dependencies)) { - if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) { + if (isPrettierEcosystemDep(dep)) { delete pkg.dependencies[dep]; changed = true; }