From f06b4b2422492f23895192a925f599c559641dc7 Mon Sep 17 00:00:00 2001 From: m0g3r <87276771+m0g3r@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:10:19 +0200 Subject: [PATCH 1/5] feat(migrate): preserve dynamic Oxlint and Oxfmt configs `vp migrate` detected and inlined only the JSON forms of the Oxlint and Oxfmt configs, so a project using `oxlint.config.ts` or `oxfmt.config.mjs` silently lost its configuration. Detection now covers the dynamic `.ts`/`.mts`/`.cts`/`.js`/`.mjs`/`.cjs` forms, and the existing tsdown merger is generalized to target `lint` and `fmt` so those files are preserved and imported into `vite.config.*` the same way tsdown configs already are. Their bare `oxlint` and `oxfmt` runtime imports are rewritten to complete `vite-plus/lint` and `vite-plus/fmt` subpaths, which migration otherwise leaves unresolvable under strict pnpm layouts once the direct packages are removed. Closes #2430 --- .../oxfmt.config.mts | 5 + .../oxlint.config.ts | 7 + .../package.json | 12 + .../snapshots.toml | 12 + .../migration_dynamic_oxc_configs.md | 115 ++++++++++ crates/vp_migration/src/import_rewriter.rs | 217 ++++++++++++++++-- crates/vp_migration/src/lib.rs | 4 +- crates/vp_migration/src/vite_config.rs | 151 +++++++++--- packages/cli/binding/index.cjs | 1 + packages/cli/binding/index.d.cts | 8 + packages/cli/binding/src/migration.rs | 23 ++ packages/cli/package.json | 6 +- .../cli/src/__tests__/exports-map.spec.ts | 32 +++ packages/cli/src/fmt.ts | 2 +- packages/cli/src/lint.ts | 7 +- .../src/migration/__tests__/detector.spec.ts | 42 ++++ .../src/migration/__tests__/migrator.spec.ts | 55 +++++ packages/cli/src/migration/detector.ts | 9 +- .../cli/src/migration/migrator/vite-config.ts | 160 ++++++++++--- 19 files changed, 763 insertions(+), 105 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxfmt.config.mts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxlint.config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots/migration_dynamic_oxc_configs.md create mode 100644 packages/cli/src/migration/__tests__/detector.spec.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxfmt.config.mts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxfmt.config.mts new file mode 100644 index 0000000000..88672bf107 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxfmt.config.mts @@ -0,0 +1,5 @@ +import { defineConfig } from 'oxfmt'; + +export default defineConfig({ + printWidth: 100, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxlint.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxlint.config.ts new file mode 100644 index 0000000000..37d49eafef --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/oxlint.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + rules: { + eqeqeq: 'error', + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/package.json new file mode 100644 index 0000000000..7df9a1d069 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-dynamic-oxc-configs", + "scripts": { + "lint": "oxlint", + "format": "oxfmt --write" + }, + "devDependencies": { + "oxfmt": "^0.1.0", + "oxlint": "^1.0.0", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots.toml new file mode 100644 index 0000000000..d6ce5d92c1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "migration_dynamic_oxc_configs" +vp = "global" +steps = [ + { argv = ["vp", "migrate", "--no-interactive"], comment = "migration should import dynamic Oxc configs into Vite+", continue-on-failure = true }, + { argv = ["vpt", "print-file", "oxlint.config.ts"], comment = "check oxlint config and helper import", continue-on-failure = true }, + { argv = ["vpt", "print-file", "oxfmt.config.mts"], comment = "check oxfmt config and helper import", continue-on-failure = true }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "check dynamic configs imported into vite config", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "check bundled Oxc dependencies removed", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive"], comment = "run migration again to check idempotency", continue-on-failure = true }, + { argv = ["vpt", "print-file", "vite.config.ts"], comment = "check vite config remains unchanged", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots/migration_dynamic_oxc_configs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots/migration_dynamic_oxc_configs.md new file mode 100644 index 0000000000..3d3a6ca028 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_dynamic_oxc_configs/snapshots/migration_dynamic_oxc_configs.md @@ -0,0 +1,115 @@ +# migration_dynamic_oxc_configs + +## `vp migrate --no-interactive` + +migration should import dynamic Oxc configs into Vite+ + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node pnpm +• 4 config updates applied, 2 files had imports rewritten +``` + +## `vpt print-file oxlint.config.ts` + +check oxlint config and helper import + +``` +import { defineConfig } from 'vite-plus/lint'; + +export default defineConfig({ + rules: { + eqeqeq: 'error', + }, +}); +``` + +## `vpt print-file oxfmt.config.mts` + +check oxfmt config and helper import + +``` +import { defineConfig } from 'vite-plus/fmt'; + +export default defineConfig({ + printWidth: 100, +}); +``` + +## `vpt print-file vite.config.ts` + +check dynamic configs imported into vite config + +``` +import oxfmtConfig from './oxfmt.config.mjs'; + +import oxlintConfig from './oxlint.config.js'; + +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + fmt: oxfmtConfig, + lint: oxlintConfig, +}); +``` + +## `vpt print-file package.json` + +check bundled Oxc dependencies removed + +``` +{ + "name": "migration-dynamic-oxc-configs", + "scripts": { + "lint": "vp lint", + "format": "vp fmt --write", + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vp migrate --no-interactive` + +run migration again to check idempotency + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file vite.config.ts` + +check vite config remains unchanged + +``` +import oxfmtConfig from './oxfmt.config.mjs'; + +import oxlintConfig from './oxlint.config.js'; + +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + staged: { + "*": "vp check --fix" + }, + fmt: oxfmtConfig, + lint: oxlintConfig, +}); +``` diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 62f377d750..9722c18492 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -1567,6 +1567,162 @@ transform: fix: $NEW_IMPORT "#; +/// ast-grep rules for rewriting the bare Oxc package imports that Vite+ owns. +/// +/// The migration removes `oxlint` and `oxfmt` from the project's direct +/// dependencies. Dynamic Oxc config files therefore need to import their +/// runtime helpers through Vite+'s public subpaths so they keep resolving in +/// strict package-manager layouts. +const REWRITE_OXC_RULES: &str = r#"--- +id: rewrite-oxlint-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: import_statement +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint + by: "vite-plus/lint" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-export +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: export_statement +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint + by: "vite-plus/lint" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-require +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + regex: ^require$ +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint + by: "vite-plus/lint" +fix: $NEW_IMPORT +--- +id: rewrite-oxlint-dynamic-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxlint['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + kind: import +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxlint + by: "vite-plus/lint" +fix: $NEW_IMPORT +--- +id: rewrite-oxfmt-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxfmt['"]$ + inside: + kind: import_statement +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxfmt + by: "vite-plus/fmt" +fix: $NEW_IMPORT +--- +id: rewrite-oxfmt-export +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxfmt['"]$ + inside: + kind: export_statement +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxfmt + by: "vite-plus/fmt" +fix: $NEW_IMPORT +--- +id: rewrite-oxfmt-require +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxfmt['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + regex: ^require$ +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxfmt + by: "vite-plus/fmt" +fix: $NEW_IMPORT +--- +id: rewrite-oxfmt-dynamic-import +language: TypeScript +rule: + pattern: $STR + kind: string + regex: ^['"]oxfmt['"]$ + inside: + kind: arguments + inside: + kind: call_expression + has: + field: function + kind: import +transform: + NEW_IMPORT: + replace: + source: $STR + replace: oxfmt + by: "vite-plus/fmt" +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 +1769,10 @@ static PARSED_TSDOWN_RULES: LazyLock>> = LazyLock::n ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown rewrite rules") }); +static PARSED_OXC_RULES: LazyLock>> = LazyLock::new(|| { + ast_grep::load_rules(REWRITE_OXC_RULES).expect("failed to parse Oxc rewrite rules") +}); + // Regex patterns for rewriting `/// ` directives. // These cannot be handled by ast-grep because triple-slash references are parsed as comments. @@ -1970,13 +2130,6 @@ pub struct RewriteImportsOptions { pub preserve_vitest_in_nuxt_packages: bool, } -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 - } -} - /// Find the nearest package.json by walking up from the file's directory. /// Stops at the root directory. fn find_nearest_package_json(file_path: &Path, root: &Path) -> Option { @@ -2199,10 +2352,6 @@ pub fn rewrite_imports_in_directory_with_options( .into_par_iter() .map(|(file_path, package_context)| { let skip_packages = package_context.skip_packages; - if skip_packages.all_skipped() { - return (file_path, FileResult::Unchanged, false); - } - match rewrite_import( &file_path, &skip_packages, @@ -2246,7 +2395,8 @@ pub fn rewrite_imports_in_directory_with_options( Ok(batch_result) } -/// Rewrite imports in a TypeScript/JavaScript file from vite/vitest to vite-plus +/// Rewrite imports in a TypeScript/JavaScript file from the bundled tool +/// packages to vite-plus. /// /// This function reads a file and rewrites the import statements /// to use 'vite-plus' instead of 'vite', 'vitest', or '@vitest/*'. @@ -2299,6 +2449,9 @@ fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bo if !skip_packages.skip_tsdown && content.contains("tsdown") { return true; } + if content.contains("oxlint") || content.contains("oxfmt") { + return true; + } false } @@ -2380,6 +2533,14 @@ fn rewrite_import_content_full( } } + // Oxc's runtime helpers must resolve through Vite+ after migration removes + // the direct oxlint/oxfmt dependencies. + let oxc_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_OXC_RULES); + if oxc_content != new_content { + new_content = oxc_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 @@ -3791,6 +3952,25 @@ export default defineConfig({ ); } + #[test] + fn test_rewrite_oxc_runtime_imports() { + let content = r#"import { defineConfig as defineLintConfig } from 'oxlint'; +export { defineConfig as defineFmtConfig } from "oxfmt"; +const lint = require('oxlint'); +const fmt = import("oxfmt");"#; + + let result = rewrite_import_content(content, &SkipPackages::default()).unwrap(); + + assert!(result.updated); + assert_eq!( + result.content, + r#"import { defineConfig as defineLintConfig } from 'vite-plus/lint'; +export { defineConfig as defineFmtConfig } from "vite-plus/fmt"; +const lint = require('vite-plus/lint'); +const fmt = import("vite-plus/fmt");"# + ); + } + // ======================== // PeerDependencies Tests // ======================== @@ -3857,18 +4037,6 @@ export default defineConfig({});"#; assert_eq!(result.content, content); } - #[test] - fn test_skip_packages_all_skipped() { - let skip_all = SkipPackages { skip_vite: true, skip_vitest: true, skip_tsdown: true }; - assert!(skip_all.all_skipped()); - - let skip_some = SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: true }; - assert!(!skip_some.all_skipped()); - - let skip_none = SkipPackages::default(); - assert!(!skip_none.all_skipped()); - } - #[test] fn test_get_skip_packages_from_package_json_with_vite_peer_dep() { use std::fs; @@ -3912,7 +4080,6 @@ export default defineConfig({});"#; assert!(skip.skip_vite); assert!(skip.skip_vitest); assert!(skip.skip_tsdown); - assert!(skip.all_skipped()); } #[test] diff --git a/crates/vp_migration/src/lib.rs b/crates/vp_migration/src/lib.rs index 855f23cd9b..34bbbcea37 100644 --- a/crates/vp_migration/src/lib.rs +++ b/crates/vp_migration/src/lib.rs @@ -22,6 +22,6 @@ pub use import_rewriter::{ }; pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts}; pub use vite_config::{ - MergeResult, has_config_key, merge_json_config, merge_tsdown_config, upsert_json_config, - wrap_lazy_plugins, + MergeResult, has_config_key, merge_dynamic_config, merge_json_config, merge_tsdown_config, + upsert_json_config, wrap_lazy_plugins, }; diff --git a/crates/vp_migration/src/vite_config.rs b/crates/vp_migration/src/vite_config.rs index f8c84a8080..f357834b84 100644 --- a/crates/vp_migration/src/vite_config.rs +++ b/crates/vp_migration/src/vite_config.rs @@ -829,42 +829,42 @@ fn indent_multiline(s: &str, spaces: usize) -> String { .join("\n") } -/// Merge tsdown config into vite.config.ts by importing it +/// Merge a dynamic config into vite.config.ts by importing it. /// -/// This function adds an import statement for the tsdown config file -/// and adds `pack: tsdownConfig` to the defineConfig. +/// This function adds a default import for the config file and assigns it to +/// the requested top-level Vite config key. /// /// # Arguments /// /// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file -/// * `tsdown_config_path` - Path to the tsdown.config.ts file (relative path like "./tsdown.config.ts") +/// * `config_path` - Relative path to the imported config file +/// * `import_name` - Local identifier for the default import +/// * `config_key` - Top-level Vite config key that receives the imported config /// /// # Returns /// /// Returns a `MergeResult` with the updated content -pub fn merge_tsdown_config( +pub fn merge_dynamic_config( vite_config_path: &Path, - tsdown_config_path: &str, + config_path: &str, + import_name: &str, + config_key: &str, ) -> Result { let vite_config_content = std::fs::read_to_string(vite_config_path)?; - merge_tsdown_config_content(&vite_config_content, tsdown_config_path) + merge_dynamic_config_content(&vite_config_content, config_path, import_name, config_key) } -/// Merge tsdown config into vite config content -/// -/// This adds: -/// 1. An import statement: `import tsdownConfig from './tsdown.config.ts'` -/// 2. The pack config in defineConfig: `pack: tsdownConfig` -/// -/// This function is idempotent - running it multiple times will not create duplicates. -fn merge_tsdown_config_content( +fn merge_dynamic_config_content( vite_config_content: &str, - tsdown_config_path: &str, + config_path: &str, + import_name: &str, + config_key: &str, ) -> Result { let uses_function_callback = check_function_callback(vite_config_content)?; - // Check if already migrated (idempotency check) - if vite_config_content.contains("import tsdownConfig from") { + // A pre-existing key wins. This makes the transform idempotent and avoids + // silently replacing a user's inline configuration. + if has_config_key(vite_config_content, config_key)? { return Ok(MergeResult { content: vite_config_content.to_string(), updated: false, @@ -872,28 +872,54 @@ fn merge_tsdown_config_content( }); } - // Step 1: Add import statement at the beginning - // Use JavaScript extensions for TypeScript files (TypeScript module resolution convention) - // .ts → .js, .mts → .mjs, .cts → .cjs - let import_path = if tsdown_config_path.ends_with(".mts") { - tsdown_config_path.replace(".mts", ".mjs") - } else if tsdown_config_path.ends_with(".cts") { - tsdown_config_path.replace(".cts", ".cjs") - } else if tsdown_config_path.ends_with(".ts") { - tsdown_config_path.replace(".ts", ".js") - } else { - tsdown_config_path.to_string() - }; - let content_with_import = - format!("import tsdownConfig from '{import_path}';\n\n{vite_config_content}"); + // Add the config key first so an unsupported Vite config shape never gets + // an orphaned import prepended to it. + let merge_rule = generate_merge_rule(import_name, config_key); + let (mut final_content, updated) = ast_grep::apply_rules(vite_config_content, &merge_rule)?; + if !updated { + return Ok(MergeResult { + content: vite_config_content.to_string(), + updated: false, + uses_function_callback, + }); + } - // Step 2: Add pack: tsdownConfig to defineConfig - let pack_rule = generate_merge_rule("tsdownConfig", "pack"); - let (final_content, _) = ast_grep::apply_rules(&content_with_import, &pack_rule)?; + // Reuse an existing default import when a partially migrated config + // already has one. Otherwise prepend it using JavaScript extensions for + // TypeScript source files, matching TypeScript module resolution. + let import_prefix = format!("import {import_name} from"); + if !vite_config_content.contains(&import_prefix) { + let import_path = if let Some(stem) = config_path.strip_suffix(".mts") { + format!("{stem}.mjs") + } else if let Some(stem) = config_path.strip_suffix(".cts") { + format!("{stem}.cjs") + } else if let Some(stem) = config_path.strip_suffix(".ts") { + format!("{stem}.js") + } else { + config_path.to_string() + }; + final_content = format!("import {import_name} from '{import_path}';\n\n{final_content}"); + } Ok(MergeResult { content: final_content, updated: true, uses_function_callback }) } +/// Merge tsdown config into vite.config.ts by importing it as `pack`. +pub fn merge_tsdown_config( + vite_config_path: &Path, + tsdown_config_path: &str, +) -> Result { + merge_dynamic_config(vite_config_path, tsdown_config_path, "tsdownConfig", "pack") +} + +#[cfg(test)] +fn merge_tsdown_config_content( + vite_config_content: &str, + tsdown_config_path: &str, +) -> Result { + merge_dynamic_config_content(vite_config_content, tsdown_config_path, "tsdownConfig", "pack") +} + #[cfg(test)] mod tests { use std::io::Write; @@ -2396,6 +2422,61 @@ export default defineConfig({});"#; assert!(result.content.contains("import tsdownConfig from './tsdown.config.cjs'")); } + #[test] + fn test_merge_dynamic_config_content() { + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: [], +});"#; + + let result = + merge_dynamic_config_content(vite_config, "./oxlint.config.ts", "oxlintConfig", "lint") + .unwrap(); + + assert!(result.updated); + assert_eq!( + result.content, + r#"import oxlintConfig from './oxlint.config.js'; + +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + lint: oxlintConfig, + plugins: [], +});"# + ); + } + + #[test] + fn test_merge_dynamic_config_content_preserves_existing_key() { + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + lint: { rules: {} }, +});"#; + + let result = + merge_dynamic_config_content(vite_config, "./oxlint.config.ts", "oxlintConfig", "lint") + .unwrap(); + + assert!(!result.updated); + assert_eq!(result.content, vite_config); + } + + #[test] + fn test_merge_dynamic_config_content_does_not_add_orphan_import() { + let vite_config = "export default makeConfig();"; + + let result = + merge_dynamic_config_content(vite_config, "./oxfmt.config.mts", "oxfmtConfig", "fmt") + .unwrap(); + + assert!(!result.updated); + assert_eq!(result.content, vite_config); + assert!(!result.content.contains("oxfmt.config.mjs")); + } + // ── upsert_json_config_content ──────────────────────────────────────── #[test] diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index 1521b26d0e..337d541a83 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -964,6 +964,7 @@ module.exports.detectWorkspace = nativeBinding.detectWorkspace; module.exports.downloadPackageManager = nativeBinding.downloadPackageManager; module.exports.ensureBlockingStdio = nativeBinding.ensureBlockingStdio; module.exports.hasConfigKey = nativeBinding.hasConfigKey; +module.exports.mergeDynamicConfig = nativeBinding.mergeDynamicConfig; module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig; module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig; module.exports.rewriteEslint = nativeBinding.rewriteEslint; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 54660f8ba8..d9d3591027 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3491,6 +3491,14 @@ export interface JsCommandResolvedResult { envs: Record; } +/** Merge a dynamic config into a top-level Vite config key by importing it. */ +export declare function mergeDynamicConfig( + viteConfigPath: string, + configPath: string, + importName: string, + configKey: string, +): MergeJsonConfigResult; + /** * Merge JSON configuration file into vite config file * diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 4d19833e8c..eb5b506453 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -244,6 +244,29 @@ pub fn merge_tsdown_config( }) } +/// Merge a dynamic config into a top-level Vite config key by importing it. +#[napi] +pub fn merge_dynamic_config( + vite_config_path: String, + config_path: String, + import_name: String, + config_key: String, +) -> Result { + let result = vp_migration::merge_dynamic_config( + Path::new(&vite_config_path), + &config_path, + &import_name, + &config_key, + ) + .map_err(anyhow::Error::from)?; + + Ok(MergeJsonConfigResult { + content: result.content, + updated: result.updated, + uses_function_callback: result.uses_function_callback, + }) +} + /// Wrap safe inline `plugins: [...]` arrays in recognized Vite config objects /// with `lazyPlugins(() => [...])` and add a `lazyPlugins` import from /// `vite-plus` when needed. diff --git a/packages/cli/package.json b/packages/cli/package.json index 68f5bc89db..c096309c3a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -64,11 +64,13 @@ }, "./fmt": { "types": "./dist/fmt.d.ts", - "import": "./dist/fmt.js" + "import": "./dist/fmt.js", + "default": "./dist/fmt.js" }, "./lint": { "types": "./dist/lint.d.ts", - "import": "./dist/lint.js" + "import": "./dist/lint.js", + "default": "./dist/lint.js" }, "./oxlint-plugin": { "module-sync": "./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..3c88cd5349 100644 --- a/packages/cli/src/__tests__/exports-map.spec.ts +++ b/packages/cli/src/__tests__/exports-map.spec.ts @@ -117,6 +117,38 @@ describe('package.json exports map', () => { }); }); +describe('Vite+ Oxc subpaths preserve runtime exports', () => { + it.each([ + ['oxlint', 'vite-plus/lint', () => import('oxlint'), () => import('vite-plus/lint')], + ['oxfmt', 'vite-plus/fmt', () => import('oxfmt'), () => import('vite-plus/fmt')], + ] as const)( + 're-exports every %s runtime helper from %s', + async (upstream, subpath, loadUpstream, loadVitePlus) => { + const [vitePlusModule, upstreamModule] = await Promise.all([loadVitePlus(), loadUpstream()]); + const expected = namedValueExports(upstreamModule); + expect(expected.length, `sanity: ${upstream} should expose value exports`).toBeGreaterThan(0); + const missing = expected.filter( + (key) => + !(key in vitePlusModule) || + (vitePlusModule as Record)[key] === undefined, + ); + expect(missing, `${upstream} value exports missing from ${subpath}`).toEqual([]); + }, + ); + + it.each([ + ['oxlint', 'vite-plus/lint'], + ['oxfmt', 'vite-plus/fmt'], + ] as const)('exposes the %s helpers to require(%s)', (upstream, subpath) => { + const vitePlusModule = requireFromHere(subpath) as Record; + const upstreamModule = requireFromHere(upstream) as Record; + const missing = namedValueExports(upstreamModule).filter( + (key) => !(key in vitePlusModule) || vitePlusModule[key] === undefined, + ); + expect(missing, `${upstream} value exports missing from ${subpath}`).toEqual([]); + }); +}); + /** * 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/fmt.ts b/packages/cli/src/fmt.ts index 681820486c..9a7d67d44a 100644 --- a/packages/cli/src/fmt.ts +++ b/packages/cli/src/fmt.ts @@ -1,2 +1,2 @@ -export { format } from 'oxfmt'; +export { defineConfig, format, jsTextToDoc } from 'oxfmt'; export type * from 'oxfmt'; diff --git a/packages/cli/src/lint.ts b/packages/cli/src/lint.ts index 79e74ad15d..df395e682f 100644 --- a/packages/cli/src/lint.ts +++ b/packages/cli/src/lint.ts @@ -1,4 +1,5 @@ -// For now, `defineConfig()` is the only non-type exports from `oxlint`, -// but in Vite+, users should use `defineConfig()` from 'vite-plus`. - +// Keep standalone oxlint.config.ts files resolvable after migration removes the +// direct `oxlint` dependency. Root Vite+ configs should still import the unified +// `defineConfig()` from `vite-plus`. +export { defineConfig } from 'oxlint'; export type * from 'oxlint'; diff --git a/packages/cli/src/migration/__tests__/detector.spec.ts b/packages/cli/src/migration/__tests__/detector.spec.ts new file mode 100644 index 0000000000..a4d28247d2 --- /dev/null +++ b/packages/cli/src/migration/__tests__/detector.spec.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { detectConfigs } from '../detector.ts'; + +describe('detectConfigs — dynamic Oxc configs', () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it.each([ + ['oxlint.config.ts', 'oxlintConfig'], + ['oxlint.config.mts', 'oxlintConfig'], + ['oxfmt.config.ts', 'oxfmtConfig'], + ['oxfmt.config.mts', 'oxfmtConfig'], + ] as const)('detects %s', (filename, configKey) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-detector-')); + fs.writeFileSync(path.join(tmpDir, filename), 'export default {};\n'); + + expect(detectConfigs(tmpDir)[configKey]).toBe(filename); + }); + + it('prefers JSON configs when both JSON and dynamic configs exist', () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-detector-')); + fs.writeFileSync(path.join(tmpDir, '.oxlintrc.json'), '{}\n'); + fs.writeFileSync(path.join(tmpDir, 'oxlint.config.ts'), 'export default {};\n'); + fs.writeFileSync(path.join(tmpDir, '.oxfmtrc.jsonc'), '{}\n'); + fs.writeFileSync(path.join(tmpDir, 'oxfmt.config.mts'), 'export default {};\n'); + + expect(detectConfigs(tmpDir)).toMatchObject({ + oxlintConfig: '.oxlintrc.json', + oxfmtConfig: '.oxfmtrc.jsonc', + }); + }); +}); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f853aaa24c..63d7b890e1 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -48,6 +48,7 @@ const { injectLintTypeCheckDefaults, ensureSvelteRuneGlobals, mergeViteConfigFiles, + rewriteAllImports, rewriteEslintPackageJson, collectInstalledPackageNames, sanitizeMigratedOxlintConfig, @@ -1281,6 +1282,60 @@ describe('mergeViteConfigFiles — Svelte rune globals', () => { }); }); +describe('mergeViteConfigFiles — dynamic Oxc configs', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-dynamic-oxc-')); + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'test' })); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + "import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({});\n", + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('imports dynamic lint and format configs without removing them', () => { + const oxlintConfig = `import { defineConfig } from 'oxlint'; + +export default defineConfig({ rules: { eqeqeq: 'error' } }); +`; + const oxfmtConfig = `import { defineConfig } from 'oxfmt'; + +export default defineConfig({ printWidth: 100 }); +`; + fs.writeFileSync(path.join(tmpDir, 'oxlint.config.ts'), oxlintConfig); + fs.writeFileSync(path.join(tmpDir, 'oxfmt.config.mts'), oxfmtConfig); + const report = createMigrationReport(); + + mergeViteConfigFiles(tmpDir, true, report); + rewriteAllImports(tmpDir, true, report); + + const viteConfig = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8'); + expect(viteConfig).toContain("import oxlintConfig from './oxlint.config.js';"); + expect(viteConfig).toContain("import oxfmtConfig from './oxfmt.config.mjs';"); + expect(viteConfig).toContain('lint: oxlintConfig'); + expect(viteConfig).toContain('fmt: oxfmtConfig'); + expect(fs.readFileSync(path.join(tmpDir, 'oxlint.config.ts'), 'utf8')).toBe( + oxlintConfig.replace("from 'oxlint'", "from 'vite-plus/lint'"), + ); + expect(fs.readFileSync(path.join(tmpDir, 'oxfmt.config.mts'), 'utf8')).toBe( + oxfmtConfig.replace("from 'oxfmt'", "from 'vite-plus/fmt'"), + ); + expect(report.mergedConfigCount).toBe(2); + expect(report.rewrittenImportFileCount).toBe(2); + + mergeViteConfigFiles(tmpDir, true, report); + rewriteAllImports(tmpDir, true, report); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toBe(viteConfig); + expect(report.mergedConfigCount).toBe(2); + expect(report.rewrittenImportFileCount).toBe(2); + }); +}); + function writePkgAt(dir: string, pkg: object): void { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index fd4065cd86..4ef5ef73de 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -92,7 +92,12 @@ export function detectConfigs(projectPath: string): ConfigFiles { // Check for oxlint configs // https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format - const oxlintConfigs = ['.oxlintrc.json', '.oxlintrc.jsonc']; + const oxlintConfigs = [ + '.oxlintrc.json', + '.oxlintrc.jsonc', + 'oxlint.config.ts', + 'oxlint.config.mts', + ]; for (const config of oxlintConfigs) { if (fs.existsSync(path.join(projectPath, config))) { configs.oxlintConfig = config; @@ -102,7 +107,7 @@ export function detectConfigs(projectPath: string): ConfigFiles { // Check for oxfmt configs // https://oxc.rs/docs/guide/usage/formatter.html#configuration-file - const oxfmtConfigs = ['.oxfmtrc.json', '.oxfmtrc.jsonc']; + const oxfmtConfigs = ['.oxfmtrc.json', '.oxfmtrc.jsonc', 'oxfmt.config.ts', 'oxfmt.config.mts']; for (const config of oxfmtConfigs) { if (fs.existsSync(path.join(projectPath, config))) { configs.oxfmtConfig = config; diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 86b8e29c49..a3e1b38544 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -7,6 +7,7 @@ import { type OxlintConfig } from 'oxlint'; import { hasConfigKey, + mergeDynamicConfig, mergeJsonConfig, mergeTsdownConfig, rewriteImportsInDirectory, @@ -231,46 +232,77 @@ export function mergeViteConfigFiles( } const viteConfig = ensureViteConfig(projectPath, configs, silent, report); if (configs.oxlintConfig) { - // Inject options.typeAware and options.typeCheck defaults before merging - const fullOxlintPath = path.join(projectPath, configs.oxlintConfig); - const oxlintJson = readJsonFile(fullOxlintPath, true) as OxlintConfig; - if (!oxlintJson.options) { - oxlintJson.options = {}; - } - // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint) - if (!hasBaseUrlInTsconfig(projectPath)) { - if (oxlintJson.options.typeAware === undefined) { - oxlintJson.options.typeAware = true; + if (isJsonOxcConfig(configs.oxlintConfig)) { + // Inject options.typeAware and options.typeCheck defaults before merging + const fullOxlintPath = path.join(projectPath, configs.oxlintConfig); + const oxlintJson = readJsonFile(fullOxlintPath, true) as OxlintConfig; + if (!oxlintJson.options) { + oxlintJson.options = {}; } - if (oxlintJson.options.typeCheck === undefined) { - oxlintJson.options.typeCheck = true; + // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint) + if (!hasBaseUrlInTsconfig(projectPath)) { + if (oxlintJson.options.typeAware === undefined) { + oxlintJson.options.typeAware = true; + } + if (oxlintJson.options.typeCheck === undefined) { + oxlintJson.options.typeCheck = true; + } + } else { + warnMigration(BASEURL_TSCONFIG_WARNING, report); } + // Drop references to plugins / jsPlugins / rules that won't resolve + // at lint time (e.g. `@oxlint/migrate` translating `@unocss/eslint-config` + // → `eslint-plugin-unocss` even when that package isn't installed). + // Resolve workspace package paths against `workspaceRoot` when the + // caller is processing a sub-package — otherwise the sanitizer would + // mistakenly look for `subPath/` and miss the + // hoisted deps it's supposed to see. + sanitizeMigratedOxlintConfig( + oxlintJson, + collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), + report, + ); + ensureSvelteRuneGlobals(oxlintJson); + const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); + // writeJsonFile preserves the user file's existing indent/newline (and adds a + // trailing newline) instead of forcing 2-space + no EOL. + writeJsonFile(fullOxlintPath, normalizedOxlintConfig as Record); + // merge oxlint config into vite.config.ts + mergeAndRemoveJsonConfig( + projectPath, + viteConfig, + configs.oxlintConfig, + 'lint', + silent, + report, + ); } else { - warnMigration(BASEURL_TSCONFIG_WARNING, report); + mergeDynamicConfigFile( + projectPath, + viteConfig, + configs.oxlintConfig, + 'oxlintConfig', + 'lint', + silent, + report, + ); } - // Drop references to plugins / jsPlugins / rules that won't resolve - // at lint time (e.g. `@oxlint/migrate` translating `@unocss/eslint-config` - // → `eslint-plugin-unocss` even when that package isn't installed). - // Resolve workspace package paths against `workspaceRoot` when the - // caller is processing a sub-package — otherwise the sanitizer would - // mistakenly look for `subPath/` and miss the - // hoisted deps it's supposed to see. - sanitizeMigratedOxlintConfig( - oxlintJson, - collectInstalledPackageNames(workspaceRoot ?? projectPath, packages), - report, - ); - ensureSvelteRuneGlobals(oxlintJson); - const normalizedOxlintConfig = ensureVitePlusImportRuleDefaults(oxlintJson); - // writeJsonFile preserves the user file's existing indent/newline (and adds a - // trailing newline) instead of forcing 2-space + no EOL. - writeJsonFile(fullOxlintPath, normalizedOxlintConfig as Record); - // merge oxlint config into vite.config.ts - mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxlintConfig, 'lint', silent, report); } if (configs.oxfmtConfig) { - // merge oxfmt config into vite.config.ts - mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxfmtConfig, 'fmt', silent, report); + if (isJsonOxcConfig(configs.oxfmtConfig)) { + // merge oxfmt config into vite.config.ts + mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxfmtConfig, 'fmt', silent, report); + } else { + mergeDynamicConfigFile( + projectPath, + viteConfig, + configs.oxfmtConfig, + 'oxfmtConfig', + 'fmt', + silent, + report, + ); + } } } @@ -421,6 +453,63 @@ function mergeAndRemoveJsonConfig( } } +function isJsonOxcConfig(configPath: string): boolean { + return configPath.endsWith('.json') || configPath.endsWith('.jsonc'); +} + +function mergeDynamicConfigFile( + projectPath: string, + viteConfigPath: string, + dynamicConfigPath: string, + importName: string, + configKey: string, + silent = false, + report?: MigrationReport, +): void { + const fullViteConfigPath = path.join(projectPath, viteConfigPath); + const fullDynamicConfigPath = path.join(projectPath, dynamicConfigPath); + + if (hasConfigKey(fullViteConfigPath, configKey)) { + warnMigration( + `${displayRelative(fullDynamicConfigPath)} found but "${configKey}" already exists in ${displayRelative(fullViteConfigPath)}`, + report, + ); + infoMigration( + `Please manually merge ${displayRelative(fullDynamicConfigPath)} into ${displayRelative(fullViteConfigPath)}`, + report, + ); + return; + } + + const result = mergeDynamicConfig( + fullViteConfigPath, + `./${dynamicConfigPath}`, + importName, + configKey, + ); + if (result.updated) { + fs.writeFileSync(fullViteConfigPath, result.content); + if (report) { + report.mergedConfigCount++; + } + if (!silent) { + prompts.log.success( + `✔ Added ${displayRelative(fullDynamicConfigPath)} to ${displayRelative(fullViteConfigPath)}`, + ); + } + return; + } + + warnMigration( + `Failed to add ${displayRelative(fullDynamicConfigPath)} to ${displayRelative(fullViteConfigPath)}`, + report, + ); + infoMigration( + `Please manually merge ${displayRelative(fullDynamicConfigPath)} into ${displayRelative(fullViteConfigPath)}`, + report, + ); +} + /** * Merge a staged config object into vite.config.ts as `staged: { ... }`. * Writes the config to a temp JSON file, calls mergeJsonConfig NAPI, then cleans up. @@ -514,7 +603,8 @@ export function wrapLazyPluginsInViteConfig( /** * Rewrite imports in all TypeScript/JavaScript files under a directory - * This rewrites vite/vitest imports to @voidzero-dev/vite-plus + * This rewrites imports from tool packages bundled by Vite+ to its public + * entry points. * @param projectPath - The root directory to search for files */ export function rewriteAllImports( From fbd65ee0548fd3910e60a0ecae273a8fd0f75a98 Mon Sep 17 00:00:00 2001 From: m0g3r <87276771+m0g3r@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:28:47 +0000 Subject: [PATCH 2/5] fix(migrate): honor oxlint and oxfmt dependency skips when rewriting imports The Oxc rewrite pass was applied unconditionally, unlike the vite, vitest and tsdown passes, which each honor the package-level `SkipPackages` flags. A workspace package that intentionally declares `oxlint` or `oxfmt` as its own runtime or peer dependency therefore had its library sources rewritten to `vite-plus/lint` / `vite-plus/fmt` even though `rewritePackageJson` keeps that dependency, so published consumers could receive an undeclared import. Split the combined Oxc rule set into separate oxlint and oxfmt rule sets and gate each on its own skip flag, computed from `peerDependencies` and `dependencies` exactly like the existing three. The fast pre-filter honors the same flags so a fully skipped package short-circuits before parsing. Projects that do not declare either package are unaffected. --- crates/vp_migration/src/import_rewriter.rs | 203 ++++++++++++++++++--- 1 file changed, 179 insertions(+), 24 deletions(-) diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 9722c18492..b602479763 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -1573,7 +1573,7 @@ fix: $NEW_IMPORT /// dependencies. Dynamic Oxc config files therefore need to import their /// runtime helpers through Vite+'s public subpaths so they keep resolving in /// strict package-manager layouts. -const REWRITE_OXC_RULES: &str = r#"--- +const REWRITE_OXLINT_RULES: &str = r#"--- id: rewrite-oxlint-import language: TypeScript rule: @@ -1647,7 +1647,11 @@ transform: replace: oxlint by: "vite-plus/lint" fix: $NEW_IMPORT ---- +"#; + +/// Same rewrite for `oxfmt`'s runtime helpers → `vite-plus/fmt`. Kept separate +/// from the `oxlint` rules so each package honors its own dependency skip. +const REWRITE_OXFMT_RULES: &str = r#"--- id: rewrite-oxfmt-import language: TypeScript rule: @@ -1769,8 +1773,12 @@ static PARSED_TSDOWN_RULES: LazyLock>> = LazyLock::n ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown rewrite rules") }); -static PARSED_OXC_RULES: LazyLock>> = LazyLock::new(|| { - ast_grep::load_rules(REWRITE_OXC_RULES).expect("failed to parse Oxc rewrite rules") +static PARSED_OXLINT_RULES: LazyLock>> = LazyLock::new(|| { + ast_grep::load_rules(REWRITE_OXLINT_RULES).expect("failed to parse oxlint rewrite rules") +}); + +static PARSED_OXFMT_RULES: LazyLock>> = LazyLock::new(|| { + ast_grep::load_rules(REWRITE_OXFMT_RULES).expect("failed to parse oxfmt rewrite rules") }); // Regex patterns for rewriting `/// ` directives. @@ -2114,6 +2122,10 @@ struct SkipPackages { skip_vitest: bool, /// Skip rewriting tsdown imports (tsdown is in peerDependencies or dependencies) skip_tsdown: bool, + /// Skip rewriting oxlint imports (oxlint is in peerDependencies or dependencies) + skip_oxlint: bool, + /// Skip rewriting oxfmt imports (oxfmt is in peerDependencies or dependencies) + skip_oxfmt: bool, } #[derive(Debug, Clone, Copy, Default)] @@ -2247,6 +2259,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"), + skip_oxfmt: has_package("peerDependencies", "oxfmt") + || has_package("dependencies", "oxfmt"), }, uses_nuxt_test_utils: ["dependencies", "devDependencies", "optionalDependencies"] .into_iter() @@ -2449,7 +2465,10 @@ fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bo if !skip_packages.skip_tsdown && content.contains("tsdown") { return true; } - if content.contains("oxlint") || content.contains("oxfmt") { + if !skip_packages.skip_oxlint && content.contains("oxlint") { + return true; + } + if !skip_packages.skip_oxfmt && content.contains("oxfmt") { return true; } false @@ -2534,11 +2553,23 @@ fn rewrite_import_content_full( } // Oxc's runtime helpers must resolve through Vite+ after migration removes - // the direct oxlint/oxfmt dependencies. - let oxc_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_OXC_RULES); - if oxc_content != new_content { - new_content = oxc_content; - updated = true; + // the direct oxlint/oxfmt dependencies. A package that declares oxlint or + // oxfmt itself keeps that dependency, so — like vite/vitest/tsdown above — + // its sources keep their original specifiers. + if !skip_packages.skip_oxlint { + let oxlint_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_OXLINT_RULES); + if oxlint_content != new_content { + new_content = oxlint_content; + updated = true; + } + } + + if !skip_packages.skip_oxfmt { + let oxfmt_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_OXFMT_RULES); + if oxfmt_content != new_content { + new_content = oxfmt_content; + updated = true; + } } // Apply reference type rewriting (/// ) @@ -3983,8 +4014,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); @@ -4006,8 +4041,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); @@ -4030,13 +4069,108 @@ 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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(!result.updated); assert_eq!(result.content, content); } + #[test] + fn test_skip_oxlint_when_declared_leaves_oxfmt_rewritten() { + // A package that declares oxlint itself keeps that dependency after + // migration, so its sources must keep the bare `oxlint` specifier. + // oxfmt is not declared, so it still routes through Vite+. + let content = r#"import { defineConfig } from 'oxlint'; +import { defineConfig as fmt } from 'oxfmt';"#; + + let skip_packages = SkipPackages { skip_oxlint: true, ..Default::default() }; + + let result = rewrite_import_content(content, &skip_packages).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { defineConfig } from 'oxlint'; +import { defineConfig as fmt } from 'vite-plus/fmt';"# + ); + } + + #[test] + fn test_skip_oxfmt_when_declared_leaves_oxlint_rewritten() { + let content = r#"import { defineConfig } from 'oxlint'; +import { defineConfig as fmt } from 'oxfmt';"#; + + let skip_packages = SkipPackages { skip_oxfmt: true, ..Default::default() }; + + let result = rewrite_import_content(content, &skip_packages).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { defineConfig } from 'vite-plus/lint'; +import { defineConfig as fmt } from 'oxfmt';"# + ); + } + + #[test] + fn test_skip_both_oxc_packages_when_declared() { + let content = r#"import { defineConfig } from 'oxlint'; +import { defineConfig as fmt } from 'oxfmt';"#; + + let skip_packages = + SkipPackages { skip_oxlint: true, skip_oxfmt: true, ..Default::default() }; + + let result = rewrite_import_content(content, &skip_packages).unwrap(); + assert!(!result.updated); + assert_eq!(result.content, content); + } + + #[test] + fn test_get_skip_packages_from_package_json_with_oxc_deps() { + use std::fs; + + let temp = tempdir().unwrap(); + + // oxlint as a peerDependency, oxfmt as a runtime dependency: both are + // whole-package skips, exactly like vite/vitest/tsdown. + let pkg_json = r#"{ + "name": "my-oxc-preset", + "peerDependencies": { + "oxlint": "^1.0.0" + }, + "dependencies": { + "oxfmt": "^0.1.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_oxfmt); + assert!(!skip.skip_vite); + } + + #[test] + fn test_oxc_imports_still_rewritten_when_not_declared() { + // The default case must be unchanged: a project that does not declare + // oxlint/oxfmt still has its helper imports routed through Vite+. + let content = r#"import { defineConfig } from 'oxlint'; +import { defineConfig as fmt } from 'oxfmt';"#; + + let result = rewrite_import_content(content, &SkipPackages::default()).unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { defineConfig } from 'vite-plus/lint'; +import { defineConfig as fmt } from 'vite-plus/fmt';"# + ); + } + #[test] fn test_get_skip_packages_from_package_json_with_vite_peer_dep() { use std::fs; @@ -4862,8 +4996,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); // vitest require is NOT rewritten; vite require IS rewritten. @@ -5329,8 +5467,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); assert_eq!( @@ -5346,8 +5488,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); assert_eq!( @@ -5363,8 +5509,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(result.updated); assert_eq!( @@ -5380,7 +5530,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(!result.updated); assert_eq!(result.content, content); From 239cd38bcaaa1e167d5ddfeed4912dffd6fee1cd Mon Sep 17 00:00:00 2001 From: m0g3r <87276771+m0g3r@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:29:41 +0000 Subject: [PATCH 3/5] feat(migrate): fail fast on conflicting Oxc configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oxlint refuses to run when a directory holds both a JSON config and a dynamic one ("Only one of `.oxlintrc.json` and `oxlint.config.ts` is allowed per directory"), so such a project cannot lint before migration either. Migration had no non-destructive path through that state: first-match detection inlined and deleted the JSON config while leaving the dynamic one on disk unreferenced, where it then silently shadows the freshly inlined `lint` block for direct `oxlint` invocations — the settings the user just migrated stop applying, with no diagnostic. Detect the ambiguity up front and interrupt instead, naming every directory and the files that collide, so the user resolves the conflict before anything is rewritten. The check runs before any file is touched and covers the workspace root and every workspace package, on both the full-migration and the already-Vite+ paths. Two JSON forms are deliberately not treated as a conflict: the ambiguity this guards against is JSON-vs-dynamic, which is the combination oxlint itself rejects and the one that orphans a config across migration. --- .../src/migration/__tests__/detector.spec.ts | 210 +++++++++++++++++- packages/cli/src/migration/bin.ts | 34 +++ packages/cli/src/migration/detector.ts | 120 +++++++++- 3 files changed, 353 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/migration/__tests__/detector.spec.ts b/packages/cli/src/migration/__tests__/detector.spec.ts index a4d28247d2..5055e9e877 100644 --- a/packages/cli/src/migration/__tests__/detector.spec.ts +++ b/packages/cli/src/migration/__tests__/detector.spec.ts @@ -2,9 +2,14 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { detectConfigs } from '../detector.ts'; +import { + collectOxcConfigConflicts, + detectConfigs, + detectOxcConfigConflicts, + formatOxcConfigConflict, +} from '../detector.ts'; describe('detectConfigs — dynamic Oxc configs', () => { let tmpDir: string; @@ -27,6 +32,8 @@ describe('detectConfigs — dynamic Oxc configs', () => { expect(detectConfigs(tmpDir)[configKey]).toBe(filename); }); + // Documents the raw precedence only. `vp migrate` never reaches it for this + // input: `assertNoOxcConfigConflicts` rejects the directory first. it('prefers JSON configs when both JSON and dynamic configs exist', () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-detector-')); fs.writeFileSync(path.join(tmpDir, '.oxlintrc.json'), '{}\n'); @@ -40,3 +47,202 @@ describe('detectConfigs — dynamic Oxc configs', () => { }); }); }); + +describe('detectOxcConfigConflicts', () => { + let tmpDir: string; + + const write = (filename: string) => + fs.writeFileSync( + path.join(tmpDir, filename), + filename.endsWith('.ts') || filename.endsWith('.mts') ? 'export default {};\n' : '{}\n', + ); + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-oxc-conflict-')); + }); + + afterEach(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('reports no conflict for a directory with no Oxc config at all', () => { + expect(detectOxcConfigConflicts(tmpDir)).toEqual([]); + }); + + it.each([ + '.oxlintrc.json', + '.oxlintrc.jsonc', + 'oxlint.config.ts', + 'oxlint.config.mts', + '.oxfmtrc.json', + 'oxfmt.config.mts', + ])('reports no conflict when only %s is present', (filename) => { + write(filename); + + expect(detectOxcConfigConflicts(tmpDir)).toEqual([]); + }); + + // Two JSON forms are deliberately NOT a conflict here: the ambiguity this + // guard exists for is JSON-vs-dynamic, which is the combination oxlint itself + // rejects and the one that leaves an unreferenced config shadowing the + // inlined block after migration. + it('reports no conflict for two JSON forms of the same tool', () => { + write('.oxlintrc.json'); + write('.oxlintrc.jsonc'); + + expect(detectOxcConfigConflicts(tmpDir)).toEqual([]); + }); + + it.each([ + ['oxlint', '.oxlintrc.json', 'oxlint.config.ts'], + ['oxlint', '.oxlintrc.jsonc', 'oxlint.config.mts'], + ['oxfmt', '.oxfmtrc.json', 'oxfmt.config.ts'], + ['oxfmt', '.oxfmtrc.jsonc', 'oxfmt.config.mts'], + ] as const)('flags %s when %s and %s coexist', (tool, jsonConfig, dynamicConfig) => { + write(jsonConfig); + write(dynamicConfig); + + expect(detectOxcConfigConflicts(tmpDir)).toEqual([ + { tool, dir: '.', jsonConfigs: [jsonConfig], dynamicConfigs: [dynamicConfig] }, + ]); + }); + + it('flags oxlint and oxfmt independently in the same directory', () => { + write('.oxlintrc.json'); + write('oxlint.config.ts'); + write('.oxfmtrc.json'); + write('oxfmt.config.ts'); + + expect(detectOxcConfigConflicts(tmpDir).map((conflict) => conflict.tool)).toEqual([ + 'oxlint', + 'oxfmt', + ]); + }); + + it('lists every present form on both sides of a conflict', () => { + write('.oxlintrc.json'); + write('.oxlintrc.jsonc'); + write('oxlint.config.ts'); + write('oxlint.config.mts'); + + expect(detectOxcConfigConflicts(tmpDir)).toEqual([ + { + tool: 'oxlint', + dir: '.', + jsonConfigs: ['.oxlintrc.json', '.oxlintrc.jsonc'], + dynamicConfigs: ['oxlint.config.ts', 'oxlint.config.mts'], + }, + ]); + }); + + it('carries the workspace-relative directory through for workspace packages', () => { + write('.oxlintrc.json'); + write('oxlint.config.ts'); + + expect(detectOxcConfigConflicts(tmpDir, 'packages/app')).toEqual([ + { + tool: 'oxlint', + dir: 'packages/app', + jsonConfigs: ['.oxlintrc.json'], + dynamicConfigs: ['oxlint.config.ts'], + }, + ]); + }); +}); + +describe('collectOxcConfigConflicts', () => { + let tmpDir: string; + + const writeAt = (dir: string, filename: string) => { + fs.mkdirSync(path.join(tmpDir, dir), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, dir, filename), + filename.endsWith('.ts') || filename.endsWith('.mts') ? 'export default {};\n' : '{}\n', + ); + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-oxc-workspace-')); + }); + + afterEach(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns nothing for a clean workspace', () => { + writeAt('.', '.oxlintrc.json'); + writeAt('packages/app', 'oxlint.config.ts'); + + expect(collectOxcConfigConflicts(tmpDir, ['packages/app'])).toEqual([]); + }); + + it('finds a conflict in a workspace package, not only at the root', () => { + writeAt('packages/app', '.oxlintrc.json'); + writeAt('packages/app', 'oxlint.config.ts'); + + expect(collectOxcConfigConflicts(tmpDir, ['packages/app'])).toEqual([ + { + tool: 'oxlint', + dir: 'packages/app', + jsonConfigs: ['.oxlintrc.json'], + dynamicConfigs: ['oxlint.config.ts'], + }, + ]); + }); + + it('reports the root before the packages, each package in order', () => { + for (const dir of ['.', 'packages/a', 'packages/b']) { + writeAt(dir, '.oxlintrc.json'); + writeAt(dir, 'oxlint.config.ts'); + } + + expect(collectOxcConfigConflicts(tmpDir, ['packages/a', 'packages/b'])).toMatchObject([ + { dir: '.' }, + { dir: 'packages/a' }, + { dir: 'packages/b' }, + ]); + }); + + it('ignores a package directory that does not exist on disk', () => { + expect(collectOxcConfigConflicts(tmpDir, ['packages/missing'])).toEqual([]); + }); + + it('checks only the root when no packages are passed', () => { + writeAt('packages/app', '.oxlintrc.json'); + writeAt('packages/app', 'oxlint.config.ts'); + + expect(collectOxcConfigConflicts(tmpDir)).toEqual([]); + }); +}); + +describe('formatOxcConfigConflict', () => { + it('names the project root for a root-level conflict', () => { + expect( + formatOxcConfigConflict({ + tool: 'oxlint', + dir: '.', + jsonConfigs: ['.oxlintrc.json'], + dynamicConfigs: ['oxlint.config.ts'], + }), + ).toBe( + 'the project root has `.oxlintrc.json` and `oxlint.config.ts` — oxlint allows only one config per directory.', + ); + }); + + it('names the package directory for a workspace conflict', () => { + expect( + formatOxcConfigConflict({ + tool: 'oxfmt', + dir: 'packages/app', + jsonConfigs: ['.oxfmtrc.json'], + dynamicConfigs: ['oxfmt.config.mts'], + }), + ).toBe( + 'packages/app has `.oxfmtrc.json` and `oxfmt.config.mts` — oxfmt allows only one config per directory.', + ); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 75a1b5148c..d4f5973588 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -35,6 +35,7 @@ import { import type { PackageDependencies } from '../utils/types.ts'; import { detectWorkspace } from '../utils/workspace.ts'; import { checkRolldownCompatibility } from './compat/runner.ts'; +import { collectOxcConfigConflicts, formatOxcConfigConflict } from './detector.ts'; import { canFormatWithOxfmt, collectChangedFormatPaths, formatMigratedProject } from './format.ts'; import { addFrameworkShim, @@ -1032,6 +1033,37 @@ async function executeMigrationPlan( }; } +/** + * Refuse to migrate a workspace where any directory holds both a JSON and a + * dynamic config for the same Oxc tool. + * + * Oxlint hard-errors on that combination itself, so the project cannot lint + * before migration either. Migration has no non-destructive way through it: + * first-match detection would inline and delete the JSON config while leaving + * the dynamic one on disk unreferenced, silently shadowing the freshly inlined + * block for direct `oxlint` invocations. Interrupting lets the user pick the + * config they mean to keep before anything is rewritten. + * + * Runs before any file is touched, on both the full-migration and the + * already-Vite+ paths. + */ +function assertNoOxcConfigConflicts(workspaceInfo: WorkspaceInfoOptional): void { + const conflicts = collectOxcConfigConflicts( + workspaceInfo.rootDir, + workspaceInfo.packages.map((pkg) => pkg.path), + ); + + if (conflicts.length === 0) { + return; + } + + const details = conflicts + .map((conflict) => ` - ${formatOxcConfigConflict(conflict)}`) + .join('\n'); + prompts.log.error(`✘ Conflicting Oxc configs:\n${details}`); + cancelAndExit('Keep a single config per directory, then run `vp migrate` again.', 1); +} + async function main() { const { projectPath, options } = parseArgs(); @@ -1053,6 +1085,8 @@ async function main() { 1, ); } + assertNoOxcConfigConflicts(workspaceInfoOptional); + const initialChangedPaths = await collectChangedFormatPaths(workspaceInfoOptional.rootDir); const preExistingChangedPaths = initialChangedPaths ? new Set(initialChangedPaths) : undefined; const resolvedPackageManager = workspaceInfoOptional.packageManager ?? 'unknown'; diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index 4ef5ef73de..34370f8ae5 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -43,6 +43,115 @@ export const PRETTIER_CONFIG_FILES = [ 'prettier.config.mts', ] as const; +// Oxlint and Oxfmt each accept a static JSON config or a dynamic TypeScript one. +// The JSON forms are inlined into `vite.config.*` during migration and deleted; +// the dynamic forms are preserved and imported instead. Detection takes the +// first match in each list, so the two forms are ordered JSON-first only to keep +// the historical precedence — `detectOxcConfigConflicts` rejects the ambiguous +// state before that precedence can matter. +// https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format +export const OXLINT_JSON_CONFIG_FILES = ['.oxlintrc.json', '.oxlintrc.jsonc'] as const; +export const OXLINT_DYNAMIC_CONFIG_FILES = ['oxlint.config.ts', 'oxlint.config.mts'] as const; +export const OXLINT_CONFIG_FILES = [ + ...OXLINT_JSON_CONFIG_FILES, + ...OXLINT_DYNAMIC_CONFIG_FILES, +] as const; + +// https://oxc.rs/docs/guide/usage/formatter.html#configuration-file +export const OXFMT_JSON_CONFIG_FILES = ['.oxfmtrc.json', '.oxfmtrc.jsonc'] as const; +export const OXFMT_DYNAMIC_CONFIG_FILES = ['oxfmt.config.ts', 'oxfmt.config.mts'] as const; +export const OXFMT_CONFIG_FILES = [ + ...OXFMT_JSON_CONFIG_FILES, + ...OXFMT_DYNAMIC_CONFIG_FILES, +] as const; + +export interface OxcConfigConflict { + /** `oxlint` or `oxfmt` — the tool whose config is ambiguous. */ + tool: 'oxlint' | 'oxfmt'; + /** Directory holding both forms, relative to the workspace root ('.' for the root). */ + dir: string; + /** Every JSON-form config present in `dir`. */ + jsonConfigs: string[]; + /** Every dynamic-form config present in `dir`. */ + dynamicConfigs: string[]; +} + +/** + * Detect directories that hold both a JSON and a dynamic config for the same Oxc + * tool. + * + * Oxlint itself refuses to run in that state ("Only one of `.oxlintrc.json` and + * `oxlint.config.ts` is allowed per directory"), so such a project is already + * broken before migration sees it. Migration cannot repair it either: first-match + * detection would inline and delete the JSON config and leave the dynamic one on + * disk unreferenced, where it then silently shadows the freshly inlined `lint` + * block for direct `oxlint` invocations. Erroring out and letting the user pick a + * single config first is the only outcome that does not quietly lose settings. + * + * `dir` is `'.'` for the workspace root; other values are workspace-relative + * package paths with forward slashes. + */ +export function detectOxcConfigConflicts( + projectPath: string, + relativeDir = '.', +): OxcConfigConflict[] { + const conflicts: OxcConfigConflict[] = []; + + const tools = [ + { + tool: 'oxlint', + jsonForms: OXLINT_JSON_CONFIG_FILES, + dynamicForms: OXLINT_DYNAMIC_CONFIG_FILES, + }, + { + tool: 'oxfmt', + jsonForms: OXFMT_JSON_CONFIG_FILES, + dynamicForms: OXFMT_DYNAMIC_CONFIG_FILES, + }, + ] as const; + + for (const { tool, jsonForms, dynamicForms } of tools) { + const present = (candidates: readonly string[]) => + candidates.filter((config) => fs.existsSync(path.join(projectPath, config))); + + const jsonConfigs = present(jsonForms); + const dynamicConfigs = present(dynamicForms); + + if (jsonConfigs.length > 0 && dynamicConfigs.length > 0) { + conflicts.push({ tool, dir: relativeDir, jsonConfigs, dynamicConfigs }); + } + } + + return conflicts; +} + +/** + * Collect Oxc config conflicts across a workspace: the root directory plus every + * workspace package. `packageDirs` holds workspace-relative paths with forward + * slashes, matching `WorkspacePackage['path']`; pass an empty array for a + * single-package project. + */ +export function collectOxcConfigConflicts( + rootDir: string, + packageDirs: readonly string[] = [], +): OxcConfigConflict[] { + return [ + ...detectOxcConfigConflicts(rootDir), + ...packageDirs.flatMap((packageDir) => + detectOxcConfigConflicts(path.join(rootDir, packageDir), packageDir), + ), + ]; +} + +/** Render one conflict as a user-facing line for the migration abort message. */ +export function formatOxcConfigConflict(conflict: OxcConfigConflict): string { + const location = conflict.dir === '.' ? 'the project root' : conflict.dir; + const files = [...conflict.jsonConfigs, ...conflict.dynamicConfigs] + .map((file) => `\`${file}\``) + .join(' and '); + return `${location} has ${files} — ${conflict.tool} allows only one config per directory.`; +} + export function detectConfigs(projectPath: string): ConfigFiles { const configs: ConfigFiles = {}; @@ -92,13 +201,7 @@ export function detectConfigs(projectPath: string): ConfigFiles { // Check for oxlint configs // https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format - const oxlintConfigs = [ - '.oxlintrc.json', - '.oxlintrc.jsonc', - 'oxlint.config.ts', - 'oxlint.config.mts', - ]; - for (const config of oxlintConfigs) { + for (const config of OXLINT_CONFIG_FILES) { if (fs.existsSync(path.join(projectPath, config))) { configs.oxlintConfig = config; break; @@ -107,8 +210,7 @@ export function detectConfigs(projectPath: string): ConfigFiles { // Check for oxfmt configs // https://oxc.rs/docs/guide/usage/formatter.html#configuration-file - const oxfmtConfigs = ['.oxfmtrc.json', '.oxfmtrc.jsonc', 'oxfmt.config.ts', 'oxfmt.config.mts']; - for (const config of oxfmtConfigs) { + for (const config of OXFMT_CONFIG_FILES) { if (fs.existsSync(path.join(projectPath, config))) { configs.oxfmtConfig = config; break; From 13a11a9f0ddafc34195e7738fd04dfc788a75d9c Mon Sep 17 00:00:00 2001 From: m0g3r <87276771+m0g3r@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:24:04 +0000 Subject: [PATCH 4/5] test(migrate): cover the Oxc config conflict interrupt with a PTY snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assert the fail-fast path end to end: `vp migrate` exits 1 naming the offending directory, and — the part that matters more than the message — nothing has been written when it does. The steps check both configs are still on disk, no `vite.config.ts` was created, and `package.json` is byte-identical, so a regression that moved the check after the first write would fail here even if the error text stayed the same. Recorded on macOS arm64 by simulacre7, who also confirmed the existing `migration_dynamic_oxc_configs` case still passes unchanged on this branch. Co-authored-by: simulacre7 <16968090+simulacre7@users.noreply.github.com> --- .../.oxlintrc.json | 5 ++ .../oxlint.config.ts | 7 +++ .../package.json | 10 ++++ .../snapshots.toml | 10 ++++ .../migration_oxc_config_conflict.md | 54 +++++++++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/.oxlintrc.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/oxlint.config.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots/migration_oxc_config_conflict.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/.oxlintrc.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/.oxlintrc.json new file mode 100644 index 0000000000..2ff50f91ec --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-console": "error" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/oxlint.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/oxlint.config.ts new file mode 100644 index 0000000000..37d49eafef --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/oxlint.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + rules: { + eqeqeq: 'error', + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/package.json new file mode 100644 index 0000000000..59183cc72a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/package.json @@ -0,0 +1,10 @@ +{ + "name": "migration-oxc-config-conflict", + "scripts": { + "lint": "oxlint" + }, + "devDependencies": { + "oxlint": "^1.0.0", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots.toml new file mode 100644 index 0000000000..4417bb0c42 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots.toml @@ -0,0 +1,10 @@ +[[case]] +name = "migration_oxc_config_conflict" +vp = "global" +steps = [ + { argv = ["vp", "migrate", "--no-interactive"], comment = "migration should refuse to start on conflicting Oxc configs", continue-on-failure = true }, + { argv = ["vpt", "stat-file", ".oxlintrc.json", "--assert", "file"], comment = "both configs left untouched by the interrupt", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "oxlint.config.ts", "--assert", "file"], continue-on-failure = true }, + { argv = ["vpt", "stat-file", "vite.config.ts", "--assert", "missing"], comment = "no file was written before the interrupt", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "package.json unchanged", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots/migration_oxc_config_conflict.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots/migration_oxc_config_conflict.md new file mode 100644 index 0000000000..d3560f40a0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxc_config_conflict/snapshots/migration_oxc_config_conflict.md @@ -0,0 +1,54 @@ +# migration_oxc_config_conflict + +## `vp migrate --no-interactive` + +migration should refuse to start on conflicting Oxc configs + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +✘ Conflicting Oxc configs: + - the project root has `.oxlintrc.json` and `oxlint.config.ts` — oxlint allows only one config per directory. +Keep a single config per directory, then run `vp migrate` again. +``` + +## `vpt stat-file .oxlintrc.json --assert file` + +both configs left untouched by the interrupt + +``` +.oxlintrc.json: file +``` + +## `vpt stat-file oxlint.config.ts --assert file` + +``` +oxlint.config.ts: file +``` + +## `vpt stat-file vite.config.ts --assert missing` + +no file was written before the interrupt + +``` +vite.config.ts: missing +``` + +## `vpt print-file package.json` + +package.json unchanged + +``` +{ + "name": "migration-oxc-config-conflict", + "scripts": { + "lint": "oxlint" + }, + "devDependencies": { + "oxlint": "^1.0.0", + "vite": "^7.0.0" + } +} +``` From 31163c5a3c231efbfe2ef150201f56dbc3892a14 Mon Sep 17 00:00:00 2001 From: m0g3r <87276771+m0g3r@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:28:20 +0000 Subject: [PATCH 5/5] fix(migrate): treat any two Oxc configs in a directory as a conflict The conflict guard fired only when a directory held a JSON *and* a dynamic config for the same tool. Running the pinned binaries shows the rule both tools enforce is one config per directory, not one config form: oxlint 1.78.0 and oxfmt 0.63.0 each fail with "Both '' and '' found in " for `.oxlintrc.json` + `.oxlintrc.jsonc` and for `oxlint.config.ts` + `oxlint.config.mts`, exactly as they do for the mixed pair. Both of those shapes previously migrated through, consuming one config and leaving the other on disk unreferenced. Detection now flags any tool with more than one config present, and the conflict carries a single `configs` list in the tool's candidate order instead of the JSON/dynamic split. The rendered line is unchanged for the two-config case and gains comma separation beyond it. --- .../src/migration/__tests__/detector.spec.ts | 60 +++++++++--------- packages/cli/src/migration/detector.ts | 62 ++++++++----------- 2 files changed, 57 insertions(+), 65 deletions(-) diff --git a/packages/cli/src/migration/__tests__/detector.spec.ts b/packages/cli/src/migration/__tests__/detector.spec.ts index 5055e9e877..5b9114e91b 100644 --- a/packages/cli/src/migration/__tests__/detector.spec.ts +++ b/packages/cli/src/migration/__tests__/detector.spec.ts @@ -84,28 +84,25 @@ describe('detectOxcConfigConflicts', () => { expect(detectOxcConfigConflicts(tmpDir)).toEqual([]); }); - // Two JSON forms are deliberately NOT a conflict here: the ambiguity this - // guard exists for is JSON-vs-dynamic, which is the combination oxlint itself - // rejects and the one that leaves an unreferenced config shadowing the - // inlined block after migration. - it('reports no conflict for two JSON forms of the same tool', () => { - write('.oxlintrc.json'); - write('.oxlintrc.jsonc'); - - expect(detectOxcConfigConflicts(tmpDir)).toEqual([]); - }); - + // The rule both tools enforce is one config per directory, not one config + // *form*: `.oxlintrc.json` + `.oxlintrc.jsonc` and `oxlint.config.ts` + + // `oxlint.config.mts` fail the same way the mixed pair does, so every + // two-config shape below is a conflict. it.each([ ['oxlint', '.oxlintrc.json', 'oxlint.config.ts'], ['oxlint', '.oxlintrc.jsonc', 'oxlint.config.mts'], + ['oxlint', '.oxlintrc.json', '.oxlintrc.jsonc'], + ['oxlint', 'oxlint.config.ts', 'oxlint.config.mts'], ['oxfmt', '.oxfmtrc.json', 'oxfmt.config.ts'], ['oxfmt', '.oxfmtrc.jsonc', 'oxfmt.config.mts'], - ] as const)('flags %s when %s and %s coexist', (tool, jsonConfig, dynamicConfig) => { - write(jsonConfig); - write(dynamicConfig); + ['oxfmt', '.oxfmtrc.json', '.oxfmtrc.jsonc'], + ['oxfmt', 'oxfmt.config.ts', 'oxfmt.config.mts'], + ] as const)('flags %s when %s and %s coexist', (tool, firstConfig, secondConfig) => { + write(firstConfig); + write(secondConfig); expect(detectOxcConfigConflicts(tmpDir)).toEqual([ - { tool, dir: '.', jsonConfigs: [jsonConfig], dynamicConfigs: [dynamicConfig] }, + { tool, dir: '.', configs: [firstConfig, secondConfig] }, ]); }); @@ -121,18 +118,17 @@ describe('detectOxcConfigConflicts', () => { ]); }); - it('lists every present form on both sides of a conflict', () => { - write('.oxlintrc.json'); + it('lists every config present, in the tool candidate order', () => { + write('oxlint.config.mts'); write('.oxlintrc.jsonc'); write('oxlint.config.ts'); - write('oxlint.config.mts'); + write('.oxlintrc.json'); expect(detectOxcConfigConflicts(tmpDir)).toEqual([ { tool: 'oxlint', dir: '.', - jsonConfigs: ['.oxlintrc.json', '.oxlintrc.jsonc'], - dynamicConfigs: ['oxlint.config.ts', 'oxlint.config.mts'], + configs: ['.oxlintrc.json', '.oxlintrc.jsonc', 'oxlint.config.ts', 'oxlint.config.mts'], }, ]); }); @@ -145,8 +141,7 @@ describe('detectOxcConfigConflicts', () => { { tool: 'oxlint', dir: 'packages/app', - jsonConfigs: ['.oxlintrc.json'], - dynamicConfigs: ['oxlint.config.ts'], + configs: ['.oxlintrc.json', 'oxlint.config.ts'], }, ]); }); @@ -188,8 +183,7 @@ describe('collectOxcConfigConflicts', () => { { tool: 'oxlint', dir: 'packages/app', - jsonConfigs: ['.oxlintrc.json'], - dynamicConfigs: ['oxlint.config.ts'], + configs: ['.oxlintrc.json', 'oxlint.config.ts'], }, ]); }); @@ -225,8 +219,7 @@ describe('formatOxcConfigConflict', () => { formatOxcConfigConflict({ tool: 'oxlint', dir: '.', - jsonConfigs: ['.oxlintrc.json'], - dynamicConfigs: ['oxlint.config.ts'], + configs: ['.oxlintrc.json', 'oxlint.config.ts'], }), ).toBe( 'the project root has `.oxlintrc.json` and `oxlint.config.ts` — oxlint allows only one config per directory.', @@ -238,11 +231,22 @@ describe('formatOxcConfigConflict', () => { formatOxcConfigConflict({ tool: 'oxfmt', dir: 'packages/app', - jsonConfigs: ['.oxfmtrc.json'], - dynamicConfigs: ['oxfmt.config.mts'], + configs: ['.oxfmtrc.json', 'oxfmt.config.mts'], }), ).toBe( 'packages/app has `.oxfmtrc.json` and `oxfmt.config.mts` — oxfmt allows only one config per directory.', ); }); + + it('separates three or more configs with commas', () => { + expect( + formatOxcConfigConflict({ + tool: 'oxlint', + dir: '.', + configs: ['.oxlintrc.json', '.oxlintrc.jsonc', 'oxlint.config.ts'], + }), + ).toBe( + 'the project root has `.oxlintrc.json`, `.oxlintrc.jsonc` and `oxlint.config.ts` — oxlint allows only one config per directory.', + ); + }); }); diff --git a/packages/cli/src/migration/detector.ts b/packages/cli/src/migration/detector.ts index 34370f8ae5..0b58314a10 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -47,8 +47,8 @@ export const PRETTIER_CONFIG_FILES = [ // The JSON forms are inlined into `vite.config.*` during migration and deleted; // the dynamic forms are preserved and imported instead. Detection takes the // first match in each list, so the two forms are ordered JSON-first only to keep -// the historical precedence — `detectOxcConfigConflicts` rejects the ambiguous -// state before that precedence can matter. +// the historical precedence — `detectOxcConfigConflicts` rejects any directory +// holding more than one of these before that precedence can matter. // https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format export const OXLINT_JSON_CONFIG_FILES = ['.oxlintrc.json', '.oxlintrc.jsonc'] as const; export const OXLINT_DYNAMIC_CONFIG_FILES = ['oxlint.config.ts', 'oxlint.config.mts'] as const; @@ -68,25 +68,25 @@ export const OXFMT_CONFIG_FILES = [ export interface OxcConfigConflict { /** `oxlint` or `oxfmt` — the tool whose config is ambiguous. */ tool: 'oxlint' | 'oxfmt'; - /** Directory holding both forms, relative to the workspace root ('.' for the root). */ + /** Directory holding the competing configs, relative to the workspace root ('.' for the root). */ dir: string; - /** Every JSON-form config present in `dir`. */ - jsonConfigs: string[]; - /** Every dynamic-form config present in `dir`. */ - dynamicConfigs: string[]; + /** Every config for `tool` present in `dir`, in the tool's own candidate order. */ + configs: string[]; } /** - * Detect directories that hold both a JSON and a dynamic config for the same Oxc - * tool. + * Detect directories that hold more than one config for the same Oxc tool. * - * Oxlint itself refuses to run in that state ("Only one of `.oxlintrc.json` and - * `oxlint.config.ts` is allowed per directory"), so such a project is already - * broken before migration sees it. Migration cannot repair it either: first-match - * detection would inline and delete the JSON config and leave the dynamic one on - * disk unreferenced, where it then silently shadows the freshly inlined `lint` - * block for direct `oxlint` invocations. Erroring out and letting the user pick a - * single config first is the only outcome that does not quietly lose settings. + * Both tools refuse to run in that state — `oxlint` and `oxfmt` each fail with + * "Both '' and '' found in " — so such a project is already broken + * before migration sees it. The rule is one config per directory, not one config + * *form*: two JSON forms (`.oxlintrc.json` + `.oxlintrc.jsonc`) and two dynamic + * forms (`oxlint.config.ts` + `oxlint.config.mts`) are rejected exactly like the + * mixed pair. Migration cannot repair any of them either: first-match detection + * would consume one config and leave the rest on disk unreferenced, where they + * then silently shadow the freshly inlined `lint` block for direct `oxlint` + * invocations. Erroring out and letting the user pick a single config first is + * the only outcome that does not quietly lose settings. * * `dir` is `'.'` for the workspace root; other values are workspace-relative * package paths with forward slashes. @@ -98,27 +98,15 @@ export function detectOxcConfigConflicts( const conflicts: OxcConfigConflict[] = []; const tools = [ - { - tool: 'oxlint', - jsonForms: OXLINT_JSON_CONFIG_FILES, - dynamicForms: OXLINT_DYNAMIC_CONFIG_FILES, - }, - { - tool: 'oxfmt', - jsonForms: OXFMT_JSON_CONFIG_FILES, - dynamicForms: OXFMT_DYNAMIC_CONFIG_FILES, - }, + { tool: 'oxlint', configFiles: OXLINT_CONFIG_FILES }, + { tool: 'oxfmt', configFiles: OXFMT_CONFIG_FILES }, ] as const; - for (const { tool, jsonForms, dynamicForms } of tools) { - const present = (candidates: readonly string[]) => - candidates.filter((config) => fs.existsSync(path.join(projectPath, config))); + for (const { tool, configFiles } of tools) { + const configs = configFiles.filter((config) => fs.existsSync(path.join(projectPath, config))); - const jsonConfigs = present(jsonForms); - const dynamicConfigs = present(dynamicForms); - - if (jsonConfigs.length > 0 && dynamicConfigs.length > 0) { - conflicts.push({ tool, dir: relativeDir, jsonConfigs, dynamicConfigs }); + if (configs.length > 1) { + conflicts.push({ tool, dir: relativeDir, configs }); } } @@ -146,9 +134,9 @@ export function collectOxcConfigConflicts( /** Render one conflict as a user-facing line for the migration abort message. */ export function formatOxcConfigConflict(conflict: OxcConfigConflict): string { const location = conflict.dir === '.' ? 'the project root' : conflict.dir; - const files = [...conflict.jsonConfigs, ...conflict.dynamicConfigs] - .map((file) => `\`${file}\``) - .join(' and '); + const quoted = conflict.configs.map((file) => `\`${file}\``); + // `a and b` for the common pair, `a, b and c` once a directory holds more. + const files = [quoted.slice(0, -1).join(', '), quoted.at(-1)].filter(Boolean).join(' and '); return `${location} has ${files} — ${conflict.tool} allows only one config per directory.`; }