From e565255f0dbac76eb2ce0ef2f47b525df68743f0 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 9 Aug 2026 20:31:00 +0800 Subject: [PATCH 1/3] feat(lint): serve the Oxlint plugin API from vite-plus Vite+ bundles Oxlint, and `vp migrate` strips a standalone `oxlint` dependency. A project's own Oxlint JS plugins import the authoring API by name. That import then stops resolving, and `vp lint` fails to load the plugin. Adding `@oxlint/plugins` back as a direct dependency fixes it, but pins a second copy against whatever Oxlint the bundled linter runs. It also does not resolve from a plugin file under pnpm's strict layout, unless every package that holds a plugin declares it. Expose the API from vite-plus instead, and repoint the imports at it: - `vite-plus/lint/plugins` re-exports `defineRule`, `definePlugin`, and `eslintCompatPlugin` from the `@oxlint/plugins` copy vite-plus already depends on. The API therefore always matches the bundled linter. - `vite-plus/lint/plugins-dev` re-exports `RuleTester`, which lives in `oxlint/plugins-dev` and breaks the same way. The subpath mirrors upstream's, so the mapping stays mechanical and later additions to that entry still fit the name. - `vp migrate` rewrites `@oxlint/plugins` and `oxlint/plugins-dev` in every statement form. It rewrites bare `oxlint` only when the import names a binding outside Oxlint's config surface. `defineConfig`, `OxlintConfig`, and `OxlintOverride` imports therefore stay as they are. Default, namespace, and side-effect imports name no binding, so the migration skips them. - The migration skips a package that declares `oxlint` or `@oxlint/plugins` in `dependencies` or `peerDependencies`. That shape marks a published Oxlint plugin, and its consumers may not run Vite+. - `prefer-vite-plus-imports` repeats the mapping, so the codemod has a standing backstop. --- crates/vp_migration/src/import_rewriter.rs | 477 +++++++++++++++++- docs/guide/lint.md | 55 ++ docs/guide/migrate-rules.md | 33 ++ packages/cli/package.json | 8 + .../cli/src/__tests__/exports-map.spec.ts | 36 ++ .../cli/src/__tests__/oxlint-plugin.spec.ts | 52 ++ packages/cli/src/lint-plugins-dev.ts | 15 + packages/cli/src/lint-plugins.ts | 22 + packages/cli/src/oxlint-plugin.ts | 113 ++++- packages/cli/tsdown.config.ts | 2 + 10 files changed, 783 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/lint-plugins-dev.ts create mode 100644 packages/cli/src/lint-plugins.ts diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 62f377d750..eb97acbf53 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_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,218 @@ 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. +/// +/// 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 every statement form rewrites. +/// +/// 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 +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/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 +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint/plugins-dev + by: "vite-plus/lint/plugins-dev" +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/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-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 +1825,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 +2171,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 +2197,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 +2318,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 +2527,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 +2613,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 +3938,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/plugins-dev'; + +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 +4151,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 +4178,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 +4206,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 +4220,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 +4273,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 +4284,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 +5113,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 +5584,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 +5605,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 +5626,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 +5647,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/package.json b/packages/cli/package.json index 9c95ad68b2..2257648e61 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/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..259a3916a0 100644 --- a/packages/cli/src/__tests__/exports-map.spec.ts +++ b/packages/cli/src/__tests__/exports-map.spec.ts @@ -117,6 +117,42 @@ 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('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__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 9297027348..7612ec2919 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -102,6 +102,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 +121,21 @@ 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'`, // `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 +215,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/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"`, + }, { 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/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 135f449209..59f1e63ae0 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -44,6 +44,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 +143,47 @@ 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 an `import ... from 'oxlint'` names at least one binding outside + * Oxlint's config surface. Such an import reaches for the plugin authoring API. + * + * Default, namespace, and bare side-effect imports name no binding. They + * return `false`, so the rule leaves them alone instead of risking 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 +248,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 +284,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'`. + * + * 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. + */ +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 +327,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 94c7d182b2..0bde200d96 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', From 8182c18632761e16e052007278d1d485db4128a9 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 9 Aug 2026 20:31:00 +0800 Subject: [PATCH 2/3] test(snapshots): cover the Oxlint plugin API rewrite end to end Two PTY fixtures for the `vite-plus/lint/plugins` work: - `lint_oxlint_plugin_api`: a local JS plugin authored against `vite-plus/lint/plugins`. It declares no `@oxlint/plugins` dependency of its own. A reported diagnostic therefore proves the export resolves and loads under `vp lint`, which is the premise of the change. The same case covers the `prefer-vite-plus-imports` autofix for all three legacy authoring specifiers, and shows that the config surface stays clean. - `migration_oxlint_js_plugin_imports`: `vp migrate` rewrites a plugin, a rule module, and a rule test. It leaves `defineConfig` and `OxlintOverride` imports alone. The migrate snapshot also records a pre-existing gap, unrelated to this change. `sanitizeMigratedOxlintConfig` derives a plugin's rule namespace from its package name. A relative-path JS plugin has no package name, so the `.oxlintrc.json` merge drops its rules. The snapshot records this with a comment instead of hiding it, so a fix shows up as a snapshot diff. --- .../lint_oxlint_plugin_api/lint/plugin.js | 21 +++ .../lint_oxlint_plugin_api/package.json | 5 + .../lint_oxlint_plugin_api/snapshots.toml | 37 +++++ .../snapshots/lint_oxlint_plugin_api.md | 92 ++++++++++++ .../src/config-surface.ts | 6 + .../src/legacy-imports.ts | 5 + .../lint_oxlint_plugin_api/src/uses-foo.ts | 2 + .../lint_oxlint_plugin_api/vite.config.ts | 14 ++ .../.oxlintrc.json | 6 + .../lint/no-foo.js | 14 ++ .../lint/no-foo.test.ts | 11 ++ .../lint/plugin.js | 8 + .../lint/shared-config.ts | 9 ++ .../package.json | 11 ++ .../snapshots.toml | 40 +++++ .../migration_oxlint_js_plugin_imports.md | 140 ++++++++++++++++++ 16 files changed, 421 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/lint/plugin.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/snapshots/lint_oxlint_plugin_api.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/config-surface.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/legacy-imports.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/src/uses-foo.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_oxlint_plugin_api/vite.config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/.oxlintrc.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/no-foo.test.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/plugin.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/lint/shared-config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_js_plugin_imports/snapshots/migration_oxlint_js_plugin_imports.md 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..8687ac4a2d --- /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 is removed and nothing replaces it. The API now comes from vite-plus. @oxlint/plugins stays on purpose: it is inert once the imports are rewritten, and removing it would also remove the peer dependency 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 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", 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..2ddbaec413 --- /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,140 @@ +# 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 is removed and nothing replaces it. The API now comes from vite-plus. @oxlint/plugins stays on purpose: it is inert once the imports are rewritten, and removing it would also remove the peer dependency 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 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 + +``` +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 + } + }, +}); +``` From a7180fa85c06fad48d9232be9de1a069ea7d8b09 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 13:43:10 +0800 Subject: [PATCH 3/3] fix(lint): address review on the Oxlint plugin API rewrite Seven fixes from review on #2328. Correctness of the rewrite: - A statement that mixes the two `oxlint` surfaces, such as `import { defineConfig, defineRule } from 'oxlint'`, is now left alone. The rewrite replaces the whole specifier, so moving it stripped `defineConfig` of its module. Splitting is the user's call. - `require('@oxlint/plugins')` and `require('oxlint/plugins-dev')` no longer rewrite. The `vite-plus/lint/*` exports are ESM-only, so a rewritten `require()` failed with ERR_PACKAGE_PATH_NOT_EXPORTED. Static import, export, and dynamic `import()` still rewrite, because those resolve through the `import` condition. The published-plugin exemption, which did not work end to end: - `rewritePackageJson` strips `oxlint` before the import rewriter reads the manifests, so `SkipPackages::skip_oxlint` never saw the signal in a real migration. `collectOxlintOwnerDirs` now captures it before the edit and passes the directories through to the rewriter. Note that `skip_tsdown` has the same latent flaw, since `tsdown` is also in `REMOVE_PACKAGES`; this change does not touch it. - `vp lint --fix` rewrote a published plugin's source unconditionally, undoing the exemption the migration had just honored. The rule now checks the nearest manifest, reusing the mtime-keyed cache shape already used for `@nuxt/test-utils`. - The `oxlint` peer entry is no longer stripped from a package that owns the plugin API. A peer is a consumer contract, not a tool the package runs, and removing it left the source importing a package the manifest no longer declared. - `declare module '@oxlint/plugins'` and the `oxlint` forms are preserved, the same way the rule already preserves Vitest-family augmentations. The re-exported types keep their upstream module identity, so a retargeted augmentation stopped merging. Cleanup: - The migration now drops a dead `@oxlint/plugins` devDependency, since nothing imports it after the rewrite. Only from devDependencies: a `dependencies` or `peerDependencies` edge marks a published plugin. Tests: 3 Rust cases for the mixed, `require`, and dynamic-import rules; 8 lint-rule cases for the published-plugin and augmentation guards; and a new `migration_oxlint_published_plugin` snapshot fixture covering the skip. --- .../snapshots.toml | 2 +- .../migration_oxlint_js_plugin_imports.md | 3 +- .../lint/index.js | 14 ++ .../package.json | 13 ++ .../snapshots.toml | 20 ++ .../migration_oxlint_published_plugin.md | 63 +++++++ crates/vp_migration/src/import_rewriter.rs | 144 ++++++++------ packages/cli/binding/index.d.cts | 1 + packages/cli/binding/src/migration.rs | 6 + .../oxlint-plugin-package/package.json | 7 + .../fixtures/oxlint-plugin-package/rule.ts | 1 + .../cli/src/__tests__/oxlint-plugin.spec.ts | 37 ++++ .../src/migration/migrator/orchestrators.ts | 11 +- .../src/migration/migrator/package-json.ts | 29 ++- packages/cli/src/migration/migrator/shared.ts | 38 ++++ .../cli/src/migration/migrator/vite-config.ts | 6 +- packages/cli/src/oxlint-plugin.ts | 177 ++++++++++++++++-- 17 files changed, 492 insertions(+), 80 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/lint/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlint_published_plugin/snapshots/migration_oxlint_published_plugin.md create mode 100644 packages/cli/src/__tests__/fixtures/oxlint-plugin-package/package.json create mode 100644 packages/cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts 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 index 8687ac4a2d..cd2df039d3 100644 --- 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 @@ -11,7 +11,7 @@ steps = [ "vpt", "print-file", "package.json", - ], comment = "oxlint is removed and nothing replaces it. The API now comes from vite-plus. @oxlint/plugins stays on purpose: it is inert once the imports are rewritten, and removing it would also remove the peer dependency of a published Oxlint plugin", continue-on-failure = true }, + ], 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", 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 index 2ddbaec413..9bb57463ec 100644 --- 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 @@ -14,7 +14,7 @@ VITE+ - The Unified Toolchain for the Web ## `vpt print-file package.json` -oxlint is removed and nothing replaces it. The API now comes from vite-plus. @oxlint/plugins stays on purpose: it is inert once the imports are rewritten, and removing it would also remove the peer dependency of a published Oxlint plugin +oxlint and @oxlint/plugins are both gone from devDependencies, and nothing replaces them. The API now comes from vite-plus ``` { @@ -24,7 +24,6 @@ oxlint is removed and nothing replaces it. The API now comes from vite-plus. @ox "prepare": "vp config" }, "devDependencies": { - "@oxlint/plugins": "^1.0.0", "vite": "catalog:", "vite-plus": "catalog:" }, 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 eb97acbf53..127ebdbd0e 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, }; @@ -1596,12 +1596,20 @@ fix: $NEW_IMPORT /// 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. +/// /// 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 every statement form rewrites. +/// 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, @@ -1639,27 +1647,6 @@ transform: 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: @@ -1713,27 +1700,6 @@ transform: by: "vite-plus/lint/plugins-dev" 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/plugins-dev" -fix: $NEW_IMPORT ---- id: rewrite-oxlint-plugins-dev-dynamic-import language: TypeScript rule: @@ -1763,13 +1729,17 @@ rule: 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)$ + 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)$ transform: NEW_IMPORT: replace: @@ -2187,11 +2157,22 @@ struct PackageRewriteContext { } /// Options controlling directory-wide import rewriting. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub struct RewriteImportsOptions { /// Preserve `vitest` and `vitest/*` module specifiers throughout packages /// whose nearest package.json declares `@nuxt/test-utils`. pub preserve_vitest_in_nuxt_packages: bool, + /// Directories of packages that declared `oxlint` or `@oxlint/plugins` in + /// `dependencies` or `peerDependencies` 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 { @@ -2406,15 +2387,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() }; @@ -3379,7 +3371,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(); @@ -3417,7 +3412,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(); @@ -4028,6 +4026,40 @@ 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_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';"#; 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/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__/fixtures/oxlint-plugin-package/rule.ts b/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts new file mode 100644 index 0000000000..123c9da993 --- /dev/null +++ b/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts @@ -0,0 +1 @@ +// fixture: a published Oxlint plugin source file diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 7612ec2919..ebc0b2231d 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', () => { @@ -136,6 +143,36 @@ new RuleTester({ `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 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'`, diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index af187c1d12..8d6b7bae4c 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -9,6 +9,7 @@ import { applyYarnWorkspaceHoistingFix, cleanupDeprecatedTsconfigOptions, collectInjectedProviderNames, + collectOxlintOwnerDirs, collectProviderSourceModes, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, @@ -75,6 +76,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 +337,7 @@ 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); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -353,6 +357,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 +470,7 @@ 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); 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..799a02d52f 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_PLUGIN_API_PACKAGES, + OXLINT_PLUGINS_PACKAGE, REMOVE_PACKAGES, VITEST_BROWSER_DEP_NAMES, VITEST_IS_MANAGED_OVERRIDE, @@ -183,10 +185,35 @@ export function rewritePackageJson( const hasBrowserDepSignal = VITEST_BROWSER_DEP_NAMES.some((name) => dependencyGroups.some(({ dependencies }) => dependencies?.[name] !== undefined), ); + // `@oxlint/plugins` becomes dead weight once the import rewrite points the + // authoring API at `vite-plus/lint/plugins`, so drop it. Only from + // devDependencies: a `dependencies` / `peerDependencies` edge marks a + // published Oxlint plugin, whose consumers supply the API themselves and + // whose source the rewrite deliberately leaves alone (`skip_oxlint`). + if (pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE]) { + delete pkg.devDependencies[OXLINT_PLUGINS_PACKAGE]; + needVitePlus = true; + } + // A `peerDependencies` entry on the Oxlint plugin API is a consumer contract, + // not a tool this package runs: it says "whoever installs me supplies the + // linter". That stays true for a published Oxlint plugin, whose source the + // import rewrite deliberately leaves on `oxlint` (`skip_oxlint`). Stripping + // the peer would leave the source importing a package the manifest no longer + // declares, so the peer entry is preserved. + const ownsOxlintApi = OXLINT_PLUGIN_API_PACKAGES.some( + (name) => pkg.peerDependencies?.[name] !== undefined, + ); // 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' && + (OXLINT_PLUGIN_API_PACKAGES as readonly string[]).includes(name) + ) { + 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/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 59f1e63ae0..5408eb636f 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/'); } @@ -169,18 +182,29 @@ function importedName(specifier: ESTree.ImportSpecifier): string | undefined { } /** - * True when an `import ... from 'oxlint'` names at least one binding outside - * Oxlint's config surface. Such an import reaches for the plugin authoring API. + * 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. * - * Default, namespace, and bare side-effect imports name no binding. They + * 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 { - return node.specifiers.some( - (specifier) => - specifier.type === 'ImportSpecifier' && - !OXLINT_CONFIG_SURFACE_EXPORTS.has(importedName(specifier) ?? ''), + 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) ?? ''), ); } @@ -248,6 +272,69 @@ 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, @@ -262,11 +349,16 @@ function reportSpecifier(context: Context, literal: ESTree.StringLiteral, replac }); } +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; @@ -283,6 +375,11 @@ 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; + } reportSpecifier(context, literal, replacement); } @@ -296,11 +393,18 @@ function maybeReportLiteral( * tell the two surfaces apart. Re-export, `require`, and dynamic `import` * statements therefore do not get this rewrite. */ -function reportLegacyOxlintPluginApiImport(context: Context, node: ESTree.ImportDeclaration) { +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); } @@ -320,29 +424,67 @@ 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); - reportLegacyOxlintPluginApiImport(context, node); + 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, + ); }, 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); + maybeReportLiteral( + context, + node.expression, + preserveUpstreamVitest, + fileIsViteConfig, + ownsOxlintApi, + ); }, TSModuleDeclaration(node) { if (node.global) { @@ -352,11 +494,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); }, }; },