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..133bae6ae1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots/lint_oxlint_plugin_api.md @@ -0,0 +1,92 @@ +# 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 │ + ╰──── + +Found 0 warnings and 3 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 }; +``` + +## `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..74a52a3623 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/config-surface.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'oxlint'; +import type { OxlintOverride } from 'oxlint'; + +export const override: OxlintOverride = { files: ['**/*.ts'] }; + +export default defineConfig({ overrides: [override] }); 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..af690fcfdc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/legacy-imports.ts @@ -0,0 +1,5 @@ +import { defineRule } from 'oxlint'; +import { definePlugin } from '@oxlint/plugins'; +import { RuleTester } from 'oxlint/plugins-dev'; + +export { defineRule, definePlugin, RuleTester }; 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_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_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 62f377d750..c19d91672f 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,235 @@ transform: fix: $NEW_IMPORT "#; +/// ast-grep rules for rewriting Oxlint JS-plugin authoring imports. +/// +/// The mapping: +/// - `import { defineRule } from '@oxlint/plugins'` → `'vite-plus/lint/plugins'` +/// - `import { RuleTester } from 'oxlint/plugins-dev'` → `'vite-plus/lint/plugins-dev'` +/// - `import { defineRule } from 'oxlint'` → `'vite-plus/lint/plugins'` +/// +/// `rewriteVitePlusImportSpecifier` in `oxlint-plugin.ts` repeats this mapping +/// for the lint autofix. Both implementations MUST stay in sync. +/// +/// Why this exists: vite-plus bundles Oxlint, so the migration strips `oxlint` +/// from the project. A JS plugin that imports the authoring API by either name +/// then stops resolving, and `vp lint` fails to load the plugin. +/// +/// The rewrite points those imports at `vite-plus` rather than re-adding +/// `@oxlint/plugins` as a direct dependency. This locks the API to the bundled +/// linter, so the user pins nothing. It also resolves from any package that +/// already has `vite-plus`. A direct `@oxlint/plugins` import does not: +/// `@oxlint/plugins` is only a transitive dependency, which pnpm's strict +/// layout hides from a user's plugin file. +/// +/// The bare `oxlint` specifier is ambiguous. It still serves the CONFIG surface +/// (`defineConfig`, `OxlintConfig`, `OxlintOverride`, and so on), which must +/// keep resolving against the standalone package. So the rewrite applies only +/// to an `import` statement that names a specifier outside that config surface. +/// The check is therefore a small, stable denylist, not an ever-growing list of +/// plugin type names. An unrecognized name falls on the side of fixing the +/// breakage. +/// +/// A statement that mixes the two surfaces, such as +/// `import { defineConfig, defineRule } from 'oxlint'`, is left alone. The +/// rewrite replaces the whole specifier, so moving it would strip +/// `defineConfig` of its module. Splitting the statement is the user's call. +/// +/// A statement carrying a default or namespace binding alongside named ones, +/// such as `import oxlint, { defineRule } from 'oxlint'`, is left alone for the +/// same reason: `vite-plus/lint/plugins` has no default export. +/// +/// A named re-export, `export { defineRule } from 'oxlint'`, names the surface +/// just as clearly as an import, so it rewrites under the same rules. A bare +/// `export * from 'oxlint'` names nothing and is left alone. +/// +/// These forms name no specifier, so the rewrite skips them: namespace imports +/// (`import * as`), default imports, bare side-effect imports, +/// `require('oxlint')`, and `import('oxlint')`. +/// +/// `@oxlint/plugins` and `oxlint/plugins-dev` are unambiguous. They expose only +/// the plugin API and the dev-time utilities, so import, export, and dynamic +/// `import()` statements all rewrite. `require()` does NOT: the +/// `vite-plus/lint/*` exports are ESM-only, so a rewritten `require()` would +/// fail to resolve with ERR_PACKAGE_PATH_NOT_EXPORTED. +/// +/// The rewrite skips a package that declares `oxlint` or `@oxlint/plugins` in +/// `dependencies` or `peerDependencies`. Those are published Oxlint plugins, +/// and their 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 +1842,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 +2188,13 @@ 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). A package that declares either + /// as a runtime/peer edge is a published Oxlint plugin: its consumers may be + /// running plain Oxlint, so redirecting the authoring API at `vite-plus` + /// would break them. A devDependency is not a signal: it is just how a + /// project's own in-repo plugin gets its types. + skip_oxlint: bool, } #[derive(Debug, Clone, Copy, Default)] @@ -1963,17 +2204,28 @@ 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` BEFORE the migration edited their + /// manifests. + /// + /// `rewritePackageJson` strips `oxlint` (it is in `REMOVE_PACKAGES`) before + /// import rewriting reads the manifests, so `get_package_rewrite_context` + /// can no longer see that signal on disk. The caller captures it up front + /// and passes it here, otherwise a published Oxlint plugin that declared + /// the legacy `oxlint` package would lose its exemption and get rewritten + /// to depend on Vite+. + 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 +2346,10 @@ 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"), }, uses_nuxt_test_utils: ["dependencies", "devDependencies", "optionalDependencies"] .into_iter() @@ -2178,15 +2434,26 @@ 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(); + // Packages whose manifest declared `oxlint` / `@oxlint/plugins` before the + // migration edited it. Matched by the package DIRECTORY because the + // manifest itself no longer carries the signal (see `oxlint_owner_dirs`). + let oxlint_owner_dirs: HashSet = options.oxlint_owner_dirs.iter().cloned().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 + let mut context = *package_context_cache .entry(package_json_path.clone()) - .or_insert_with(|| get_package_rewrite_context(&package_json_path)) + .or_insert_with(|| 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() }; @@ -2299,6 +2566,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 } @@ -2380,6 +2652,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 @@ -3134,7 +3418,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(); @@ -3172,7 +3459,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(); @@ -3693,6 +3983,229 @@ 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_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() { + // `vite-plus/lint/plugins` is an ESM-only export, so a rewritten + // `require()` would fail with ERR_PACKAGE_PATH_NOT_EXPORTED. + 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 `import` condition, so the + // ESM-only export is reachable. + 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' { @@ -3803,8 +4316,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); @@ -3826,8 +4343,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); @@ -3850,7 +4371,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); @@ -3859,10 +4385,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(); @@ -3902,7 +4438,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"); @@ -3912,9 +4449,55 @@ 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_get_skip_packages_from_package_json_with_vite_dependency() { use std::fs; @@ -4695,8 +5278,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. @@ -5162,8 +5749,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!( @@ -5179,8 +5770,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!( @@ -5196,8 +5791,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!( @@ -5213,7 +5812,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 561cbacf73..08ceeae9ff 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -196,6 +196,39 @@ 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`. That shape marks a published Oxlint +plugin. Its consumers may not run Vite+. + ### What Is Never Rewritten - `declare module 'vitest'` and `declare module '@vitest/browser*'`: module diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 00bee183d8..fbf3da21e2 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3621,6 +3621,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 4d19833e8c..0a753d7255 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -290,11 +290,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 9c95ad68b2..128041fca7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -70,6 +70,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", + "import": "./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..bdfbd7f513 100644 --- a/packages/cli/src/__tests__/exports-map.spec.ts +++ b/packages/cli/src/__tests__/exports-map.spec.ts @@ -117,6 +117,51 @@ describe('package.json exports map', () => { }); }); +/** + * Migration rewrites Oxlint JS-plugin authoring imports to + * `vite-plus/lint/plugins` and `vite-plus/lint/plugins-dev`. See the + * `rewrite-oxlint-plugin-*` rules in `import_rewriter.rs` and + * `rewriteVitePlusImportSpecifier` in `oxlint-plugin.ts`. + * + * That rewrite exists so a user's plugin file reaches the API through + * `vite-plus` instead of pinning its own `@oxlint/plugins`. These entrypoints + * MUST therefore stay resolvable, and they MUST keep re-exporting the upstream + * surface. If either breaks, every migrated plugin fails when `vp lint` loads + * it. + */ +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` deliberately does not: it is ESM-only upstream. + 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'); + }); +}); + /** * 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-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..3aa8cfba51 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -20,6 +20,13 @@ 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', +); describe('oxlint plugin config defaults', () => { it('adds vite-plus js plugin and lint rule defaults', () => { @@ -102,6 +109,15 @@ 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(); + }); }); new RuleTester({ @@ -112,6 +128,67 @@ 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'`, + { + 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 x = require(...)` has require semantics, and the vite-plus lint + // subpaths are ESM-only. + { + 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: `import { defineRule } from '@oxlint/plugins'`, + filename: oxlintPluginPackageFilename, + }, + { + code: `import { defineRule } from 'oxlint'`, + filename: oxlintPluginPackageFilename, + }, + { + code: `import { RuleTester } from 'oxlint/plugins-dev'`, + filename: oxlintPluginPackageFilename, + }, + // `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 +268,40 @@ new RuleTester({ }, ], invalid: [ + { + 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: `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..4a9264c770 --- /dev/null +++ b/packages/cli/src/lint-plugins-dev.ts @@ -0,0 +1,15 @@ +// Oxlint's dev-time plugin utilities (`RuleTester`), re-exported from the copy +// of Oxlint that ships with Vite+. Companion to `vite-plus/lint/plugins`: rule +// *tests* break the same way plugin *sources* do, just at a different specifier +// (these utilities live in `oxlint/plugins-dev`, not `@oxlint/plugins`). +// +// The subpath mirrors upstream's rather than naming today's single export, so +// the mapping stays a mechanical `oxlint/plugins-dev` -> +// `vite-plus/lint/plugins-dev`, and whatever upstream adds to that entry later +// still arrives under a name that fits. +// +// Kept out of `vite-plus/lint/plugins` on purpose: these are test-only, and +// importing the authoring API should not pull them in. + +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..1e46c7aadc --- /dev/null +++ b/packages/cli/src/lint-plugins.ts @@ -0,0 +1,22 @@ +// The Oxlint JS-plugin authoring API, re-exported from the copy of +// `@oxlint/plugins` that ships with Vite+. +// +// Oxlint used to expose `defineRule` and `definePlugin` from its main entry. +// They now live in `@oxlint/plugins`. The plugin API is versioned against the +// linter that loads the plugin, and `vp lint` runs the bundled Oxlint. So a +// project that declares its own `@oxlint/plugins` must keep that pin in step +// with whatever Vite+ bundles. +// +// An import from here removes that pin. The API is always the one the bundled +// linter understands. It also resolves from any package that already has +// `vite-plus` installed. A direct `@oxlint/plugins` import does not: +// `@oxlint/plugins` is a transitive dependency, which pnpm's strict layout +// hides from a user's plugin file. +// +// `vp migrate` rewrites legacy `oxlint` and `@oxlint/plugins` authoring imports +// to this specifier. The `vite-plus/prefer-vite-plus-imports` lint rule +// enforces it. See `crates/vp_migration/src/import_rewriter.rs` and +// `packages/cli/src/oxlint-plugin.ts`. The two mappings must stay in sync. + +export { definePlugin, defineRule, eslintCompatPlugin } from '@oxlint/plugins'; +export type * from '@oxlint/plugins'; diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index af187c1d12..c6813b0bad 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, @@ -75,6 +77,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..f8cc153890 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -27,6 +27,8 @@ import { findDeclaredSpec, resolveProviderPeerSpec, OPT_IN_BROWSER_PROVIDERS, + OXLINT_PLUGINS_PACKAGE, + OXLINT_PLUGIN_API_PACKAGES, REMOVE_PACKAGES, VITEST_BROWSER_DEP_NAMES, VITEST_IS_MANAGED_OVERRIDE, @@ -183,10 +185,58 @@ export function rewritePackageJson( const hasBrowserDepSignal = VITEST_BROWSER_DEP_NAMES.some((name) => dependencyGroups.some(({ dependencies }) => dependencies?.[name] !== undefined), ); + // A `dependencies` / `peerDependencies` edge on the Oxlint plugin API marks a + // published Oxlint plugin: the API is part of what it ships against, not a + // tool it runs. The import rewrite leaves such a package's source on `oxlint` + // (`skip_oxlint`), so its manifest edge is preserved too. Stripping it would + // leave the source importing a package the manifest no longer declares. + // + // Both groups are checked, matching `collectOxlintOwnerDirs`. A peer-only + // check would preserve the source of a plugin that declares `oxlint` under + // `dependencies` while deleting the edge that provides it. + const ownsOxlintApi = OXLINT_PLUGIN_API_PACKAGES.some( + (name) => pkg.dependencies?.[name] !== undefined || pkg.peerDependencies?.[name] !== undefined, + ); + // `@oxlint/plugins` often becomes dead weight once the import rewrite points + // the authoring API at `vite-plus/lint/plugins`, but not always: the rewrite + // preserves several forms. The deletion therefore happens AFTER the rewrite, + // in `dropDeadOxlintPluginsDependency`, where the question is simply whether + // anything still names the package. + // + // The `vite-plus` edge is still decided here, though. A leaf whose only + // migration signal is this dependency will have its imports repointed at + // `vite-plus/lint/plugins`, so it needs a direct `vite-plus` edge to resolve + // them under an isolated layout such as Yarn PnP. + // An optional install edge provisions the API the same way a dev one does, + // so it is the same signal. + if ( + (pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined || + pkg.optionalDependencies?.[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 1ab9aa861e..617e7a81e6 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -264,3 +264,41 @@ 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. +// A package that declares either in `dependencies` or `peerDependencies` is a +// published Oxlint plugin, so its source must keep resolving the API from that +// package rather than from `vite-plus`. +export const OXLINT_PLUGINS_PACKAGE = '@oxlint/plugins'; + +export const OXLINT_PLUGIN_API_PACKAGES = ['oxlint', OXLINT_PLUGINS_PACKAGE] as const; + +/** + * Collect the directories of packages that own the Oxlint plugin API, so the + * import rewriter can exempt them. + * + * Must run BEFORE `rewritePackageJson`. That function strips `oxlint` (it is in + * {@link REMOVE_PACKAGES}), and the import rewriter reads the manifests only + * afterwards, by which point the signal is gone from disk. + */ +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) { + continue; + } + const owns = OXLINT_PLUGIN_API_PACKAGES.some( + (name) => + pkg.dependencies?.[name] !== undefined || pkg.peerDependencies?.[name] !== undefined, + ); + if (owns) { + 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..be2ef9321d 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, + OXLINT_PLUGIN_API_PACKAGES, PLAYWRIGHT_PROVIDER, WEBDRIVERIO_PROVIDER, readPackageJsonIfExists, @@ -215,6 +218,12 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ function sourceTreeMatches( projectPath: string, matchesContent: (content: string) => boolean, + // Cross nested package.json boundaries. Off by default, because most signals + // are per-package and a sub-package is scanned on its own pass. On for the + // dependency-retention check, where a nested example or fixture directory + // that is not a workspace member would otherwise go unscanned while still + // resolving the root's dependency. + crossPackageBoundaries = false, ): boolean { const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; @@ -225,7 +234,11 @@ 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 ( + !crossPackageBoundaries && + !isRoot && + entries.some((e) => e.isFile() && e.name === 'package.json') + ) { return false; } for (const entry of entries) { @@ -336,3 +349,81 @@ export function collectProviderSourceModes(projectPath: string): Record content.includes('@oxlint/plugins'), + // Nested non-workspace packages (examples, fixtures) resolve the root's + // dependency by walking up, so they must be scanned before it is deleted. + true, + ); +} + +/** + * 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 published contract (`dependencies` or + * `peerDependencies`), and skips any package whose source still names it + * through a form the rewrite preserves. + */ +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) { + continue; + } + const declaredIn = (['devDependencies', 'optionalDependencies'] as const).filter( + (field) => pkg?.[field]?.[OXLINT_PLUGINS_PACKAGE] !== undefined, + ); + if (declaredIn.length === 0) { + continue; + } + const ownsApi = OXLINT_PLUGIN_API_PACKAGES.some( + (name) => + pkg.dependencies?.[name] !== undefined || pkg.peerDependencies?.[name] !== undefined, + ); + if (ownsApi || sourceTreeReferencesOxlintPluginsPackage(dir)) { + continue; + } + editJsonFile<{ + devDependencies?: Record; + optionalDependencies?: Record; + }>(packageJsonPath, (json) => { + let changed = false; + for (const field of declaredIn) { + if (json[field]?.[OXLINT_PLUGINS_PACKAGE]) { + delete json[field][OXLINT_PLUGINS_PACKAGE]; + changed = true; + } + } + return changed ? json : undefined; + }); + } +} diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 86b8e29c49..c2c1f8be10 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..b21a7c21ce 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -36,6 +36,19 @@ function isVitestFamilyDeclareModuleSpecifier(specifier: string): boolean { // (no migrate-resolved custom path). vitest/tsdown/@vitest are unaffected. const VITE_CONFIG_FILE_BASENAMES = new Set(viteConfigEntryBasenames); +// `declare module '@oxlint/plugins'` (and the `oxlint` / `oxlint/plugins-dev` +// forms) are preserved for the same reason as the vitest family above: +// `vite-plus/lint/plugins*` re-exports the upstream types, so the module +// identity a user augments stays `@oxlint/plugins`. Retargeting the +// augmentation would stop it merging with the upstream declarations. +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 +57,37 @@ 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'; + +// Everything the `oxlint` package still exports from its main entry: the config +// surface. Those imports are correct as they are, so the rule must not redirect +// them. Any other name in an `import ... from 'oxlint'` belongs to the +// pre-`@oxlint/plugins` authoring API, such as `defineRule`, `Context`, or +// `ESTree`. That API no longer resolves once the migration strips the +// standalone `oxlint` dependency. +// +// This is a denylist, not an allowlist of about 60 plugin type names. The +// denylist is small and stable, and an unrecognized name falls on the side of +// fixing the breakage. It mirrors the `rewrite-oxlint-plugin-api-import` rule +// in `crates/vp_migration/src/import_rewriter.rs`. The two MUST stay in sync. +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,9 +156,64 @@ function rewriteVitePlusImportSpecifier(specifier: string): string | null { } } + // The Oxlint JS-plugin authoring API. Vite+ bundles Oxlint, so a project's + // own plugin should reach the API through `vite-plus`. Otherwise it pins + // `@oxlint/plugins` against whatever Oxlint the bundled linter runs. These + // two specifiers serve nothing but the plugin API, so they always rewrite. + // `reportLegacyOxlintPluginApiImport` handles the ambiguous bare `oxlint` + // specifier. + 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 importedName(specifier: ESTree.ImportSpecifier): string | undefined { + const imported = specifier.imported; + if (imported.type === 'Identifier') { + return imported.name; + } + return typeof imported.value === 'string' ? imported.value : undefined; +} + +/** + * True when EVERY named binding of an `import ... from 'oxlint'` sits outside + * Oxlint's config surface. Such an import reaches only for the plugin + * authoring API. + * + * A statement that mixes the two surfaces returns `false`. The autofix replaces + * the whole specifier, and `vite-plus/lint/plugins` exports no `defineConfig`, + * so moving a mixed statement would leave the file invalid. + * + * Default, namespace, and bare side-effect imports name no binding. They also + * return `false`, so the rule leaves them alone instead of risking a wrong + * rewrite. + */ +function importsOxlintPluginApi(node: ESTree.ImportDeclaration): boolean { + // A default or namespace binding disqualifies the statement outright. + // `vite-plus/lint/plugins` has no default export, so redirecting + // `import oxlint, { defineRule } from 'oxlint'` would leave the file invalid. + if (node.specifiers.some((specifier) => specifier.type !== 'ImportSpecifier')) { + return false; + } + const named = node.specifiers.filter( + (specifier): specifier is ESTree.ImportSpecifier => specifier.type === 'ImportSpecifier', + ); + if (named.length === 0) { + return false; + } + // A statement that mixes the two surfaces is left alone. The autofix replaces + // the whole specifier, so moving it would strip `defineConfig` of its module. + return named.every( + (specifier) => !OXLINT_CONFIG_SURFACE_EXPORTS.has(importedName(specifier) ?? ''), + ); +} + function quoteSpecifier(literal: ESTree.StringLiteral, replacement: string): string { const quote = literal.raw?.startsWith("'") ? "'" : '"'; return `${quote}${replacement}${quote}`; @@ -179,11 +278,93 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { } } +// Same mtime-keyed shape as `nuxtTestUtilsPackageCache`, for the same reason: a +// long-lived lint process must re-read the manifest after the user edits it. +const oxlintOwnerPackageCache = new Map(); + +/** + * True when the nearest package.json declares `oxlint` or `@oxlint/plugins` in + * `dependencies` or `peerDependencies`. + * + * That shape marks a published Oxlint plugin, whose consumers may run plain + * Oxlint. Rewriting its source to import from `vite-plus` would break them, so + * the autofix must leave it alone. `vp migrate` skips the same package shape + * (`SkipPackages::skip_oxlint`); without this check `vp lint --fix` would + * immediately undo that exemption. + * + * A devDependency is deliberately NOT a signal: that is how a project's own + * in-repo plugin gets its types, and those imports SHOULD move to `vite-plus`. + */ +function nearestPackageOwnsOxlintApi(filename: string): boolean { + if (!path.isAbsolute(filename)) { + return false; + } + let directory = path.dirname(filename); + while (true) { + const packageJsonPath = path.join(directory, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + let mtimeMs: number | undefined; + try { + mtimeMs = fs.statSync(packageJsonPath).mtimeMs; + } catch { + // Unreadable manifest: bypass the cache, as above. + } + const cached = + mtimeMs === undefined ? undefined : oxlintOwnerPackageCache.get(packageJsonPath); + if (cached !== undefined && cached.mtimeMs === mtimeMs) { + return cached.ownsOxlintApi; + } + let ownsOxlintApi = false; + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + dependencies?: Record; + peerDependencies?: Record; + }; + ownsOxlintApi = [pkg.dependencies, pkg.peerDependencies].some( + (dependencies) => + dependencies?.[OXLINT_PACKAGE] !== undefined || + dependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined, + ); + } catch { + // Invalid or unreadable package metadata cannot opt into the exception. + } + if (mtimeMs !== undefined) { + oxlintOwnerPackageCache.set(packageJsonPath, { mtimeMs, ownsOxlintApi }); + } + return ownsOxlintApi; + } + const parent = path.dirname(directory); + if (parent === directory) { + return false; + } + directory = parent; + } +} + +function reportSpecifier(context: Context, literal: ESTree.StringLiteral, replacement: string) { + 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; +} + function maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, preserveUpstreamVitest = false, fileIsViteConfig = false, + ownsOxlintApi = false, ) { if (!literal || literal.type !== 'Literal' || typeof literal.value !== 'string') { return; @@ -200,18 +381,68 @@ function maybeReportLiteral( if (!replacement) { return; } + // A published Oxlint plugin keeps resolving the authoring API from the + // package it declares. See `nearestPackageOwnsOxlintApi`. + 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); +} + +/** + * `import { defineRule } from 'oxlint'` → `'vite-plus/lint/plugins'`. + * + * This is separate from {@link maybeReportLiteral} because the specifier string + * alone cannot decide the bare `oxlint` case. That specifier still serves the + * config surface. Only an `ImportDeclaration` shows the named bindings that + * tell the two surfaces apart. Re-export, `require`, and dynamic `import` + * statements therefore do not get this rewrite. + */ +/** + * `export { defineRule } from 'oxlint'` → `'vite-plus/lint/plugins'`. + * + * A named re-export identifies the surface exactly as an import does, so it + * follows the same rules. A bare `export * from 'oxlint'` names nothing and is + * left alone. + */ +function reportLegacyOxlintPluginApiExport( + context: Context, + node: ESTree.ExportNamedDeclaration, + ownsOxlintApi: boolean, +) { + const literal = node.source; + if (!literal || literal.value !== OXLINT_PACKAGE || ownsOxlintApi) { + return; + } + const named = node.specifiers; + if (named.length === 0) { + return; + } + const allPluginApi = named.every((specifier) => { + const local = specifier.local; + const name = local.type === 'Identifier' ? local.name : undefined; + return name !== undefined && !OXLINT_CONFIG_SURFACE_EXPORTS.has(name); }); + if (!allPluginApi) { + return; + } + reportSpecifier(context, literal, VITE_PLUS_LINT_PLUGINS); +} + +function reportLegacyOxlintPluginApiImport( + context: Context, + node: ESTree.ImportDeclaration, + ownsOxlintApi: boolean, +) { + const literal = node.source; + if (literal.value !== OXLINT_PACKAGE || !importsOxlintPluginApi(node)) { + return; + } + if (ownsOxlintApi) { + return; + } + reportSpecifier(context, literal, VITE_PLUS_LINT_PLUGINS); } export const preferVitePlusImportsRule = defineRule({ @@ -230,28 +461,78 @@ export const preferVitePlusImportsRule = defineRule({ createOnce(context: Context) { let preserveUpstreamVitest = false; let fileIsViteConfig = false; + let ownsOxlintApi = false; return { Program() { preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); fileIsViteConfig = isViteConfigFile(context.filename); + ownsOxlintApi = nearestPackageOwnsOxlintApi(context.filename); }, ImportDeclaration(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral( + context, + node.source, + preserveUpstreamVitest, + fileIsViteConfig, + ownsOxlintApi, + ); + reportLegacyOxlintPluginApiImport(context, node, ownsOxlintApi); }, ExportAllDeclaration(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral( + context, + node.source, + preserveUpstreamVitest, + fileIsViteConfig, + ownsOxlintApi, + ); }, ExportNamedDeclaration(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral( + context, + node.source, + preserveUpstreamVitest, + fileIsViteConfig, + ownsOxlintApi, + ); + reportLegacyOxlintPluginApiExport(context, node, ownsOxlintApi); }, ImportExpression(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral( + context, + node.source, + preserveUpstreamVitest, + fileIsViteConfig, + ownsOxlintApi, + ); }, TSImportType(node) { - maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + maybeReportLiteral( + context, + node.source, + preserveUpstreamVitest, + fileIsViteConfig, + ownsOxlintApi, + ); }, TSExternalModuleReference(node) { - maybeReportLiteral(context, node.expression, preserveUpstreamVitest, fileIsViteConfig); + // `import plugins = require('...')` has require semantics, and the + // `vite-plus/lint/*` subpaths are ESM-only, so they are skipped here + // for the same reason the migrate rewriter skips `require()`. + if ( + node.expression.type === 'Literal' && + typeof node.expression.value === 'string' && + isOxlintApiSpecifier(node.expression.value) + ) { + return; + } + maybeReportLiteral( + context, + node.expression, + preserveUpstreamVitest, + fileIsViteConfig, + ownsOxlintApi, + ); }, TSModuleDeclaration(node) { if (node.global) { @@ -261,11 +542,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, preserveUpstreamVitest, fileIsViteConfig, ownsOxlintApi); }, }; }, diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 94c7d182b2..83a7880c9d 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', @@ -88,6 +90,13 @@ export default defineConfig([ entry: { 'define-config': './src/define-config.ts', index: './src/index.cts', + // `@oxlint/plugins` ships CJS, so the shim can too. A `.cts` plugin, or + // a `.ts` one compiled with `module: commonjs`, emits its import as + // `require()`, which an ESM-only export would reject. + // + // `lint-plugins-dev` deliberately has no CJS build: `oxlint/plugins-dev` + // is ESM-only upstream, so the shim mirrors exactly what upstream can do. + 'lint-plugins': './src/lint-plugins.ts', }, outDir: 'dist', format: 'cjs',