From fd4ad0431b337e51df59d781731f7bf9cf9d2696 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 9 Aug 2026 20:31:00 +0800 Subject: [PATCH 01/10] 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 cf00aadf00d077a9b2a2b702a3c736cfb5adff84 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 9 Aug 2026 20:31:00 +0800 Subject: [PATCH 02/10] 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 9aa3b7d400901cdee9e1dd21f50a4640788d23f2 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 13:43:10 +0800 Subject: [PATCH 03/10] 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); }, }; }, From 14f8216a34d3ebcf61d9543574a4dbbe4e252d0e Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 19:07:02 +0800 Subject: [PATCH 04/10] fix(test): drop the comment-only lint fixture file `vp check` failed with `unicorn(no-empty-file)`: Oxlint counts a file whose only content is a comment as empty. The file was never needed. `nearestPackageOwnsOxlintApi` walks up from the filename to find the nearest package.json and never reads the file itself, so the RuleTester cases only need a path, not a file on disk. This matches the existing `fixtures/nuxt-test-utils/` fixture, which is a package.json alone while the spec filenames it is referenced by do not exist. --- .../cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 packages/cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts diff --git a/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts b/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts deleted file mode 100644 index 123c9da993..0000000000 --- a/packages/cli/src/__tests__/fixtures/oxlint-plugin-package/rule.ts +++ /dev/null @@ -1 +0,0 @@ -// fixture: a published Oxlint plugin source file From 4f1f9a75781c8e8a92536f432911fc37b9682b8a Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 19:38:43 +0800 Subject: [PATCH 05/10] fix(lint): address second review round on the Oxlint plugin API rewrite Five findings, two of them interactions between fixes from the first round. - Deleting the dead `@oxlint/plugins` devDependency broke a preserved `require('@oxlint/plugins')`. Round one stopped rewriting `require()` because the target is ESM-only, which left the direct dependency as the only resolvable copy under pnpm's strict layout. The deletion is now gated on a source scan for those CommonJS forms. - `collectOxlintOwnerDirs` treats `dependencies` or `peerDependencies` as ownership, but the manifest-preservation check only looked at peers. A published plugin declaring `oxlint` under `dependencies` kept its source but lost the edge that provided it. Both checks now match. - A preserved `catalog:` peer would dangle, because the catalog rewrite still drops every `REMOVE_PACKAGES` entry. Such a reference is now resolved to the concrete range before the entry goes away. - `import plugins = require('@oxlint/plugins')` was still autofixed. It has require semantics, so it is skipped like plain `require()`. - `import oxlint, { defineRule } from 'oxlint'` was rewritten, but `vite-plus/lint/plugins` has no default export. A default or namespace binding now disqualifies the statement, in both implementations. Tests: one Rust case for the default-binding guard, three lint-rule cases for the import-equals and default-binding guards. --- crates/vp_migration/src/import_rewriter.rs | 25 +++++++++ .../cli/src/__tests__/oxlint-plugin.spec.ts | 13 +++++ .../src/migration/migrator/orchestrators.ts | 4 ++ .../src/migration/migrator/package-json.ts | 52 +++++++++++++------ .../cli/src/migration/migrator/source-scan.ts | 24 +++++++++ packages/cli/src/oxlint-plugin.ts | 16 ++++++ 6 files changed, 119 insertions(+), 15 deletions(-) diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 127ebdbd0e..33fd3b43ae 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -1601,6 +1601,10 @@ fix: $NEW_IMPORT /// rewrite replaces the whole specifier, so moving it would strip /// `defineConfig` of its module. Splitting the statement is the user's call. /// +/// A statement carrying a default or namespace binding alongside named ones, +/// such as `import oxlint, { defineRule } from 'oxlint'`, is left alone for the +/// same reason: `vite-plus/lint/plugins` has no default export. +/// /// 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')`. @@ -1740,6 +1744,15 @@ rule: has: field: name regex: ^(defineConfig|AllowWarnDeny|DummyRule|DummyRuleMap|ExternalPluginEntry|ExternalPluginsConfig|OxlintConfig|OxlintEnv|OxlintGlobals|OxlintOverride|RuleCategories)$ + - not: + has: + kind: namespace_import + stopBy: end + - not: + has: + kind: import_clause + has: + kind: identifier transform: NEW_IMPORT: replace: @@ -4037,6 +4050,18 @@ new RuleTester().run('no-foo', noFoo, { valid: [], invalid: [] });"# assert_eq!(result.content, mixed); } + #[test] + fn test_rewrite_import_content_oxlint_default_binding_is_left_alone() { + // `vite-plus/lint/plugins` has no default export, so redirecting a + // statement that carries one would leave the file invalid. + let mixed = r#"import oxlint, { defineRule } from 'oxlint'; +import * as everything2, { definePlugin } from 'oxlint';"#; + + let result = rewrite_import_content(mixed, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, mixed); + } + #[test] fn test_rewrite_import_content_oxlint_require_is_left_alone() { // `vite-plus/lint/plugins` is an ESM-only export, so a rewritten diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index ebc0b2231d..538fddbe9d 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -146,6 +146,19 @@ new RuleTester({ // A statement that mixes the two surfaces stays put: the autofix replaces // the whole specifier, and vite-plus/lint/plugins exports no defineConfig. `import { defineConfig, defineRule } from 'oxlint'`, + // A default or namespace binding disqualifies the statement too: + // vite-plus/lint/plugins has no default export. + `import oxlint, { defineRule } from 'oxlint'`, + // `import x = require(...)` has require semantics, and the vite-plus lint + // subpaths are ESM-only. + { + code: `import plugins = require('@oxlint/plugins')`, + filename: 'plugin.cts', + }, + { + code: `import tester = require('oxlint/plugins-dev')`, + filename: 'plugin.cts', + }, // A published Oxlint plugin keeps resolving the API from its own peer. { code: `import { defineRule } from '@oxlint/plugins'`, diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 8d6b7bae4c..812d975d89 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -10,6 +10,7 @@ import { cleanupDeprecatedTsconfigOptions, collectInjectedProviderNames, collectOxlintOwnerDirs, + sourceTreeRequiresOxlintPluginApi, collectProviderSourceModes, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, @@ -79,6 +80,7 @@ export function rewriteStandaloneProject( // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter // reads the manifests afterwards and would no longer see the signal. const oxlintOwnerDirs = collectOxlintOwnerDirs(projectPath, workspaceInfo.packages); + const requiresOxlintPluginApiCjs = sourceTreeRequiresOxlintPluginApi(projectPath); // 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 @@ -253,6 +255,7 @@ export function rewriteStandaloneProject( retainedVitestModule, requiredVitestPeer, providerCatalogAdditions, + requiresOxlintPluginApiCjs, ); // ensure vite-plus is in devDependencies — but only when it isn't already a @@ -564,6 +567,7 @@ export function rewriteMonorepoProject( retainedVitestModule, requiredVitestPeer, providerCatalogAdditions, + sourceTreeRequiresOxlintPluginApi(projectPath), ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own diff --git a/packages/cli/src/migration/migrator/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts index 799a02d52f..29b67a3f87 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -71,6 +71,11 @@ export function rewritePackageJson( // one only through source/a shim). An already-installed copy of such a provider // must REFERENCE that catalog entry, not pin a concrete version. See #2005. providerCatalogAdditions: ReadonlySet = new Set(), + // Whether the source tree still reaches the Oxlint plugin API through a + // CommonJS `require()`. Those forms survive the import rewrite untouched, so + // the direct `@oxlint/plugins` dependency stays load-bearing. Computed by the + // caller, which owns the project path (see `sourceTreeRequiresOxlintPluginApi`). + requiresOxlintPluginApiCjs = false, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -185,33 +190,50 @@ export function rewritePackageJson( const hasBrowserDepSignal = VITEST_BROWSER_DEP_NAMES.some((name) => dependencyGroups.some(({ dependencies }) => dependencies?.[name] !== undefined), ); + // A `dependencies` / `peerDependencies` edge on the Oxlint plugin API marks a + // published Oxlint plugin: the API is part of what it ships against, not a + // tool it runs. The import rewrite leaves such a package's source on `oxlint` + // (`skip_oxlint`), so its manifest edge is preserved too. Stripping it would + // leave the source importing a package the manifest no longer declares. + // + // Both groups are checked, matching `collectOxlintOwnerDirs`. A peer-only + // check would preserve the source of a plugin that declares `oxlint` under + // `dependencies` while deleting the edge that provides it. + const ownsOxlintApi = OXLINT_PLUGIN_API_PACKAGES.some( + (name) => pkg.dependencies?.[name] !== undefined || pkg.peerDependencies?.[name] !== undefined, + ); // `@oxlint/plugins` 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]) { + // authoring API at `vite-plus/lint/plugins`, so drop it from devDependencies. + // Two exceptions: a published plugin owns the API (above), and a CommonJS + // `require()` of it survives the rewrite untouched, so the direct dependency + // is still the only resolvable copy under pnpm's strict layout. + if ( + pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE] && + !ownsOxlintApi && + !requiresOxlintPluginApiCjs + ) { 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 { dependencyField, dependencies } of dependencyGroups) { if ( ownsOxlintApi && - dependencyField === 'peerDependencies' && + (dependencyField === 'peerDependencies' || dependencyField === 'dependencies') && (OXLINT_PLUGIN_API_PACKAGES as readonly string[]).includes(name) ) { + // A `catalog:` reference would dangle once the catalog entry for a + // REMOVE_PACKAGES name is dropped, and the next install fails. Resolve + // it to the concrete range the catalog currently points at. + const current = dependencies?.[name]; + if (current?.startsWith('catalog:') && dependencies) { + const resolved = catalogDependencyResolver?.(current, name); + if (resolved) { + dependencies[name] = resolved; + } + } continue; } if (dependencies?.[name]) { diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 32d32877c8..90ca04ed52 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -336,3 +336,27 @@ export function collectProviderSourceModes(projectPath: string): Record specifier.type !== 'ImportSpecifier')) { + return false; + } const named = node.specifiers.filter( (specifier): specifier is ESTree.ImportSpecifier => specifier.type === 'ImportSpecifier', ); @@ -478,6 +484,16 @@ export const preferVitePlusImportsRule = defineRule({ ); }, TSExternalModuleReference(node) { + // `import plugins = require('...')` has require semantics, and the + // `vite-plus/lint/*` subpaths are ESM-only, so they are skipped here + // for the same reason the migrate rewriter skips `require()`. + if ( + node.expression.type === 'Literal' && + typeof node.expression.value === 'string' && + isOxlintApiSpecifier(node.expression.value) + ) { + return; + } maybeReportLiteral( context, node.expression, From c73a93c7f9b593d46b5902816707e45956874ff9 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 20:10:41 +0800 Subject: [PATCH 06/10] fix(lint): address third review round on the Oxlint plugin API rewrite Three of five findings were real. - `export { defineRule } from 'oxlint'` was not rewritten. A named re-export identifies the surface exactly as an import does, so it now follows the same rules in both implementations. `export * from 'oxlint'` names nothing and stays put, as do config-surface re-exports. - The CommonJS scan that guards the `@oxlint/plugins` deletion matched four fixed substrings, so `require( '@oxlint/plugins' )` and a line break before the argument slipped through. It is now a tolerant regex that also catches `createRequire(...)(...)`, and it errs toward keeping the dependency: a false positive leaves one unused entry, a false negative breaks a plugin at load time. - The migrate fixture records that `oxlint` itself is still deleted while a config-surface import survives. That gap predates this PR, since those imports were never rewritten and `oxlint` was always in REMOVE_PACKAGES, so it is captured with a comment rather than fixed here. The other two did not reproduce, and both are now pinned by tests: - `import plugins = require('@oxlint/plugins')` is NOT rewritten by the Rust rule. tree-sitter does not surface it as a plain `import_statement` string, so the ESM rules never match it. - `type C = import('@oxlint/plugins').Context` IS rewritten, and correctly: the shim re-exports the types, so the type resolves through it. --- .../snapshots.toml | 2 +- .../migration_oxlint_js_plugin_imports.md | 2 +- crates/vp_migration/src/import_rewriter.rs | 78 +++++++++++++++++++ .../cli/src/__tests__/oxlint-plugin.spec.ts | 9 +++ .../cli/src/migration/migrator/source-scan.ts | 19 +++-- packages/cli/src/oxlint-plugin.ts | 32 ++++++++ 6 files changed, 133 insertions(+), 9 deletions(-) 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 cd2df039d3..b37fd2b231 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 @@ -31,7 +31,7 @@ steps = [ "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 }, + ], comment = "the config surface is NOT redirected. vite-plus/lint/plugins has no defineConfig or OxlintOverride. KNOWN PRE-EXISTING GAP, wider than this PR: `oxlint` is in REMOVE_PACKAGES, so the migration deletes the dependency while this import survives. Under pnpm strict layout the import then fails to resolve. That predates the plugin-API rewrite, since config-surface imports were never rewritten and `oxlint` was always removed. Recorded so a fix shows up as a snapshot diff", continue-on-failure = true }, { argv = [ "vpt", "print-file", 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 9bb57463ec..0249f2dd10 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 @@ -93,7 +93,7 @@ new RuleTester().run('no-foo', noFoo, { ## `vpt print-file lint/shared-config.ts` -the config surface is NOT redirected. vite-plus/lint/plugins has no defineConfig or OxlintOverride +the config surface is NOT redirected. vite-plus/lint/plugins has no defineConfig or OxlintOverride. KNOWN PRE-EXISTING GAP, wider than this PR: `oxlint` is in REMOVE_PACKAGES, so the migration deletes the dependency while this import survives. Under pnpm strict layout the import then fails to resolve. That predates the plugin-API rewrite, since config-surface imports were never rewritten and `oxlint` was always removed. Recorded so a fix shows up as a snapshot diff ``` import { defineConfig } from 'oxlint'; diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 33fd3b43ae..7fd2712f10 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -1605,6 +1605,10 @@ fix: $NEW_IMPORT /// such as `import oxlint, { defineRule } from 'oxlint'`, is left alone for the /// same reason: `vite-plus/lint/plugins` has no default export. /// +/// A named re-export, `export { defineRule } from 'oxlint'`, names the surface +/// just as clearly as an import, so it rewrites under the same rules. A bare +/// `export * from 'oxlint'` names nothing and is left alone. +/// /// These forms name no specifier, so the rewrite skips them: namespace imports /// (`import * as`), default imports, bare side-effect imports, /// `require('oxlint')`, and `import('oxlint')`. @@ -1725,6 +1729,33 @@ transform: by: "vite-plus/lint/plugins-dev" fix: $NEW_IMPORT --- +id: rewrite-oxlint-plugin-api-export +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: export_statement + all: + - has: + kind: export_specifier + stopBy: end + - not: + has: + kind: export_specifier + stopBy: end + has: + field: name + regex: ^(defineConfig|AllowWarnDeny|DummyRule|DummyRuleMap|ExternalPluginEntry|ExternalPluginsConfig|OxlintConfig|OxlintEnv|OxlintGlobals|OxlintOverride|RuleCategories)$ +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint + by: "vite-plus/lint/plugins" +fix: $NEW_IMPORT +--- id: rewrite-oxlint-plugin-api-import language: TypeScript rule: @@ -4062,6 +4093,53 @@ import * as everything2, { definePlugin } from 'oxlint';"#; assert_eq!(result.content, mixed); } + #[test] + fn test_rewrite_import_content_oxlint_named_reexport() { + let barrel = r#"export { defineRule } from 'oxlint'; +export type { Context } from 'oxlint';"#; + + let result = rewrite_import_content(barrel, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"export { defineRule } from 'vite-plus/lint/plugins'; +export type { Context } from 'vite-plus/lint/plugins';"# + ); + } + + #[test] + fn test_rewrite_import_content_oxlint_config_reexport_is_left_alone() { + let barrel = r#"export { defineConfig } from 'oxlint'; +export * from 'oxlint';"#; + + let result = rewrite_import_content(barrel, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, barrel); + } + + #[test] + fn test_rewrite_import_content_oxlint_import_equals_is_left_alone() { + // Verified against the parser, not assumed: tree-sitter does not treat + // `import x = require(...)` as a plain `import_statement` string, so + // the ESM rules never see it. Pinned so that stays true. + let cjs = r#"import plugins = require('@oxlint/plugins');"#; + + let result = rewrite_import_content(cjs, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, cjs); + } + + #[test] + fn test_rewrite_import_content_oxlint_import_type_rewrites() { + // A type-position `import(...)` resolves through the shim's re-exported + // types, so rewriting it is correct. + let ty = r#"type C = import('@oxlint/plugins').Context;"#; + + let result = rewrite_import_content(ty, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!(result.content, r#"type C = import('vite-plus/lint/plugins').Context;"#); + } + #[test] fn test_rewrite_import_content_oxlint_require_is_left_alone() { // `vite-plus/lint/plugins` is an ESM-only export, so a rewritten diff --git a/packages/cli/src/__tests__/oxlint-plugin.spec.ts b/packages/cli/src/__tests__/oxlint-plugin.spec.ts index 538fddbe9d..3aa8cfba51 100644 --- a/packages/cli/src/__tests__/oxlint-plugin.spec.ts +++ b/packages/cli/src/__tests__/oxlint-plugin.spec.ts @@ -159,6 +159,9 @@ new RuleTester({ code: `import tester = require('oxlint/plugins-dev')`, filename: 'plugin.cts', }, + // `export *` names nothing, and a config-surface re-export is correct. + `export * from 'oxlint'`, + `export { defineConfig } from 'oxlint'`, // A published Oxlint plugin keeps resolving the API from its own peer. { code: `import { defineRule } from '@oxlint/plugins'`, @@ -293,6 +296,12 @@ new RuleTester({ errors: 1, output: `import { defineRule as rule } from "vite-plus/lint/plugins"`, }, + { + // A named re-export identifies the surface just as an import does. + code: `export { defineRule } from 'oxlint'`, + errors: 1, + output: `export { defineRule } from 'vite-plus/lint/plugins'`, + }, { code: `import { page } from '@vitest/browser/context'`, errors: 1, diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 90ca04ed52..fa843cfb3d 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -343,12 +343,17 @@ export function collectProviderSourceModes(projectPath: string): Record OXLINT_PLUGIN_API_CJS_RE.test(content)); } diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 8a73177702..b21a7c21ce 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -399,6 +399,37 @@ function maybeReportLiteral( * tell the two surfaces apart. Re-export, `require`, and dynamic `import` * statements therefore do not get this rewrite. */ +/** + * `export { defineRule } from 'oxlint'` → `'vite-plus/lint/plugins'`. + * + * A named re-export identifies the surface exactly as an import does, so it + * follows the same rules. A bare `export * from 'oxlint'` names nothing and is + * left alone. + */ +function reportLegacyOxlintPluginApiExport( + context: Context, + node: ESTree.ExportNamedDeclaration, + ownsOxlintApi: boolean, +) { + const literal = node.source; + if (!literal || literal.value !== OXLINT_PACKAGE || ownsOxlintApi) { + return; + } + const named = node.specifiers; + if (named.length === 0) { + return; + } + const allPluginApi = named.every((specifier) => { + const local = specifier.local; + const name = local.type === 'Identifier' ? local.name : undefined; + return name !== undefined && !OXLINT_CONFIG_SURFACE_EXPORTS.has(name); + }); + if (!allPluginApi) { + return; + } + reportSpecifier(context, literal, VITE_PLUS_LINT_PLUGINS); +} + function reportLegacyOxlintPluginApiImport( context: Context, node: ESTree.ImportDeclaration, @@ -464,6 +495,7 @@ export const preferVitePlusImportsRule = defineRule({ fileIsViteConfig, ownsOxlintApi, ); + reportLegacyOxlintPluginApiExport(context, node, ownsOxlintApi); }, ImportExpression(node) { maybeReportLiteral( From 729e7b3a71fbc3b2c19303e7a8da03df8239f6f7 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 20:39:14 +0800 Subject: [PATCH 07/10] feat(lint): serve the plugin authoring API to CommonJS too Fourth review round. One real finding, one trivial, one that did not reproduce. `vite-plus/lint/plugins` now ships a CJS build alongside the ESM one, and its export gained a `require` condition. A `.cts` plugin, or a `.ts` one compiled with `module: commonjs`, emits its import as `require()`, which the ESM-only export rejected at runtime even though typechecking passed. `@oxlint/plugins` ships CJS, so the shim can mirror it. `lint/plugins-dev` deliberately stays ESM-only: `oxlint/plugins-dev` is ESM-only upstream, so the shim mirrors exactly what upstream can offer rather than inventing a capability. The CommonJS scan that guards the dependency deletion now also tolerates block comments inside the call, and separately catches the `createRequire` indirection. Its comment previously claimed the latter without doing it. `export const pluginApi = '@oxlint/plugins'` is NOT rewritten, contrary to the report: `inside:` matches the immediate parent, and that string sits under a lexical_declaration rather than directly under the export statement. Pinned by a test so a rule loosened to `stopBy: end` cannot start corrupting data. --- crates/vp_migration/src/import_rewriter.rs | 14 +++++++++ packages/cli/package.json | 3 +- .../cli/src/__tests__/exports-map.spec.ts | 9 ++++++ .../cli/src/migration/migrator/source-scan.ts | 31 ++++++++++++------- packages/cli/tsdown.config.ts | 7 +++++ 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 7fd2712f10..b5cc41edda 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -4140,6 +4140,20 @@ export * from 'oxlint';"#; assert_eq!(result.content, r#"type C = import('vite-plus/lint/plugins').Context;"#); } + #[test] + fn test_rewrite_import_content_oxlint_export_const_literal_is_data() { + // `inside:` matches the immediate parent. In `export const x = '...'` + // the string sits under a lexical_declaration, not directly under the + // export_statement, so the re-export rules never see it. Pinned so a + // rule loosened to `stopBy: end` cannot start corrupting data. + let data = r#"export const pluginApi = '@oxlint/plugins'; +export const legacy = 'oxlint';"#; + + let result = rewrite_import_content(data, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, data); + } + #[test] fn test_rewrite_import_content_oxlint_require_is_left_alone() { // `vite-plus/lint/plugins` is an ESM-only export, so a rewritten diff --git a/packages/cli/package.json b/packages/cli/package.json index 2257648e61..128041fca7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -72,7 +72,8 @@ }, "./lint/plugins": { "types": "./dist/lint-plugins.d.ts", - "import": "./dist/lint-plugins.js" + "import": "./dist/lint-plugins.js", + "require": "./dist/lint-plugins.cjs" }, "./lint/plugins-dev": { "types": "./dist/lint-plugins-dev.d.ts", diff --git a/packages/cli/src/__tests__/exports-map.spec.ts b/packages/cli/src/__tests__/exports-map.spec.ts index 259a3916a0..bdfbd7f513 100644 --- a/packages/cli/src/__tests__/exports-map.spec.ts +++ b/packages/cli/src/__tests__/exports-map.spec.ts @@ -147,6 +147,15 @@ describe('Oxlint JS-plugin authoring entrypoints', () => { ); }); + it('serves the authoring API to CommonJS too', () => { + // A `.cts` plugin, or a `.ts` one compiled with `module: commonjs`, emits + // its import as `require()`. `@oxlint/plugins` ships CJS, so the shim does + // too. `plugins-dev` deliberately does not: it is ESM-only upstream. + const plugins = requireFromHere('vite-plus/lint/plugins') as Record; + expect(plugins.defineRule).toBeTypeOf('function'); + expect(plugins.definePlugin).toBeTypeOf('function'); + }); + it('exposes RuleTester from vite-plus/lint/plugins-dev', async () => { const ruleTester = await import('vite-plus/lint/plugins-dev'); expect(ruleTester.RuleTester).toBeTypeOf('function'); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index fa843cfb3d..7ac1de377d 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -343,17 +343,22 @@ export function collectProviderSourceModes(projectPath: string): Record OXLINT_PLUGIN_API_CJS_RE.test(content)); + return sourceTreeMatches( + projectPath, + (content) => + OXLINT_PLUGIN_API_CJS_RE.test(content) || OXLINT_PLUGIN_API_CREATE_REQUIRE_RE.test(content), + ); } diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 0bde200d96..83a7880c9d 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -90,6 +90,13 @@ export default defineConfig([ entry: { 'define-config': './src/define-config.ts', index: './src/index.cts', + // `@oxlint/plugins` ships CJS, so the shim can too. A `.cts` plugin, or + // a `.ts` one compiled with `module: commonjs`, emits its import as + // `require()`, which an ESM-only export would reject. + // + // `lint-plugins-dev` deliberately has no CJS build: `oxlint/plugins-dev` + // is ESM-only upstream, so the shim mirrors exactly what upstream can do. + 'lint-plugins': './src/lint-plugins.ts', }, outDir: 'dist', format: 'cjs', From f160c0255ab150b91e9b86ee266fc7aedfdca8c8 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 21:05:04 +0800 Subject: [PATCH 08/10] refactor(migrate): decide the @oxlint/plugins deletion after the rewrite Fifth review round found two more spellings the pre-rewrite scan missed: a template-literal `require(`@oxlint/plugins`)` and a JSDoc `@typedef {import('@oxlint/plugins').Context}`, which is a comment and so invisible to every ast-grep string rule. Both are symptoms of the same wrong shape. The scan ran BEFORE the import rewrite, so it had to predict which specifiers the rewriter would leave behind, and every missed spelling silently deleted a dependency that was still load-bearing. Three rounds of regex patching did not converge. The deletion now runs AFTER the rewrite, in `dropDeadOxlintPluginsDependency`. By then every form the rewriter handles has already become a `vite-plus/lint/*` specifier, so a plain substring scan for the package name answers the only question that matters: does anything still need it? That covers `require()`, import-equals, JSDoc, template literals, and plain strings without enumerating any of them, and the tolerant-regex machinery is gone. Snapshots are unchanged, which is the point: same outcome for the cases that were already right, and no longer wrong for the ones that were not. --- .../src/migration/migrator/orchestrators.ts | 7 +- .../src/migration/migrator/package-json.ts | 24 ++---- .../cli/src/migration/migrator/source-scan.ts | 77 ++++++++++++------- 3 files changed, 59 insertions(+), 49 deletions(-) diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 812d975d89..c6813b0bad 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -10,7 +10,7 @@ import { cleanupDeprecatedTsconfigOptions, collectInjectedProviderNames, collectOxlintOwnerDirs, - sourceTreeRequiresOxlintPluginApi, + dropDeadOxlintPluginsDependency, collectProviderSourceModes, collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, @@ -80,7 +80,6 @@ export function rewriteStandaloneProject( // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter // reads the manifests afterwards and would no longer see the signal. const oxlintOwnerDirs = collectOxlintOwnerDirs(projectPath, workspaceInfo.packages); - const requiresOxlintPluginApiCjs = sourceTreeRequiresOxlintPluginApi(projectPath); // 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 @@ -255,7 +254,6 @@ export function rewriteStandaloneProject( retainedVitestModule, requiredVitestPeer, providerCatalogAdditions, - requiresOxlintPluginApiCjs, ); // ensure vite-plus is in devDependencies — but only when it isn't already a @@ -341,6 +339,7 @@ export function rewriteStandaloneProject( mergeTsdownConfigFile(projectPath, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging rewriteAllImports(projectPath, silent, report, true, oxlintOwnerDirs); + dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager setPackageManager(projectPath, workspaceInfo.downloadPackageManager); @@ -474,6 +473,7 @@ export function rewriteMonorepo( mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report); // rewrite imports in all TypeScript/JavaScript files before lazy plugin import merging rewriteAllImports(workspaceInfo.rootDir, silent, report, true, oxlintOwnerDirs); + dropDeadOxlintPluginsDependency(workspaceInfo.rootDir, workspaceInfo.packages); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); @@ -567,7 +567,6 @@ export function rewriteMonorepoProject( retainedVitestModule, requiredVitestPeer, providerCatalogAdditions, - sourceTreeRequiresOxlintPluginApi(projectPath), ); // If this SUB-workspace now depends on `vite-plus` and Yarn isolates its // hoisting (via the root `nmHoistingLimits` OR the workspace's own diff --git a/packages/cli/src/migration/migrator/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts index 29b67a3f87..501eb93ec9 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -28,7 +28,6 @@ import { resolveProviderPeerSpec, OPT_IN_BROWSER_PROVIDERS, OXLINT_PLUGIN_API_PACKAGES, - OXLINT_PLUGINS_PACKAGE, REMOVE_PACKAGES, VITEST_BROWSER_DEP_NAMES, VITEST_IS_MANAGED_OVERRIDE, @@ -71,11 +70,6 @@ export function rewritePackageJson( // one only through source/a shim). An already-installed copy of such a provider // must REFERENCE that catalog entry, not pin a concrete version. See #2005. providerCatalogAdditions: ReadonlySet = new Set(), - // Whether the source tree still reaches the Oxlint plugin API through a - // CommonJS `require()`. Those forms survive the import rewrite untouched, so - // the direct `@oxlint/plugins` dependency stays load-bearing. Computed by the - // caller, which owns the project path (see `sourceTreeRequiresOxlintPluginApi`). - requiresOxlintPluginApiCjs = false, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -202,19 +196,11 @@ export function rewritePackageJson( const ownsOxlintApi = OXLINT_PLUGIN_API_PACKAGES.some( (name) => pkg.dependencies?.[name] !== undefined || pkg.peerDependencies?.[name] !== undefined, ); - // `@oxlint/plugins` becomes dead weight once the import rewrite points the - // authoring API at `vite-plus/lint/plugins`, so drop it from devDependencies. - // Two exceptions: a published plugin owns the API (above), and a CommonJS - // `require()` of it survives the rewrite untouched, so the direct dependency - // is still the only resolvable copy under pnpm's strict layout. - if ( - pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE] && - !ownsOxlintApi && - !requiresOxlintPluginApiCjs - ) { - delete pkg.devDependencies[OXLINT_PLUGINS_PACKAGE]; - needVitePlus = true; - } + // `@oxlint/plugins` often becomes dead weight once the import rewrite points + // the authoring API at `vite-plus/lint/plugins`, but not always: the rewrite + // preserves several forms. The deletion therefore happens AFTER the rewrite, + // in `dropDeadOxlintPluginsDependency`, where the question is simply whether + // anything still names the package. // remove packages that are replaced with vite-plus for (const name of REMOVE_PACKAGES) { let wasRemoved = false; diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 7ac1de377d..bdc2655c5e 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -2,10 +2,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { type WorkspacePackage } from '../../types/index.ts'; +import { editJsonFile } from '../../utils/json.ts'; import { hasVitestTypesInTsconfig } from '../../utils/tsconfig.ts'; import { projectUsesVitestDirectly } from '../migrator.ts'; import { OPT_IN_BROWSER_PROVIDERS, + OXLINT_PLUGINS_PACKAGE, + OXLINT_PLUGIN_API_PACKAGES, PLAYWRIGHT_PROVIDER, WEBDRIVERIO_PROVIDER, readPackageJsonIfExists, @@ -343,34 +346,56 @@ export function collectProviderSourceModes(projectPath: string): Record - OXLINT_PLUGIN_API_CJS_RE.test(content) || OXLINT_PLUGIN_API_CREATE_REQUIRE_RE.test(content), - ); +export function dropDeadOxlintPluginsDependency( + rootDir: string, + packages?: readonly { path: string }[], +): void { + const dirs = [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; + for (const dir of dirs) { + const packageJsonPath = path.join(dir, 'package.json'); + const pkg = readPackageJsonIfExists(packageJsonPath); + if (!pkg?.devDependencies?.[OXLINT_PLUGINS_PACKAGE]) { + continue; + } + const ownsApi = OXLINT_PLUGIN_API_PACKAGES.some( + (name) => + pkg.dependencies?.[name] !== undefined || pkg.peerDependencies?.[name] !== undefined, + ); + if (ownsApi || sourceTreeReferencesOxlintPluginsPackage(dir)) { + continue; + } + editJsonFile<{ devDependencies?: Record }>(packageJsonPath, (json) => { + if (!json.devDependencies?.[OXLINT_PLUGINS_PACKAGE]) { + return undefined; + } + delete json.devDependencies[OXLINT_PLUGINS_PACKAGE]; + return json; + }); + } } From dbde05c23cb13d86f03f230474f70aadb1fc72a7 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 21:31:17 +0800 Subject: [PATCH 09/10] fix(migrate): stop rewriting exported string data, restore the leaf edge Sixth review round, three findings, all real. `export default '@oxlint/plugins'` had its exported DATA VALUE rewritten. That literal is a direct child of the export statement, so an unconstrained `inside: export_statement` matched it. The export rules now match the `source` field, so only a genuine re-export qualifies. My earlier probe of this claim tested `export const x = '...'` and a literal nested in an object, both of which sit below a declaration and are safe. It missed the bare direct-child form, so the disproof was wrong. Pinned now. Moving the dependency deletion after the rewrite dropped the `needVitePlus` signal it used to set. A monorepo leaf whose only migration signal was an `@oxlint/plugins` devDependency would get its imports repointed at `vite-plus/lint/plugins` without gaining a direct `vite-plus` edge. The edge decision moves back to `rewritePackageJson`, where the pre-rewrite manifest is still intact; only the deletion happens later. The retention scan stopped at nested package.json boundaries, so a non-workspace example or fixture directory holding a preserved `require('@oxlint/plugins')` went unscanned while the root dependency it resolved was deleted. That scan now crosses package boundaries, which is correct for this question specifically: the nested file resolves the root's dependency by walking up. --- crates/vp_migration/src/import_rewriter.rs | 16 +++++++++++++++ .../src/migration/migrator/package-json.ts | 9 +++++++++ .../cli/src/migration/migrator/source-scan.ts | 20 +++++++++++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index b5cc41edda..c19d91672f 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -1647,6 +1647,7 @@ rule: regex: ^['"]@oxlint/plugins['"]$ inside: kind: export_statement + field: source transform: NEW_IMPORT: replace: @@ -1700,6 +1701,7 @@ rule: regex: ^['"]oxlint/plugins-dev['"]$ inside: kind: export_statement + field: source transform: NEW_IMPORT: replace: @@ -1737,6 +1739,7 @@ rule: regex: ^['"]oxlint['"]$ inside: kind: export_statement + field: source all: - has: kind: export_specifier @@ -4140,6 +4143,19 @@ export * from 'oxlint';"#; assert_eq!(result.content, r#"type C = import('vite-plus/lint/plugins').Context;"#); } + #[test] + fn test_rewrite_import_content_oxlint_export_default_literal_is_data() { + // `export default '...'` puts the string directly under the + // export_statement, so an unconstrained `inside:` rewrote the exported + // DATA VALUE. The rules match the `source` field only. + let data = r#"export default '@oxlint/plugins'; +export = 'oxlint/plugins-dev';"#; + + let result = rewrite_import_content(data, &SkipPackages::default()).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, data); + } + #[test] fn test_rewrite_import_content_oxlint_export_const_literal_is_data() { // `inside:` matches the immediate parent. In `export const x = '...'` diff --git a/packages/cli/src/migration/migrator/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts index 501eb93ec9..00b85f5263 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -27,6 +27,7 @@ import { findDeclaredSpec, resolveProviderPeerSpec, OPT_IN_BROWSER_PROVIDERS, + OXLINT_PLUGINS_PACKAGE, OXLINT_PLUGIN_API_PACKAGES, REMOVE_PACKAGES, VITEST_BROWSER_DEP_NAMES, @@ -201,6 +202,14 @@ export function rewritePackageJson( // preserves several forms. The deletion therefore happens AFTER the rewrite, // in `dropDeadOxlintPluginsDependency`, where the question is simply whether // anything still names the package. + // + // The `vite-plus` edge is still decided here, though. A leaf whose only + // migration signal is this dependency will have its imports repointed at + // `vite-plus/lint/plugins`, so it needs a direct `vite-plus` edge to resolve + // them under an isolated layout such as Yarn PnP. + if (pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE] && !ownsOxlintApi) { + needVitePlus = true; + } // remove packages that are replaced with vite-plus for (const name of REMOVE_PACKAGES) { let wasRemoved = false; diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index bdc2655c5e..675de547bf 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -218,6 +218,12 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ function sourceTreeMatches( projectPath: string, matchesContent: (content: string) => boolean, + // Cross nested package.json boundaries. Off by default, because most signals + // are per-package and a sub-package is scanned on its own pass. On for the + // dependency-retention check, where a nested example or fixture directory + // that is not a workspace member would otherwise go unscanned while still + // resolving the root's dependency. + crossPackageBoundaries = false, ): boolean { const scanDir = (dir: string, isRoot: boolean): boolean => { let entries: fs.Dirent[]; @@ -228,7 +234,11 @@ function sourceTreeMatches( } // A nested package.json marks a separate workspace package — it is migrated // (and scanned) on its own pass, so don't let its files leak into this one. - if (!isRoot && entries.some((e) => e.isFile() && e.name === 'package.json')) { + if ( + !crossPackageBoundaries && + !isRoot && + entries.some((e) => e.isFile() && e.name === 'package.json') + ) { return false; } for (const entry of entries) { @@ -361,7 +371,13 @@ export function collectProviderSourceModes(projectPath: string): Record content.includes('@oxlint/plugins'), + // Nested non-workspace packages (examples, fixtures) resolve the root's + // dependency by walking up, so they must be scanned before it is deleted. + true, + ); } /** From 93c0f22ce2b76dba12dc3b506e78f14f37a55a75 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 21:54:06 +0800 Subject: [PATCH 10/10] fix(migrate): treat an optional plugin API edge like a dev one Seventh review round, one finding. `@oxlint/plugins` declared only in `optionalDependencies` still has its imports repointed at `vite-plus/lint/plugins`, but it did not set `needVitePlus`, so a standalone project or an isolated leaf could finish with rewritten imports and no declared `vite-plus`. An optional install edge provisions the API the same way a dev one does, so both now feed the same signal, and the post-rewrite cleanup clears whichever of the two declared it. --- .../src/migration/migrator/package-json.ts | 8 ++++++- .../cli/src/migration/migrator/source-scan.ts | 24 ++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/migration/migrator/package-json.ts b/packages/cli/src/migration/migrator/package-json.ts index 00b85f5263..f8cc153890 100644 --- a/packages/cli/src/migration/migrator/package-json.ts +++ b/packages/cli/src/migration/migrator/package-json.ts @@ -207,7 +207,13 @@ export function rewritePackageJson( // migration signal is this dependency will have its imports repointed at // `vite-plus/lint/plugins`, so it needs a direct `vite-plus` edge to resolve // them under an isolated layout such as Yarn PnP. - if (pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE] && !ownsOxlintApi) { + // An optional install edge provisions the API the same way a dev one does, + // so it is the same signal. + if ( + (pkg.devDependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined || + pkg.optionalDependencies?.[OXLINT_PLUGINS_PACKAGE] !== undefined) && + !ownsOxlintApi + ) { needVitePlus = true; } // remove packages that are replaced with vite-plus diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 675de547bf..be2ef9321d 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -396,7 +396,13 @@ export function dropDeadOxlintPluginsDependency( for (const dir of dirs) { const packageJsonPath = path.join(dir, 'package.json'); const pkg = readPackageJsonIfExists(packageJsonPath); - if (!pkg?.devDependencies?.[OXLINT_PLUGINS_PACKAGE]) { + if (!pkg) { + continue; + } + const declaredIn = (['devDependencies', 'optionalDependencies'] as const).filter( + (field) => pkg?.[field]?.[OXLINT_PLUGINS_PACKAGE] !== undefined, + ); + if (declaredIn.length === 0) { continue; } const ownsApi = OXLINT_PLUGIN_API_PACKAGES.some( @@ -406,12 +412,18 @@ export function dropDeadOxlintPluginsDependency( if (ownsApi || sourceTreeReferencesOxlintPluginsPackage(dir)) { continue; } - editJsonFile<{ devDependencies?: Record }>(packageJsonPath, (json) => { - if (!json.devDependencies?.[OXLINT_PLUGINS_PACKAGE]) { - return undefined; + editJsonFile<{ + devDependencies?: Record; + optionalDependencies?: Record; + }>(packageJsonPath, (json) => { + let changed = false; + for (const field of declaredIn) { + if (json[field]?.[OXLINT_PLUGINS_PACKAGE]) { + delete json[field][OXLINT_PLUGINS_PACKAGE]; + changed = true; + } } - delete json.devDependencies[OXLINT_PLUGINS_PACKAGE]; - return json; + return changed ? json : undefined; }); } }