diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/package.json new file mode 100644 index 0000000000..4b6c7a81b0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-oxlint-existing-vite-plus", + "private": true, + "type": "module", + "packageManager": "npm@11.11.1", + "workspaces": ["packages/*"], + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "vite-plus": "^0.1.20" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/packages/app/package.json new file mode 100644 index 0000000000..10c8f182b1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/packages/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "private": true, + "type": "module", + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "vite-plus": "^0.1.20" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/packages/app/plugin.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/packages/app/plugin.ts new file mode 100644 index 0000000000..4215360361 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/packages/app/plugin.ts @@ -0,0 +1 @@ +export { definePlugin, defineRule } from '@oxlint/plugins'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/plugin.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/plugin.ts new file mode 100644 index 0000000000..75a21e53d4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/plugin.ts @@ -0,0 +1 @@ +import { definePlugin, defineRule, type Context, type ESTree } from '@oxlint/plugins'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/snapshots.toml new file mode 100644 index 0000000000..8bcb3b1c40 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/snapshots.toml @@ -0,0 +1,16 @@ +[[case]] +name = "migration_oxlint_existing_vite_plus" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], snapshot = false }, + { argv = ["vpt", "print-file", "package.json", "packages/app/package.json"], comment = "Migration removes unused plugin API dependencies from the root and workspace package." }, + { argv = ["vpt", "print-file", "plugin.ts", "packages/app/plugin.ts"], comment = "Imports and exports use the bundled plugin API." }, + { argv = ["vpt", "json-edit", "package.json", "devDependencies.@oxlint/plugins", "1.79.0"], snapshot = false }, + { argv = ["vp", "install", "--ignore-scripts"], snapshot = false }, + { argv = ["node", "--input-type=module", "-e", "import fs from 'node:fs'; import assert from 'node:assert/strict'; const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); assert.equal(lock.packages[''].devDependencies['@oxlint/plugins'], '1.79.0');"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "An already migrated project still cleans up a leftover dependency and updates its lockfile." }, + { argv = ["node", "--input-type=module", "-e", "import fs from 'node:fs'; import assert from 'node:assert/strict'; for (const file of ['package.json', 'packages/app/package.json']) { const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); assert.equal(pkg.devDependencies['@oxlint/plugins'], undefined); } const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); for (const name of ['', 'packages/app']) { assert.equal(lock.packages[name].devDependencies['@oxlint/plugins'], undefined); } console.log('No direct @oxlint/plugins dependencies remain in manifests or lockfile.');"], comment = "The cleanup must run an install even when it changes no imports or toolchain versions." }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "A further migration leaves the project unchanged." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/snapshots/migration_oxlint_existing_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/snapshots/migration_oxlint_existing_vite_plus.md new file mode 100644 index 0000000000..62bf510927 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/snapshots/migration_oxlint_existing_vite_plus.md @@ -0,0 +1,85 @@ +# migration_oxlint_existing_vite_plus + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + + +## `vpt print-file package.json packages/app/package.json` + +Migration removes unused plugin API dependencies from the root and workspace package. + +``` +{ + "name": "migration-oxlint-existing-vite-plus", + "private": true, + "workspaces": [ + "packages/*" + ], + "type": "module", + "devDependencies": { + "vite-plus": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "packageManager": "npm@11.11.1" +} +{ + "name": "app", + "private": true, + "type": "module", + "devDependencies": { + "vite-plus": "" + } +} +``` + +## `vpt print-file plugin.ts packages/app/plugin.ts` + +Imports and exports use the bundled plugin API. + +``` +import { definePlugin, defineRule, type Context, type ESTree } from "vite-plus/lint/plugins"; +export { definePlugin, defineRule } from "vite-plus/lint/plugins"; +``` + +## `vpt json-edit package.json devDependencies.@oxlint/plugins 1.79.0` + + +## `vp install --ignore-scripts` + + +## `node --input-type=module -e 'import fs from '\''node:fs'\''; import assert from '\''node:assert/strict'\''; const lock = JSON.parse(fs.readFileSync('\''package-lock.json'\'', '\''utf8'\'')); assert.equal(lock.packages['\'''\''].devDependencies['\''@oxlint/plugins'\''], '\''1.79.0'\'');'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +An already migrated project still cleans up a leftover dependency and updates its lockfile. + +``` +VITE+ - The Unified Toolchain for the Web + +Formatting code... + +Code formatted +◇ Updated . to Vite+ +• Node npm +✓ Dependencies installed in +``` + +## `node --input-type=module -e 'import fs from '\''node:fs'\''; import assert from '\''node:assert/strict'\''; for (const file of ['\''package.json'\'', '\''packages/app/package.json'\'']) { const pkg = JSON.parse(fs.readFileSync(file, '\''utf8'\'')); assert.equal(pkg.devDependencies['\''@oxlint/plugins'\''], undefined); } const lock = JSON.parse(fs.readFileSync('\''package-lock.json'\'', '\''utf8'\'')); for (const name of ['\'''\'', '\''packages/app'\'']) { assert.equal(lock.packages[name].devDependencies['\''@oxlint/plugins'\''], undefined); } console.log('\''No direct @oxlint/plugins dependencies remain in manifests or lockfile.'\'');'` + +The cleanup must run an install even when it changes no imports or toolchain versions. + +``` +No direct @oxlint/plugins dependencies remain in manifests or lockfile. +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +A further migration leaves the project unchanged. + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/test-fixture/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/test-fixture/package.json new file mode 100644 index 0000000000..3dbed85be3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_existing_vite_plus/test-fixture/package.json @@ -0,0 +1,7 @@ +{ + "name": "uninstalled-test-fixture", + "private": true, + "devDependencies": { + "uninstalled-plugin": "1.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/bin/check-plugin b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/bin/check-plugin new file mode 100755 index 0000000000..32580001eb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/bin/check-plugin @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log(typeof require('@oxlint/plugins').defineRule); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/package.json new file mode 100644 index 0000000000..739220275a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-oxlint-extensionless-script", + "private": true, + "packageManager": "pnpm@11.24.0", + "scripts": { + "check-plugin": "node bin/check-plugin" + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "vite-plus": "latest" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/pnpm-workspace.yaml new file mode 100644 index 0000000000..0c79d3d6f9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +autoInstallPeers: false +hoist: false +minimumReleaseAge: 0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/snapshots.toml new file mode 100644 index 0000000000..6c36b0d329 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/snapshots.toml @@ -0,0 +1,14 @@ +[[case]] +name = "migration_oxlint_extensionless_script" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "install", "--ignore-scripts"], snapshot = false }, + { argv = ["vp", "run", "check-plugin"], comment = "The extensionless Node script works before migration." }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], snapshot = false }, + { argv = ["vpt", "print-file", "package.json", "bin/check-plugin"], comment = "Keep the dependency used by the unchanged extensionless script." }, + { argv = ["vpt", "rm", "-rf", "node_modules"], snapshot = false }, + { argv = ["vp", "install", "--ignore-scripts"], snapshot = false }, + { argv = ["vp", "run", "check-plugin"], comment = "The script still resolves the plugin API after a strict pnpm reinstall." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/snapshots/migration_oxlint_extensionless_script.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/snapshots/migration_oxlint_extensionless_script.md new file mode 100644 index 0000000000..54aa3b028a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_extensionless_script/snapshots/migration_oxlint_extensionless_script.md @@ -0,0 +1,57 @@ +# migration_oxlint_extensionless_script + +## `vp install --ignore-scripts` + + +## `vp run check-plugin` + +The extensionless Node script works before migration. + +``` +VITE+ - The Unified Toolchain for the Web + +$ node bin/check-plugin ⊘ cache disabled +function +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + + +## `vpt print-file package.json bin/check-plugin` + +Keep the dependency used by the unchanged extensionless script. + +``` +{ + "name": "migration-oxlint-extensionless-script", + "private": true, + "scripts": { + "check-plugin": "node bin/check-plugin" + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@11.24.0" +} +#!/usr/bin/env node +console.log(typeof require('@oxlint/plugins').defineRule); +``` + +## `vpt rm -rf node_modules` + + +## `vp install --ignore-scripts` + + +## `vp run check-plugin` + +The script still resolves the plugin API after a strict pnpm reinstall. + +``` +VITE+ - The Unified Toolchain for the Web + +$ node bin/check-plugin ⊘ cache disabled +function +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/.gitignore new file mode 100644 index 0000000000..fd3807ca80 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +artifacts/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/check.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/check.cjs new file mode 100644 index 0000000000..5ec03cd591 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/check.cjs @@ -0,0 +1 @@ +console.log(typeof require('review-oxlint-plugin')); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/package.json new file mode 100644 index 0000000000..424cc5c4f5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/package.json @@ -0,0 +1,13 @@ +{ + "name": "migration-oxlint-required-peer", + "private": true, + "packageManager": "pnpm@11.24.0", + "scripts": { + "check-plugin": "node check.cjs" + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "review-oxlint-plugin": "file:artifacts/review-oxlint-plugin-1.0.0.tgz", + "vite-plus": "latest" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/plugin/index.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/plugin/index.cjs new file mode 100644 index 0000000000..4563e1f8c4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/plugin/index.cjs @@ -0,0 +1 @@ +module.exports = require('@oxlint/plugins').defineRule; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/plugin/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/plugin/package.json new file mode 100644 index 0000000000..886b261add --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/plugin/package.json @@ -0,0 +1,9 @@ +{ + "name": "review-oxlint-plugin", + "version": "1.0.0", + "exports": "./index.cjs", + "packageManager": "pnpm@11.24.0", + "peerDependencies": { + "@oxlint/plugins": "^1.79.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/pnpm-workspace.yaml new file mode 100644 index 0000000000..0c79d3d6f9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +autoInstallPeers: false +hoist: false +minimumReleaseAge: 0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/snapshots.toml new file mode 100644 index 0000000000..d6ebdbb056 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/snapshots.toml @@ -0,0 +1,17 @@ +[[case]] +name = "migration_oxlint_required_peer" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "pm", "pack", "--pack-destination", "../artifacts"], cwd = "plugin", snapshot = false }, + { argv = ["vpt", "rm", "-rf", "plugin"], comment = "Only the installed dependency contains a reference to the required peer.", snapshot = false }, + { argv = ["vp", "install", "--ignore-scripts"], snapshot = false }, + { argv = ["vp", "run", "check-plugin"], comment = "The installed plugin can load its required peer before migration." }, + { argv = ["node", "-e", "const { createRequire } = require('node:module'); console.log(typeof createRequire(require.resolve('vite-plus/package.json'))('@oxlint/plugins').defineRule);"], comment = "Vite+ also has its own transitive copy of the plugin API." }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], snapshot = false }, + { argv = ["vpt", "print-file", "package.json"], comment = "Keep the direct provider even though application source never names the peer." }, + { argv = ["vpt", "rm", "-rf", "node_modules"], snapshot = false }, + { argv = ["vp", "install", "--ignore-scripts"], snapshot = false }, + { argv = ["vp", "run", "check-plugin"], comment = "The plugin still loads after reinstall with automatic peers and hoisting disabled." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/snapshots/migration_oxlint_required_peer.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/snapshots/migration_oxlint_required_peer.md new file mode 100644 index 0000000000..0164e10183 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_required_peer/snapshots/migration_oxlint_required_peer.md @@ -0,0 +1,72 @@ +# migration_oxlint_required_peer + +## `cd plugin && vp pm pack --pack-destination ../artifacts` + + +## `vpt rm -rf plugin` + +Only the installed dependency contains a reference to the required peer. + + +## `vp install --ignore-scripts` + + +## `vp run check-plugin` + +The installed plugin can load its required peer before migration. + +``` +VITE+ - The Unified Toolchain for the Web + +$ node check.cjs ⊘ cache disabled +function +``` + +## `node -e 'const { createRequire } = require('\''node:module'\''); console.log(typeof createRequire(require.resolve('\''vite-plus/package.json'\''))('\''@oxlint/plugins'\'').defineRule);'` + +Vite+ also has its own transitive copy of the plugin API. + +``` +function +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + + +## `vpt print-file package.json` + +Keep the direct provider even though application source never names the peer. + +``` +{ + "name": "migration-oxlint-required-peer", + "private": true, + "scripts": { + "check-plugin": "node check.cjs" + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "review-oxlint-plugin": "file:artifacts/review-oxlint-plugin-1.0.0.tgz", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@11.24.0" +} +``` + +## `vpt rm -rf node_modules` + + +## `vp install --ignore-scripts` + + +## `vp run check-plugin` + +The plugin still loads after reinstall with automatic peers and hoisting disabled. + +``` +VITE+ - The Unified Toolchain for the Web + +$ node check.cjs ⊘ cache disabled +function +``` diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c395b594db..8ebde07548 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -8793,6 +8793,7 @@ describe('existing Vite+ core migration finalization', () => { }); expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true)).toEqual({ + dependencies: false, scripts: true, tsconfigTypes: true, imports: true, @@ -8869,6 +8870,7 @@ export default defineConfig({ const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true)).toEqual({ + dependencies: false, scripts: false, tsconfigTypes: false, imports: true, @@ -8885,6 +8887,7 @@ export default defineConfig({ ); expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true)).toEqual({ + dependencies: false, scripts: false, tsconfigTypes: false, imports: false, @@ -8921,6 +8924,7 @@ export default defineConfig({ entry: 'src/index.ts' }); ); expect(result).toEqual({ + dependencies: false, scripts: false, tsconfigTypes: false, imports: true, diff --git a/packages/cli/src/migration/__tests__/oxlint-plugin-dependency.spec.ts b/packages/cli/src/migration/__tests__/oxlint-plugin-dependency.spec.ts index 79b3005847..7dfda3757c 100644 --- a/packages/cli/src/migration/__tests__/oxlint-plugin-dependency.spec.ts +++ b/packages/cli/src/migration/__tests__/oxlint-plugin-dependency.spec.ts @@ -4,14 +4,19 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { PackageManager } from '../../types/index.ts'; +import { PackageManager, type WorkspaceInfo } from '../../types/index.ts'; +import { readJsonFile, writeJsonFile } from '../../utils/json.ts'; import { collectOxlintOwnerDirs, dropDeadOxlintPluginsDependency, + finalizeCoreMigrationForExistingVitePlus, packageOwnsOxlintApi, + rewriteMonorepo, rewritePackageJson, + rewriteStandaloneProject, sourceTreeReferencesOxlintPluginsPackage, usesVitestBrowserMode, + type DependencyBag, } from '../migrator.ts'; describe('Oxlint plugin dependency cleanup', () => { @@ -25,6 +30,324 @@ describe('Oxlint plugin dependency cleanup', () => { fs.rmSync(projectPath, { recursive: true, force: true }); }); + it.each(['@oxlint/plugins', 'vite-plus/lint/plugins'])( + 'cleans up an existing Vite+ workspace with imports from %s', + (specifier) => { + const appPath = path.join(projectPath, 'packages', 'app'); + fs.mkdirSync(appPath, { recursive: true }); + for (const dir of [projectPath, appPath]) { + writeJsonFile(path.join(dir, 'package.json'), { + devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' }, + }); + fs.writeFileSync( + path.join(dir, 'plugin.ts'), + `import { definePlugin, defineRule, type Context, type ESTree } from '${specifier}';`, + ); + } + const workspace = { + rootDir: projectPath, + packages: [{ name: 'app', path: 'packages/app' }], + }; + + const result = finalizeCoreMigrationForExistingVitePlus(workspace, true); + + expect(result.imports).toBe(specifier === '@oxlint/plugins'); + expect(result.dependencies).toBe(true); + for (const dir of [projectPath, appPath]) { + expect(fs.readFileSync(path.join(dir, 'plugin.ts'), 'utf8')).toContain( + "from 'vite-plus/lint/plugins'", + ); + expect(readJsonFile(path.join(dir, 'package.json'))).toEqual({ + devDependencies: { 'vite-plus': 'latest' }, + }); + } + expect(finalizeCoreMigrationForExistingVitePlus(workspace, true).dependencies).toBe(false); + }, + ); + + it.each([false, true])( + 'cleans up before newly injected browser packages are installed (monorepo: %s)', + (isMonorepo) => { + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, { + name: 'project', + devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' }, + }); + const browserProjectPath = isMonorepo + ? path.join(projectPath, 'packages', 'app') + : projectPath; + if (isMonorepo) { + fs.mkdirSync(browserProjectPath, { recursive: true }); + fs.writeFileSync(path.join(browserProjectPath, 'package.json'), '{"name":"app"}'); + fs.writeFileSync( + path.join(projectPath, 'pnpm-workspace.yaml'), + 'packages:\n - packages/*\n', + ); + } + fs.writeFileSync( + path.join(browserProjectPath, 'browser.ts'), + "import { playwright } from '@vitest/browser-playwright';", + ); + fs.writeFileSync( + path.join(projectPath, 'plugin.ts'), + "import { defineRule } from '@oxlint/plugins';", + ); + const workspace: WorkspaceInfo = { + rootDir: projectPath, + isMonorepo, + monorepoScope: '', + workspacePatterns: isMonorepo ? ['packages/*'] : [], + parentDirs: [], + packages: isMonorepo ? [{ name: 'app', path: 'packages/app' }] : [], + packageManager: PackageManager.pnpm, + packageManagerVersion: '10.33.0', + downloadPackageManager: { + name: PackageManager.pnpm, + packageName: 'pnpm', + version: '10.33.0', + installDir: projectPath, + binPrefix: projectPath, + }, + }; + + if (isMonorepo) { + rewriteMonorepo(workspace, true, true); + } else { + rewriteStandaloneProject(projectPath, workspace, true, true); + } + + expect(readJsonFile(packageJsonPath).devDependencies).not.toHaveProperty('@oxlint/plugins'); + expect( + readJsonFile(path.join(browserProjectPath, 'package.json')).devDependencies, + ).toHaveProperty('@vitest/browser-playwright'); + }, + ); + + it.each([ + { scripts: { 'check-plugin': `node -e "require('@oxlint/plugins')"` } }, + { imports: { '#plugin-api': '@oxlint/plugins' } }, + { dependencies: { '@oxlint/plugins': '^1.79.0' } }, + { peerDependencies: { '@oxlint/plugins': '^1.79.0' } }, + { optionalDependencies: { '@oxlint/plugins': '^1.79.0' } }, + ])('retains an existing Vite+ dependency required by %j', (references) => { + const pkg = { + ...references, + devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' }, + }; + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, pkg); + fs.writeFileSync( + path.join(projectPath, 'plugin.ts'), + "import { defineRule } from '@oxlint/plugins';", + ); + + const result = finalizeCoreMigrationForExistingVitePlus({ rootDir: projectPath }, true); + + expect(result.dependencies).toBe(false); + expect(readJsonFile(packageJsonPath)).toEqual(pkg); + expect(result.imports).toBe(!packageOwnsOxlintApi(pkg)); + }); + + it('retains a root dependency used by an ignored nested plugin after finalization', () => { + const pkg = { devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' } }; + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, pkg); + fs.writeFileSync(path.join(projectPath, '.gitignore'), 'dist/\n'); + const outputPath = path.join(projectPath, 'packages', 'app', 'dist'); + fs.mkdirSync(outputPath, { recursive: true }); + fs.writeFileSync(path.join(outputPath, '..', 'package.json'), '{"name":"app"}'); + fs.writeFileSync( + path.join(outputPath, 'plugin.cjs'), + "const { defineRule } = require('@oxlint/plugins');", + ); + + const result = finalizeCoreMigrationForExistingVitePlus( + { rootDir: projectPath, packages: [{ name: 'app', path: 'packages/app' }] }, + true, + ); + + expect(result.dependencies).toBe(false); + expect(readJsonFile(packageJsonPath)).toEqual(pkg); + }); + + it.each(['dependencies', 'devDependencies', 'optionalDependencies'] as const)( + 'retains the provider of an installed %s plugin required peer', + (field) => { + const pkg: DependencyBag = { + devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' }, + }; + pkg[field] = { ...pkg[field], 'review-oxlint-plugin': '1.0.0' }; + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, pkg); + const pluginPath = path.join(projectPath, 'node_modules', 'review-oxlint-plugin'); + fs.mkdirSync(pluginPath, { recursive: true }); + writeJsonFile(path.join(pluginPath, 'package.json'), { + name: 'review-oxlint-plugin', + version: '1.0.0', + exports: './index.cjs', + peerDependencies: { '@oxlint/plugins': '^1.79.0' }, + }); + fs.writeFileSync(path.join(pluginPath, 'index.cjs'), "require('@oxlint/plugins');"); + fs.writeFileSync(path.join(projectPath, 'check.cjs'), "require('review-oxlint-plugin');"); + + const result = finalizeCoreMigrationForExistingVitePlus({ rootDir: projectPath }, true); + + expect(result.dependencies).toBe(false); + expect(readJsonFile(packageJsonPath)).toEqual(pkg); + }, + ); + + it.each(['missing', 'invalid', 'optional', 'unrelated'])( + 'handles %s installed peer metadata conservatively', + (metadata) => { + const pkg = { + devDependencies: { + 'vite-plus': 'latest', + '@oxlint/plugins': '^1.79.0', + 'review-oxlint-plugin': '1.0.0', + }, + }; + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, pkg); + if (metadata !== 'missing') { + const pluginPath = path.join(projectPath, 'node_modules', 'review-oxlint-plugin'); + fs.mkdirSync(pluginPath, { recursive: true }); + fs.writeFileSync( + path.join(pluginPath, 'package.json'), + metadata === 'invalid' + ? '{' + : JSON.stringify({ + name: 'review-oxlint-plugin', + version: '1.0.0', + peerDependencies: { + [metadata === 'optional' ? '@oxlint/plugins' : 'some-other-api']: '^1.79.0', + }, + peerDependenciesMeta: { '@oxlint/plugins': { optional: true } }, + }), + ); + } + + const result = finalizeCoreMigrationForExistingVitePlus({ rootDir: projectPath }, true); + const keepProvider = metadata === 'missing' || metadata === 'invalid'; + + expect(result.dependencies).toBe(!keepProvider); + expect( + JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).devDependencies['@oxlint/plugins'], + ).toBe(keepProvider ? '^1.79.0' : undefined); + }, + ); + + it.each([false, true])( + 'retains a root peer provider for a nested plugin (workspace: %s)', + (isWorkspacePackage) => { + const pkg = { devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' } }; + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, pkg); + const appPath = path.join(projectPath, 'packages', 'app'); + fs.mkdirSync(appPath, { recursive: true }); + writeJsonFile(path.join(appPath, 'package.json'), { + dependencies: { 'review-oxlint-plugin': '1.0.0' }, + }); + const pluginPath = path.join(appPath, 'node_modules', 'review-oxlint-plugin'); + fs.mkdirSync(pluginPath, { recursive: true }); + writeJsonFile(path.join(pluginPath, 'package.json'), { + name: 'review-oxlint-plugin', + peerDependencies: { '@oxlint/plugins': '^1.79.0' }, + }); + + const result = finalizeCoreMigrationForExistingVitePlus( + { + rootDir: projectPath, + packages: isWorkspacePackage ? [{ name: 'app', path: 'packages/app' }] : undefined, + }, + true, + ); + + expect(result.dependencies).toBe(false); + expect(readJsonFile(packageJsonPath)).toEqual(pkg); + }, + ); + + it.each([{ '.': { import: './index.js' } }, { '.': './dist/index.js' }])( + 'reads installed peer metadata despite inaccessible exports %j', + (exports) => { + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, { + devDependencies: { + 'vite-plus': 'latest', + '@oxlint/plugins': '^1.79.0', + 'review-oxlint-plugin': '1.0.0', + }, + }); + const pluginPath = path.join(projectPath, 'node_modules', 'review-oxlint-plugin'); + fs.mkdirSync(pluginPath, { recursive: true }); + writeJsonFile(path.join(pluginPath, 'package.json'), { + name: 'review-oxlint-plugin', + version: '1.0.0', + exports, + }); + fs.writeFileSync(path.join(pluginPath, 'index.js'), 'export default {};'); + + const result = finalizeCoreMigrationForExistingVitePlus({ rootDir: projectPath }, true); + + expect(result.dependencies).toBe(true); + expect( + JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).devDependencies['@oxlint/plugins'], + ).toBeUndefined(); + }, + ); + + it.each([false, true])( + 'only retains unknown nested peer contracts for workspace packages (workspace: %s)', + (isWorkspacePackage) => { + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, { + devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' }, + }); + const nestedPath = path.join(projectPath, 'nested'); + fs.mkdirSync(nestedPath); + writeJsonFile(path.join(nestedPath, 'package.json'), { + name: 'nested', + devDependencies: { 'uninstalled-plugin': '1.0.0' }, + }); + + const result = finalizeCoreMigrationForExistingVitePlus( + { + rootDir: projectPath, + packages: isWorkspacePackage ? [{ name: 'nested', path: 'nested' }] : undefined, + }, + true, + ); + + expect(result.dependencies).toBe(!isWorkspacePackage); + expect( + JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).devDependencies['@oxlint/plugins'], + ).toBe(isWorkspacePackage ? '^1.79.0' : undefined); + }, + ); + + it.each(['#!/usr/bin/env node\n', ''])( + 'retains an extensionless Node script with prefix %j', + (prefix) => { + const pkg = { + devDependencies: { 'vite-plus': 'latest', '@oxlint/plugins': '^1.79.0' }, + scripts: { 'check-plugin': 'node bin/check-plugin' }, + }; + const packageJsonPath = path.join(projectPath, 'package.json'); + writeJsonFile(packageJsonPath, pkg); + fs.mkdirSync(path.join(projectPath, 'bin')); + const scriptPath = path.join(projectPath, 'bin', 'check-plugin'); + const script = `${prefix}console.log(typeof require('@oxlint/plugins').defineRule);`; + fs.writeFileSync(scriptPath, script); + + const result = finalizeCoreMigrationForExistingVitePlus({ rootDir: projectPath }, true); + + expect(result.dependencies).toBe(false); + expect(fs.readFileSync(scriptPath, 'utf8')).toBe(script); + expect(readJsonFile(packageJsonPath)).toEqual(pkg); + }, + ); + it.each([`node -e "require('@oxlint/plugins')"`, `node -e "import('@oxlint/plugins')"`])( 'retains a dependency used by the inline script %s', (script) => { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index ceaaa61ce8..53f0a72c35 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -1112,6 +1112,7 @@ async function main() { pendingCoreMigration, ); if ( + coreMigrationResult.dependencies || coreMigrationResult.scripts || coreMigrationResult.tsconfigTypes || coreMigrationResult.imports || @@ -1172,7 +1173,7 @@ async function main() { ) : undefined; - let needsInstall = false; + let needsInstall = coreMigrationResult.dependencies; if (vitePlusBootstrapPending) { const downloadResult = await ensureExistingPackageManager(); if (downloadResult && packageManager) { diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts index 46b0d51d76..3b6ca2b1be 100644 --- a/packages/cli/src/migration/migrator/core-finalization.ts +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -6,6 +6,7 @@ import { type WorkspacePackage } from '../../types/index.ts'; import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { rulesDir } from '../../utils/path.ts'; import { + dropDeadOxlintPluginsDependency, hasTsconfigTypesToRewrite, mergeTsdownConfigFile, rewriteAllImports, @@ -78,6 +79,7 @@ export type PendingCoreMigration = { }; export type CoreMigrationFinalizationResult = { + dependencies: boolean; scripts: boolean; tsconfigTypes: boolean; imports: boolean; @@ -143,6 +145,7 @@ export function finalizeCoreMigrationForExistingVitePlus( ): CoreMigrationFinalizationResult { const projectPaths = getCoreMigrationProjectPaths(workspaceInfo); const result: CoreMigrationFinalizationResult = { + dependencies: false, scripts: false, tsconfigTypes: false, imports: false, @@ -163,6 +166,10 @@ export function finalizeCoreMigrationForExistingVitePlus( } result.imports = rewriteAllImports(workspaceInfo.rootDir, silent, report, true); + result.dependencies = dropDeadOxlintPluginsDependency( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); // Partial migrations can already have a Vite+ dependency while leaving // tsdown.config.* undiscoverable by vp pack. Finalize those configs on the diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 908162a4a8..118179ffc4 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -9,11 +9,12 @@ import { applyYarnWorkspaceHoistingFix, cleanupDeprecatedTsconfigOptions, collectInjectedProviderNames, + collectOxlintDependencyNames, collectOxlintOwnerDirs, - dropDeadOxlintPluginsDependency, collectProviderSourceModes, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, + dropDeadOxlintPluginsDependency, dropRemovePackageOverrideKeys, ensureDirectViteForPnpm, ensurePnpmWorkspaceExoticSubdepsSetting, @@ -81,6 +82,10 @@ export function rewriteStandaloneProject( // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter // reads the manifests afterwards and would no longer see the signal. const oxlintOwnerDirs = collectOxlintOwnerDirs(projectPath, workspaceInfo.packages); + const originalOxlintDependencies = collectOxlintDependencyNames( + projectPath, + workspaceInfo.packages, + ); // Source-tree scan signals are computed once here and reused below (and inside // projectUsesVitestDirectly / collectInjectedProviderNames) so the source tree // is traversed once each instead of repeatedly. They do not depend on @@ -339,7 +344,7 @@ export function rewriteStandaloneProject( mergeTsdownConfigFile(projectPath, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging rewriteAllImports(projectPath, silent, report, true, oxlintOwnerDirs); - dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages); + dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages, originalOxlintDependencies); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -362,6 +367,10 @@ export function rewriteMonorepo( // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter // reads the manifests afterwards and would no longer see the signal. const oxlintOwnerDirs = collectOxlintOwnerDirs(workspaceInfo.rootDir, workspaceInfo.packages); + const originalOxlintDependencies = collectOxlintDependencyNames( + workspaceInfo.rootDir, + workspaceInfo.packages, + ); const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings( workspaceInfo.downloadPackageManager.version, @@ -473,7 +482,11 @@ export function rewriteMonorepo( mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging rewriteAllImports(workspaceInfo.rootDir, silent, report, true, oxlintOwnerDirs); - dropDeadOxlintPluginsDependency(workspaceInfo.rootDir, workspaceInfo.packages); + dropDeadOxlintPluginsDependency( + workspaceInfo.rootDir, + workspaceInfo.packages, + originalOxlintDependencies, + ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index a0b0eb21bb..eac2f7c197 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -2,7 +2,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { type WorkspacePackage } from '../../types/index.ts'; -import { editJsonFile } from '../../utils/json.ts'; +import { VITE_PLUS_NAME } from '../../utils/constants.ts'; +import { editJsonFile, readJsonFile } from '../../utils/json.ts'; +import { detectPackageMetadata } from '../../utils/package.ts'; import { hasVitestTypesInTsconfig } from '../../utils/tsconfig.ts'; import { projectUsesVitestDirectly } from '../migrator.ts'; import { @@ -191,8 +193,8 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ '.cache', ]); -// Built plugins can still load the original API after migration. Only installed -// dependencies and version-control metadata are irrelevant to retention. +// Built plugins can still load the original API after migration. Skip installed +// source and VCS metadata; dependency peer contracts are checked separately. const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn']); /** @@ -227,6 +229,8 @@ function sourceTreeMatches( // package imports/scripts, even when they are not workspace members. crossPackageBoundaries?: boolean; includePackageReferences?: boolean; + includeExtensionless?: boolean; + matchesPackage?: (projectPath: string, pkg: DependencyBag) => boolean; skipDirs?: ReadonlySet; } = {}, ): boolean { @@ -259,6 +263,7 @@ function sourceTreeMatches( } else if ( entry.isFile() && (VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name)) || + (options.includeExtensionless && path.extname(entry.name) === '') || (options.includePackageReferences && entry.name === 'package.json')) ) { try { @@ -266,7 +271,13 @@ function sourceTreeMatches( if (entry.name === 'package.json') { // Check alias targets and inline scripts without counting dependency // declarations as uses. Serialization includes conditional targets. - const pkg = JSON.parse(content) as { imports?: unknown; scripts?: unknown }; + const pkg = JSON.parse(content) as DependencyBag & { + imports?: unknown; + scripts?: unknown; + }; + if (options.matchesPackage?.(dir, pkg)) { + return true; + } content = JSON.stringify({ imports: pkg.imports, scripts: pkg.scripts }); } if (matchesContent(content)) { @@ -368,29 +379,121 @@ export function collectProviderSourceModes(projectPath: string): Record> = collectOxlintDependencyNames( + projectPath, + ), +): boolean { return sourceTreeMatches(projectPath, (content) => content.includes(OXLINT_PLUGINS_PACKAGE), { crossPackageBoundaries: true, includePackageReferences: true, + // Node can execute these scripts without a shebang or executable bit. + includeExtensionless: true, + // Nested fixtures and templates can declare dependencies that this workspace + // never installs. Their source still counts, but unknown peers do not. + matchesPackage: (dir, pkg) => + projectListsRequiredOxlintPluginsPeer(dir, pkg, { + retainUnknownPeers: originalDependencies.has(dir), + originalDependencyNames: originalDependencies.get(dir), + }), skipDirs: OXLINT_RETENTION_SKIP_DIRS, }); } +// Keep the explicit peer provider in strict package-manager layouts. A copy +// installed transitively through vite-plus cannot satisfy another plugin's peer. +export function projectListsRequiredOxlintPluginsPeer( + projectPath: string, + pkg: DependencyBag, + { + retainUnknownPeers = true, + originalDependencyNames, + }: { + retainUnknownPeers?: boolean; + originalDependencyNames?: ReadonlySet; + } = {}, +): boolean { + const dependencyNames = collectInstallDependencyNames(pkg); + dependencyNames.delete(OXLINT_PLUGINS_PACKAGE); + // These toolchain packages do not require an @oxlint/plugins peer. + dependencyNames.delete(VITE_PLUS_NAME); + dependencyNames.delete('vite'); + for (const name of dependencyNames) { + if (originalDependencyNames && !originalDependencyNames.has(name)) { + continue; + } + const metadata = detectPackageMetadata(projectPath, name); + if (!metadata) { + if (retainUnknownPeers) { + return true; + } + continue; + } + try { + const installedPkg = readJsonFile(path.join(metadata.path, 'package.json')) as { + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + if ( + typeof installedPkg.peerDependencies?.[OXLINT_PLUGINS_PACKAGE] === 'string' && + installedPkg.peerDependenciesMeta?.[OXLINT_PLUGINS_PACKAGE]?.optional !== true + ) { + return true; + } + } catch { + // An unknown peer contract is not evidence that the provider is unused. + if (retainUnknownPeers) { + return true; + } + } + } + return false; +} + +export function collectInstallDependencyNames(pkg?: DependencyBag): Set { + return new Set([ + ...Object.keys(pkg?.dependencies ?? {}), + ...Object.keys(pkg?.devDependencies ?? {}), + ...Object.keys(pkg?.optionalDependencies ?? {}), + ]); +} + +// Capture the original dependency names before migration injects new toolchain +// packages. Check peers only for original dependencies that survive migration; +// newly injected packages are not installed until after source cleanup. +export function collectOxlintDependencyNames( + rootDir: string, + packages?: readonly { path: string }[], +): Map> { + const names = new Map>(); + for (const dir of [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]) { + const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')); + names.set(dir, collectInstallDependencyNames(pkg)); + } + return names; +} + /** * Drop `@oxlint/plugins` from devDependencies once nothing names it any more. * * Runs AFTER the import rewrite, so the scan sees final source. Skips a package * that owns the API as a runtime or peer dependency, and skips any package - * whose source or build output still names it. + * whose source, build output, or installed dependencies still require it. */ export function dropDeadOxlintPluginsDependency( rootDir: string, packages?: readonly { path: string }[], -): void { + originalDependencies: ReadonlyMap> = collectOxlintDependencyNames( + rootDir, + packages, + ), +): boolean { + let changed = false; const dirs = [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; for (const dir of dirs) { const packageJsonPath = path.join(dir, 'package.json'); @@ -398,7 +501,10 @@ export function dropDeadOxlintPluginsDependency( if (pkg?.devDependencies?.[OXLINT_PLUGINS_PACKAGE] === undefined) { continue; } - if (packageOwnsOxlintApi(pkg) || sourceTreeReferencesOxlintPluginsPackage(dir)) { + if ( + packageOwnsOxlintApi(pkg) || + sourceTreeReferencesOxlintPluginsPackage(dir, originalDependencies) + ) { continue; } editJsonFile<{ @@ -408,7 +514,9 @@ export function dropDeadOxlintPluginsDependency( return undefined; } delete json.devDependencies[OXLINT_PLUGINS_PACKAGE]; + changed = true; return json; }); } + return changed; } diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index 05dfcf5d1d..383b52b570 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -54,6 +54,14 @@ function resolvePackageJsonWithNode( } catch { // Packages with an exports map often do not expose `./package.json`. } + // Read installed manifests independently of public entry points. Import-only + // exports and workspace packages whose dist is not built still have metadata. + for (const searchPath of require.resolve.paths(packageName) ?? []) { + const candidate = path.join(searchPath, packageName, 'package.json'); + if (fs.existsSync(candidate)) { + return candidate; + } + } try { return findOwningPackageJson(require.resolve(packageName), packageName); } catch {