diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/lint/plugin.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/lint/plugin.js new file mode 100644 index 0000000000..cceae65444 --- /dev/null +++ b/crates/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/package.json new file mode 100644 index 0000000000..47b4407f49 --- /dev/null +++ b/crates/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots.toml new file mode 100644 index 0000000000..85420a61c4 --- /dev/null +++ b/crates/vite_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 and declares no @oxlint/plugins dependency; a reported diagnostic proves the export resolved and loaded", continue-on-failure = true }, + { argv = [ + "vp", + "lint", + "src/legacy-imports.ts", + ], comment = "prefer-vite-plus-imports flags the legacy authoring specifiers", continue-on-failure = true }, + { argv = [ + "vp", + "lint", + "src/config-surface.ts", + ], comment = "oxlint still owns defineConfig/OxlintOverride, so these are clean", continue-on-failure = true }, + { argv = [ + "vp", + "lint", + "--fix", + "src/legacy-imports.ts", + ], comment = "autofix mirrors 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/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots/lint_oxlint_plugin_api.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots/lint_oxlint_plugin_api.md new file mode 100644 index 0000000000..0e108ae9ef --- /dev/null +++ b/crates/vite_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 and declares no @oxlint/plugins dependency; a reported diagnostic 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 flags the 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/rule-tester' 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/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` + +autofix mirrors 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/rule-tester'; + +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/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/config-surface.ts b/crates/vite_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/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/legacy-imports.ts b/crates/vite_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/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/uses-foo.ts b/crates/vite_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/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/vite.config.ts new file mode 100644 index 0000000000..43748fe077 --- /dev/null +++ b/crates/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/.oxlintrc.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/.oxlintrc.json new file mode 100644 index 0000000000..32cc17a9a2 --- /dev/null +++ b/crates/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.js b/crates/vite_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/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.test.ts b/crates/vite_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/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/plugin.js b/crates/vite_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/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/shared-config.ts b/crates/vite_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/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/package.json new file mode 100644 index 0000000000..9c11b4ab01 --- /dev/null +++ b/crates/vite_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/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots.toml new file mode 100644 index 0000000000..927afe03f2 --- /dev/null +++ b/crates/vite_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 have to move to vite-plus", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "package.json", + ], comment = "oxlint is removed and nothing replaces it: the API now comes from vite-plus. @oxlint/plugins is deliberately left in place (it is inert once the imports are rewritten, and stripping it would also strip the peer dep of a published Oxlint plugin)", 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 moved to 'oxlint/plugins-dev' upstream and breaks the same way -> 'vite-plus/lint/rule-tester'; the plugin type import follows the runtime API", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "lint/shared-config.ts", + ], comment = "config surface is NOT redirected: vite-plus/lint/plugins has no defineConfig/OxlintOverride", continue-on-failure = true }, + { argv = [ + "vpt", + "print-file", + "vite.config.ts", + ], comment = "the jsPlugins entry survives the .oxlintrc.json merge and still points at the (now rewritten) plugin file. KNOWN PRE-EXISTING GAP, unrelated to the import rewrite: `local/no-foo` is dropped because sanitizeMigratedOxlintConfig derives a plugin's rule namespace from its package name, and a relative-path plugin has none (its namespace comes from `meta.name` at load time). Recorded so a fix shows up as a snapshot diff", continue-on-failure = true }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots/migration_oxlint_js_plugin_imports.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots/migration_oxlint_js_plugin_imports.md new file mode 100644 index 0000000000..16790d9fd3 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots/migration_oxlint_js_plugin_imports.md @@ -0,0 +1,140 @@ +# migration_oxlint_js_plugin_imports + +## `vp migrate --no-interactive` + +the standalone oxlint dependency goes away, so the JS plugin's authoring imports have to 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 is removed and nothing replaces it: the API now comes from vite-plus. @oxlint/plugins is deliberately left in place (it is inert once the imports are rewritten, and stripping it would also strip the peer dep of a published Oxlint plugin) + +``` +{ + "name": "migration-oxlint-js-plugin-imports", + "scripts": { + "lint": "vp lint .", + "prepare": "vp config" + }, + "devDependencies": { + "@oxlint/plugins": "^1.0.0", + "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 moved to 'oxlint/plugins-dev' upstream and breaks the same way -> 'vite-plus/lint/rule-tester'; the plugin type import follows the runtime API + +``` +import type { Context } from 'vite-plus/lint/plugins'; +import { RuleTester } from 'vite-plus/lint/rule-tester'; + +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` + +config surface is NOT redirected: vite-plus/lint/plugins has no defineConfig/OxlintOverride + +``` +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 and still points at the (now rewritten) plugin file. KNOWN PRE-EXISTING GAP, unrelated to the import rewrite: `local/no-foo` is dropped because sanitizeMigratedOxlintConfig derives a plugin's rule namespace from its package name, and a relative-path plugin has none (its namespace comes from `meta.name` at load time). Recorded 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/vite_migration/src/import_rewriter.rs b/crates/vite_migration/src/import_rewriter.rs index 2ca4d8ee17..10fd3dd42e 100644 --- a/crates/vite_migration/src/import_rewriter.rs +++ b/crates/vite_migration/src/import_rewriter.rs @@ -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,213 @@ transform: fix: $NEW_IMPORT "#; +/// ast-grep rules for rewriting Oxlint JS-plugin authoring imports. +/// +/// This rewrites (the canonical mapping shared with the `oxlint-plugin.ts` +/// `rewriteVitePlusImportSpecifier` autofix; both implementations MUST stay +/// in sync): +/// - `import { defineRule } from '@oxlint/plugins'` → `'vite-plus/lint/plugins'` +/// - `import { RuleTester } from 'oxlint/plugins-dev'` → `'vite-plus/lint/rule-tester'` +/// - `import { defineRule } from 'oxlint'` → `'vite-plus/lint/plugins'` +/// +/// Why this exists: the migration strips `oxlint` from the project (vite-plus +/// bundles it), so a JS plugin importing the authoring API by either name stops +/// resolving and `vp lint` fails to load the plugin. Pointing the import at +/// `vite-plus` instead of re-adding `@oxlint/plugins` as a direct dependency +/// keeps the API version locked to the bundled linter, with nothing for the user +/// to keep in sync, and resolves from any package that already has `vite-plus` +/// (`@oxlint/plugins` is only a transitive dependency, so it is NOT resolvable +/// from a user's plugin file under pnpm's strict layout). +/// +/// The bare `oxlint` specifier is ambiguous: it still legitimately serves the +/// CONFIG surface (`defineConfig`, `OxlintConfig`, `OxlintOverride`, …), which +/// must not be redirected. So it is rewritten only for `import` statements +/// carrying a named specifier that is NOT part of that config surface. That +/// makes the check a small, stable denylist rather than an ever-growing list of +/// plugin type names, and an unrecognized name falls on the side of fixing the +/// breakage. Namespace (`import * as`), default, and bare side-effect imports +/// carry no discriminating name and are deliberately left alone, as are +/// `require('oxlint')` / `import('oxlint')`. +/// +/// `@oxlint/plugins` and `oxlint/plugins-dev` are unambiguous (they expose only +/// the plugin API and the rule tester), so every statement form is rewritten. +/// +/// Packages that declare `oxlint` or `@oxlint/plugins` in `dependencies` / +/// `peerDependencies` are skipped entirely (see `SkipPackages::skip_oxlint`): +/// those are published Oxlint plugins whose consumers may not have Vite+. +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 +transform: + NEW_IMPORT: + replace: + source: $STR + replace: "@oxlint/plugins" + by: "vite-plus/lint/plugins" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-require +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]@oxlint/plugins['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + regex: ^require$ +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/rule-tester" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-dev-export +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint/plugins-dev['"]$ + inside: + kind: export_statement +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint/plugins-dev + by: "vite-plus/lint/rule-tester" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugins-dev-require +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint/plugins-dev['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + regex: ^require$ +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint/plugins-dev + by: "vite-plus/lint/rule-tester" +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/rule-tester" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-plugin-api-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: import_statement + has: + kind: import_specifier + stopBy: end + not: + 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 +"#; + static PARSED_VITE_RULES: LazyLock>> = LazyLock::new(|| { ast_grep::load_rules(REWRITE_VITE_RULES).expect("failed to parse vite rewrite rules") }); @@ -1613,6 +1820,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 +2166,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)] @@ -1973,7 +2192,7 @@ pub struct RewriteImportsOptions { 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 +2313,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() @@ -2299,6 +2522,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 +2608,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 @@ -3693,6 +3933,109 @@ 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/rule-tester'; + +new RuleTester().run('no-foo', noFoo, { valid: [], invalid: [] });"# + ); + } + + #[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 +4146,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 +4173,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 +4201,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 +4215,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 +4268,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 +4279,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 +5108,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 +5579,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 +5600,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 +5621,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 +5642,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 90ba2804f7..a79af8f10d 100644 --- a/docs/guide/lint.md +++ b/docs/guide/lint.md @@ -48,3 +48,55 @@ This path is powered by [tsgolint](https://github.com/oxc-project/tsgolint) on t ## JS Plugins 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. + +### 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/rule-tester`. + +Both entrypoints re-export the copy that ships with Vite+, so the API always +matches the bundled Oxlint. Prefer them over adding `@oxlint/plugins` or +`oxlint` as a direct dependency: a separately pinned copy drifts from the +linter that actually loads your plugin, and it is not resolvable from a plugin +file under pnpm's strict layout unless every package that hosts one declares +it. `vp migrate` rewrites existing `oxlint` / `@oxlint/plugins` imports for you +(see [Oxlint JS Plugin Imports](/guide/migrate-rules#oxlint-js-plugin-imports)), +and the `vite-plus/prefer-vite-plus-imports` rule flags any that come back. diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 561cbacf73..9d8acca454 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -196,6 +196,34 @@ 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. A project's own Oxlint JS plugins import the authoring API by name, +and that import stops resolving once the dependency is gone, so `vp lint` fails +to load the plugin. The migration repoints those imports at Vite+: + +- `@oxlint/plugins` is rewritten to `vite-plus/lint/plugins`. +- `oxlint/plugins-dev` is rewritten to `vite-plus/lint/rule-tester`. +- `oxlint` is rewritten to `vite-plus/lint/plugins` when the import names a + binding from the authoring API (`defineRule`, `definePlugin`, `Context`, and + so on). Older Oxlint releases exposed that API from the main entry; it now + lives in `@oxlint/plugins`. + +Importing through Vite+ keeps the plugin API on the version the bundled linter +understands, with no second package to pin, and it resolves from any package +that already depends on `vite-plus`. + +`oxlint` imports that name only the config surface (`defineConfig`, +`OxlintConfig`, `OxlintOverride`, and so on) keep resolving against the +standalone package and are left alone. So are default, namespace, and bare +side-effect `oxlint` imports, which carry no binding name to tell the two +surfaces apart. + +A package that declares `oxlint` or `@oxlint/plugins` in `dependencies` or +`peerDependencies` is skipped entirely: that shape marks a published Oxlint +plugin whose consumers may not be running Vite+. + ### What Is Never Rewritten - `declare module 'vitest'` and `declare module '@vitest/browser*'`: module diff --git a/packages/cli/package.json b/packages/cli/package.json index ef42d4ca9a..42fe6f7804 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -70,6 +70,14 @@ "types": "./dist/lint.d.ts", "import": "./dist/lint.js" }, + "./lint/plugins": { + "types": "./dist/lint-plugins.d.ts", + "import": "./dist/lint-plugins.js" + }, + "./lint/rule-tester": { + "types": "./dist/lint-rule-tester.d.ts", + "import": "./dist/lint-rule-tester.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 79d22e324b..fbd28b95c5 100644 --- a/packages/cli/src/__tests__/exports-map.spec.ts +++ b/packages/cli/src/__tests__/exports-map.spec.ts @@ -93,6 +93,40 @@ describe('package.json exports map', () => { }); }); +/** + * Migration rewrites Oxlint JS-plugin authoring imports to `vite-plus/lint/plugins` + * and `vite-plus/lint/rule-tester` (see the `rewrite-oxlint-plugin-*` rules in + * `import_rewriter.rs` and `rewriteVitePlusImportSpecifier` in + * `oxlint-plugin.ts`). The whole point of that rewrite is that a user's plugin + * file reaches the API through `vite-plus` instead of pinning its own + * `@oxlint/plugins`, so these entrypoints MUST stay resolvable and MUST keep + * re-exporting the upstream surface, otherwise every migrated plugin breaks at + * the moment `vp lint` tries to load 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('exposes RuleTester from vite-plus/lint/rule-tester', async () => { + const ruleTester = await import('vite-plus/lint/rule-tester'); + 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__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 9297027348..bb17281e80 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -102,6 +102,14 @@ 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/rule-tester'); + // The bare `oxlint` specifier still serves the config surface, so it cannot + // be judged from the specifier alone: the rule decides per import statement. + expect(rewriteVitePlusImportSpecifier('oxlint')).toBeNull(); + }); }); new RuleTester({ @@ -112,6 +120,20 @@ 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. Redirecting these would break them. + `import { defineConfig } from 'oxlint'`, + { + code: `import type { OxlintConfig, OxlintOverride } from 'oxlint'`, + filename: 'types.ts', + }, + // No named binding means no way to tell the config surface from the plugin + // API, so these are left alone rather than risk 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/rule-tester'`, // `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 +213,34 @@ 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/rule-tester"`, + }, + { + // The pre-`@oxlint/plugins` authoring API: `oxlint` no longer exports it, + // and the migration strips the standalone 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"`, + }, { code: `import { page } from '@vitest/browser/context'`, errors: 1, diff --git a/packages/cli/src/lint-plugins.ts b/packages/cli/src/lint-plugins.ts new file mode 100644 index 0000000000..07b9cd9dee --- /dev/null +++ b/packages/cli/src/lint-plugins.ts @@ -0,0 +1,20 @@ +// The Oxlint JS-plugin authoring API, re-exported from the copy of +// `@oxlint/plugins` that ships with Vite+. +// +// Oxlint used to expose `defineRule`/`definePlugin` from its main entry; they +// now live in `@oxlint/plugins`, and the plugin API is versioned against the +// linter that loads the plugin. Since `vp lint` runs the bundled Oxlint, a +// project that declares its own `@oxlint/plugins` has to keep that pin in sync +// with whatever Vite+ bundles. Importing from here removes that pin: the API is +// always the one the bundled linter understands, and it resolves from any +// package that already has `vite-plus` installed (`@oxlint/plugins` is a +// transitive dependency and is therefore NOT resolvable from a user's plugin +// file under pnpm's strict layout). +// +// `vp migrate` rewrites legacy `oxlint` / `@oxlint/plugins` plugin-API imports +// to this specifier, and the `vite-plus/prefer-vite-plus-imports` lint rule +// enforces it. See `crates/vite_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/lint-rule-tester.ts b/packages/cli/src/lint-rule-tester.ts new file mode 100644 index 0000000000..0c776fb328 --- /dev/null +++ b/packages/cli/src/lint-rule-tester.ts @@ -0,0 +1,10 @@ +// Oxlint's `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 (`RuleTester` moved from +// `oxlint` to `oxlint/plugins-dev`). +// +// Kept out of `vite-plus/lint/plugins` on purpose: it is a test-only utility, +// and importing the authoring API should not pull it in. + +export { RuleTester } from 'oxlint/plugins-dev'; +export type * from 'oxlint/plugins-dev'; diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 135f449209..193f9020cd 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -44,6 +44,36 @@ 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_RULE_TESTER = 'vite-plus/lint/rule-tester'; + +// Everything the `oxlint` package still exports from its main entry: the config +// surface. Those imports are correct as-is and must not be redirected. Anything +// else named in an `import … from 'oxlint'` is the pre-`@oxlint/plugins` +// authoring API (`defineRule`, `Context`, `ESTree`, …), which no longer resolves +// once the migration strips the standalone `oxlint` dependency. +// +// Kept as a denylist rather than an allowlist of ~60 plugin type names: it is +// small and stable, and an unrecognized name falls on the side of fixing the +// breakage. Mirrors the `rewrite-oxlint-plugin-api-import` rule in +// `crates/vite_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 +142,45 @@ 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` rather than pinning + // `@oxlint/plugins` against whatever Oxlint the bundled linter runs. These two + // specifiers serve nothing but the plugin API, so they rewrite unconditionally; + // the ambiguous bare `oxlint` specifier is handled in `reportLegacyOxlintPluginApiImport`. + if (specifier === OXLINT_PLUGINS_PACKAGE) { + return VITE_PLUS_LINT_PLUGINS; + } + + if (specifier === OXLINT_PLUGINS_DEV_SUBPATH) { + return VITE_PLUS_LINT_RULE_TESTER; + } + 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 an `import … from 'oxlint'` names at least one binding outside + * Oxlint's config surface, i.e. it is reaching for the plugin authoring API. + * + * Default, namespace, and bare side-effect imports carry no discriminating name, + * so they report `false` and are left alone rather than risk a wrong rewrite. + */ +function importsOxlintPluginApi(node: ESTree.ImportDeclaration): boolean { + return node.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' && + !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,6 +245,20 @@ function nearestPackageUsesNuxtTestUtils(filename: string): boolean { } } +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 maybeReportLiteral( context: Context, literal: ESTree.Expression | ESTree.TSModuleDeclaration['id'] | null | undefined, @@ -201,17 +281,24 @@ function maybeReportLiteral( 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'`. + * + * Split out from {@link maybeReportLiteral} because the bare `oxlint` specifier + * cannot be judged from the specifier string alone: it still serves the config + * surface. Only an `ImportDeclaration` exposes the named bindings needed to tell + * the two apart, so the other statement forms (re-export, `require`, dynamic + * `import`) intentionally do not get this rewrite. + */ +function reportLegacyOxlintPluginApiImport(context: Context, node: ESTree.ImportDeclaration) { + const literal = node.source; + if (literal.value !== OXLINT_PACKAGE || !importsOxlintPluginApi(node)) { + return; + } + reportSpecifier(context, literal, VITE_PLUS_LINT_PLUGINS); } export const preferVitePlusImportsRule = defineRule({ @@ -237,6 +324,7 @@ export const preferVitePlusImportsRule = defineRule({ }, ImportDeclaration(node) { maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); + reportLegacyOxlintPluginApiImport(context, node); }, ExportAllDeclaration(node) { maybeReportLiteral(context, node.source, preserveUpstreamVitest, fileIsViteConfig); diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index b7f22145ab..e0389dace4 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-rule-tester': './src/lint-rule-tester.ts', 'oxlint-plugin': './src/oxlint-plugin.ts', 'tsgolint-path': './src/utils/tsgolint-path.ts', pack: './src/pack.ts',