diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index d35e78ba96..38929609db 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -327,6 +327,13 @@ jobs: vp check --fix vp run check vp test run --project unit --shard=1/3 + - name: videojs-v10 + node-version: 24 + command: | + node $GITHUB_WORKSPACE/ecosystem-ci/verify-videojs-v10.ts + # The upstream preset keeps existing diagnostics at warning level. + vp lint --quiet + vp test run tools/oxlint/anti-slop/rules/tests - name: reactive-resume node-version: 24 command: | diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/lint/plugin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/lint/plugin.js new file mode 100644 index 0000000000..cceae65444 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/lint/plugin.js @@ -0,0 +1,21 @@ +// Authored against the API vite-plus re-exports, with no `@oxlint/plugins` +// dependency of its own: the point of the test is that this resolves and loads. +import { definePlugin, defineRule } from 'vite-plus/lint/plugins'; + +const noFoo = defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, +}); + +export default definePlugin({ + meta: { name: 'local' }, + rules: { 'no-foo': noFoo }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/package.json new file mode 100644 index 0000000000..47b4407f49 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/package.json @@ -0,0 +1,5 @@ +{ + "name": "lint-oxlint-plugin-api", + "version": "0.0.0", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots.toml new file mode 100644 index 0000000000..3879e20c20 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots.toml @@ -0,0 +1,37 @@ +[[case]] +name = "lint_oxlint_plugin_api" +vp = "local" +skip-platforms = [{ os = "linux", libc = "musl" }] +steps = [ + { argv = [ + "vp", + "lint", + "src/uses-foo.ts", + ], comment = "the local JS plugin imports its API from vite-plus/lint/plugins. It declares no @oxlint/plugins dependency. A reported diagnostic therefore proves the export resolved and loaded", continue-on-failure = true }, + { argv = [ + "vp", + "lint", + "src/legacy-imports.ts", + ], comment = "prefer-vite-plus-imports reports the three legacy authoring specifiers", continue-on-failure = true }, + { argv = [ + "vp", + "lint", + "src/config-surface.ts", + ], comment = "oxlint still owns defineConfig and OxlintOverride, so these are clean", continue-on-failure = true }, + { argv = [ + "vp", + "lint", + "--fix", + "src/legacy-imports.ts", + ], comment = "the autofix matches what vp migrate rewrites", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "src/legacy-imports.ts", + ], continue-on-failure = true }, + { argv = [ + "vp", + "lint", + "src/legacy-imports.ts", + ], comment = "confirm the rewritten file is clean", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots/lint_oxlint_plugin_api.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots/lint_oxlint_plugin_api.md new file mode 100644 index 0000000000..63f4d244dd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots/lint_oxlint_plugin_api.md @@ -0,0 +1,109 @@ +# lint_oxlint_plugin_api + +## `vp lint src/uses-foo.ts` + +the local JS plugin imports its API from vite-plus/lint/plugins. It declares no @oxlint/plugins dependency. A reported diagnostic therefore proves the export resolved and loaded + +**Exit code:** 1 + +``` + + × local(no-foo): Do not name things "foo". + ╭─[src/uses-foo.ts:1:14] + 1 │ export const foo = 1; + · ─── + 2 │ export const bar = 2; + ╰──── + +Found 0 warnings and 1 error. +Finished in on 1 file with rules using threads. +``` + +## `vp lint src/legacy-imports.ts` + +prefer-vite-plus-imports reports the three legacy authoring specifiers + +**Exit code:** 1 + +``` + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/lint/plugins' instead of 'oxlint' in Vite+ projects. + ╭─[src/legacy-imports.ts:1:28] + 1 │ import { defineRule } from 'oxlint'; + · ──────── + 2 │ import { definePlugin } from '@oxlint/plugins'; + ╰──── + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/lint/plugins' instead of '@oxlint/plugins' in Vite+ projects. + ╭─[src/legacy-imports.ts:2:30] + 1 │ import { defineRule } from 'oxlint'; + 2 │ import { definePlugin } from '@oxlint/plugins'; + · ───────────────── + 3 │ import { RuleTester } from 'oxlint/plugins-dev'; + ╰──── + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/lint/plugins-dev' instead of 'oxlint/plugins-dev' in Vite+ projects. + ╭─[src/legacy-imports.ts:3:28] + 2 │ import { definePlugin } from '@oxlint/plugins'; + 3 │ import { RuleTester } from 'oxlint/plugins-dev'; + · ──────────────────── + 4 │ + ╰──── + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/lint/plugins' instead of 'oxlint' in Vite+ projects. + ╭─[src/legacy-imports.ts:6:38] + 5 │ export { defineRule, definePlugin, RuleTester }; + 6 │ export { 'defineRule' as rule } from 'oxlint'; + · ──────── + 7 │ export type { 'Context' as RuleContext } from 'oxlint'; + ╰──── + + × vite-plus(prefer-vite-plus-imports): Use 'vite-plus/lint/plugins' instead of 'oxlint' in Vite+ projects. + ╭─[src/legacy-imports.ts:7:47] + 6 │ export { 'defineRule' as rule } from 'oxlint'; + 7 │ export type { 'Context' as RuleContext } from 'oxlint'; + · ──────── + ╰──── + +Found 0 warnings and 5 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint src/config-surface.ts` + +oxlint still owns defineConfig and OxlintOverride, so these are clean + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vp lint --fix src/legacy-imports.ts` + +the autofix matches what vp migrate rewrites + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vpt print-file src/legacy-imports.ts` + +``` +import { defineRule } from 'vite-plus/lint/plugins'; +import { definePlugin } from 'vite-plus/lint/plugins'; +import { RuleTester } from 'vite-plus/lint/plugins-dev'; + +export { defineRule, definePlugin, RuleTester }; +export { 'defineRule' as rule } from 'vite-plus/lint/plugins'; +export type { 'Context' as RuleContext } from 'vite-plus/lint/plugins'; +``` + +## `vp lint src/legacy-imports.ts` + +confirm the rewritten file is clean + +``` +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/config-surface.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/config-surface.ts new file mode 100644 index 0000000000..4265f3b794 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/config-surface.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'oxlint'; +import type { OxlintOverride } from 'oxlint'; + +export const override: OxlintOverride = { files: ['**/*.ts'] }; + +export default defineConfig({ overrides: [override] }); +export { 'defineConfig' as config } from 'oxlint'; +export type { 'OxlintOverride' as Override } from 'oxlint'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/legacy-imports.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/legacy-imports.ts new file mode 100644 index 0000000000..f54c131ea4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/legacy-imports.ts @@ -0,0 +1,7 @@ +import { defineRule } from 'oxlint'; +import { definePlugin } from '@oxlint/plugins'; +import { RuleTester } from 'oxlint/plugins-dev'; + +export { defineRule, definePlugin, RuleTester }; +export { 'defineRule' as rule } from 'oxlint'; +export type { 'Context' as RuleContext } from 'oxlint'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/uses-foo.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/uses-foo.ts new file mode 100644 index 0000000000..e3a53f2f9e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/uses-foo.ts @@ -0,0 +1,2 @@ +export const foo = 1; +export const bar = 2; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/vite.config.ts new file mode 100644 index 0000000000..43748fe077 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + lint: { + jsPlugins: [ + './lint/plugin.js', + { name: 'vite-plus', specifier: 'vite-plus/oxlint-plugin' }, + ], + rules: { + 'local/no-foo': 'error', + 'vite-plus/prefer-vite-plus-imports': 'error', + }, + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/package.json new file mode 100644 index 0000000000..d03de37aaf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-oxlint-built-plugin", + "private": true, + "type": "module", + "packageManager": "pnpm@11.24.0", + "scripts": { + "lint": "oxlint ." + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "oxlint": "1.81.0", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/plugin.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/plugin.cjs new file mode 100644 index 0000000000..f755aa8000 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/plugin.cjs @@ -0,0 +1,19 @@ +const { definePlugin, defineRule } = require('@oxlint/plugins'); + +module.exports = definePlugin({ + meta: { name: 'built' }, + rules: { + 'no-foo': defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, + }), + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/pnpm-workspace.yaml new file mode 100644 index 0000000000..6739b220f6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +hoist: false +minimumReleaseAge: 0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/snapshots.toml new file mode 100644 index 0000000000..cb75a5c38d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/snapshots.toml @@ -0,0 +1,17 @@ +[[case]] +name = "migration_oxlint_built_plugin" +vp = "global" +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["git", "init"], snapshot = false }, + { argv = ["vpt", "mkdir", "dist"], snapshot = false }, + { argv = ["vpt", "cp", "plugin.cjs", "dist/plugin.cjs"], snapshot = false }, + { argv = ["vpt", "rm", "plugin.cjs"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], snapshot = false }, + { argv = ["vpt", "print-file", "package.json"], comment = "The ignored build artifact still needs a direct @oxlint/plugins dependency." }, + { argv = ["vpt", "print-file", "dist/plugin.cjs"], comment = "Migration does not rewrite or rebuild the ignored CommonJS plugin." }, + { argv = ["vpt", "rm", "-rf", "node_modules"], snapshot = false }, + { argv = ["vp", "install", "--ignore-scripts"], snapshot = false }, + { argv = ["vp", "lint", "src/input.js"], comment = "After a strict pnpm reinstall, the built plugin loads and reports its rule." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/snapshots/migration_oxlint_built_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/snapshots/migration_oxlint_built_plugin.md new file mode 100644 index 0000000000..617b57321a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/snapshots/migration_oxlint_built_plugin.md @@ -0,0 +1,88 @@ +# migration_oxlint_built_plugin + +## `git init` + + +## `vpt mkdir dist` + + +## `vpt cp plugin.cjs dist/plugin.cjs` + + +## `vpt rm plugin.cjs` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + + +## `vpt print-file package.json` + +The ignored build artifact still needs a direct @oxlint/plugins dependency. + +``` +{ + "name": "migration-oxlint-built-plugin", + "private": true, + "type": "module", + "packageManager": "pnpm@11.24.0", + "scripts": { + "lint": "vp lint ." + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` + +## `vpt print-file dist/plugin.cjs` + +Migration does not rewrite or rebuild the ignored CommonJS plugin. + +``` +const { definePlugin, defineRule } = require('@oxlint/plugins'); + +module.exports = definePlugin({ + meta: { name: 'built' }, + rules: { + 'no-foo': defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, + }), + }, +}); +``` + +## `vpt rm -rf node_modules` + + +## `vp install --ignore-scripts` + + +## `vp lint src/input.js` + +After a strict pnpm reinstall, the built plugin loads and reports its rule. + +``` +VITE+ - The Unified Toolchain for the Web + +note: You are running `vp lint` as a Vite+ built-in command. If you meant to run the lint npm script, use `vpr lint` instead. + + ⚠ built(no-foo): Do not name things "foo". + ╭─[src/input.js:1:14] + 1 │ export const foo = 1; + · ─── + ╰──── + +Found 1 warning and 0 errors. +Finished in on 1 file with rules using threads. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/src/input.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/src/input.js new file mode 100644 index 0000000000..bb1843d113 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/src/input.js @@ -0,0 +1 @@ +export const foo = 1; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/vite.config.ts new file mode 100644 index 0000000000..e6e8930a73 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_built_plugin/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + lint: { + jsPlugins: [{ name: 'built', specifier: './dist/plugin.cjs' }], + rules: { 'built/no-foo': 'warn' }, + options: { typeAware: false, typeCheck: false }, + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/package.json new file mode 100644 index 0000000000..59db02f233 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-oxlint-inline-script", + "private": true, + "packageManager": "pnpm@11.24.0", + "scripts": { + "check-plugin": "node -e \"console.log(typeof require('@oxlint/plugins').defineRule)\"" + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/pnpm-workspace.yaml new file mode 100644 index 0000000000..6739b220f6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +hoist: false +minimumReleaseAge: 0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/snapshots.toml new file mode 100644 index 0000000000..d6e53b76d3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "migration_oxlint_inline_script" +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"], comment = "The unchanged inline script still needs a direct @oxlint/plugins dependency." }, + { argv = ["vpt", "rm", "-rf", "node_modules"], snapshot = false }, + { argv = ["vp", "install", "--ignore-scripts"], snapshot = false }, + { argv = ["vp", "run", "check-plugin"], comment = "The script resolves its plugin API after a strict pnpm reinstall." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/snapshots/migration_oxlint_inline_script.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/snapshots/migration_oxlint_inline_script.md new file mode 100644 index 0000000000..3264101e8b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_inline_script/snapshots/migration_oxlint_inline_script.md @@ -0,0 +1,41 @@ +# migration_oxlint_inline_script + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + + +## `vpt print-file package.json` + +The unchanged inline script still needs a direct @oxlint/plugins dependency. + +``` +{ + "name": "migration-oxlint-inline-script", + "private": true, + "scripts": { + "check-plugin": "node -e \"console.log(typeof require('@oxlint/plugins').defineRule)\"" + }, + "devDependencies": { + "@oxlint/plugins": "1.79.0", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@11.24.0" +} +``` + +## `vpt rm -rf node_modules` + + +## `vp install --ignore-scripts` + + +## `vp run check-plugin` + +The script resolves its plugin API after a strict pnpm reinstall. + +``` +VITE+ - The Unified Toolchain for the Web + +$ node -e "console.log(typeof require('@oxlint/plugins').defineRule)" ⊘ cache disabled +function +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/.oxlintrc.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/.oxlintrc.json new file mode 100644 index 0000000000..32cc17a9a2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/.oxlintrc.json @@ -0,0 +1,6 @@ +{ + "jsPlugins": ["./lint/plugin.js"], + "rules": { + "local/no-foo": "error" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.js new file mode 100644 index 0000000000..10af15a535 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.js @@ -0,0 +1,14 @@ +import { defineRule } from 'oxlint'; + +export const noFoo = defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.test.ts new file mode 100644 index 0000000000..77052ea6ee --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.test.ts @@ -0,0 +1,11 @@ +import type { Context } from 'oxlint'; +import { RuleTester } from 'oxlint/plugins-dev'; + +import { noFoo } from './no-foo.js'; + +export type RuleContext = Context; + +new RuleTester().run('no-foo', noFoo, { + valid: ['const bar = 1;'], + invalid: [{ code: 'const foo = 1;', errors: 1 }], +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/plugin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/plugin.js new file mode 100644 index 0000000000..759432be6f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/plugin.js @@ -0,0 +1,8 @@ +import { definePlugin } from '@oxlint/plugins'; + +import { noFoo } from './no-foo.js'; + +export default definePlugin({ + meta: { name: 'local' }, + rules: { 'no-foo': noFoo }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/shared-config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/shared-config.ts new file mode 100644 index 0000000000..f8a39a8f8a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/shared-config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'oxlint'; +import type { OxlintOverride } from 'oxlint'; + +export const testOverride: OxlintOverride = { + files: ['**/*.test.ts'], + rules: { 'local/no-foo': 'off' }, +}; + +export default defineConfig({ overrides: [testOverride] }); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/package.json new file mode 100644 index 0000000000..9c11b4ab01 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/package.json @@ -0,0 +1,11 @@ +{ + "name": "migration-oxlint-js-plugin-imports", + "scripts": { + "lint": "oxlint ." + }, + "devDependencies": { + "@oxlint/plugins": "^1.0.0", + "oxlint": "^1.0.0", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots.toml new file mode 100644 index 0000000000..b37fd2b231 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots.toml @@ -0,0 +1,40 @@ +[[case]] +name = "migration_oxlint_js_plugin_imports" +vp = "global" +steps = [ + { argv = [ + "vp", + "migrate", + "--no-interactive", + ], comment = "the standalone oxlint dependency goes away, so the JS plugin's authoring imports must move to vite-plus", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "package.json", + ], comment = "oxlint and @oxlint/plugins are both gone from devDependencies, and nothing replaces them. The API now comes from vite-plus", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "lint/no-foo.js", + ], comment = "legacy `defineRule` from 'oxlint' -> 'vite-plus/lint/plugins'", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "lint/plugin.js", + ], comment = "'@oxlint/plugins' -> 'vite-plus/lint/plugins'", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "lint/no-foo.test.ts", + ], comment = "RuleTester lives in 'oxlint/plugins-dev' upstream and breaks the same way, so it maps to 'vite-plus/lint/plugins-dev'. The plugin type import follows the runtime API", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "lint/shared-config.ts", + ], comment = "the config surface is NOT redirected. vite-plus/lint/plugins has no defineConfig or OxlintOverride. KNOWN PRE-EXISTING GAP, wider than this PR: `oxlint` is in REMOVE_PACKAGES, so the migration deletes the dependency while this import survives. Under pnpm strict layout the import then fails to resolve. That predates the plugin-API rewrite, since config-surface imports were never rewritten and `oxlint` was always removed. Recorded so a fix shows up as a snapshot diff", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "vite.config.ts", + ], comment = "the jsPlugins entry survives the .oxlintrc.json merge. It still points at the plugin file, which is now rewritten. KNOWN PRE-EXISTING GAP, unrelated to the import rewrite: the merge drops `local/no-foo`. sanitizeMigratedOxlintConfig derives a plugin's rule namespace from its package name, and a relative-path plugin has no package name. Its namespace comes from `meta.name` at load time instead. Recorded here so a fix shows up as a snapshot diff", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots/migration_oxlint_js_plugin_imports.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots/migration_oxlint_js_plugin_imports.md new file mode 100644 index 0000000000..0249f2dd10 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots/migration_oxlint_js_plugin_imports.md @@ -0,0 +1,139 @@ +# migration_oxlint_js_plugin_imports + +## `vp migrate --no-interactive` + +the standalone oxlint dependency goes away, so the JS plugin's authoring imports must move to vite-plus + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node pnpm +• 3 config updates applied, 3 files had imports rewritten +``` + +## `vpt print-file package.json` + +oxlint and @oxlint/plugins are both gone from devDependencies, and nothing replaces them. The API now comes from vite-plus + +``` +{ + "name": "migration-oxlint-js-plugin-imports", + "scripts": { + "lint": "vp lint .", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vpt print-file lint/no-foo.js` + +legacy `defineRule` from 'oxlint' -> 'vite-plus/lint/plugins' + +``` +import { defineRule } from 'vite-plus/lint/plugins'; + +export const noFoo = defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, +}); +``` + +## `vpt print-file lint/plugin.js` + +'@oxlint/plugins' -> 'vite-plus/lint/plugins' + +``` +import { definePlugin } from 'vite-plus/lint/plugins'; + +import { noFoo } from './no-foo.js'; + +export default definePlugin({ + meta: { name: 'local' }, + rules: { 'no-foo': noFoo }, +}); +``` + +## `vpt print-file lint/no-foo.test.ts` + +RuleTester lives in 'oxlint/plugins-dev' upstream and breaks the same way, so it maps to 'vite-plus/lint/plugins-dev'. The plugin type import follows the runtime API + +``` +import type { Context } from 'vite-plus/lint/plugins'; +import { RuleTester } from 'vite-plus/lint/plugins-dev'; + +import { noFoo } from './no-foo.js'; + +export type RuleContext = Context; + +new RuleTester().run('no-foo', noFoo, { + valid: ['const bar = 1;'], + invalid: [{ code: 'const foo = 1;', errors: 1 }], +}); +``` + +## `vpt print-file lint/shared-config.ts` + +the config surface is NOT redirected. vite-plus/lint/plugins has no defineConfig or OxlintOverride. KNOWN PRE-EXISTING GAP, wider than this PR: `oxlint` is in REMOVE_PACKAGES, so the migration deletes the dependency while this import survives. Under pnpm strict layout the import then fails to resolve. That predates the plugin-API rewrite, since config-surface imports were never rewritten and `oxlint` was always removed. Recorded so a fix shows up as a snapshot diff + +``` +import { defineConfig } from 'oxlint'; +import type { OxlintOverride } from 'oxlint'; + +export const testOverride: OxlintOverride = { + files: ['**/*.test.ts'], + rules: { 'local/no-foo': 'off' }, +}; + +export default defineConfig({ overrides: [testOverride] }); +``` + +## `vpt print-file vite.config.ts` + +the jsPlugins entry survives the .oxlintrc.json merge. It still points at the plugin file, which is now rewritten. KNOWN PRE-EXISTING GAP, unrelated to the import rewrite: the merge drops `local/no-foo`. sanitizeMigratedOxlintConfig derives a plugin's rule namespace from its package name, and a relative-path plugin has no package name. Its namespace comes from `meta.name` at load time instead. Recorded here so a fix shows up as a snapshot diff + +``` +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + fmt: {}, + lint: { + "jsPlugins": [ + "./lint/plugin.js", + { + "name": "vite-plus", + "specifier": "vite-plus/oxlint-plugin" + } + ], + "rules": { + "vite-plus/prefer-vite-plus-imports": "error" + }, + "options": { + "typeAware": true, + "typeCheck": true + } + }, +}); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/consumer/check.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/consumer/check.mjs new file mode 100644 index 0000000000..24d8374013 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/consumer/check.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +// The runner exposes its checkout in an ancestor node_modules directory. +// Reject that fallback: consumers do not install a plugin's devDependencies. +registerHooks({ + resolve(specifier, context, nextResolve) { + assert( + specifier !== 'vite-plus' && !specifier.startsWith('vite-plus/'), + 'The published plugin must not depend on vite-plus', + ); + return nextResolve(specifier, context); + }, +}); + +const { default: plugin } = await import('oxlint-plugin-optional-example'); +assert.equal(plugin.meta.name, 'optional'); +assert.equal(typeof plugin.rules['no-foo'].create, 'function'); +console.log('The published plugin loads with its optional API and without vite-plus.'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/consumer/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/consumer/package.json new file mode 100644 index 0000000000..6aefe636dc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/consumer/package.json @@ -0,0 +1,9 @@ +{ + "name": "plugin-consumer", + "private": true, + "type": "module", + "packageManager": "pnpm@11.24.0", + "dependencies": { + "oxlint-plugin-optional-example": "file:../artifacts/oxlint-plugin-optional-example-1.0.0.tgz" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/package.json new file mode 100644 index 0000000000..0ce27631b1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "oxlint-plugin-optional-example", + "version": "1.0.0", + "type": "module", + "exports": "./plugin.js", + "files": ["plugin.js"], + "packageManager": "pnpm@11.24.0", + "scripts": { + "lint": "oxlint ." + }, + "optionalDependencies": { + "@oxlint/plugins": "1.79.0" + }, + "devDependencies": { + "oxlint": "1.81.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/plugin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/plugin.js new file mode 100644 index 0000000000..44b303965a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/plugin.js @@ -0,0 +1,19 @@ +import { definePlugin, defineRule } from '@oxlint/plugins'; + +export default definePlugin({ + meta: { name: 'optional' }, + rules: { + 'no-foo': defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, + }), + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/pnpm-workspace.yaml new file mode 100644 index 0000000000..6739b220f6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +hoist: false +minimumReleaseAge: 0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/snapshots.toml new file mode 100644 index 0000000000..986e0d6275 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/snapshots.toml @@ -0,0 +1,14 @@ +[[case]] +name = "migration_oxlint_optional_plugin" +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 = ["vp", "lint", "--fix", "plugin.js"], comment = "Lint autofix must preserve the optional runtime API import." }, + ["vpt", "print-file", "package.json"], + ["vpt", "print-file", "plugin.js"], + { argv = ["vp", "pm", "pack", "--pack-destination", "artifacts"], snapshot = false }, + { argv = ["pnpm", "install", "--ignore-workspace", "--ignore-scripts"], cwd = "consumer", snapshot = false }, + { argv = ["node", "check.mjs"], cwd = "consumer", comment = "Install the packed plugin as a consumer dependency, with no vite-plus dependency." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/snapshots/migration_oxlint_optional_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/snapshots/migration_oxlint_optional_plugin.md new file mode 100644 index 0000000000..dcdfbb4800 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_optional_plugin/snapshots/migration_oxlint_optional_plugin.md @@ -0,0 +1,79 @@ +# migration_oxlint_optional_plugin + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + + +## `vp lint --fix plugin.js` + +Lint autofix must preserve the optional runtime API import. + +``` +VITE+ - The Unified Toolchain for the Web + +note: You are running `vp lint` as a Vite+ built-in command. If you meant to run the lint npm script, use `vpr lint` instead. +Found 0 warnings and 0 errors. +Finished in on 1 file with rules using threads. +``` + +## `vpt print-file package.json` + +``` +{ + "name": "oxlint-plugin-optional-example", + "version": "1.0.0", + "files": [ + "plugin.js" + ], + "type": "module", + "exports": "./plugin.js", + "scripts": { + "lint": "vp lint ." + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "optionalDependencies": { + "@oxlint/plugins": "1.79.0" + }, + "packageManager": "pnpm@11.24.0" +} +``` + +## `vpt print-file plugin.js` + +``` +import { definePlugin, defineRule } from "@oxlint/plugins"; + +export default definePlugin({ + meta: { name: "optional" }, + rules: { + "no-foo": defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === "foo") { + context.report({ node, messageId: "noFoo" }); + } + }, + }; + }, + }), + }, +}); +``` + +## `vp pm pack --pack-destination artifacts` + + +## `cd consumer && pnpm install --ignore-workspace --ignore-scripts` + + +## `cd consumer && node check.mjs` + +Install the packed plugin as a consumer dependency, with no vite-plus dependency. + +``` +The published plugin loads with its optional API and without vite-plus. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/lint/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/lint/index.js new file mode 100644 index 0000000000..10af15a535 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/lint/index.js @@ -0,0 +1,14 @@ +import { defineRule } from 'oxlint'; + +export const noFoo = defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/package.json new file mode 100644 index 0000000000..f38f94116b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/package.json @@ -0,0 +1,13 @@ +{ + "name": "oxlint-plugin-example", + "version": "1.0.0", + "scripts": { + "lint": "oxlint ." + }, + "peerDependencies": { + "oxlint": "^1.0.0" + }, + "devDependencies": { + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots.toml new file mode 100644 index 0000000000..5847b74ee9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots.toml @@ -0,0 +1,20 @@ +[[case]] +name = "migration_oxlint_published_plugin" +vp = "global" +steps = [ + { argv = [ + "vp", + "migrate", + "--no-interactive", + ], comment = "this package declares `oxlint` as a peer dependency, which marks it a published Oxlint plugin", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "lint/index.js", + ], comment = "the authoring import stays on 'oxlint'. Consumers of a published plugin may run plain Oxlint, so a rewrite to vite-plus would break them. This also covers the ordering trap: rewritePackageJson strips `oxlint` before the import rewriter reads the manifest, so the skip signal is captured up front", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "package.json", + ], comment = "the `oxlint` peer entry survives. It is a consumer contract, not a tool this package runs, and stripping it would leave the source importing a package the manifest no longer declares", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots/migration_oxlint_published_plugin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots/migration_oxlint_published_plugin.md new file mode 100644 index 0000000000..0f72b8192e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots/migration_oxlint_published_plugin.md @@ -0,0 +1,63 @@ +# migration_oxlint_published_plugin + +## `vp migrate --no-interactive` + +this package declares `oxlint` as a peer dependency, which marks it a published Oxlint plugin + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node pnpm +• 2 config updates applied +``` + +## `vpt print-file lint/index.js` + +the authoring import stays on 'oxlint'. Consumers of a published plugin may run plain Oxlint, so a rewrite to vite-plus would break them. This also covers the ordering trap: rewritePackageJson strips `oxlint` before the import rewriter reads the manifest, so the skip signal is captured up front + +``` +import { defineRule } from 'oxlint'; + +export const noFoo = defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, +}); +``` + +## `vpt print-file package.json` + +the `oxlint` peer entry survives. It is a consumer contract, not a tool this package runs, and stripping it would leave the source importing a package the manifest no longer declares + +``` +{ + "name": "oxlint-plugin-example", + "version": "1.0.0", + "scripts": { + "lint": "vp lint .", + "prepare": "vp config" + }, + "peerDependencies": { + "oxlint": "^1.0.0" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 7b7493fb5e..7115bcd5b0 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::LazyLock, }; @@ -216,7 +216,7 @@ fix: $NEW_IMPORT /// ast-grep rules for rewriting vitest imports. /// /// This rewrites (the canonical mapping shared with the `oxlint-plugin.ts` -/// `rewriteVitePlusImportSpecifier` autofix — both implementations MUST stay +/// `rewriteVitePlusImportSpecifier` autofix; both implementations MUST stay /// in sync and only produce targets that exist in the `vite-plus` package /// `exports` map, otherwise Node fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`): /// - `import { ... } from 'vitest'` → `import { ... } from 'vite-plus/test'` @@ -1567,6 +1567,198 @@ transform: fix: $NEW_IMPORT "#; +/// ast-grep rules for rewriting Oxlint JS-plugin authoring imports. +/// +/// - `@oxlint/plugins` → `vite-plus/lint/plugins` +/// - `oxlint/plugins-dev` → `vite-plus/lint/plugins-dev` +/// - Named plugin API imports/exports from `oxlint` → `vite-plus/lint/plugins` +/// +/// Keep the mapping and config-name denylist in sync with `oxlint-plugin.ts`. +/// The shims use the bundled linter's API and resolve under strict pnpm layouts. +/// +/// Bare `oxlint` also exposes config APIs. Rewrite a statement only when all +/// bindings are named and outside that config surface. Leave mixed, default, +/// namespace, side-effect, export-all, and dynamic-import forms unchanged. +/// +/// The other two specifiers expose only plugin APIs, so their import, export, +/// dynamic-import, and import-type forms all rewrite. Preserve require calls +/// and module augmentations; cleanup retains any remaining `@oxlint/plugins` use. +/// Published plugins are exempt because consumers may not have Vite+; see +/// `SkipPackages::skip_oxlint`. +const REWRITE_OXLINT_PLUGIN_RULES: &str = r#"--- +id: rewrite-oxlint-plugins-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]@oxlint/plugins['"]$ + inside: + kind: import_statement +transform: + NEW_IMPORT: + replace: + source: $STR + replace: "@oxlint/plugins" + by: "vite-plus/lint/plugins" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-export +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]@oxlint/plugins['"]$ + inside: + kind: export_statement + field: source +transform: + NEW_IMPORT: + replace: + source: $STR + replace: "@oxlint/plugins" + by: "vite-plus/lint/plugins" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-dynamic-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]@oxlint/plugins['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + kind: import +transform: + NEW_IMPORT: + replace: + source: $STR + replace: "@oxlint/plugins" + by: "vite-plus/lint/plugins" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-dev-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint/plugins-dev['"]$ + inside: + kind: import_statement +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint/plugins-dev + by: "vite-plus/lint/plugins-dev" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-dev-export +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint/plugins-dev['"]$ + inside: + kind: export_statement + field: source +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint/plugins-dev + by: "vite-plus/lint/plugins-dev" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-dev-dynamic-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint/plugins-dev['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + kind: import +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint/plugins-dev + by: "vite-plus/lint/plugins-dev" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugin-api-export +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: export_statement + field: source + all: + - has: + kind: export_specifier + stopBy: end + - not: + has: + kind: export_specifier + stopBy: end + has: + field: name + regex: ^['"]?(defineConfig|AllowWarnDeny|DummyRule|DummyRuleMap|ExternalPluginEntry|ExternalPluginsConfig|OxlintConfig|OxlintEnv|OxlintGlobals|OxlintOverride|RuleCategories)['"]?$ +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint + by: "vite-plus/lint/plugins" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugin-api-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: import_statement + all: + - has: + kind: import_specifier + stopBy: end + - not: + has: + kind: import_specifier + stopBy: end + has: + field: name + regex: ^['"]?(defineConfig|AllowWarnDeny|DummyRule|DummyRuleMap|ExternalPluginEntry|ExternalPluginsConfig|OxlintConfig|OxlintEnv|OxlintGlobals|OxlintOverride|RuleCategories)['"]?$ + - not: + has: + kind: namespace_import + stopBy: end + - not: + has: + kind: import_clause + has: + kind: identifier +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint + by: "vite-plus/lint/plugins" +fix: $NEW_IMPORT +"#; + static PARSED_VITE_RULES: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_VITE_RULES).expect("failed to parse vite rewrite rules") }); @@ -1613,6 +1805,11 @@ static PARSED_TSDOWN_RULES: LazyLock>> = LazyLock::n ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown rewrite rules") }); +static PARSED_OXLINT_PLUGIN_RULES: LazyLock>> = LazyLock::new(|| { + ast_grep::load_rules(REWRITE_OXLINT_PLUGIN_RULES) + .expect("failed to parse oxlint plugin rewrite rules") +}); + // Regex patterns for rewriting `/// ` directives. // These cannot be handled by ast-grep because triple-slash references are parsed as comments. @@ -1954,6 +2151,11 @@ struct SkipPackages { skip_vitest: bool, /// Skip rewriting tsdown imports (tsdown is in peerDependencies or dependencies) skip_tsdown: bool, + /// Skip rewriting Oxlint JS-plugin API imports (`oxlint` or `@oxlint/plugins` + /// is in peerDependencies or dependencies, or @oxlint/plugins is optional). + /// Published plugin consumers may not have Vite+. DevDependencies alone + /// do not mark a published plugin and do not prevent rewriting. + skip_oxlint: bool, } #[derive(Debug, Clone, Copy, Default)] @@ -1963,17 +2165,22 @@ struct PackageRewriteContext { } /// Options controlling directory-wide import rewriting. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub struct RewriteImportsOptions { /// Preserve `vitest` and `vitest/*` module specifiers throughout packages /// whose nearest package.json declares `@nuxt/test-utils`. pub preserve_vitest_in_nuxt_packages: bool, + /// Directories of packages that declared `oxlint` or `@oxlint/plugins` in + /// `dependencies` or `peerDependencies`, or optional `@oxlint/plugins`, + /// before migration. Capture these before manifest edits so ownership + /// does not depend on which dependency declarations survive those edits. + pub oxlint_owner_dirs: Vec, } impl SkipPackages { /// Check if all packages should be skipped (file can be skipped entirely) const fn all_skipped(&self) -> bool { - self.skip_vite && self.skip_vitest && self.skip_tsdown + self.skip_vite && self.skip_vitest && self.skip_tsdown && self.skip_oxlint } } @@ -2094,6 +2301,11 @@ fn get_package_rewrite_context(package_json_path: &Path) -> PackageRewriteContex || has_package("dependencies", "vitest"), skip_tsdown: has_package("peerDependencies", "tsdown") || has_package("dependencies", "tsdown"), + skip_oxlint: has_package("peerDependencies", "oxlint") + || has_package("dependencies", "oxlint") + || has_package("peerDependencies", "@oxlint/plugins") + || has_package("dependencies", "@oxlint/plugins") + || has_package("optionalDependencies", "@oxlint/plugins"), }, uses_nuxt_test_utils: ["dependencies", "devDependencies", "optionalDependencies"] .into_iter() @@ -2182,15 +2394,23 @@ pub fn rewrite_imports_in_directory_with_options( // Pre-compute package context for each file (requires mutable cache, done sequentially). let mut package_context_cache: HashMap = HashMap::new(); + let oxlint_owner_dirs: HashSet = options.oxlint_owner_dirs.into_iter().collect(); + let files_with_context: Vec<(PathBuf, PackageRewriteContext)> = walk_result .files .into_iter() .map(|file_path| { let package_context = if let Some(package_json_path) = find_nearest_package_json(&file_path, root) { - *package_context_cache - .entry(package_json_path.clone()) - .or_insert_with(|| get_package_rewrite_context(&package_json_path)) + *package_context_cache.entry(package_json_path.clone()).or_insert_with(|| { + let mut context = get_package_rewrite_context(&package_json_path); + if let Some(package_dir) = package_json_path.parent() + && oxlint_owner_dirs.contains(package_dir) + { + context.skip_packages.skip_oxlint = true; + } + context + }) } else { PackageRewriteContext::default() }; @@ -2325,6 +2545,11 @@ fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bo if !skip_packages.skip_tsdown && content.contains("tsdown") { return true; } + // Covers the bare `oxlint` specifier plus `@oxlint/plugins` and + // `oxlint/plugins-dev`, which all contain it as a substring. + if !skip_packages.skip_oxlint && content.contains("oxlint") { + return true; + } false } @@ -2407,6 +2632,18 @@ fn rewrite_import_content_full( } } + // Apply Oxlint JS-plugin API rules if not skipped (using pre-parsed rules). + // Unlike `vite`, these are NOT scoped to config entry files: the imports + // that break live in the plugin and rule sources themselves. + if !skip_packages.skip_oxlint { + let oxlint_content = + ast_grep::apply_loaded_rules(&new_content, &PARSED_OXLINT_PLUGIN_RULES); + if oxlint_content != new_content { + new_content = oxlint_content; + updated = true; + } + } + // Apply reference type rewriting (/// ) // These cannot be handled by ast-grep because they are parsed as comments. // `vite` reference directives are pass-through type surfaces, so they @@ -3207,7 +3444,10 @@ import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, let result = rewrite_imports_in_directory_with_options( temp.path(), - RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, + RewriteImportsOptions { + preserve_vitest_in_nuxt_packages: true, + ..RewriteImportsOptions::default() + }, ) .unwrap(); @@ -3245,7 +3485,10 @@ import { mockNuxtImport } from '@nuxt/test-utils/runtime';"#, let result = rewrite_imports_in_directory_with_options( temp.path(), - RewriteImportsOptions { preserve_vitest_in_nuxt_packages: true }, + RewriteImportsOptions { + preserve_vitest_in_nuxt_packages: true, + ..RewriteImportsOptions::default() + }, ) .unwrap(); @@ -3766,6 +4009,255 @@ export default defineConfig({ ); } + #[test] + fn test_rewrite_import_content_oxlint_plugins_scoped() { + let plugin = r#"import { definePlugin, defineRule } from "@oxlint/plugins"; +import type { Context, ESTree } from '@oxlint/plugins';"#; + + let result = rewrite_import_content(plugin, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { definePlugin, defineRule } from "vite-plus/lint/plugins"; +import type { Context, ESTree } from 'vite-plus/lint/plugins';"# + ); + } + + #[test] + fn test_rewrite_import_content_oxlint_plugin_api_bare_specifier() { + // The pre-`@oxlint/plugins` authoring API, which is what projects + // migrating off a standalone `oxlint` dependency actually have. + let rule = r#"import { defineRule } from 'oxlint'; + +export const noFoo = defineRule({ create: () => ({}) });"#; + + let result = rewrite_import_content(rule, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { defineRule } from 'vite-plus/lint/plugins'; + +export const noFoo = defineRule({ create: () => ({}) });"# + ); + } + + #[test] + fn test_rewrite_import_content_oxlint_plugin_api_type_only_and_aliased() { + let rule = r#"import type { Context } from 'oxlint'; +import { defineRule as rule } from "oxlint";"#; + + let result = rewrite_import_content(rule, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import type { Context } from 'vite-plus/lint/plugins'; +import { defineRule as rule } from "vite-plus/lint/plugins";"# + ); + } + + #[test] + fn test_rewrite_import_content_oxlint_config_surface_is_preserved() { + // `oxlint` still owns the config surface; only the plugin authoring API + // moved. Redirecting these at `vite-plus/lint/plugins` would break them. + let config = r#"import { defineConfig } from 'oxlint'; +import type { OxlintConfig, OxlintOverride } from 'oxlint'; + +export default defineConfig({});"#; + + let result = rewrite_import_content(config, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, config); + } + + #[test] + fn test_rewrite_import_content_oxlint_quoted_config_names_are_preserved() { + let content = r#"import { "defineConfig" as cfg } from 'oxlint'; +import type { 'OxlintConfig' as Config } from 'oxlint'; +import { 'defineConfig' as cfg2, defineRule } from 'oxlint'; +export { "defineConfig" as config } from 'oxlint'; +export type { 'OxlintOverride' as Override } from 'oxlint';"#; + + let result = rewrite_import_content(content, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, content); + } + + #[test] + fn test_rewrite_import_content_oxlint_quoted_plugin_names() { + let content = r#"import { "defineRule" as rule } from 'oxlint'; +export { 'definePlugin' as plugin } from 'oxlint';"#; + + let result = rewrite_import_content(content, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { "defineRule" as rule } from 'vite-plus/lint/plugins'; +export { 'definePlugin' as plugin } from 'vite-plus/lint/plugins';"# + ); + } + + #[test] + fn test_rewrite_import_content_oxlint_ambiguous_forms_are_left_alone() { + // No named specifier means no way to tell the config surface from the + // plugin API, so these stay put rather than risk a wrong rewrite. + let content = r#"import oxlint from 'oxlint'; +import * as everything from 'oxlint'; +import 'oxlint'; +const lazy = require('oxlint');"#; + + let result = rewrite_import_content(content, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, content); + } + + #[test] + fn test_rewrite_import_content_oxlint_plugins_dev_rule_tester() { + let test_file = r#"import { RuleTester } from 'oxlint/plugins-dev'; + +new RuleTester().run('no-foo', noFoo, { valid: [], invalid: [] });"#; + + let result = rewrite_import_content(test_file, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { RuleTester } from 'vite-plus/lint/plugins-dev'; + +new RuleTester().run('no-foo', noFoo, { valid: [], invalid: [] });"# + ); + } + + #[test] + fn test_rewrite_import_content_oxlint_mixed_surfaces_are_left_alone() { + // Replacing the specifier would move `defineConfig` to an entry that + // does not export it. Splitting the statement is the user's call. + let mixed = r#"import { defineConfig, defineRule } from 'oxlint';"#; + + let result = rewrite_import_content(mixed, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, mixed); + } + + #[test] + fn test_rewrite_import_content_oxlint_default_binding_is_left_alone() { + // `vite-plus/lint/plugins` has no default export, so redirecting a + // statement that carries one would leave the file invalid. + let mixed = r#"import oxlint, { defineRule } from 'oxlint'; +import * as everything2, { definePlugin } from 'oxlint';"#; + + let result = rewrite_import_content(mixed, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, mixed); + } + + #[test] + fn test_rewrite_import_content_oxlint_named_reexport() { + let barrel = r#"export { defineRule } from 'oxlint'; +export type { Context } from 'oxlint';"#; + + let result = rewrite_import_content(barrel, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"export { defineRule } from 'vite-plus/lint/plugins'; +export type { Context } from 'vite-plus/lint/plugins';"# + ); + } + + #[test] + fn test_rewrite_import_content_oxlint_config_reexport_is_left_alone() { + let barrel = r#"export { defineConfig } from 'oxlint'; +export * from 'oxlint';"#; + + let result = rewrite_import_content(barrel, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, barrel); + } + + #[test] + fn test_rewrite_import_content_oxlint_import_equals_is_left_alone() { + // Verified against the parser, not assumed: tree-sitter does not treat + // `import x = require(...)` as a plain `import_statement` string, so + // the ESM rules never see it. Pinned so that stays true. + let cjs = r#"import plugins = require('@oxlint/plugins');"#; + + let result = rewrite_import_content(cjs, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, cjs); + } + + #[test] + fn test_rewrite_import_content_oxlint_import_type_rewrites() { + // A type-position `import(...)` resolves through the shim's re-exported + // types, so rewriting it is correct. + let ty = r#"type C = import('@oxlint/plugins').Context;"#; + + let result = rewrite_import_content(ty, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!(result.content, r#"type C = import('vite-plus/lint/plugins').Context;"#); + } + + #[test] + fn test_rewrite_import_content_oxlint_export_default_literal_is_data() { + // `export default '...'` puts the string directly under the + // export_statement, so an unconstrained `inside:` rewrote the exported + // DATA VALUE. The rules match the `source` field only. + let data = r#"export default '@oxlint/plugins'; +export = 'oxlint/plugins-dev';"#; + + let result = rewrite_import_content(data, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, data); + } + + #[test] + fn test_rewrite_import_content_oxlint_export_const_literal_is_data() { + // `inside:` matches the immediate parent. In `export const x = '...'` + // the string sits under a lexical_declaration, not directly under the + // export_statement, so the re-export rules never see it. Pinned so a + // rule loosened to `stopBy: end` cannot start corrupting data. + let data = r#"export const pluginApi = '@oxlint/plugins'; +export const legacy = 'oxlint';"#; + + let result = rewrite_import_content(data, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, data); + } + + #[test] + fn test_rewrite_import_content_oxlint_require_is_left_alone() { + // The rules preserve require calls even though the exports support + // CommonJS. Dependency cleanup must retain these referenced packages. + let cjs = r#"const { defineRule } = require('@oxlint/plugins'); +const { RuleTester } = require('oxlint/plugins-dev');"#; + + let result = rewrite_import_content(cjs, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, cjs); + } + + #[test] + fn test_rewrite_import_content_oxlint_dynamic_import_still_rewrites() { + // Dynamic import resolves through the shim's ESM entry. + let dynamic = r#"const plugins = await import('@oxlint/plugins');"#; + + let result = rewrite_import_content(dynamic, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!(result.content, r#"const plugins = await import('vite-plus/lint/plugins');"#); + } + + #[test] + fn test_rewrite_import_content_oxlint_skipped_for_published_plugins() { + let plugin = r#"import { defineRule } from '@oxlint/plugins';"#; + + let result = rewrite_import_content( + plugin, + &SkipPackages { skip_oxlint: true, ..SkipPackages::default() }, + ) + .unwrap(); + assert!(!result.updated); + assert_eq!(result.content, plugin); + } + #[test] fn test_rewrite_declare_module_tsdown() { let content = r#"declare module 'tsdown' { @@ -3876,8 +4368,12 @@ import { describe } from 'vitest'; export default defineConfig({});"#; - let skip_packages = - SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: false }; + let skip_packages = SkipPackages { + skip_vite: true, + skip_vitest: false, + skip_tsdown: false, + skip_oxlint: false, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); @@ -3899,8 +4395,12 @@ import { describe } from 'vitest'; export default defineConfig({});"#; - let skip_packages = - SkipPackages { skip_vite: false, skip_vitest: true, skip_tsdown: false }; + let skip_packages = SkipPackages { + skip_vite: false, + skip_vitest: true, + skip_tsdown: false, + skip_oxlint: false, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); @@ -3923,7 +4423,12 @@ import { build } from 'tsdown'; export default defineConfig({});"#; - let skip_packages = SkipPackages { skip_vite: true, skip_vitest: true, skip_tsdown: true }; + let skip_packages = SkipPackages { + skip_vite: true, + skip_vitest: true, + skip_tsdown: true, + skip_oxlint: true, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(!result.updated); @@ -3932,10 +4437,20 @@ export default defineConfig({});"#; #[test] fn test_skip_packages_all_skipped() { - let skip_all = SkipPackages { skip_vite: true, skip_vitest: true, skip_tsdown: true }; + let skip_all = SkipPackages { + skip_vite: true, + skip_vitest: true, + skip_tsdown: true, + skip_oxlint: true, + }; assert!(skip_all.all_skipped()); - let skip_some = SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: true }; + let skip_some = SkipPackages { + skip_vite: true, + skip_vitest: false, + skip_tsdown: true, + skip_oxlint: false, + }; assert!(!skip_some.all_skipped()); let skip_none = SkipPackages::default(); @@ -3975,7 +4490,8 @@ export default defineConfig({});"#; "peerDependencies": { "vite": "^5.0.0", "vitest": "^1.0.0", - "tsdown": "^1.0.0" + "tsdown": "^1.0.0", + "oxlint": "^1.0.0" } }"#; let package_json_path = temp.path().join("package.json"); @@ -3985,9 +4501,126 @@ export default defineConfig({});"#; assert!(skip.skip_vite); assert!(skip.skip_vitest); assert!(skip.skip_tsdown); + assert!(skip.skip_oxlint); assert!(skip.all_skipped()); } + #[test] + fn test_get_skip_packages_from_package_json_with_oxlint_plugins_peer_dependency() { + use std::fs; + + let temp = tempdir().unwrap(); + + // A published Oxlint plugin declares the authoring API as a peer so its + // consumers supply it. Redirecting those imports at `vite-plus` would + // break consumers running plain Oxlint. + let pkg_json = r#"{ + "name": "oxlint-plugin-example", + "peerDependencies": { + "@oxlint/plugins": "^1.0.0" + } +}"#; + let package_json_path = temp.path().join("package.json"); + fs::write(&package_json_path, pkg_json).unwrap(); + + let skip = get_skip_packages_from_package_json(&package_json_path); + assert!(skip.skip_oxlint); + assert!(!skip.skip_vite); + } + + #[test] + fn test_get_skip_packages_from_package_json_oxlint_dev_dependency_is_not_a_skip_signal() { + use std::fs; + + let temp = tempdir().unwrap(); + + // A devDependency is how a project's own in-repo plugin gets its types; + // it does not make the package a published Oxlint plugin. + let pkg_json = r#"{ + "name": "my-app", + "devDependencies": { + "@oxlint/plugins": "^1.0.0", + "oxlint": "^1.0.0" + } +}"#; + let package_json_path = temp.path().join("package.json"); + fs::write(&package_json_path, pkg_json).unwrap(); + + let skip = get_skip_packages_from_package_json(&package_json_path); + assert!(!skip.skip_oxlint); + } + + #[test] + fn test_captured_oxlint_ownership_stops_at_nested_package_boundary() { + let temp = tempdir().unwrap(); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let nested = temp.path().join("nested"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write(nested.join("package.json"), "{}").unwrap(); + let content = "import { defineRule } from '@oxlint/plugins';"; + let root_files = [temp.path().join("plugin.js"), temp.path().join("rule.js")]; + let nested_file = nested.join("plugin.js"); + for file in root_files.iter().chain(std::iter::once(&nested_file)) { + std::fs::write(file, content).unwrap(); + } + + let result = rewrite_imports_in_directory_with_options( + temp.path(), + RewriteImportsOptions { + oxlint_owner_dirs: vec![temp.path().to_path_buf()], + ..RewriteImportsOptions::default() + }, + ) + .unwrap(); + + assert_eq!(result.modified_files, vec![nested_file.clone()]); + for file in root_files { + assert_eq!(std::fs::read_to_string(file).unwrap(), content); + } + assert_eq!( + std::fs::read_to_string(nested_file).unwrap(), + "import { defineRule } from 'vite-plus/lint/plugins';" + ); + } + + #[test] + fn test_optional_oxlint_plugin_api_is_preserved() { + let temp = tempdir().unwrap(); + std::fs::write( + temp.path().join("package.json"), + r#"{"optionalDependencies":{"@oxlint/plugins":"^1.79.0"}}"#, + ) + .unwrap(); + let file = temp.path().join("plugin.js"); + let content = "import { defineRule } from '@oxlint/plugins';"; + std::fs::write(&file, content).unwrap(); + + let result = rewrite_imports_in_directory(temp.path()).unwrap(); + + assert!(result.modified_files.is_empty()); + assert_eq!(std::fs::read_to_string(file).unwrap(), content); + } + + #[test] + fn test_optional_oxlint_keeps_existing_rewrite_policy() { + let temp = tempdir().unwrap(); + std::fs::write( + temp.path().join("package.json"), + r#"{"optionalDependencies":{"oxlint":"^1.79.0"}}"#, + ) + .unwrap(); + let file = temp.path().join("plugin.js"); + std::fs::write(&file, "import { defineRule } from 'oxlint';").unwrap(); + + let result = rewrite_imports_in_directory(temp.path()).unwrap(); + + assert_eq!(result.modified_files, vec![file.clone()]); + assert_eq!( + std::fs::read_to_string(file).unwrap(), + "import { defineRule } from 'vite-plus/lint/plugins';" + ); + } + #[test] fn test_get_skip_packages_from_package_json_with_vite_dependency() { use std::fs; @@ -4768,8 +5401,12 @@ module.exports = defineConfig({});"# // also be skipped (parity with the import-shape rule). let content = r#"const vi = require('vitest'); const { defineConfig } = require('vite');"#; - let skip_packages = - SkipPackages { skip_vite: false, skip_vitest: true, skip_tsdown: false }; + let skip_packages = SkipPackages { + skip_vite: false, + skip_vitest: true, + skip_tsdown: false, + skip_oxlint: false, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); // vitest require is NOT rewritten; vite require IS rewritten. @@ -5235,8 +5872,12 @@ export default defineConfig({});"# let content = r#"/// /// "#; - let skip_packages = - SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: false }; + let skip_packages = SkipPackages { + skip_vite: true, + skip_vitest: false, + skip_tsdown: false, + skip_oxlint: false, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); assert_eq!( @@ -5252,8 +5893,12 @@ export default defineConfig({});"# /// /// "#; - let skip_packages = - SkipPackages { skip_vite: false, skip_vitest: true, skip_tsdown: false }; + let skip_packages = SkipPackages { + skip_vite: false, + skip_vitest: true, + skip_tsdown: false, + skip_oxlint: false, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); assert_eq!( @@ -5269,8 +5914,12 @@ export default defineConfig({});"# let content = r#"/// /// "#; - let skip_packages = - SkipPackages { skip_vite: false, skip_vitest: false, skip_tsdown: true }; + let skip_packages = SkipPackages { + skip_vite: false, + skip_vitest: false, + skip_tsdown: true, + skip_oxlint: false, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); assert_eq!( @@ -5286,7 +5935,12 @@ export default defineConfig({});"# /// /// "#; - let skip_packages = SkipPackages { skip_vite: true, skip_vitest: true, skip_tsdown: true }; + let skip_packages = SkipPackages { + skip_vite: true, + skip_vitest: true, + skip_tsdown: true, + skip_oxlint: true, + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(!result.updated); assert_eq!(result.content, content); diff --git a/docs/guide/lint.md b/docs/guide/lint.md index 7ea4a9728d..27a575f5ea 100644 --- a/docs/guide/lint.md +++ b/docs/guide/lint.md @@ -50,3 +50,58 @@ This path is powered by [tsgolint](https://github.com/oxc-project/tsgolint) on t If you are migrating from ESLint and still depend on a few critical JavaScript-based ESLint plugins, Oxlint has [JS plugin support](https://oxc.rs/docs/guide/usage/linter/js-plugins) that can help you keep those plugins running while you complete the migration. JS Plugins also enable [writing your own custom rules](https://oxc.rs/docs/guide/usage/linter/writing-js-plugins.html) for Oxlint. + +### Writing Your Own Rules + +Import the plugin authoring API from `vite-plus/lint/plugins`: + +```js [lint/my-plugin.js] +import { definePlugin, defineRule } from 'vite-plus/lint/plugins'; + +const noFoo = defineRule({ + meta: { messages: { noFoo: 'Do not name things "foo".' } }, + create(context) { + return { + Identifier(node) { + if (node.name === 'foo') { + context.report({ node, messageId: 'noFoo' }); + } + }, + }; + }, +}); + +export default definePlugin({ + meta: { name: 'my' }, + rules: { 'no-foo': noFoo }, +}); +``` + +Register it under `lint.jsPlugins` and enable its rules: + +```ts [vite.config.ts] +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + lint: { + jsPlugins: ['./lint/my-plugin.js'], + rules: { + 'my/no-foo': 'error', + }, + }, +}); +``` + +For rule tests, `RuleTester` is available from `vite-plus/lint/plugins-dev`. + +Both entrypoints re-export the copy that ships with Vite+. The API therefore +always matches the bundled Oxlint. + +Use them instead of adding `@oxlint/plugins` or `oxlint` as a direct +dependency. A separately pinned copy can drift from the linter that loads your +plugin. It also does not resolve from a plugin file under pnpm's strict layout, +unless every package that holds a plugin declares it. + +`vp migrate` rewrites existing `oxlint` and `@oxlint/plugins` imports for you. +See [Oxlint JS Plugin Imports](/guide/migrate-rules#oxlint-js-plugin-imports). +The `vite-plus/prefer-vite-plus-imports` rule reports any that come back. diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 48efaca562..a28ddea160 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -250,6 +250,44 @@ surface are written against `vite-plus` by hand. needed. - Existing `vite-plus/test*` imports are left unchanged. +### Oxlint JS Plugin Imports + +Vite+ bundles Oxlint, so the migration removes a standalone `oxlint` +dependency. Your own Oxlint JS plugins import the authoring API by name. That +import stops resolving when the dependency goes away. `vp lint` then fails to +load the plugin. + +The migration repoints those imports at Vite+: + +- It rewrites `@oxlint/plugins` to `vite-plus/lint/plugins`. +- It rewrites `oxlint/plugins-dev` to `vite-plus/lint/plugins-dev`. +- It rewrites `oxlint` to `vite-plus/lint/plugins` when the import names a + binding from the authoring API, such as `defineRule`, `definePlugin`, or + `Context`. Older Oxlint releases exposed that API from the main entry. It now + lives in `@oxlint/plugins`. + +An import through Vite+ always matches the version of Oxlint that Vite+ +bundles. You pin no second package. The import also resolves from any package +that already depends on `vite-plus`. + +The migration leaves three forms alone: + +- `oxlint` imports that name only the config surface, such as `defineConfig`, + `OxlintConfig`, or `OxlintOverride`. These still resolve against the + standalone package. +- Default and namespace `oxlint` imports. They name no binding, so the + migration cannot tell the two surfaces apart. +- Bare side-effect `oxlint` imports, for the same reason. + +The migration also skips a package that declares `oxlint` or `@oxlint/plugins` +in `dependencies` or `peerDependencies`, or `@oxlint/plugins` in +`optionalDependencies`. These dependencies can supply a published Oxlint plugin. +Its consumers may not run Vite+. + +The cleanup retains a development dependency on `@oxlint/plugins` when source, +package import aliases, or built plugins still reference it. This includes +ignored output in directories such as `dist`, `build`, and `out`. + ### What Is Never Rewritten - `declare module 'vitest'` and `declare module '@vitest/browser*'`: module diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index dc1e3c023d..89f199ad69 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -83,6 +83,12 @@ "hash": "2a2ce31ecc286ae38ab1f0bce13d94418da8a513", "forceFreshMigration": true }, + "videojs-v10": { + "repository": "https://github.com/videojs/v10.git", + "branch": "main", + "hash": "a86f3d735973e47669d529f3edba178585c9cfa9", + "forceFreshMigration": true + }, "reactive-resume": { "repository": "https://github.com/amruthpillai/reactive-resume.git", "branch": "main", diff --git a/ecosystem-ci/verify-videojs-v10.ts b/ecosystem-ci/verify-videojs-v10.ts new file mode 100644 index 0000000000..a96866ce26 --- /dev/null +++ b/ecosystem-ci/verify-videojs-v10.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(`${process.cwd()}/`); +const pkg = JSON.parse(readFileSync('package.json', 'utf-8')) as Record< + string, + Record | undefined +>; + +// A fresh pnpm install must load the migrated plugin without direct Oxlint dependencies. +for (const name of ['@oxlint/plugins', 'oxlint']) { + for (const field of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', + ]) { + assert.equal(pkg[field]?.[name], undefined, `${field} still contains ${name}`); + } + assert.throws(() => require.resolve(`${name}/package.json`), { code: 'MODULE_NOT_FOUND' }); +} + +const pluginDir = 'tools/oxlint/anti-slop'; +for (const [file, specifier] of [ + ['index.ts', 'vite-plus/lint/plugins'], + ['rules/padding-line-between-statements.ts', 'vite-plus/lint/plugins'], + ['rules/tests/padding-line-between-statements.test.ts', 'vite-plus/lint/plugins-dev'], +]) { + const source = readFileSync(`${pluginDir}/${file}`, 'utf-8'); + assert.ok( + source.includes(`from "${specifier}"`) || source.includes(`from '${specifier}'`), + `${file} must import from ${specifier}`, + ); +} + +console.log('ok Video.js custom rules and RuleTester use the Vite+ plugin APIs'); diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index cb6d058f30..7e637156df 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3802,6 +3802,7 @@ export declare function rewriteEslint(scriptsJson: string): string | null; export declare function rewriteImportsInDirectory( root: string, preserveVitestInNuxtPackages?: boolean | undefined | null, + oxlintOwnerDirs?: Array | undefined | null, ): BatchRewriteResult; /** diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 81b7b14899..39501fc176 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -292,11 +292,17 @@ pub fn wrap_lazy_plugins(vite_config_path: String) -> Result, + oxlint_owner_dirs: Option>, ) -> Result { let result = vp_migration::rewrite_imports_in_directory_with_options( Path::new(&root), vp_migration::RewriteImportsOptions { preserve_vitest_in_nuxt_packages: preserve_vitest_in_nuxt_packages.unwrap_or(false), + oxlint_owner_dirs: oxlint_owner_dirs + .unwrap_or_default() + .into_iter() + .map(std::path::PathBuf::from) + .collect(), }, ) .map_err(anyhow::Error::from)?; diff --git a/packages/cli/package.json b/packages/cli/package.json index 1e33dad8f9..54a0760e1d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -69,6 +69,15 @@ "types": "./dist/lint.d.ts", "import": "./dist/lint.js" }, + "./lint/plugins": { + "types": "./dist/lint-plugins.d.ts", + "import": "./dist/lint-plugins.js", + "require": "./dist/lint-plugins.cjs" + }, + "./lint/plugins-dev": { + "types": "./dist/lint-plugins-dev.d.ts", + "default": "./dist/lint-plugins-dev.js" + }, "./oxlint-plugin": { "module-sync": "./dist/oxlint-plugin.js", "node": "./dist/oxlint-plugin.js", diff --git a/packages/cli/src/__tests__/exports-map.spec.ts b/packages/cli/src/__tests__/exports-map.spec.ts index 73e5b6ca71..5c1d76510c 100644 --- a/packages/cli/src/__tests__/exports-map.spec.ts +++ b/packages/cli/src/__tests__/exports-map.spec.ts @@ -117,6 +117,50 @@ describe('package.json exports map', () => { }); }); +// Migrated plugins depend on these entry points resolving the upstream APIs. +describe('Oxlint JS-plugin authoring entrypoints', () => { + it('re-exports the full @oxlint/plugins value surface', async () => { + const [lintPlugins, oxlintPlugins] = await Promise.all([ + import('vite-plus/lint/plugins'), + import('@oxlint/plugins'), + ]); + const expected = namedValueExports(oxlintPlugins); + expect(expected.length, 'sanity: @oxlint/plugins should expose value exports').toBeGreaterThan( + 0, + ); + const missing = expected.filter( + (key) => !(key in lintPlugins) || (lintPlugins as Record)[key] === undefined, + ); + expect(missing, '@oxlint/plugins value exports missing from vite-plus/lint/plugins').toEqual( + [], + ); + }); + + it('serves the authoring API to CommonJS too', () => { + // A `.cts` plugin, or a `.ts` one compiled with `module: commonjs`, emits + // its import as `require()`. `@oxlint/plugins` ships CJS, so the shim does + // too. `plugins-dev` instead exposes its ESM entry to both module systems. + const plugins = requireFromHere('vite-plus/lint/plugins') as Record; + expect(plugins.defineRule).toBeTypeOf('function'); + expect(plugins.definePlugin).toBeTypeOf('function'); + }); + + it('exposes RuleTester from vite-plus/lint/plugins-dev', async () => { + const ruleTester = await import('vite-plus/lint/plugins-dev'); + expect(ruleTester.RuleTester).toBeTypeOf('function'); + }); + + it('serves the same RuleTester to CommonJS', async () => { + // Static imports in .cts files compile to require() after migration. + const ruleTester = requireFromHere('vite-plus/lint/plugins-dev') as Record; + const upstream = requireFromHere('oxlint/plugins-dev') as Record; + const esm = await import('vite-plus/lint/plugins-dev'); + expect(ruleTester.RuleTester).toBeTypeOf('function'); + expect(ruleTester.RuleTester).toBe(upstream.RuleTester); + expect(ruleTester.RuleTester).toBe(esm.RuleTester); + }); +}); + /** * Migration rewrites the `vitest/config` specifier to bare `vite-plus` (see the * Rust `import_rewriter.rs` rule and the `prefer-vite-plus-imports` oxlint rule diff --git a/packages/cli/src/__tests__/fixtures/oxlint-optional-plugin-package/package.json b/packages/cli/src/__tests__/fixtures/oxlint-optional-plugin-package/package.json new file mode 100644 index 0000000000..5ed0eef894 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/oxlint-optional-plugin-package/package.json @@ -0,0 +1,7 @@ +{ + "name": "oxlint-plugin-optional-example", + "version": "1.0.0", + "optionalDependencies": { + "@oxlint/plugins": "^1.79.0" + } +} diff --git a/packages/cli/src/__tests__/fixtures/oxlint-optional-tool-package/package.json b/packages/cli/src/__tests__/fixtures/oxlint-optional-tool-package/package.json new file mode 100644 index 0000000000..b92e77549f --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/oxlint-optional-tool-package/package.json @@ -0,0 +1,6 @@ +{ + "name": "oxlint-optional-tool-example", + "optionalDependencies": { + "oxlint": "^1.79.0" + } +} diff --git a/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/package.json b/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/package.json new file mode 100644 index 0000000000..ced95bc496 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/package.json @@ -0,0 +1,7 @@ +{ + "name": "oxlint-plugin-example", + "private": true, + "peerDependencies": { + "@oxlint/plugins": "^1.0.0" + } +} diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 9297027348..4a116a6730 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -1,7 +1,9 @@ +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { RuleTester } from 'oxlint/plugins-dev'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createDefaultVitePlusLintConfig, @@ -20,6 +22,21 @@ const nuxtUnitTestFilename = path.join( import.meta.dirname, 'fixtures/nuxt-test-utils/unit.spec.ts', ); +// A package that declares `@oxlint/plugins` as a peer is a published Oxlint +// plugin. Its consumers may run plain Oxlint, so the autofix must not move its +// authoring imports to `vite-plus`. +const oxlintPluginPackageFilename = path.join( + import.meta.dirname, + 'fixtures/oxlint-plugin-package/rule.ts', +); +const oxlintOptionalPluginPackageFilename = path.join( + import.meta.dirname, + 'fixtures/oxlint-optional-plugin-package/rule.ts', +); +const oxlintOptionalToolPackageFilename = path.join( + import.meta.dirname, + 'fixtures/oxlint-optional-tool-package/rule.ts', +); describe('oxlint plugin config defaults', () => { it('adds vite-plus js plugin and lint rule defaults', () => { @@ -102,6 +119,79 @@ describe('rewriteVitePlusImportSpecifier', () => { expect(rewriteVitePlusImportSpecifier('vitest/node')).toBe('vite-plus/test/node'); expect(rewriteVitePlusImportSpecifier('tsx')).toBeNull(); }); + + it('maps the Oxlint plugin authoring API to vite-plus', () => { + expect(rewriteVitePlusImportSpecifier('@oxlint/plugins')).toBe('vite-plus/lint/plugins'); + expect(rewriteVitePlusImportSpecifier('oxlint/plugins-dev')).toBe('vite-plus/lint/plugins-dev'); + // The bare `oxlint` specifier still serves the config surface. The + // specifier alone cannot decide it, so the rule checks each import + // statement. + expect(rewriteVitePlusImportSpecifier('oxlint')).toBeNull(); + }); +}); + +function checkImports(filename: string, preservedSpecifiers: readonly string[]): void { + const tests: RuleTester.TestCases = { valid: [], invalid: [] }; + for (const [specifier, binding, replacement] of [ + ['vitest', 'expect', 'vite-plus/test'], + ['@oxlint/plugins', 'defineRule', 'vite-plus/lint/plugins'], + ]) { + const code = `import { ${binding} } from '${specifier}'`; + if (preservedSpecifiers.includes(specifier)) { + tests.valid.push({ filename, code }); + } else { + tests.invalid.push({ + filename, + code, + output: `import { ${binding} } from '${replacement}'`, + errors: [{ messageId: 'preferVitePlusImports' }], + }); + } + } + new RuleTester().run(PREFER_VITE_PLUS_IMPORTS_RULE_NAME, preferVitePlusImportsRule, tests); +} + +describe('package import exceptions', () => { + let projectPath: string; + + beforeEach(() => { + projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-lint-package-exceptions-')); + }); + + afterEach(() => { + fs.rmSync(projectPath, { recursive: true, force: true }); + }); + + it('refreshes independent exceptions when the manifest changes', () => { + const packageJsonPath = path.join(projectPath, 'package.json'); + const states = [ + { pkg: { devDependencies: { '@nuxt/test-utils': '*' } }, preserved: ['vitest'] }, + { pkg: { peerDependencies: { '@oxlint/plugins': '*' } }, preserved: ['@oxlint/plugins'] }, + { pkg: { optionalDependencies: { '@oxlint/plugins': '*' } }, preserved: ['@oxlint/plugins'] }, + { pkg: { devDependencies: { '@oxlint/plugins': '*' } }, preserved: [] }, + ]; + for (const [index, { pkg, preserved }] of states.entries()) { + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + // Explicit timestamps avoid filesystem precision affecting invalidation. + fs.utimesSync(packageJsonPath, 100 + index, 100 + index); + checkImports(path.join(projectPath, 'rule.ts'), preserved); + checkImports(path.join(projectPath, 'other.ts'), preserved); + } + }); + + it('does not inherit exceptions across nested package boundaries', () => { + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ dependencies: { '@nuxt/test-utils': '*', '@oxlint/plugins': '*' } }), + ); + const nestedPath = path.join(projectPath, 'nested'); + fs.mkdirSync(nestedPath); + fs.writeFileSync(path.join(nestedPath, 'package.json'), '{}'); + + checkImports(path.join(projectPath, 'rule.ts'), ['vitest', '@oxlint/plugins']); + checkImports(path.join(nestedPath, 'rule.ts'), []); + checkImports(path.join(projectPath, 'other.ts'), ['vitest', '@oxlint/plugins']); + }); }); new RuleTester({ @@ -112,6 +202,90 @@ new RuleTester({ valid: [ `import { defineConfig } from 'vite-plus'`, `export { expect } from 'vite-plus/test'`, + // Oxlint's config surface still lives in the `oxlint` package. Only the + // plugin authoring API moved. A redirect would break these imports. + `import { defineConfig } from 'oxlint'`, + `import { "defineConfig" as cfg } from 'oxlint'`, + `export { 'defineConfig' as cfg } from 'oxlint'`, + `export { 'defineRule' as rule, 'defineConfig' as cfg } from 'oxlint'`, + { + code: `export type { 'OxlintConfig' as Config } from 'oxlint'`, + filename: 'types.ts', + }, + { + code: `import type { 'OxlintConfig' as Config } from 'oxlint'`, + filename: 'types.ts', + }, + { + code: `import type { OxlintConfig, OxlintOverride } from 'oxlint'`, + filename: 'types.ts', + }, + // These name no binding, so nothing tells the config surface from the + // plugin API. The rule leaves them alone instead of risking a wrong + // autofix. + `import oxlint from 'oxlint'`, + `import * as oxlint from 'oxlint'`, + `import 'oxlint'`, + `import { defineRule } from 'vite-plus/lint/plugins'`, + `import { RuleTester } from 'vite-plus/lint/plugins-dev'`, + // A statement that mixes the two surfaces stays put: the autofix replaces + // the whole specifier, and vite-plus/lint/plugins exports no defineConfig. + `import { defineConfig, defineRule } from 'oxlint'`, + // A default or namespace binding disqualifies the statement too: + // vite-plus/lint/plugins has no default export. + `import oxlint, { defineRule } from 'oxlint'`, + // Import-equals declarations stay unchanged, matching the migrator. + { + code: `import plugins = require('@oxlint/plugins')`, + filename: 'plugin.cts', + }, + { + code: `import tester = require('oxlint/plugins-dev')`, + filename: 'plugin.cts', + }, + // `export *` names nothing, and a config-surface re-export is correct. + `export * from 'oxlint'`, + `export { defineConfig } from 'oxlint'`, + // A published Oxlint plugin keeps resolving the API from its own peer. + { + code: `export { 'defineRule' as rule } from 'oxlint'`, + filename: oxlintPluginPackageFilename, + }, + { + code: `import { defineRule } from '@oxlint/plugins'`, + filename: oxlintPluginPackageFilename, + }, + { + code: `import { defineRule } from 'oxlint'`, + filename: oxlintPluginPackageFilename, + }, + { + code: `import { RuleTester } from 'oxlint/plugins-dev'`, + filename: oxlintPluginPackageFilename, + }, + // An optional runtime API is also part of a published plugin's contract. + ...[ + `import { defineRule } from '@oxlint/plugins'`, + `export { definePlugin } from '@oxlint/plugins'`, + `const plugins = await import('@oxlint/plugins')`, + `import { defineRule } from 'oxlint'`, + `export { 'defineRule' as rule } from 'oxlint'`, + `import { RuleTester } from 'oxlint/plugins-dev'`, + ].map((code) => ({ code, filename: oxlintOptionalPluginPackageFilename })), + // `declare module` keeps the upstream module identity, so augmentations + // still merge with the upstream declarations. + { + code: `declare module '@oxlint/plugins' {}`, + filename: 'types.ts', + }, + { + code: `declare module 'oxlint' {}`, + filename: 'types.ts', + }, + { + code: `declare module 'oxlint/plugins-dev' {}`, + filename: 'types.ts', + }, // `vitest/package.json` must NOT be autofixed — `vite-plus` has no // `./test/package.json` export, so a rewrite would break resolution. `import pkg from 'vitest/package.json'`, @@ -191,6 +365,63 @@ new RuleTester({ }, ], invalid: [ + { + // Optional oxlint retains the existing tool-migration policy. + code: `import { defineRule } from 'oxlint'`, + filename: oxlintOptionalToolPackageFilename, + errors: 1, + output: `import { defineRule } from 'vite-plus/lint/plugins'`, + }, + { + code: `import { definePlugin, defineRule } from '@oxlint/plugins'`, + errors: 1, + output: `import { definePlugin, defineRule } from 'vite-plus/lint/plugins'`, + }, + { + code: `import { RuleTester } from "oxlint/plugins-dev"`, + errors: 1, + output: `import { RuleTester } from "vite-plus/lint/plugins-dev"`, + }, + { + // The pre-`@oxlint/plugins` authoring API. `oxlint` no longer exports + // it, and the migration strips the dependency it came from. + code: `import { defineRule } from 'oxlint'`, + errors: 1, + output: `import { defineRule } from 'vite-plus/lint/plugins'`, + }, + { + code: `import type { Context, ESTree } from 'oxlint'`, + errors: 1, + filename: 'types.ts', + output: `import type { Context, ESTree } from 'vite-plus/lint/plugins'`, + }, + { + code: `import { defineRule as rule } from "oxlint"`, + errors: 1, + output: `import { defineRule as rule } from "vite-plus/lint/plugins"`, + }, + { + // A named re-export identifies the surface just as an import does. + code: `export { defineRule } from 'oxlint'`, + errors: 1, + output: `export { defineRule } from 'vite-plus/lint/plugins'`, + }, + { + code: `export { 'defineRule' as rule } from 'oxlint'`, + errors: 1, + output: `export { 'defineRule' as rule } from 'vite-plus/lint/plugins'`, + }, + { + code: `export { "definePlugin" as plugin, defineRule as rule } from "oxlint"`, + errors: 1, + output: `export { "definePlugin" as plugin, defineRule as rule } from "vite-plus/lint/plugins"`, + }, + { + code: `export type { 'Context' as RuleContext } from 'oxlint'`, + errors: 1, + filename: 'types.ts', + output: `export type { 'Context' as RuleContext } from 'vite-plus/lint/plugins'`, + }, { code: `import { page } from '@vitest/browser/context'`, errors: 1, diff --git a/packages/cli/src/lint-plugins-dev.ts b/packages/cli/src/lint-plugins-dev.ts new file mode 100644 index 0000000000..f1cd3c9967 --- /dev/null +++ b/packages/cli/src/lint-plugins-dev.ts @@ -0,0 +1,5 @@ +// Keep test utilities separate so plugin imports do not load them at runtime. +// The subpath mirrors oxlint/plugins-dev and uses the bundled Oxlint version. + +export { RuleTester } from 'oxlint/plugins-dev'; +export type * from 'oxlint/plugins-dev'; diff --git a/packages/cli/src/lint-plugins.ts b/packages/cli/src/lint-plugins.ts new file mode 100644 index 0000000000..fbbf697c42 --- /dev/null +++ b/packages/cli/src/lint-plugins.ts @@ -0,0 +1,6 @@ +// Use the plugin API that matches the bundled linter. This entry also makes +// the API accessible through a direct vite-plus dependency under strict pnpm. +// The migrator and prefer-vite-plus-imports rule both target this entry. + +export { definePlugin, defineRule, eslintCompatPlugin } from '@oxlint/plugins'; +export type * from '@oxlint/plugins'; diff --git a/packages/cli/src/migration/__tests__/oxlint-plugin-dependency.spec.ts b/packages/cli/src/migration/__tests__/oxlint-plugin-dependency.spec.ts new file mode 100644 index 0000000000..79b3005847 --- /dev/null +++ b/packages/cli/src/migration/__tests__/oxlint-plugin-dependency.spec.ts @@ -0,0 +1,236 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { PackageManager } from '../../types/index.ts'; +import { + collectOxlintOwnerDirs, + dropDeadOxlintPluginsDependency, + packageOwnsOxlintApi, + rewritePackageJson, + sourceTreeReferencesOxlintPluginsPackage, + usesVitestBrowserMode, +} from '../migrator.ts'; + +describe('Oxlint plugin dependency cleanup', () => { + let projectPath: string; + + beforeEach(() => { + projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-oxlint-plugin-dependency-')); + }); + + afterEach(() => { + fs.rmSync(projectPath, { recursive: true, force: true }); + }); + + it.each([`node -e "require('@oxlint/plugins')"`, `node -e "import('@oxlint/plugins')"`])( + 'retains a dependency used by the inline script %s', + (script) => { + const pkg = { + scripts: { 'check-plugin': script }, + devDependencies: { '@oxlint/plugins': '^1.79.0' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm); + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + + expect(pkg.scripts['check-plugin']).toBe(script); + expect(sourceTreeReferencesOxlintPluginsPackage(projectPath)).toBe(true); + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual(pkg); + }, + ); + + it('retains a dependency used by a nested non-workspace package script', () => { + const pkg = { devDependencies: { '@oxlint/plugins': '^1.79.0' } }; + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + const examplePath = path.join(projectPath, 'example'); + fs.mkdirSync(examplePath); + fs.writeFileSync( + path.join(examplePath, 'package.json'), + JSON.stringify({ scripts: { 'check-plugin': `node -e "require('@oxlint/plugins')"` } }), + ); + + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual(pkg); + }); + + it('ignores dependency declarations and descriptive metadata when scripts use vite-plus', () => { + const packageJsonPath = path.join(projectPath, 'package.json'); + const metadata = { + description: 'Previously used @oxlint/plugins', + scripts: { 'check-plugin': `node -e "require('vite-plus/lint/plugins')"` }, + }; + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ ...metadata, devDependencies: { '@oxlint/plugins': '^1.79.0' } }), + ); + + expect(sourceTreeReferencesOxlintPluginsPackage(projectPath)).toBe(false); + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual({ + ...metadata, + devDependencies: {}, + }); + }); + + it.each(['devDependencies', 'optionalDependencies'] as const)( + 'retains an import alias target in %s', + (field) => { + const pkg = { + imports: { '#plugin-api': '@oxlint/plugins' }, + [field]: { '@oxlint/plugins': '^1.79.0' }, + }; + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + fs.writeFileSync( + path.join(projectPath, 'plugin.js'), + `import { defineRule } from '#plugin-api';`, + ); + + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual(pkg); + }, + ); + + it('retains conditional aliases in nested non-workspace packages', () => { + const pkg = { devDependencies: { '@oxlint/plugins': '^1.79.0' } }; + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + const examplePath = path.join(projectPath, 'example'); + fs.mkdirSync(examplePath); + fs.writeFileSync( + path.join(examplePath, 'package.json'), + JSON.stringify({ + imports: { + '#plugin-api': { + node: { import: '@oxlint/plugins', require: '@oxlint/plugins' }, + default: './fallback.js', + }, + }, + }), + ); + fs.writeFileSync( + path.join(examplePath, 'plugin.js'), + `import { defineRule } from '#plugin-api';`, + ); + + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual(pkg); + }); + + it('removes unused dependencies when aliases already target vite-plus', () => { + const packageJsonPath = path.join(projectPath, 'package.json'); + const imports = { '#plugin-api': 'vite-plus/lint/plugins' }; + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + imports, + devDependencies: { '@oxlint/plugins': '^1.79.0', 'vite-plus': 'latest' }, + }), + ); + fs.writeFileSync( + path.join(projectPath, 'plugin.js'), + `import { defineRule } from '#plugin-api';`, + ); + + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual({ + imports, + devDependencies: { 'vite-plus': 'latest' }, + }); + }); + + it.each(['dist', 'build', 'out', '.cache'])( + 'retains dependencies used by an ignored %s plugin', + (directory) => { + const pkg = { devDependencies: { '@oxlint/plugins': '^1.79.0' } }; + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + fs.writeFileSync(path.join(projectPath, '.gitignore'), `${directory}/\n`); + fs.mkdirSync(path.join(projectPath, directory)); + fs.writeFileSync( + path.join(projectPath, directory, 'plugin.cjs'), + `const { defineRule } = require('@oxlint/plugins');`, + ); + fs.writeFileSync( + path.join(projectPath, directory, 'test.js'), + `import { chromium } from '@vitest/browser-playwright';`, + ); + + expect(sourceTreeReferencesOxlintPluginsPackage(projectPath)).toBe(true); + expect(usesVitestBrowserMode(projectPath)).toBe(false); + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual(pkg); + }, + ); + + it.each(['node_modules', '.git'])( + 'does not retain a dependency referenced only in %s', + (directory) => { + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ devDependencies: { '@oxlint/plugins': '^1.79.0' } }), + ); + fs.mkdirSync(path.join(projectPath, directory)); + fs.writeFileSync( + path.join(projectPath, directory, 'plugin.cjs'), + `require('@oxlint/plugins');`, + ); + + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).devDependencies).toEqual({}); + }, + ); + + it('preserves an optional runtime API without introducing a development-only replacement', () => { + const pkg = { + name: 'oxlint-plugin-optional-example', + optionalDependencies: { '@oxlint/plugins': '^1.79.0' }, + devDependencies: {}, + }; + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + + expect(collectOxlintOwnerDirs(projectPath)).toEqual([projectPath]); + rewritePackageJson(pkg, PackageManager.pnpm); + expect(pkg.optionalDependencies).toEqual({ '@oxlint/plugins': '^1.79.0' }); + expect(pkg.devDependencies).not.toHaveProperty('vite-plus'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual(pkg); + }); + + it('retains a development copy alongside an optional runtime API', () => { + const pkg = { + optionalDependencies: { '@oxlint/plugins': '^1.79.0' }, + devDependencies: { '@oxlint/plugins': '^1.79.0' }, + }; + const packageJsonPath = path.join(projectPath, 'package.json'); + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg)); + + dropDeadOxlintPluginsDependency(projectPath); + + expect(JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))).toEqual(pkg); + }); + + it('keeps optional oxlint on the existing tool-migration path', () => { + const pkg = { optionalDependencies: { oxlint: '^1.79.0' } }; + expect(packageOwnsOxlintApi(pkg)).toBe(false); + rewritePackageJson(pkg, PackageManager.pnpm); + expect(pkg.optionalDependencies).toEqual({}); + }); +}); diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 86a6f02225..908162a4a8 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -9,6 +9,8 @@ import { applyYarnWorkspaceHoistingFix, cleanupDeprecatedTsconfigOptions, collectInjectedProviderNames, + collectOxlintOwnerDirs, + dropDeadOxlintPluginsDependency, collectProviderSourceModes, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, @@ -76,6 +78,9 @@ export function rewriteStandaloneProject( const packageManager = workspaceInfo.packageManager; const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath); + // 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); // 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 @@ -333,7 +338,8 @@ export function rewriteStandaloneProject( injectFmtDefaults(projectPath, silent, report); mergeTsdownConfigFile(projectPath, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(projectPath, silent, report, true); + rewriteAllImports(projectPath, silent, report, true, oxlintOwnerDirs); + dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -353,6 +359,9 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packageManager, ); + // 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 pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings( workspaceInfo.downloadPackageManager.version, @@ -463,7 +472,8 @@ export function rewriteMonorepo( injectFmtDefaults(workspaceInfo.rootDir, silent, report); mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging - rewriteAllImports(workspaceInfo.rootDir, silent, report, true); + rewriteAllImports(workspaceInfo.rootDir, silent, report, true, oxlintOwnerDirs); + dropDeadOxlintPluginsDependency(workspaceInfo.rootDir, workspaceInfo.packages); 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/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts index 070ceeb0f0..8b8234c283 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -27,6 +27,9 @@ import { findDeclaredSpec, resolveProviderPeerSpec, OPT_IN_BROWSER_PROVIDERS, + OXLINT_PLUGINS_PACKAGE, + OXLINT_PLUGIN_API_PACKAGES, + packageOwnsOxlintApi, REMOVE_PACKAGES, VITEST_BROWSER_DEP_NAMES, VITEST_IS_MANAGED_OVERRIDE, @@ -183,10 +186,35 @@ export function rewritePackageJson( const hasBrowserDepSignal = VITEST_BROWSER_DEP_NAMES.some((name) => dependencyGroups.some(({ dependencies }) => dependencies?.[name] !== undefined), ); + // Published plugins keep their upstream imports and the dependencies that + // supply them, including optional @oxlint/plugins integrations. + const ownsOxlintApi = packageOwnsOxlintApi(pkg); + // Rewritten imports need a direct vite-plus dependency. Defer removal of + // @oxlint/plugins until dropDeadOxlintPluginsDependency checks the final source. + if (pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined && !ownsOxlintApi) { + needVitePlus = true; + } // remove packages that are replaced with vite-plus for (const name of REMOVE_PACKAGES) { let wasRemoved = false; - for (const { dependencies } of dependencyGroups) { + for (const { dependencyField, dependencies } of dependencyGroups) { + if ( + ownsOxlintApi && + (dependencyField === 'peerDependencies' || dependencyField === 'dependencies') && + (OXLINT_PLUGIN_API_PACKAGES as readonly string[]).includes(name) + ) { + // A `catalog:` reference would dangle once the catalog entry for a + // REMOVE_PACKAGES name is dropped, and the next install fails. Resolve + // it to the concrete range the catalog currently points at. + const current = dependencies?.[name]; + if (current?.startsWith('catalog:') && dependencies) { + const resolved = catalogDependencyResolver?.(current, name); + if (resolved) { + dependencies[name] = resolved; + } + } + continue; + } if (dependencies?.[name]) { delete dependencies[name]; wasRemoved = true; diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index 89b85ac8d9..7ab78a21c7 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -321,3 +321,39 @@ export function pnpmMajor(version: string | undefined): number | undefined { const coerced = version ? semver.coerce(version)?.version : undefined; return coerced ? semver.major(coerced) : undefined; } + +// Packages that own the Oxlint JS-plugin authoring API as a published contract. +// Optional `@oxlint/plugins` is also a runtime contract for consumers of a +// published integration. Optional `oxlint` keeps the existing tool policy. +export const OXLINT_PLUGINS_PACKAGE = '@oxlint/plugins'; + +export const OXLINT_PLUGIN_API_PACKAGES = ['oxlint', OXLINT_PLUGINS_PACKAGE] as const; + +export function packageOwnsOxlintApi(pkg: DependencyBag): boolean { + return ( + pkg.optionalDependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined || + OXLINT_PLUGIN_API_PACKAGES.some( + (name) => + pkg.dependencies?.[name] !== undefined || pkg.peerDependencies?.[name] !== undefined, + ) + ); +} + +/** + * Capture plugin owners before manifest edits, so the import rewriter can + * preserve their upstream API imports after those edits. + */ +export function collectOxlintOwnerDirs( + rootDir: string, + packages?: readonly { path: string }[], +): string[] { + const owners: string[] = []; + const candidates = [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; + for (const dir of candidates) { + const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')); + if (pkg && packageOwnsOxlintApi(pkg)) { + owners.push(dir); + } + } + return owners; +} diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 32d32877c8..a0b0eb21bb 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -2,10 +2,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { type WorkspacePackage } from '../../types/index.ts'; +import { editJsonFile } from '../../utils/json.ts'; import { hasVitestTypesInTsconfig } from '../../utils/tsconfig.ts'; import { projectUsesVitestDirectly } from '../migrator.ts'; import { OPT_IN_BROWSER_PROVIDERS, + OXLINT_PLUGINS_PACKAGE, + packageOwnsOxlintApi, PLAYWRIGHT_PROVIDER, WEBDRIVERIO_PROVIDER, readPackageJsonIfExists, @@ -188,6 +191,10 @@ 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. +const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn']); + /** * Detect whether a package uses vitest's browser mode. * @@ -215,8 +222,16 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ function sourceTreeMatches( projectPath: string, matchesContent: (content: string) => boolean, + options: { + // Nested examples can resolve the root's dependency through source or + // package imports/scripts, even when they are not workspace members. + crossPackageBoundaries?: boolean; + includePackageReferences?: boolean; + skipDirs?: ReadonlySet; + } = {}, ): boolean { - const scanDir = (dir: string, isRoot: boolean): boolean => { + const skipDirs = options.skipDirs ?? VITEST_SCAN_SKIP_DIRS; + function scanDir(dir: string, isRoot: boolean): boolean { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -225,21 +240,36 @@ function sourceTreeMatches( } // A nested package.json marks a separate workspace package — it is migrated // (and scanned) on its own pass, so don't let its files leak into this one. - if (!isRoot && entries.some((e) => e.isFile() && e.name === 'package.json')) { + if ( + !options.crossPackageBoundaries && + !isRoot && + entries.some((e) => e.isFile() && e.name === 'package.json') + ) { return false; } for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { - if (VITEST_SCAN_SKIP_DIRS.has(entry.name)) { + if (skipDirs.has(entry.name)) { continue; } if (scanDir(entryPath, false)) { return true; } - } else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) { + } else if ( + entry.isFile() && + (VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name)) || + (options.includePackageReferences && entry.name === 'package.json')) + ) { try { - if (matchesContent(fs.readFileSync(entryPath, 'utf8'))) { + let content = fs.readFileSync(entryPath, 'utf8'); + 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 }; + content = JSON.stringify({ imports: pkg.imports, scripts: pkg.scripts }); + } + if (matchesContent(content)) { return true; } } catch { @@ -248,7 +278,7 @@ function sourceTreeMatches( } } return false; - }; + } return scanDir(projectPath, true); } @@ -336,3 +366,49 @@ export function collectProviderSourceModes(projectPath: string): Record content.includes(OXLINT_PLUGINS_PACKAGE), { + crossPackageBoundaries: true, + includePackageReferences: true, + skipDirs: OXLINT_RETENTION_SKIP_DIRS, + }); +} + +/** + * 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. + */ +export function dropDeadOxlintPluginsDependency( + rootDir: string, + packages?: readonly { path: string }[], +): void { + const dirs = [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; + for (const dir of dirs) { + const packageJsonPath = path.join(dir, 'package.json'); + const pkg = readPackageJsonIfExists(packageJsonPath); + if (pkg?.devDependencies?.[OXLINT_PLUGINS_PACKAGE] === undefined) { + continue; + } + if (packageOwnsOxlintApi(pkg) || sourceTreeReferencesOxlintPluginsPackage(dir)) { + continue; + } + editJsonFile<{ + devDependencies?: Record; + }>(packageJsonPath, (json) => { + if (json.devDependencies?.[OXLINT_PLUGINS_PACKAGE] === undefined) { + return undefined; + } + delete json.devDependencies[OXLINT_PLUGINS_PACKAGE]; + return json; + }); + } +} diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 7846ad2b87..2310ba9e08 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -522,8 +522,12 @@ export function rewriteAllImports( silent = false, report?: MigrationReport, preserveNuxtVitestImports = true, + // Directories of packages that own the Oxlint plugin API, captured before + // `rewritePackageJson` stripped `oxlint` from their manifests. See + // `collectOxlintOwnerDirs`. + oxlintOwnerDirs: string[] = [], ): boolean { - const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports); + const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports, oxlintOwnerDirs); const modified = result.modifiedFiles.length; const preserved = result.preservedVitestFiles.length; const errors = result.errors.length; diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 135f449209..145837e5c7 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -36,6 +36,15 @@ function isVitestFamilyDeclareModuleSpecifier(specifier: string): boolean { // (no migrate-resolved custom path). vitest/tsdown/@vitest are unaffected. const VITE_CONFIG_FILE_BASENAMES = new Set(viteConfigEntryBasenames); +// Keep augmentations on the upstream module whose types the shims re-export. +function isOxlintFamilyDeclareModuleSpecifier(specifier: string): boolean { + return ( + specifier === OXLINT_PACKAGE || + specifier.startsWith(`${OXLINT_PACKAGE}/`) || + specifier === OXLINT_PLUGINS_PACKAGE + ); +} + function isViteSpecifier(specifier: string): boolean { return specifier === 'vite' || specifier.startsWith('vite/'); } @@ -44,6 +53,28 @@ function isViteConfigFile(filename: string): boolean { return VITE_CONFIG_FILE_BASENAMES.has(path.basename(filename)); } +const OXLINT_PACKAGE = 'oxlint'; +const OXLINT_PLUGINS_PACKAGE = '@oxlint/plugins'; +const OXLINT_PLUGINS_DEV_SUBPATH = 'oxlint/plugins-dev'; +const VITE_PLUS_LINT_PLUGINS = 'vite-plus/lint/plugins'; +const VITE_PLUS_LINT_PLUGINS_DEV = 'vite-plus/lint/plugins-dev'; + +// Names outside this config surface use the legacy plugin API. Keep this list +// in sync with the Oxlint rules in crates/vp_migration/src/import_rewriter.rs. +const OXLINT_CONFIG_SURFACE_EXPORTS = new Set([ + 'defineConfig', + 'AllowWarnDeny', + 'DummyRule', + 'DummyRuleMap', + 'ExternalPluginEntry', + 'ExternalPluginsConfig', + 'OxlintConfig', + 'OxlintEnv', + 'OxlintGlobals', + 'OxlintOverride', + 'RuleCategories', +]); + function rewriteVitePlusImportSpecifier(specifier: string): string | null { if (specifier === 'vite') { return 'vite-plus'; @@ -112,27 +143,63 @@ function rewriteVitePlusImportSpecifier(specifier: string): string | null { } } + // These entry points expose only plugin APIs. Bare `oxlint` also exposes + // config APIs, so it needs the named-binding checks below. + if (specifier === OXLINT_PLUGINS_PACKAGE) { + return VITE_PLUS_LINT_PLUGINS; + } + + if (specifier === OXLINT_PLUGINS_DEV_SUBPATH) { + return VITE_PLUS_LINT_PLUGINS_DEV; + } + return null; } +function moduleBindingName(node: ESTree.ImportSpecifier['imported']): string { + return node.type === 'Identifier' ? node.name : node.value; +} + +// Replacing the source affects the whole import. Require named plugin bindings +// only: the shim has neither config exports nor a default export. +function importsOxlintPluginApi(node: ESTree.ImportDeclaration): boolean { + return ( + node.specifiers.length > 0 && + node.specifiers.every( + (specifier) => + specifier.type === 'ImportSpecifier' && + !OXLINT_CONFIG_SURFACE_EXPORTS.has(moduleBindingName(specifier.imported)), + ) + ); +} + function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): string { const quote = literal.raw?.startsWith("'") ? "'" : '"'; return `${quote}${replacement}${quote}`; } -// Keyed by package.json path and invalidated by its mtime so a long-lived lint -// process (editor/LSP session) re-reads the manifest after the user adds or -// removes `@nuxt/test-utils`, instead of reusing the pre-edit decision forever. -const nuxtTestUtilsPackageCache = new Map< - string, - { mtimeMs: number; usesNuxtTestUtils: boolean } ->(); +type PackageDependencies = { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; +}; + +type PackageMatchCache = Map; + +// Separate decisions share the same mtime-based invalidation in editor sessions. +const nuxtTestUtilsPackageCache: PackageMatchCache = new Map(); +const oxlintOwnerPackageCache: PackageMatchCache = new Map(); function isUpstreamVitestSpecifier(specifier: string): boolean { return specifier === 'vitest' || specifier.startsWith('vitest/'); } -function nearestPackageUsesNuxtTestUtils(filename: string): boolean { +function nearestPackageMatches( + filename: string, + cache: PackageMatchCache, + matchesPackage: (pkg: PackageDependencies) => boolean, +): boolean { if (!path.isAbsolute(filename)) { return false; } @@ -144,32 +211,23 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { try { mtimeMs = fs.statSync(packageJsonPath).mtimeMs; } catch { - // Unreadable manifest: bypass the cache entirely below. A sentinel - // value would collide with an entry cached during an earlier failure - // and pin the pre-edit decision. + // Bypass the cache when stat fails; a sentinel could reuse stale data. } - const cached = - mtimeMs === undefined ? undefined : nuxtTestUtilsPackageCache.get(packageJsonPath); + const cached = mtimeMs === undefined ? undefined : cache.get(packageJsonPath); if (cached !== undefined && cached.mtimeMs === mtimeMs) { - return cached.usesNuxtTestUtils; + return cached.matches; } - let usesNuxtTestUtils = false; + let matches = false; try { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { - dependencies?: Record; - devDependencies?: Record; - optionalDependencies?: Record; - }; - usesNuxtTestUtils = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( - (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, - ); + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageDependencies; + matches = matchesPackage(pkg); } catch { // Invalid or unreadable package metadata cannot opt into the exception. } if (mtimeMs !== undefined) { - nuxtTestUtilsPackageCache.set(packageJsonPath, { mtimeMs, usesNuxtTestUtils }); + cache.set(packageJsonPath, { mtimeMs, matches }); } - return usesNuxtTestUtils; + return matches; } const parent = path.dirname(directory); if (parent === directory) { @@ -179,12 +237,63 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { } } +function nearestPackageUsesNuxtTestUtils(filename: string): boolean { + return nearestPackageMatches(filename, nuxtTestUtilsPackageCache, (pkg) => + [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some( + (dependencies) => dependencies?.['@nuxt/test-utils'] !== undefined, + ), + ); +} + +// Match the migrator's published-plugin exemption. Development-only APIs do not +// exempt a package; optional @oxlint/plugins is a consumer runtime dependency. +function nearestPackageOwnsOxlintApi(filename: string): boolean { + return nearestPackageMatches( + filename, + oxlintOwnerPackageCache, + (pkg) => + pkg.optionalDependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined || + [pkg.dependencies, pkg.peerDependencies].some( + (dependencies) => + dependencies?.[OXLINT_PACKAGE] !== undefined || + dependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined, + ), + ); +} + +function reportSpecifier( + context: Context, + literal: ESTree.StringLiteral, + replacement: string, +): void { + context.report({ + node: literal, + messageId: 'preferVitePlusImports', + data: { + from: literal.value, + to: replacement, + }, + fix(fixer) { + return fixer.replaceText(literal, quoteSpecifier(literal, replacement)); + }, + }); +} + +function isOxlintApiSpecifier(specifier: string): boolean { + return specifier === OXLINT_PLUGINS_PACKAGE || specifier === OXLINT_PLUGINS_DEV_SUBPATH; +} + +interface ImportRewriteOptions { + preserveUpstreamVitest: boolean; + fileIsViteConfig: boolean; + ownsOxlintApi: boolean; +} + function maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, - preserveUpstreamVitest = false, - fileIsViteConfig = false, -) { + { preserveUpstreamVitest, fileIsViteConfig, ownsOxlintApi }: ImportRewriteOptions, +): void { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; } @@ -200,18 +309,45 @@ function maybeReportLiteral( if (!replacement) { return; } + if (ownsOxlintApi && isOxlintApiSpecifier(literal.value)) { + return; + } - context.report({ - node: literal, - messageId: 'preferVitePlusImports', - data: { - from: literal.value, - to: replacement, - }, - fix(fixer) { - return fixer.replaceText(literal, quoteSpecifier(literal, replacement)); - }, - }); + reportSpecifier(context, literal, replacement); +} + +// Bare `oxlint` needs named-binding checks beyond maybeReportLiteral's mapping. +function reportLegacyOxlintPluginApiExport( + context: Context, + node: ESTree.ExportNamedDeclaration, + ownsOxlintApi: boolean, +): void { + const literal = node.source; + if (!literal || literal.value !== OXLINT_PACKAGE || ownsOxlintApi) { + return; + } + if (node.specifiers.length === 0) { + return; + } + const allPluginApi = node.specifiers.every( + (specifier) => !OXLINT_CONFIG_SURFACE_EXPORTS.has(moduleBindingName(specifier.local)), + ); + if (!allPluginApi) { + return; + } + reportSpecifier(context, literal, VITE_PLUS_LINT_PLUGINS); +} + +function reportLegacyOxlintPluginApiImport( + context: Context, + node: ESTree.ImportDeclaration, + ownsOxlintApi: boolean, +): void { + const literal = node.source; + if (literal.value !== OXLINT_PACKAGE || ownsOxlintApi || !importsOxlintPluginApi(node)) { + return; + } + reportSpecifier(context, literal, VITE_PLUS_LINT_PLUGINS); } export const preferVitePlusImportsRule = defineRule({ @@ -228,30 +364,45 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { - let preserveUpstreamVitest = false; - let fileIsViteConfig = false; + const options: ImportRewriteOptions = { + preserveUpstreamVitest: false, + fileIsViteConfig: false, + ownsOxlintApi: false, + }; return { Program() { - preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); - fileIsViteConfig = isViteConfigFile(context.filename); + options.preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); + options.fileIsViteConfig = isViteConfigFile(context.filename); + options.ownsOxlintApi = nearestPackageOwnsOxlintApi(context.filename); }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral(context, node.source, options); + reportLegacyOxlintPluginApiImport(context, node, options.ownsOxlintApi); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral(context, node.source, options); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral(context, node.source, options); + reportLegacyOxlintPluginApiExport(context, node, options.ownsOxlintApi); }, ImportExpression(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral(context, node.source, options); }, TSImportType(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral(context, node.source, options); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression, preserveUpstreamVitest, fileIsViteConfig); + // Keep import-equals declarations unchanged, matching the migrator's + // treatment of require calls. + if ( + node.expression.type === 'Literal' && + typeof node.expression.value === 'string' && + isOxlintApiSpecifier(node.expression.value) + ) { + return; + } + maybeReportLiteral(context, node.expression, options); }, TSModuleDeclaration(node) { if (node.global) { @@ -261,11 +412,12 @@ export const preferVitePlusImportsRule = defineRule({ if ( id?.type === 'Literal' && typeof id.value === 'string' && - isVitestFamilyDeclareModuleSpecifier(id.value) + (isVitestFamilyDeclareModuleSpecifier(id.value) || + isOxlintFamilyDeclareModuleSpecifier(id.value)) ) { return; } - maybeReportLiteral(context, id, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral(context, id, options); }, }; }, diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 152c230a9e..f68e7ad32e 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -49,6 +49,8 @@ export default defineConfig([ 'define-config': './src/define-config.ts', fmt: './src/fmt.ts', lint: './src/lint.ts', + 'lint-plugins': './src/lint-plugins.ts', + 'lint-plugins-dev': './src/lint-plugins-dev.ts', 'oxlint-plugin': './src/oxlint-plugin.ts', 'tsgolint-path': './src/utils/tsgolint-path.ts', pack: './src/pack.ts', @@ -112,6 +114,9 @@ export default defineConfig([ entry: { 'define-config': './src/define-config.ts', index: './src/index.cts', + // Match @oxlint/plugins' CJS support for compiled plugins. plugins-dev + // uses its ESM entry for both import and require, matching upstream. + 'lint-plugins': './src/lint-plugins.ts', }, outDir: 'dist', format: 'cjs',