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_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" + } +} +``` diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 62f377d750..b602479763 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -1567,6 +1567,166 @@ 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_OXLINT_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 +"#; + +/// 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: + 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 +1773,14 @@ static PARSED_TSDOWN_RULES: LazyLock>> = LazyLock::n ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown 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. // These cannot be handled by ast-grep because triple-slash references are parsed as comments. @@ -1954,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)] @@ -1970,13 +2142,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 { @@ -2094,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() @@ -2199,10 +2368,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 +2411,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 +2465,12 @@ fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bo if !skip_packages.skip_tsdown && content.contains("tsdown") { return true; } + if !skip_packages.skip_oxlint && content.contains("oxlint") { + return true; + } + if !skip_packages.skip_oxfmt && content.contains("oxfmt") { + return true; + } false } @@ -2380,6 +2552,26 @@ fn rewrite_import_content_full( } } + // Oxc's runtime helpers must resolve through Vite+ after migration removes + // 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 (/// ) // 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 +3983,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 // ======================== @@ -3803,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); @@ -3826,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); @@ -3850,7 +4069,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, + ..Default::default() + }; let result = rewrite_import_content(content, &skip_packages).unwrap(); assert!(!result.updated); @@ -3858,15 +4082,93 @@ export default defineConfig({});"#; } #[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()); + 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_some = SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: true }; - assert!(!skip_some.all_skipped()); + let skip_packages = SkipPackages { skip_oxlint: true, ..Default::default() }; - let skip_none = SkipPackages::default(); - assert!(!skip_none.all_skipped()); + 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] @@ -3912,7 +4214,6 @@ export default defineConfig({});"#; assert!(skip.skip_vite); assert!(skip.skip_vitest); assert!(skip.skip_tsdown); - assert!(skip.all_skipped()); } #[test] @@ -4695,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. @@ -5162,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!( @@ -5179,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!( @@ -5196,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!( @@ -5213,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); 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..5b9114e91b --- /dev/null +++ b/packages/cli/src/migration/__tests__/detector.spec.ts @@ -0,0 +1,252 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + collectOxcConfigConflicts, + detectConfigs, + detectOxcConfigConflicts, + formatOxcConfigConflict, +} 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); + }); + + // 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'); + 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', + }); + }); +}); + +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([]); + }); + + // 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'], + ['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: '.', configs: [firstConfig, secondConfig] }, + ]); + }); + + 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 config present, in the tool candidate order', () => { + write('oxlint.config.mts'); + write('.oxlintrc.jsonc'); + write('oxlint.config.ts'); + write('.oxlintrc.json'); + + expect(detectOxcConfigConflicts(tmpDir)).toEqual([ + { + tool: 'oxlint', + dir: '.', + configs: ['.oxlintrc.json', '.oxlintrc.jsonc', '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', + configs: ['.oxlintrc.json', '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', + configs: ['.oxlintrc.json', '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: '.', + configs: ['.oxlintrc.json', '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', + 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/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 34c9bd43b5..0730eb280d 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, @@ -1288,6 +1289,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/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 fd4065cd86..0b58314a10 100644 --- a/packages/cli/src/migration/detector.ts +++ b/packages/cli/src/migration/detector.ts @@ -43,6 +43,103 @@ 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 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; +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 the competing configs, relative to the workspace root ('.' for the root). */ + dir: string; + /** Every config for `tool` present in `dir`, in the tool's own candidate order. */ + configs: string[]; +} + +/** + * Detect directories that hold more than one config for the same Oxc tool. + * + * 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. + */ +export function detectOxcConfigConflicts( + projectPath: string, + relativeDir = '.', +): OxcConfigConflict[] { + const conflicts: OxcConfigConflict[] = []; + + const tools = [ + { tool: 'oxlint', configFiles: OXLINT_CONFIG_FILES }, + { tool: 'oxfmt', configFiles: OXFMT_CONFIG_FILES }, + ] as const; + + for (const { tool, configFiles } of tools) { + const configs = configFiles.filter((config) => fs.existsSync(path.join(projectPath, config))); + + if (configs.length > 1) { + conflicts.push({ tool, dir: relativeDir, configs }); + } + } + + 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 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.`; +} + export function detectConfigs(projectPath: string): ConfigFiles { const configs: ConfigFiles = {}; @@ -92,8 +189,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']; - for (const config of oxlintConfigs) { + for (const config of OXLINT_CONFIG_FILES) { if (fs.existsSync(path.join(projectPath, config))) { configs.oxlintConfig = config; break; @@ -102,8 +198,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']; - for (const config of oxfmtConfigs) { + for (const config of OXFMT_CONFIG_FILES) { if (fs.existsSync(path.join(projectPath, config))) { configs.oxfmtConfig = config; break; 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(