diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/app.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/app.test.ts new file mode 100644 index 0000000000..057a5af85f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/app.test.ts @@ -0,0 +1,2 @@ +import { test } from 'vitest'; +test('application', () => {}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/examples/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/examples/vite.config.ts new file mode 100644 index 0000000000..0b79ce8fee --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/examples/vite.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite'; +export default defineConfig({}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/package.json new file mode 100644 index 0000000000..16cf6cd188 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/package.json @@ -0,0 +1 @@ +{ "name": "lint-import-overrides", "private": true, "type": "module", "devDependencies": { "vite-plus": "latest" } } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/snapshots.toml new file mode 100644 index 0000000000..57c465b518 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/snapshots.toml @@ -0,0 +1,10 @@ +[[case]] +name = "lint_vite_plus_imports_overrides" +vp = "local" +comment = "Existing lint.overrides can disable only the import rule in templates and examples, with no migrate configuration. Other lint rules remain active." +steps = [ + { argv = ["vp", "lint", "--threads=1", "--fix", "app.test.ts", "templates/starter.test.ts", "examples/vite.config.ts"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "app.test.ts"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "templates/starter.test.ts"] }, + { argv = ["vpt", "print-file", "examples/vite.config.ts"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/snapshots/lint_vite_plus_imports_overrides.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/snapshots/lint_vite_plus_imports_overrides.md new file mode 100644 index 0000000000..fb05943e8c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/snapshots/lint_vite_plus_imports_overrides.md @@ -0,0 +1,44 @@ +# lint_vite_plus_imports_overrides + +Existing lint.overrides can disable only the import rule in templates and examples, with no migrate configuration. Other lint rules remain active. + +## `vp lint --threads=1 --fix app.test.ts templates/starter.test.ts examples/vite.config.ts` + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[templates/starter.test.ts:2:1] + 1 │ import { test } from 'vitest'; + 2 │ debugger; + · ───────── + 3 │ test('template', () => {}); + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 3 files with rules using threads. +``` + +## `vpt print-file app.test.ts` + +``` +import { test } from 'vite-plus/test'; +test('application', () => {}); +``` + +## `vpt print-file templates/starter.test.ts` + +``` +import { test } from 'vitest'; +debugger; +test('template', () => {}); +``` + +## `vpt print-file examples/vite.config.ts` + +``` +import { defineConfig } from 'vite'; +export default defineConfig({}); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/templates/starter.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/templates/starter.test.ts new file mode 100644 index 0000000000..a95b040e4a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/templates/starter.test.ts @@ -0,0 +1,3 @@ +import { test } from 'vitest'; +debugger; +test('template', () => {}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/vite.config.ts new file mode 100644 index 0000000000..40a3af6960 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/lint_vite_plus_imports_overrides/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite-plus'; +export default defineConfig({ + lint: { + jsPlugins: [{ name: 'vite-plus', specifier: 'vite-plus/oxlint-plugin' }], + rules: { 'vite-plus/prefer-vite-plus-imports': 'error', 'no-debugger': 'error' }, + overrides: [ + { + files: ['templates/**', 'examples/**'], + rules: { 'vite-plus/prefer-vite-plus-imports': 'off' }, + }, + ], + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/examples/standalone/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/examples/standalone/package.json new file mode 100644 index 0000000000..14a01f696b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/examples/standalone/package.json @@ -0,0 +1 @@ +{ "name": "standalone-template", "private": true, "type": "module", "devDependencies": { "vite": "^8.0.0", "vitest": "^4.1.11" } } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/examples/standalone/unit.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/examples/standalone/unit.test.ts new file mode 100644 index 0000000000..fee5fb1242 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/examples/standalone/unit.test.ts @@ -0,0 +1,2 @@ +import { expect, test } from 'vitest'; +test('outside-workspace', () => expect(1).toBe(1)); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/package.json new file mode 100644 index 0000000000..74a3099faf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/package.json @@ -0,0 +1,7 @@ +{ + "name": "migration-ignore-patterns", + "private": true, + "type": "module", + "devDependencies": { "vite-plus": "catalog:" }, + "devEngines": { "packageManager": { "name": "pnpm", "version": "10.33.0", "onFail": "download" } } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/package.json new file mode 100644 index 0000000000..6c0d9255f6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/package.json @@ -0,0 +1,5 @@ +{ + "name": "app", "private": true, "type": "module", + "scripts": { "build": "vite build", "test": "vitest run" }, + "devDependencies": { "vite": "catalog:", "vitest": "catalog:legacy", "@vitest/coverage-v8": "catalog:legacy" } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/unit.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/unit.test.ts new file mode 100644 index 0000000000..43ccd6e1ae --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/unit.test.ts @@ -0,0 +1,2 @@ +import { expect, test } from 'vitest'; +test('application', () => expect(1).toBe(1)); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/vite.config.ts new file mode 100644 index 0000000000..0b79ce8fee --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/packages/app/vite.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite'; +export default defineConfig({}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/pnpm-workspace.yaml new file mode 100644 index 0000000000..e7f5c119eb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - packages/* + - templates/* +catalog: + vite-plus: latest + vite: ^8.0.0 +catalogs: + legacy: + vitest: ^4.1.11 + '@vitest/coverage-v8': ^4.1.11 +overrides: + unrelated-package: 1.0.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots.toml new file mode 100644 index 0000000000..e5aaeafc16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots.toml @@ -0,0 +1,126 @@ +[[case]] +name = "migration_ignore_patterns_upgrade" +vp = "local" +comment = "Preserve templates and their shared catalogs and overrides, re-include one template, and leave non-workspace examples alone." +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"] }, + { argv = ["vpt", "print-file", "templates/starter/package.json"] }, + { argv = ["vpt", "print-file", "templates/starter/vite.config.ts"] }, + { argv = ["vpt", "print-file", "templates/starter/unit.test.ts"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "templates/starter/tsconfig.json"] }, + { argv = ["vpt", "print-file", "examples/standalone/unit.test.ts"] }, + { argv = ["vpt", "print-file", "packages/app/package.json"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/unit.test.ts"] }, + { argv = ["vpt", "print-file", "templates/managed/unit.test.ts"] }, + { argv = ["vpt", "print-file", "pnpm-workspace.yaml"] }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "Rerunning uses the same policy and explicit managed dependency specs." }, + { argv = ["vpt", "write-file", "packages/app/unit.test.ts", "import { test } from 'vitest';\ntest('autofix', () => {});\n"] }, + { argv = ["vp", "lint", "--threads=1", "--fix", "templates/starter/unit.test.ts", "packages/app/unit.test.ts"], continue-on-failure = true, comment = "Ignored imports remain upstream; other rules still report the template's debugger statement." }, + { argv = ["vpt", "print-file", "templates/starter/unit.test.ts"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/unit.test.ts"] }, +] + +[[case]] +name = "migration_ignore_patterns_fresh" +vp = "local" +comment = "Initial migration honors the same exclusions." +steps = [ + { argv = ["vpt", "json-edit", "package.json", "devDependencies", "{}"] }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"] }, + { argv = ["vpt", "print-file", "templates/starter/package.json"] }, + { argv = ["vpt", "print-file", "templates/starter/vite.config.ts"] }, + { argv = ["vpt", "print-file", "templates/starter/unit.test.ts"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "templates/starter/tsconfig.json"] }, + { argv = ["vpt", "print-file", "examples/standalone/unit.test.ts"] }, + { argv = ["vpt", "print-file", "packages/app/package.json"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/unit.test.ts"] }, + { argv = ["vpt", "print-file", "templates/managed/unit.test.ts"] }, + { argv = ["vpt", "print-file", "pnpm-workspace.yaml"] }, +] + +[[case]] +name = "migration_ignore_patterns_override_conflict" +vp = "local" +comment = "A conflicting global override is reported before project files change." +steps = [ + { argv = ["vpt", "write-file", "pnpm-workspace.yaml", "packages: ['packages/*', 'templates/*']\noverrides:\n vite: ^8.0.0\n"] }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/package.json"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/unit.test.ts"] }, + { argv = ["vpt", "print-file", "pnpm-workspace.yaml"] }, +] + + +[[case]] +name = "migration_ignore_patterns_full" +vp = "local" +comment = "Full setup honors exclusions, and vp check --fix keeps the import policy." +steps = [ + { argv = ["vp", "migrate", "--full", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"] }, + { argv = ["vpt", "print-file", "templates/starter/package.json"] }, + { argv = ["vpt", "print-file", "templates/starter/vite.config.ts"] }, + { argv = ["vpt", "print-file", "templates/starter/unit.test.ts"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "templates/starter/tsconfig.json"] }, + { argv = ["vpt", "print-file", "examples/standalone/unit.test.ts"] }, + { argv = ["vpt", "print-file", "packages/app/package.json"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/unit.test.ts"] }, + { argv = ["vpt", "print-file", "templates/managed/unit.test.ts"] }, + { argv = ["vpt", "print-file", "pnpm-workspace.yaml"] }, + { argv = ["vp", "migrate", "--full", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "Rerunning uses the same policy and explicit managed dependency specs." }, + { argv = ["vpt", "write-file", "packages/app/unit.test.ts", "import { test } from 'vitest';\ntest('autofix', () => {});\n"] }, + { argv = ["vp", "check", "--no-fmt", "--fix", "templates/starter/unit.test.ts", "packages/app/unit.test.ts"], continue-on-failure = true, comment = "Ignored imports remain upstream; other rules still report the template's debugger statement." }, + { argv = ["vpt", "print-file", "templates/starter/unit.test.ts"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/unit.test.ts"] }, +] + +[[case]] +name = "migration_ignore_patterns_parent_negation" +vp = "local" +steps = [ + { argv = ["vpt", "write-file", "vite.config.ts", "export default { migrate: { ignorePatterns: ['templates/', '!templates/managed/unit.test.ts'] } };\n"] }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"] }, + { argv = ["vpt", "print-file", "templates/managed/unit.test.ts"] }, + { argv = ["vpt", "print-file", "packages/app/unit.test.ts"] }, +] + +[[case]] +name = "migration_ignore_patterns_invalid" +vp = "local" +steps = [ + { argv = ["vpt", "write-file", "vite.config.ts", "export default { migrate: { ignorePatterns: 'templates/' } };\n"] }, + { argv = ["vp", "migrate", "--no-interactive"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "packages/app/package.json"], continue-on-failure = true }, +] + +[[case]] +name = "migration_ignore_patterns_runtime_config" +vp = "local" +comment = "A runtime config export retains the policy, and the root config can be a plain object." +steps = [ + { argv = ["vpt", "write-file", "vite.config.ts", "const excluded = ['templates/*', '!templates/managed/'];\nexport default () => ({ migrate: { ignorePatterns: excluded } });\n"] }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"] }, + { argv = ["vpt", "print-file", "templates/starter/unit.test.ts"] }, + { argv = ["vpt", "print-file", "templates/managed/unit.test.ts"] }, +] + +[[case]] +name = "migration_ignore_patterns_explicit_target" +vp = "local" +comment = "An explicit non-workspace template target must not bypass the workspace policy." +steps = [ + { argv = ["vp", "migrate", "examples/standalone", "--full", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "examples/standalone/package.json"], continue-on-failure = true }, + { argv = ["vpt", "print-file", "examples/standalone/unit.test.ts"] }, +] + +[[case]] +name = "migration_ignore_patterns_baseurl_references" +vp = "local" +comment = "The external baseUrl fixer follows references and extends; skip it so excluded configs stay unchanged." +steps = [ + { argv = ["vpt", "write-file", "tsconfig.json", "{ \"compilerOptions\": { \"baseUrl\": \".\" }, \"references\": [{ \"path\": \"./templates/starter\" }] }\n"] }, + { argv = ["vpt", "write-file", "templates/starter/tsconfig.json", "{ \"extends\": \"../../tsconfig.json\", \"compilerOptions\": { \"baseUrl\": \".\", \"paths\": { \"@/*\": [\"src/*\"] } } }\n"] }, + { argv = ["vp", "migrate", "--full", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"] }, + { argv = ["vpt", "print-file", "templates/starter/tsconfig.json"] }, + { argv = ["vpt", "print-file", "tsconfig.json"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_baseurl_references.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_baseurl_references.md new file mode 100644 index 0000000000..30d48e1f1d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_baseurl_references.md @@ -0,0 +1,43 @@ +# migration_ignore_patterns_baseurl_references + +The external baseUrl fixer follows references and extends; skip it so excluded configs stay unchanged. + +## `vpt write-file tsconfig.json '{ "compilerOptions": { "baseUrl": "." }, "references": [{ "path": "./templates/starter" }] } +'` + +``` +``` + +## `vpt write-file templates/starter/tsconfig.json '{ "extends": "../../tsconfig.json", "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } } +'` + +``` +``` + +## `vp migrate --full --no-interactive --no-hooks --no-agent --no-editor` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• 3 files had imports rewritten +• Package manager settings configured +! Warnings: + - Skipped automatic tsconfig baseUrl removal because its external fixer cannot honor migrate.ignorePatterns. Remove baseUrl manually in migrated projects to enable type-aware linting. +``` + +## `vpt print-file templates/starter/tsconfig.json` + +``` +{ "extends": "../../tsconfig.json", "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } } +``` + +## `vpt print-file tsconfig.json` + +``` +{ "compilerOptions": { "baseUrl": "." }, "references": [{ "path": "./templates/starter" }] } +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_explicit_target.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_explicit_target.md new file mode 100644 index 0000000000..902581add3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_explicit_target.md @@ -0,0 +1,27 @@ +# migration_ignore_patterns_explicit_target + +An explicit non-workspace template target must not bypass the workspace policy. + +## `vp migrate examples/standalone --full --no-interactive --no-hooks --no-agent --no-editor` + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +■ Cannot migrate examples/standalone: excluded by migrate.ignorePatterns in /vite.config.ts. No project files were changed. +Error: Cannot migrate examples/standalone: excluded by migrate.ignorePatterns in /vite.config.ts. No project files were changed. +``` + +## `vpt print-file examples/standalone/package.json` + +``` +{ "name": "standalone-template", "private": true, "type": "module", "devDependencies": { "vite": "^8.0.0", "vitest": "^4.1.11" } } +``` + +## `vpt print-file examples/standalone/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +test('outside-workspace', () => expect(1).toBe(1)); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_fresh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_fresh.md new file mode 100644 index 0000000000..fc01414323 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_fresh.md @@ -0,0 +1,114 @@ +# migration_ignore_patterns_fresh + +Initial migration honors the same exclusions. + +## `vpt json-edit package.json devDependencies {}` + +``` +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node pnpm +• 3 files had imports rewritten +``` + +## `vpt print-file templates/starter/package.json` + +``` +{ + "name": "starter", "private": true, "type": "module", + "scripts": { "build": "vite build", "test": "vitest run" }, + "devDependencies": { "vite": "catalog:", "vitest": "catalog:legacy", "@vitest/coverage-v8": "catalog:legacy" } +} +``` + +## `vpt print-file templates/starter/vite.config.ts` + +``` +import { defineConfig } from 'vite'; +export default defineConfig({test: {poolOptions: {threads: {singleThread: true}}}}); +``` + +## `vpt print-file templates/starter/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +debugger; +test('template', () => expect(1).toBe(1)); +``` + +## `vpt print-file templates/starter/tsconfig.json` + +``` +{ "compilerOptions": { "types": ["vite/client", "vitest/globals"], "esModuleInterop": false } } +``` + +## `vpt print-file examples/standalone/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +test('outside-workspace', () => expect(1).toBe(1)); +``` + +## `vpt print-file packages/app/package.json` + +``` +{ + "name": "app", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "test": "vp test run" + }, + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "", + "@vitest/coverage-v8": "", + "vite-plus": "" + } +} +``` + +## `vpt print-file packages/app/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('application', () => expect(1).toBe(1)); +``` + +## `vpt print-file templates/managed/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('included', () => expect(1).toBe(1)); +``` + +## `vpt print-file pnpm-workspace.yaml` + +``` +packages: + - packages/* + - templates/* +catalog: + vite-plus: latest + vite: ^8.0.0 +catalogs: + legacy: + vitest: ^4.1.11 + '@vitest/coverage-v8': ^4.1.11 +overrides: + unrelated-package: 1.0.0 +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_full.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_full.md new file mode 100644 index 0000000000..12c5eddb3d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_full.md @@ -0,0 +1,164 @@ +# migration_ignore_patterns_full + +Full setup honors exclusions, and vp check --fix keeps the import policy. + +## `vp migrate --full --no-interactive --no-hooks --no-agent --no-editor` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• 3 files had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file templates/starter/package.json` + +``` +{ + "name": "starter", "private": true, "type": "module", + "scripts": { "build": "vite build", "test": "vitest run" }, + "devDependencies": { "vite": "catalog:", "vitest": "catalog:legacy", "@vitest/coverage-v8": "catalog:legacy" } +} +``` + +## `vpt print-file templates/starter/vite.config.ts` + +``` +import { defineConfig } from 'vite'; +export default defineConfig({test: {poolOptions: {threads: {singleThread: true}}}}); +``` + +## `vpt print-file templates/starter/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +debugger; +test('template', () => expect(1).toBe(1)); +``` + +## `vpt print-file templates/starter/tsconfig.json` + +``` +{ "compilerOptions": { "types": ["vite/client", "vitest/globals"], "esModuleInterop": false } } +``` + +## `vpt print-file examples/standalone/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +test('outside-workspace', () => expect(1).toBe(1)); +``` + +## `vpt print-file packages/app/package.json` + +``` +{ + "name": "app", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "test": "vp test run" + }, + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "", + "@vitest/coverage-v8": "" + } +} +``` + +## `vpt print-file packages/app/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('application', () => expect(1).toBe(1)); +``` + +## `vpt print-file templates/managed/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('included', () => expect(1).toBe(1)); +``` + +## `vpt print-file pnpm-workspace.yaml` + +``` +packages: + - packages/* + - templates/* +catalog: + vite-plus: latest + vite: ^8.0.0 +catalogs: + legacy: + vitest: ^4.1.11 + '@vitest/coverage-v8': ^4.1.11 +overrides: + unrelated-package: 1.0.0 +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +``` + +## `vp migrate --full --no-interactive --no-hooks --no-agent --no-editor` + +Rerunning uses the same policy and explicit managed dependency specs. + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt write-file packages/app/unit.test.ts 'import { test } from '\''vitest'\''; +test('\''autofix'\'', () => {}); +'` + +``` +``` + +## `vp check --no-fmt --fix templates/starter/unit.test.ts packages/app/unit.test.ts` + +Ignored imports remain upstream; other rules still report the template's debugger statement. + +**Exit code:** 1 + +``` +error: Lint issues found +× eslint(no-debugger): `debugger` statement is not allowed + ╭─[templates/starter/unit.test.ts:2:1] + 1 │ import { expect, test } from 'vitest'; + 2 │ debugger; + · ───────── + 3 │ test('template', () => expect(1).toBe(1)); + ╰──── + help: Remove the debugger statement + +Found 1 error and 0 warnings in 2 files (, threads) +``` + +## `vpt print-file templates/starter/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +debugger; +test('template', () => expect(1).toBe(1)); +``` + +## `vpt print-file packages/app/unit.test.ts` + +``` +import { test } from 'vite-plus/test'; +test('autofix', () => {}); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_invalid.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_invalid.md new file mode 100644 index 0000000000..87c5f49b0a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_invalid.md @@ -0,0 +1,28 @@ +# migration_ignore_patterns_invalid + +## `vpt write-file vite.config.ts 'export default { migrate: { ignorePatterns: '\''templates/'\'' } }; +'` + +``` +``` + +## `vp migrate --no-interactive` + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +■ migrate.ignorePatterns must be an array of strings. +Error: migrate.ignorePatterns must be an array of strings. +``` + +## `vpt print-file packages/app/package.json` + +``` +{ + "name": "app", "private": true, "type": "module", + "scripts": { "build": "vite build", "test": "vitest run" }, + "devDependencies": { "vite": "catalog:", "vitest": "catalog:legacy", "@vitest/coverage-v8": "catalog:legacy" } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_override_conflict.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_override_conflict.md new file mode 100644 index 0000000000..935a3ee49a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_override_conflict.md @@ -0,0 +1,47 @@ +# migration_ignore_patterns_override_conflict + +A conflicting global override is reported before project files change. + +## `vpt write-file pnpm-workspace.yaml 'packages: ['\''packages/*'\'', '\''templates/*'\''] +overrides: + vite: ^8.0.0 +'` + +``` +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +■ Cannot migrate while preserving excluded packages: override "vite" ("^8.0.0") conflicts with "npm:@voidzero-dev/vite-plus-core@". Review the root override or migrate the excluded packages first. No project files were changed. +Error: Cannot migrate while preserving excluded packages: override "vite" ("^8.0.0") conflicts with "npm:@voidzero-dev/vite-plus-core@". Review the root override or migrate the excluded packages first. No project files were changed. +``` + +## `vpt print-file packages/app/package.json` + +``` +{ + "name": "app", "private": true, "type": "module", + "scripts": { "build": "vite build", "test": "vitest run" }, + "devDependencies": { "vite": "catalog:", "vitest": "catalog:legacy", "@vitest/coverage-v8": "catalog:legacy" } +} +``` + +## `vpt print-file packages/app/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +test('application', () => expect(1).toBe(1)); +``` + +## `vpt print-file pnpm-workspace.yaml` + +``` +packages: ['packages/*', 'templates/*'] +overrides: + vite: ^8.0.0 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_parent_negation.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_parent_negation.md new file mode 100644 index 0000000000..c06a67cd3b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_parent_negation.md @@ -0,0 +1,35 @@ +# migration_ignore_patterns_parent_negation + +## `vpt write-file vite.config.ts 'export default { migrate: { ignorePatterns: ['\''templates/'\'', '\'''\!'templates/managed/unit.test.ts'\''] } }; +'` + +``` +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• 3 files had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file templates/managed/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +test('included', () => expect(1).toBe(1)); +``` + +## `vpt print-file packages/app/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('application', () => expect(1).toBe(1)); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_runtime_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_runtime_config.md new file mode 100644 index 0000000000..4cbf7d2eda --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_runtime_config.md @@ -0,0 +1,39 @@ +# migration_ignore_patterns_runtime_config + +A runtime config export retains the policy, and the root config can be a plain object. + +## `vpt write-file vite.config.ts 'const excluded = ['\''templates/*'\'', '\'''\!'templates/managed/'\'']; +export default () => ({ migrate: { ignorePatterns: excluded } }); +'` + +``` +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• 4 files had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file templates/starter/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +debugger; +test('template', () => expect(1).toBe(1)); +``` + +## `vpt print-file templates/managed/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('included', () => expect(1).toBe(1)); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_upgrade.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_upgrade.md new file mode 100644 index 0000000000..3e6a52f834 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/snapshots/migration_ignore_patterns_upgrade.md @@ -0,0 +1,165 @@ +# migration_ignore_patterns_upgrade + +Preserve templates and their shared catalogs and overrides, re-include one template, and leave non-workspace examples alone. + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• 3 files had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file templates/starter/package.json` + +``` +{ + "name": "starter", "private": true, "type": "module", + "scripts": { "build": "vite build", "test": "vitest run" }, + "devDependencies": { "vite": "catalog:", "vitest": "catalog:legacy", "@vitest/coverage-v8": "catalog:legacy" } +} +``` + +## `vpt print-file templates/starter/vite.config.ts` + +``` +import { defineConfig } from 'vite'; +export default defineConfig({test: {poolOptions: {threads: {singleThread: true}}}}); +``` + +## `vpt print-file templates/starter/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +debugger; +test('template', () => expect(1).toBe(1)); +``` + +## `vpt print-file templates/starter/tsconfig.json` + +``` +{ "compilerOptions": { "types": ["vite/client", "vitest/globals"], "esModuleInterop": false } } +``` + +## `vpt print-file examples/standalone/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +test('outside-workspace', () => expect(1).toBe(1)); +``` + +## `vpt print-file packages/app/package.json` + +``` +{ + "name": "app", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "test": "vp test run" + }, + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vitest": "", + "@vitest/coverage-v8": "" + } +} +``` + +## `vpt print-file packages/app/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('application', () => expect(1).toBe(1)); +``` + +## `vpt print-file templates/managed/unit.test.ts` + +``` +import { expect, test } from 'vite-plus/test'; +test('included', () => expect(1).toBe(1)); +``` + +## `vpt print-file pnpm-workspace.yaml` + +``` +packages: + - packages/* + - templates/* +catalog: + vite-plus: latest + vite: ^8.0.0 +catalogs: + legacy: + vitest: ^4.1.11 + '@vitest/coverage-v8': ^4.1.11 +overrides: + unrelated-package: 1.0.0 +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +``` + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +Rerunning uses the same policy and explicit managed dependency specs. + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt write-file packages/app/unit.test.ts 'import { test } from '\''vitest'\''; +test('\''autofix'\'', () => {}); +'` + +``` +``` + +## `vp lint --threads=1 --fix templates/starter/unit.test.ts packages/app/unit.test.ts` + +Ignored imports remain upstream; other rules still report the template's debugger statement. + +**Exit code:** 1 + +``` + + × eslint(no-debugger): `debugger` statement is not allowed + ╭─[templates/starter/unit.test.ts:2:1] + 1 │ import { expect, test } from 'vitest'; + 2 │ debugger; + · ───────── + 3 │ test('template', () => expect(1).toBe(1)); + ╰──── + help: Remove the debugger statement + +Found 0 warnings and 1 error. +Finished in on 2 files with rules using threads. +``` + +## `vpt print-file templates/starter/unit.test.ts` + +``` +import { expect, test } from 'vitest'; +debugger; +test('template', () => expect(1).toBe(1)); +``` + +## `vpt print-file packages/app/unit.test.ts` + +``` +import { test } from 'vite-plus/test'; +test('autofix', () => {}); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/managed/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/managed/package.json new file mode 100644 index 0000000000..fb282db2e7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/managed/package.json @@ -0,0 +1 @@ +{ "name": "managed", "private": true, "type": "module", "devDependencies": { "vite": "^8.0.0", "vitest": "^4.1.11" } } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/managed/unit.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/managed/unit.test.ts new file mode 100644 index 0000000000..64ccb0c91d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/managed/unit.test.ts @@ -0,0 +1,2 @@ +import { expect, test } from 'vitest'; +test('included', () => expect(1).toBe(1)); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/package.json new file mode 100644 index 0000000000..dad97a5f9b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/package.json @@ -0,0 +1,5 @@ +{ + "name": "starter", "private": true, "type": "module", + "scripts": { "build": "vite build", "test": "vitest run" }, + "devDependencies": { "vite": "catalog:", "vitest": "catalog:legacy", "@vitest/coverage-v8": "catalog:legacy" } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/tsconfig.json new file mode 100644 index 0000000000..dc98a30fcd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "types": ["vite/client", "vitest/globals"], "esModuleInterop": false } } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/unit.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/unit.test.ts new file mode 100644 index 0000000000..a8415c55f6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/unit.test.ts @@ -0,0 +1,3 @@ +import { expect, test } from 'vitest'; +debugger; +test('template', () => expect(1).toBe(1)); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/vite.config.ts new file mode 100644 index 0000000000..2df3caf172 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/templates/starter/vite.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite'; +export default defineConfig({test: {poolOptions: {threads: {singleThread: true}}}}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/vite.config.ts new file mode 100644 index 0000000000..e27914cdc7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_ignore_patterns/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; +export default defineConfig({ + migrate: { ignorePatterns: ['/templates/*', '!/templates/managed/', '/examples/'] }, + lint: { + jsPlugins: ['vite-plus/oxlint-plugin'], + rules: { 'vite-plus/prefer-vite-plus-imports': 'error', 'no-debugger': 'error' }, + }, +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/package.json new file mode 100644 index 0000000000..772d7f1641 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/package.json @@ -0,0 +1,14 @@ +{ + "name": "vite-starter", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run" + }, + "devDependencies": { + "vite": "^8.0.0", + "vitest": "^4.1.11" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/src/example.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/src/example.test.ts new file mode 100644 index 0000000000..be9a49686b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/src/example.test.ts @@ -0,0 +1,5 @@ +import { expect, test } from 'vitest'; + +test('starter', () => { + expect(1 + 1).toBe(2); +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/tsconfig.json new file mode 100644 index 0000000000..9b63a9c44f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "types": ["vite/client", "vitest/globals"] + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/vite.config.ts new file mode 100644 index 0000000000..c049f46e10 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/examples/starter/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/package.json new file mode 100644 index 0000000000..0b5804846a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/package.json @@ -0,0 +1,15 @@ +{ + "name": "migration-workspace-vite-template", + "private": true, + "type": "module", + "devDependencies": { + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.33.0", + "onFail": "download" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/packages/app/package.json new file mode 100644 index 0000000000..44e2883301 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/packages/app/package.json @@ -0,0 +1,11 @@ +{ + "name": "app", + "private": true, + "type": "module", + "scripts": { + "build": "vp build" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/packages/app/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/packages/app/vite.config.ts new file mode 100644 index 0000000000..3210accbf9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/packages/app/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/pnpm-workspace.yaml new file mode 100644 index 0000000000..0f1b01f634 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +packages: + - packages/* + - examples/* +catalog: + vite-plus: latest + vite: npm:@voidzero-dev/vite-plus-core@latest +overrides: + vite: 'catalog:' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/snapshots.toml new file mode 100644 index 0000000000..2a81fc7335 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/snapshots.toml @@ -0,0 +1,14 @@ +[[case]] +name = "migration_workspace_vite_template" +vp = "local" +comment = "Reproduce #3406: an existing Vite+ workspace converts an independently reusable Vite/Vitest template. This records the current behavior, not the desired exclusion behavior." +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "ordinary upgrade without --full also migrates the template" }, + { argv = ["vpt", "print-file", "examples/starter/vite.config.ts"], comment = "the template's Vite import is converted to vite-plus" }, + { argv = ["vpt", "print-file", "examples/starter/src/example.test.ts"], comment = "the template's Vitest import is converted to vite-plus/test" }, + { argv = ["vpt", "print-file", "examples/starter/package.json"], comment = "record dependency and script changes that also affect standalone use" }, + { argv = ["vpt", "print-file", "examples/starter/tsconfig.json"], comment = "record related type-reference changes" }, + { argv = ["vpt", "print-file", "pnpm-workspace.yaml"], comment = "the template remains a workspace member and shares toolchain overrides" }, + { argv = ["vpt", "print-file", "packages/app/package.json"], comment = "control: the application continues to use Vite+" }, + { argv = ["vpt", "print-file", "packages/app/vite.config.ts"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/snapshots/migration_workspace_vite_template.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/snapshots/migration_workspace_vite_template.md new file mode 100644 index 0000000000..a90c3b5806 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_vite_template/snapshots/migration_workspace_vite_template.md @@ -0,0 +1,132 @@ +# migration_workspace_vite_template + +Reproduce #3406: an existing Vite+ workspace converts an independently reusable Vite/Vitest template. This records the current behavior, not the desired exclusion behavior. + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +ordinary upgrade without --full also migrates the template + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• 2 files had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file examples/starter/vite.config.ts` + +the template's Vite import is converted to vite-plus + +``` +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + test: { + // Vitest v4 compatibility: preserve mock call history. + // Remove after tests no longer rely on calls from setup or earlier tests. + // https://viteplus.dev/guide/vitest-v5#remove-unneeded-compatibility-settings + // https://vitest.dev/guide/migration/#clearmocks-is-enabled-by-default + clearMocks: false + } +}); +``` + +## `vpt print-file examples/starter/src/example.test.ts` + +the template's Vitest import is converted to vite-plus/test + +``` +import { expect, test } from 'vite-plus/test'; + +test('starter', () => { + expect(1 + 1).toBe(2); +}); +``` + +## `vpt print-file examples/starter/package.json` + +record dependency and script changes that also affect standalone use + +``` +{ + "name": "vite-starter", + "private": true, + "type": "module", + "scripts": { + "dev": "vp dev", + "build": "vp build", + "test": "vp test run" + }, + "devDependencies": { + "vite": "catalog:", + "vitest": "catalog:" + } +} +``` + +## `vpt print-file examples/starter/tsconfig.json` + +record related type-reference changes + +``` +{ + "compilerOptions": { + "types": ["vite/client", "vitest/globals"] + } +} +``` + +## `vpt print-file pnpm-workspace.yaml` + +the template remains a workspace member and shares toolchain overrides + +``` +packages: + - packages/* + - examples/* +catalog: + vite-plus: + vite: npm:@voidzero-dev/vite-plus-core@ + vitest: +overrides: + vite@*: 'catalog:' + vitest@*: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +``` + +## `vpt print-file packages/app/package.json` + +control: the application continues to use Vite+ + +``` +{ + "name": "app", + "private": true, + "type": "module", + "scripts": { + "build": "vp build" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} +``` + +## `vpt print-file packages/app/vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +export default defineConfig({}); +``` diff --git a/crates/vp_migration/src/file_walker.rs b/crates/vp_migration/src/file_walker.rs index 91585964f9..636d3d273d 100644 --- a/crates/vp_migration/src/file_walker.rs +++ b/crates/vp_migration/src/file_walker.rs @@ -3,6 +3,37 @@ use std::path::{Path, PathBuf}; use ignore::{WalkBuilder, gitignore::GitignoreBuilder}; use vp_error::Error; +/// Config-relative gitignore rules shared by migration and its lint autofix. +#[derive(Clone)] +pub struct MigrationIgnoreMatcher { + root: PathBuf, + matcher: ignore::gitignore::Gitignore, +} + +impl MigrationIgnoreMatcher { + pub fn new(root: &Path, patterns: &[String]) -> Result { + let mut builder = GitignoreBuilder::new(root); + for pattern in patterns { + builder.add_line(None, pattern)?; + } + Ok(Self { root: root.to_path_buf(), matcher: builder.build()? }) + } + + pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool { + let Ok(relative) = path.strip_prefix(&self.root) else { return false }; + let mut current = self.root.clone(); + let mut components = relative.components().peekable(); + while let Some(component) = components.next() { + current.push(component); + // Git cannot re-include a child of an excluded directory. + if self.matcher.matched(¤t, components.peek().is_some() || is_dir).is_ignore() { + return true; + } + } + false + } +} + /// File extensions to process for import rewriting. /// /// Includes CommonJS (`.cjs`/`.cts`) variants in addition to ESM, because @@ -82,6 +113,13 @@ pub fn is_directory_gitignored(root: &Path, directory: &Path) -> Result Result { + find_ts_files_with_ignore(root, None) +} + +pub fn find_ts_files_with_ignore( + root: &Path, + ignore: Option, +) -> Result { let mut files = Vec::new(); let walker = WalkBuilder::new(root) @@ -90,6 +128,12 @@ pub fn find_ts_files(root: &Path) -> Result { .git_global(true) // Respect global gitignore .git_exclude(true) // Respect .git/info/exclude .require_git(false) // Work even if not a git repo + .filter_entry(move |entry| { + !ignore.as_ref().is_some_and(|matcher| { + matcher + .is_ignored(entry.path(), entry.file_type().is_some_and(|kind| kind.is_dir())) + }) + }) .build(); for entry in walker { @@ -120,6 +164,30 @@ mod tests { use super::*; + #[test] + fn migration_patterns_follow_gitignore_semantics() { + let temp = tempdir().unwrap(); + for (patterns, file, is_dir, expected) in [ + (vec!["/templates/"], "templates/app/vite.config.ts", false, true), + (vec!["/templates/"], "nested/templates/a.ts", false, false), + (vec!["templates/"], "nested/templates/a.ts", false, true), + (vec!["templates/*", "!templates/app/"], "templates/app/a.ts", false, false), + (vec!["templates/", "!templates/app/a.ts"], "templates/app/a.ts", false, true), + (vec!["**/*.test.ts", "!app.test.ts"], "app.test.ts", false, false), + (vec!["# comment", "\\!literal/"], "!literal/a.ts", false, true), + (vec!["templates/"], "templates", false, false), + (vec!["templates/"], "templates", true, true), + ] { + let patterns = patterns.into_iter().map(String::from).collect::>(); + let matcher = MigrationIgnoreMatcher::new(temp.path(), &patterns).unwrap(); + assert_eq!( + matcher.is_ignored(&temp.path().join(file), is_dir), + expected, + "{patterns:?}: {file}" + ); + } + } + #[test] fn test_inherited_directory_gitignore() { for (root_rules, package_rules, expected) in [ diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index fd272760b5..3fcc443183 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -2062,6 +2062,9 @@ struct PackageRewriteContext { /// Options controlling directory-wide import rewriting. #[derive(Debug, Clone, Default)] pub struct RewriteImportsOptions { + /// Config-relative exclusions, also applied when the scan starts below the config root. + pub ignore_root: Option, + pub ignore_patterns: Vec, /// Preserve `vitest` and `vitest/*` module specifiers throughout packages /// whose nearest package.json declares `@nuxt/test-utils`. pub preserve_vitest_in_nuxt_packages: bool, @@ -2285,7 +2288,11 @@ pub fn rewrite_imports_in_directory_with_options( root: &Path, options: RewriteImportsOptions, ) -> Result { - let walk_result = file_walker::find_ts_files(root)?; + let ignore = file_walker::MigrationIgnoreMatcher::new( + options.ignore_root.as_deref().unwrap_or(root), + &options.ignore_patterns, + )?; + let walk_result = file_walker::find_ts_files_with_ignore(root, Some(ignore))?; // Pre-compute package context for each file (requires mutable cache, done sequentially). let mut package_context_cache: HashMap = HashMap::new(); diff --git a/crates/vp_migration/src/lib.rs b/crates/vp_migration/src/lib.rs index 250147e70f..1e328d54f0 100644 --- a/crates/vp_migration/src/lib.rs +++ b/crates/vp_migration/src/lib.rs @@ -19,7 +19,7 @@ mod source_analysis; mod task_cache; mod vite_config; -pub use file_walker::{WalkResult, find_ts_files, is_directory_gitignored}; +pub use file_walker::{MigrationIgnoreMatcher, WalkResult, find_ts_files, is_directory_gitignored}; pub use import_rewriter::{ BatchRewriteResult, RewriteImportsOptions, rewrite_imports_in_directory, rewrite_imports_in_directory_with_options, diff --git a/crates/vp_static_config/src/lib.rs b/crates/vp_static_config/src/lib.rs index d79c2d1a06..26f488a98c 100644 --- a/crates/vp_static_config/src/lib.rs +++ b/crates/vp_static_config/src/lib.rs @@ -18,7 +18,8 @@ use rustc_hash::FxHashMap; use vt_path::AbsolutePath; /// Packages whose `defineConfig` helpers preserve top-level config fields. -const TRUSTED_DEFINE_CONFIG_PACKAGES: &[&str] = &["vite-plus", "vite"]; +const TRUSTED_DEFINE_CONFIG_PACKAGES: &[&str] = + &["vite-plus", "vite", "vitest/config", "vite-plus/test/config"]; /// The name of the config helper static extraction trusts. const DEFINE_CONFIG: &str = "defineConfig"; @@ -174,6 +175,13 @@ fn extract_config_fields(program: &Program<'_>) -> FieldMap { // ESM: export default ... if let Statement::ExportDefaultDeclaration(decl) = stmt { if let Some(expr) = decl.declaration.as_expression() { + if let Expression::Identifier(id) = expr.get_inner_expression() { + return extract_exported_const( + program, + id.name.as_str(), + has_trusted_define_config_binding, + ); + } return extract_config_from_expr(expr, has_trusted_define_config_binding); } // export default class/function — not analyzable @@ -195,6 +203,36 @@ fn extract_config_fields(program: &Program<'_>) -> FieldMap { FieldMap::unanalyzable() } +// A config can be assigned to a const before exporting it. Only accept the +// imports/declaration/export shape: other statements could mutate that object +// or pass it to code that does, so those require normal runtime resolution. +fn extract_exported_const( + program: &Program<'_>, + name: &str, + has_trusted_define_config_binding: bool, +) -> FieldMap { + let mut initializer = None; + for stmt in &program.body { + match stmt { + Statement::ImportDeclaration(_) | Statement::ExportDefaultDeclaration(_) => {} + Statement::VariableDeclaration(decl) + if decl.kind == VariableDeclarationKind::Const && decl.declarations.len() == 1 => + { + let declarator = &decl.declarations[0]; + if !matches!(&declarator.id, BindingPattern::BindingIdentifier(id) if id.name == name) + { + return FieldMap::unanalyzable(); + } + initializer = declarator.init.as_ref(); + } + _ => return FieldMap::unanalyzable(), + } + } + initializer.map_or_else(FieldMap::unanalyzable, |expr| { + extract_config_from_expr(expr, has_trusted_define_config_binding) + }) +} + /// Extract the config object from an expression that is either: /// - `defineConfig({ ... })` → extract the object argument /// - `defineConfig(() => ({ ... }))` → extract from arrow function expression body @@ -752,6 +790,48 @@ mod tests { assert_non_static(&result, "run"); } + #[test] + fn vitest_config_preserves_migration_fields_without_evaluating_plugins() { + for import in [ + "import { defineConfig } from 'vitest/config';", + "import { defineConfig } from 'vite-plus/test/config';", + ] { + let source = [ + import, + "export default defineConfig({ + plugins: [plugin()], + migrate: { ignorePatterns: ['templates/'] }, + });", + ] + .join("\n"); + let result = parse(&source); + assert_json( + &result, + "migrate", + serde_json::json!({ "ignorePatterns": ["templates/"] }), + ); + assert!(result.get("run").is_none()); + } + } + + #[test] + fn exported_const_config_is_static_only_without_possible_mutations() { + let source = "import { defineConfig } from 'vitest/config'; + const config = defineConfig({ migrate: { ignorePatterns: ['templates/'] } });"; + let result = parse(&[source, "export default config;"].join("\n")); + assert_json(&result, "migrate", serde_json::json!({ "ignorePatterns": ["templates/"] })); + assert!(result.get("run").is_none()); + for mutation in [ + "config.migrate = { ignorePatterns: [] };", + "Object.assign(config, other);", + "const changed = mutate(config);", + ] { + let result = parse(&[source, mutation, "export default config;"].join("\n")); + assert_non_static(&result, "migrate"); + } + assert_non_static(&parse("let config = {}; export default config;"), "migrate"); + } + #[test] fn define_config_type_import_from_vite_plus_is_non_static() { let result = parse( diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index 576a522ead..5042e8588d 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -45,6 +45,67 @@ vp migrate my-app ## Migration Flow +### Excluding templates and examples + +Use `migrate.ignorePatterns` in the workspace-root `vite.config.ts` to keep +independently reusable projects on Vite or Vitest: + +```ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + migrate: { + ignorePatterns: ['templates/', 'examples/'], + }, +}); +``` + +Patterns use `.gitignore` syntax and are relative to the root config directory. +They support comments, anchored paths, directory patterns, and ordered `!` +negation. To include one template, use `['templates/*', '!templates/managed/']`. +As with Git, a negation cannot include a file whose parent directory is excluded. + +The policy is read before migration makes edits. Literal config blocks can be +read without loading plugins. Dynamic configs require the dependencies needed +to load the config to be installed first. + +Excluded files keep their dependencies, scripts, configs, and imports, and are +omitted from automatic formatting. The same policy applies to initial migration, +upgrades, and `--full`. Workspace membership stays unchanged. Migration reads +excluded package manifests to identify shared dependency settings. + +Automatic ESLint comment renaming and `baseUrl` removal are skipped when +exclusions are configured because those external tools cannot honor the patterns. +ESLint disable comments continue to work. Remove `baseUrl` manually in migrated +projects to enable type-aware linting. + +When excluded packages are present, migration preserves existing catalogs and +overrides and uses explicit dependency versions in migrated packages. It does not +add global toolchain overrides. Existing overrides still apply during installation; +an incompatible toolchain override must be resolved before migration can proceed. +Catalog references in an excluded template still require its original workspace. + +The `vite-plus/prefer-vite-plus-imports` lint rule also respects these exclusions, +including during `vp lint --fix` and `vp check --fix`. Other lint rules and normal +formatting remain enabled. To disable only the import rule with existing lint +configuration, use: + +```ts +lint: { + overrides: [ + { + files: ['templates/**', 'examples/**'], + rules: { 'vite-plus/prefer-vite-plus-imports': 'off' }, + }, + ], +}, +``` + +This lint override disables both diagnostics and autofix for that rule. It does +not exclude files from `vp migrate`. + +### Steps + The `migrate` command is designed to move existing projects onto Vite+ quickly. Here is what the command does: - Updates project dependencies diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index b616595e5f..60c1142e1a 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -1102,6 +1102,7 @@ module.exports.resetNativeMemoryStats = nativeBinding.resetNativeMemoryStats; module.exports.resolveTsconfig = nativeBinding.resolveTsconfig; module.exports.shutdownAsyncRuntime = nativeBinding.shutdownAsyncRuntime; module.exports.startAsyncRuntime = nativeBinding.startAsyncRuntime; +module.exports.MigrationIgnoreMatcher = nativeBinding.MigrationIgnoreMatcher; module.exports.analyzeMigrationSource = nativeBinding.analyzeMigrationSource; module.exports.detectWorkspace = nativeBinding.detectWorkspace; module.exports.documentationUrl = nativeBinding.documentationUrl; @@ -1120,6 +1121,7 @@ module.exports.parseHooksArgs = nativeBinding.parseHooksArgs; module.exports.parseLiteralCommands = nativeBinding.parseLiteralCommands; module.exports.parseMigrateArgs = nativeBinding.parseMigrateArgs; module.exports.parseStagedArgs = nativeBinding.parseStagedArgs; +module.exports.readStaticMigrationConfig = nativeBinding.readStaticMigrationConfig; module.exports.removeConfigKey = nativeBinding.removeConfigKey; module.exports.resolveNpmAuthorization = nativeBinding.resolveNpmAuthorization; module.exports.resolveNpmRegistry = nativeBinding.resolveNpmRegistry; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 3f25e0aab7..fffd403926 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3610,6 +3610,11 @@ export interface ViteImportGlobMeta { isSubImportsPattern?: boolean; } +export declare class MigrationIgnoreMatcher { + constructor(root: string, patterns: Array); + isIgnored(path: string, isDir: boolean): boolean; +} + /** Parse source and resolve lexical bindings for the TypeScript migration rules. */ export declare function analyzeMigrationSource(filename: string, source: string): string; @@ -3974,6 +3979,9 @@ export interface PathAccess { readDir: boolean; } +/** JSON for a static migrate block; undefined requests normal config resolution. */ +export declare function readStaticMigrationConfig(root: string): string | null; + /** Remove a top-level key from a recognized Vite config object. */ export declare function removeConfigKey( viteConfigPath: string, @@ -4035,6 +4043,8 @@ export declare function rewriteImportsInDirectory( root: string, preserveVitestInNuxtPackages?: boolean | undefined | null, oxlintOwnerDirs?: Array | undefined | null, + ignoreRoot?: string | undefined | null, + ignorePatterns?: Array | undefined | null, ): BatchRewriteResult; /** diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index aa0901e775..b4f9cdf156 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -3,6 +3,37 @@ use std::path::Path; use napi::{anyhow, bindgen_prelude::*}; use napi_derive::napi; +#[napi] +pub struct MigrationIgnoreMatcher(vp_migration::MigrationIgnoreMatcher); + +#[napi] +impl MigrationIgnoreMatcher { + #[napi(constructor)] + pub fn new(root: String, patterns: Vec) -> Result { + Ok(Self( + vp_migration::MigrationIgnoreMatcher::new(Path::new(&root), &patterns) + .map_err(anyhow::Error::from)?, + )) + } + + #[napi] + pub fn is_ignored(&self, path: String, is_dir: bool) -> bool { + self.0.is_ignored(Path::new(&path), is_dir) + } +} + +/// JSON for a static migrate block; undefined requests normal config resolution. +#[napi] +pub fn read_static_migration_config(root: String) -> Result> { + let root = vt_path::AbsolutePathBuf::new(root.into()) + .ok_or_else(|| Error::from_reason("Migration root must be absolute"))?; + Ok(match vp_static_config::resolve_static_config(&root).get("migrate") { + Some(vp_static_config::FieldValue::Json(value)) => Some(value.to_string()), + Some(vp_static_config::FieldValue::NonStatic) => None, + None => Some("{}".to_string()), + }) +} + /// A literal argument with its original JavaScript UTF-16 source range. #[napi(object)] pub struct LiteralCommandToken { @@ -367,10 +398,14 @@ pub fn rewrite_imports_in_directory( root: String, preserve_vitest_in_nuxt_packages: Option, oxlint_owner_dirs: Option>, + ignore_root: Option, + ignore_patterns: Option>, ) -> Result { let result = vp_migration::rewrite_imports_in_directory_with_options( Path::new(&root), vp_migration::RewriteImportsOptions { + ignore_root: ignore_root.map(std::path::PathBuf::from), + ignore_patterns: ignore_patterns.unwrap_or_default(), preserve_vitest_in_nuxt_packages: preserve_vitest_in_nuxt_packages.unwrap_or(false), oxlint_owner_dirs: oxlint_owner_dirs .unwrap_or_default() diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 8ecf080232..01b7805ffd 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { ignoreInput, ignoreOutput } from '@voidzero-dev/vite-task-client'; import type { OxfmtConfig } from 'oxfmt'; import type { OxlintConfig } from 'oxlint'; -import { resolveConfig, searchForWorkspaceRoot } from 'vite'; +import { resolveConfig as viteResolveConfig, searchForWorkspaceRoot } from 'vite'; import type { Alias, InlineConfig as ViteInlineConfig, @@ -27,6 +27,7 @@ import { import type { InlineConfig as VitestInlineConfig } from 'vitest/node'; import type { CreateTemplateEntry } from './create/org-manifest.ts'; +import { migrationIgnorePatterns, type MigrateConfig } from './migrate-config.ts'; import type { PackUserConfig } from './pack.ts'; import type { RunConfig } from './run-config.ts'; import type { StagedConfig } from './staged-config.ts'; @@ -42,6 +43,8 @@ declare module 'vite' { fmt?: OxfmtConfig; + migrate?: MigrateConfig; + /** * Defaults for the `vp check` composite command. Each flag mirrors the * matching CLI option (`--no-fmt` / `--no-lint`) and only affects @@ -112,6 +115,26 @@ declare module 'vite' { } } +// Oxlint loads the lint block through this public resolver, including configs +// which export a plain object instead of calling our defineConfig helper. +export const resolveConfig: typeof viteResolveConfig = async (...args) => { + const config = await viteResolveConfig(...args); + const patterns = migrationIgnorePatterns(config.migrate); + if (config.lint && patterns.length > 0) { + Object.assign(config.lint, { + ...config.lint, + settings: { + ...config.lint.settings, + 'vite-plus/migrate': { + root: config.configFile ? dirname(config.configFile) : config.root, + ignorePatterns: patterns, + }, + }, + }); + } + return config; +}; + type ViteUserConfigFnObject = (env: ConfigEnv) => UserConfig; type ViteUserConfigFnPromise = (env: ConfigEnv) => Promise; type ViteUserConfigFn = (env: ConfigEnv) => UserConfig | Promise; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d8f7b7fff8..5a449ea7f1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,9 +2,11 @@ export { defineConfig, defineProject, lazyPlugins, + resolveConfig, resolveConfigFromFile, } from './define-config.ts'; export type { ResolveConfigFromFileOptions } from './define-config.ts'; +export type { MigrateConfig } from './migrate-config.ts'; export * from 'vite'; diff --git a/packages/cli/src/migrate-config.ts b/packages/cli/src/migrate-config.ts new file mode 100644 index 0000000000..8986f081b5 --- /dev/null +++ b/packages/cli/src/migrate-config.ts @@ -0,0 +1,21 @@ +export interface MigrateConfig { + /** Paths excluded from migration, using .gitignore syntax relative to this config. */ + ignorePatterns?: string[]; +} + +export function migrationIgnorePatterns(config: unknown): string[] { + if (config === undefined) { + return []; + } + if (config === null || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('migrate must be an object.'); + } + const patterns = (config as MigrateConfig).ignorePatterns; + if (patterns === undefined) { + return []; + } + if (!Array.isArray(patterns) || patterns.some((pattern) => typeof pattern !== 'string')) { + throw new Error('migrate.ignorePatterns must be an array of strings.'); + } + return patterns; +} diff --git a/packages/cli/src/migration-ignore.ts b/packages/cli/src/migration-ignore.ts new file mode 100644 index 0000000000..79ac324465 --- /dev/null +++ b/packages/cli/src/migration-ignore.ts @@ -0,0 +1,7 @@ +import path from 'node:path'; + +import { MigrationIgnoreMatcher } from '../binding/index.js'; + +export function createMigrationIgnoreMatcher(root: string, patterns: string[]) { + return new MigrationIgnoreMatcher(path.resolve(root), patterns); +} diff --git a/packages/cli/src/migration/__tests__/ignore-patterns.spec.ts b/packages/cli/src/migration/__tests__/ignore-patterns.spec.ts new file mode 100644 index 0000000000..699b5a346d --- /dev/null +++ b/packages/cli/src/migration/__tests__/ignore-patterns.spec.ts @@ -0,0 +1,162 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { VITEST_VERSION, VITE_PLUS_OVERRIDE_PACKAGES } from '../../utils/constants.ts'; +import { readJsonFile } from '../../utils/json.ts'; +import { readYamlFile } from '../../utils/yaml.ts'; +import { editJsonFile, editYamlFile } from '../files.ts'; +import { formatMigratedProject } from '../format.ts'; +import { mergeTsdownConfigFile, rewriteAllImports } from '../migrator.ts'; +import { createMigrationReport } from '../report.ts'; +import { checkEnclosingMigrationPolicy, loadMigrationScope, withMigrationScope } from '../scope.ts'; + +describe('migration exclusions', () => { + let root: string; + const write = (file: string, content: string) => { + const filename = path.join(root, file); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); + return filename; + }; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migration-ignore-')); + write('package.json', '{"private":true}'); + write( + 'vite.config.ts', + `export default { migrate: { ignorePatterns: ['templates/', 'keep.config.ts'] } };`, + ); + write('templates/starter/package.json', '{"name":"starter"}'); + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('loads a static policy without executing uninstalled plugins', async () => { + write( + 'vite.config.ts', + `import { defineConfig } from 'vite'; +import missing from 'uninstalled-plugin'; +export default defineConfig({ plugins: [missing()], migrate: { ignorePatterns: ['templates/'] } });`, + ); + const scope = await loadMigrationScope(root); + expect(scope.ignores(path.join(root, 'templates/starter/vite.config.ts'))).toBe(true); + }); + + it('rejects explicit excluded targets while allowing an independent clone', async () => { + const target = path.join(root, 'templates/starter'); + await expect(checkEnclosingMigrationPolicy(target)).rejects.toThrow( + 'excluded by migrate.ignorePatterns', + ); + fs.mkdirSync(path.join(target, '.git')); + await expect(checkEnclosingMigrationPolicy(target)).resolves.toBeUndefined(); + }); + + it('preserves excluded source and rewrites included source, including a scan below the root', async () => { + const excluded = write('templates/starter/test.ts', `import { test } from 'vitest';`); + const included = write('app/test.ts', `import { test } from 'vitest';`); + withMigrationScope(await loadMigrationScope(root), () => { + expect(rewriteAllImports(root, true)).toBe(true); + expect(rewriteAllImports(path.dirname(excluded), true)).toBe(false); + }); + expect(fs.readFileSync(excluded, 'utf8')).toBe(`import { test } from 'vitest';`); + expect(fs.readFileSync(included, 'utf8')).toBe(`import { test } from 'vite-plus/test';`); + }); + + it('preserves shared catalogs and overrides in YAML and JSON, including nested Bun catalogs', async () => { + const workspace = write( + 'pnpm-workspace.yaml', + 'catalog:\n vite: ^8.0.0\ncatalogs:\n old:\n vitest: ^4.0.0\noverrides:\n unrelated: 1.0.0\n', + ); + const manifest = write( + 'package.json', + JSON.stringify({ + private: true, + workspaces: { packages: ['templates/*'], catalogs: { old: { vitest: '^4.0.0' } } }, + pnpm: { overrides: { unrelated: '1.0.0' } }, + }), + ); + const before = readYamlFile(workspace); + withMigrationScope(await loadMigrationScope(root), () => { + editYamlFile(workspace, (doc) => { + doc.setIn(['catalog', 'vite'], VITE_PLUS_OVERRIDE_PACKAGES.vite); + doc.delete('catalogs'); + doc.setIn(['overrides', 'vite'], VITE_PLUS_OVERRIDE_PACKAGES.vite); + doc.set('unrelatedSetting', true); + }); + editJsonFile(manifest, (pkg) => { + pkg.workspaces = { packages: ['templates/*'] }; + delete pkg.pnpm; + pkg.overrides = { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite }; + pkg.devDependencies = { 'vite-plus': 'latest' }; + return pkg; + }); + }); + expect(readYamlFile(workspace)).toEqual({ ...before, unrelatedSetting: true }); + expect(readJsonFile(manifest)).toMatchObject({ + workspaces: { catalogs: { old: { vitest: '^4.0.0' } } }, + pnpm: { overrides: { unrelated: '1.0.0' } }, + devDependencies: { 'vite-plus': 'latest' }, + }); + expect(readJsonFile(manifest)).not.toHaveProperty('overrides'); + }); + + it.each([ + { overrides: { 'vite@*': '^8.0.0' } }, + { resolutions: { '**/vitest': '^4.0.0' } }, + { overrides: { 'vite-plus': { vitest: '^4.0.0' } } }, + { overrides: { vitest: { '.': '^4.0.0' } } }, + ])('rejects conflicting shared overrides before any writes: %j', async (settings) => { + const file = write('package.json', JSON.stringify(settings)); + const before = fs.readFileSync(file, 'utf8'); + await expect(loadMigrationScope(root)).rejects.toThrow('No project files were changed.'); + expect(fs.readFileSync(file, 'utf8')).toBe(before); + }); + + it('accepts compatible global overrides and preserves overrides scoped to an excluded package', async () => { + write( + 'package.json', + JSON.stringify({ + overrides: { + vitest: VITEST_VERSION, + starter: { vitest: '^4.0.0' }, + }, + }), + ); + await expect(loadMigrationScope(root)).resolves.toBeDefined(); + }); + + it('does not consume a source config when its destination is ignored', async () => { + write('vite.config.ts', `export default { migrate: { ignorePatterns: ['vite.config.ts'] } };`); + const source = write('tsdown.config.json', '{"entry":["src/index.ts"]}'); + withMigrationScope(await loadMigrationScope(root), () => { + expect(mergeTsdownConfigFile(root, true)).toBe(false); + }); + expect(fs.readFileSync(source, 'utf8')).toBe('{"entry":["src/index.ts"]}'); + }); + + it('filters automatic formatting outside Git and when explicit changed paths are supplied', async () => { + write('app.ts', 'export const app=1;'); + write('templates/starter/app.ts', 'export const template=1;'); + const scope = await loadMigrationScope(root); + for (const changed of [undefined, ['app.ts', 'templates/starter/app.ts']]) { + const formatted: string[] = []; + await withMigrationScope(scope, () => + formatMigratedProject(root, false, createMigrationReport(), { + collectPaths: async () => changed, + format: async (_cwd, _interactive, paths) => { + expect(paths).toBeDefined(); + formatted.push(...paths!); + return { status: 'formatted', durationMs: 0 }; + }, + }), + ); + expect(formatted).toContain('app.ts'); + expect( + formatted.some( + (file) => file.startsWith(`templates${path.sep}`) || file.startsWith('templates/'), + ), + ).toBe(false); + } + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index de19f1893e..b17f630ba4 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -100,6 +100,13 @@ import { import { prepareNpmViteAliasReinstall } from './npm-reinstall.ts'; import type { MigrationOptions } from './options.ts'; import { addMigrationWarning, createMigrationReport, type MigrationReport } from './report.ts'; +import { + checkEnclosingMigrationPolicy, + isMigrationIgnored, + loadMigrationScope, + migrationScope, + withMigrationScope, +} from './scope.ts'; import { collectMigrationSetupPlan, collectTsupMigrationDecision, @@ -197,6 +204,15 @@ async function fixBaseUrlForWorkspace( if (!fixBaseUrl) { return []; } + // ts5to6 follows references and extends outside its input config, but does + // not accept exclusions. Filtering input paths alone cannot protect them. + if (migrationScope()?.patterns.length) { + addMigrationWarning( + report, + 'Skipped automatic tsconfig baseUrl removal because its external fixer cannot honor migrate.ignorePatterns. Remove baseUrl manually in migrated projects to enable type-aware linting.', + ); + return []; + } const fixedProjectPaths: string[] = []; for (const projectPath of getWorkspaceProjectPaths(workspaceInfo)) { @@ -949,6 +965,7 @@ async function executeMigrationPlan( // 9. Write agent instructions (using pre-resolved decisions) updateMigrationProgress('Writing agent instructions'); await writeAgentInstructions({ + shouldWriteFile: (file) => !isMigrationIgnored(file), projectRoot: workspaceInfo.rootDir, targetPaths: plan.selectedAgentTargetPaths, interactive, @@ -959,6 +976,7 @@ async function executeMigrationPlan( // 10. Write editor configs (using pre-resolved decisions) updateMigrationProgress('Writing editor configs'); await writeEditorConfigs({ + shouldWriteFile: (file) => !isMigrationIgnored(file), projectRoot: workspaceInfo.rootDir, editorId: plan.selectedEditor, interactive, @@ -1061,6 +1079,21 @@ async function main() { 1, ); } + await checkEnclosingMigrationPolicy(workspaceInfoOptional.rootDir); + const scope = await loadMigrationScope( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packageManager, + ); + workspaceInfoOptional.packages = workspaceInfoOptional.packages?.filter( + (pkg) => !scope.ignores(path.join(workspaceInfoOptional.rootDir, pkg.path), true), + ); + return withMigrationScope(scope, () => migrateWorkspace(workspaceInfoOptional, options)); +} + +async function migrateWorkspace( + workspaceInfoOptional: WorkspaceInfoOptional, + options: MigrationOptions, +) { const initialChangedPaths = await collectChangedFormatPaths(workspaceInfoOptional.rootDir); const preExistingChangedPaths = initialChangedPaths ? new Set(initialChangedPaths) : undefined; const resolvedPackageManager = workspaceInfoOptional.packageManager ?? 'unknown'; @@ -1654,6 +1687,7 @@ async function main() { if (plan.selectedAgentTargetPaths && plan.selectedAgentTargetPaths.length > 0) { updateMigrationProgress('Writing agent instructions'); await writeAgentInstructions({ + shouldWriteFile: (file) => !isMigrationIgnored(file), projectRoot: workspaceInfoOptional.rootDir, targetPaths: plan.selectedAgentTargetPaths, interactive: options.interactive, @@ -1666,6 +1700,7 @@ async function main() { if (plan.selectedEditor) { updateMigrationProgress('Writing editor configs'); await writeEditorConfigs({ + shouldWriteFile: (file) => !isMigrationIgnored(file), projectRoot: workspaceInfoOptional.rootDir, editorId: plan.selectedEditor, interactive: options.interactive, diff --git a/packages/cli/src/migration/files.ts b/packages/cli/src/migration/files.ts new file mode 100644 index 0000000000..d539b13cdb --- /dev/null +++ b/packages/cli/src/migration/files.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseDocument } from 'yaml'; + +import { readJsonFile, writeJsonFile as writeJson } from '../utils/json.ts'; +import { type YamlDocument } from '../utils/yaml.ts'; +import { isMigrationIgnored, migrationScope, sharedDependencyFields } from './scope.ts'; + +export { readJsonFile, isJsonFile } from '../utils/json.ts'; +export { readYamlFile, scalarString, type YamlDocument } from '../utils/yaml.ts'; + +function ignored(file: fs.PathLike | number): boolean { + return ( + typeof file !== 'number' && + isMigrationIgnored(file instanceof URL ? fileURLToPath(file) : file.toString()) + ); +} + +// Keep migration writes behind one boundary. Reads still see excluded files: +// shared dependency ownership must be determined from the complete project. +const migrationFs: typeof fs = Object.assign(Object.create(fs) as typeof fs, { + writeFileSync(file, ...args) { + if (!ignored(file)) { + fs.writeFileSync(file, ...args); + } + }, + appendFileSync(file, ...args) { + if (!ignored(file)) { + fs.appendFileSync(file, ...args); + } + }, + unlinkSync(file) { + if (!ignored(file)) { + fs.unlinkSync(file); + } + }, + rmSync(file, ...args) { + if (!ignored(file)) { + fs.rmSync(file, ...args); + } + }, + renameSync(from, to) { + if (!ignored(from) && !ignored(to)) { + fs.renameSync(from, to); + } + }, +} satisfies Pick< + typeof fs, + 'writeFileSync' | 'appendFileSync' | 'unlinkSync' | 'rmSync' | 'renameSync' +>); +export default migrationFs; + +function preserveShared(file: string): boolean { + const scope = migrationScope(); + return ( + !!scope?.preserveSharedDependencies && + path.dirname(file) === scope.root && + ['package.json', 'pnpm-workspace.yaml', '.yarnrc.yml'].includes(path.basename(file)) + ); +} + +export function writeJsonFile(file: string, data: Record): void { + if (isMigrationIgnored(file)) { + return; + } + if (preserveShared(file) && fs.existsSync(file)) { + const before = parseDocument(fs.readFileSync(file, 'utf8')); + const after = parseDocument(JSON.stringify(data)); + for (const keys of sharedDependencyFields) { + if (before.hasIn(keys)) { + after.setIn(keys, before.getIn(keys)); + } else if (after.hasIn(keys)) { + after.deleteIn(keys); + } + } + data = after.toJS() as Record; + } + writeJson(file, data); +} + +export function editJsonFile = Record>( + file: string, + callback: (content: T) => T | undefined, +): void { + if (isMigrationIgnored(file)) { + return; + } + const updated = callback(readJsonFile(file) as T); + if (updated) { + writeJsonFile(file, updated); + } +} + +export function editYamlFile(file: string, callback: (doc: YamlDocument) => void): void { + if (isMigrationIgnored(file)) { + return; + } + const before = parseDocument(fs.readFileSync(file, 'utf8')); + const after = parseDocument(fs.readFileSync(file, 'utf8')); + callback(after); + if (preserveShared(file)) { + for (const keys of sharedDependencyFields) { + if (before.hasIn(keys)) { + after.setIn(keys, before.getIn(keys)); + } else if (after.hasIn(keys)) { + after.deleteIn(keys); + } + } + } + fs.writeFileSync(file, after.toString({ singleQuote: true }), 'utf8'); +} diff --git a/packages/cli/src/migration/format.ts b/packages/cli/src/migration/format.ts index 81ac5b1203..5c9e6484a4 100644 --- a/packages/cli/src/migration/format.ts +++ b/packages/cli/src/migration/format.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { runCommandSilently } from '../utils/command.ts'; import { type CommandRunSummary, runViteFmt } from '../utils/prompts.ts'; import { addMigrationWarning, type MigrationReport } from './report.ts'; +import { isMigrationIgnored, migrationScope } from './scope.ts'; type FormatRunner = ( cwd: string, @@ -163,7 +164,29 @@ export async function formatMigratedProject( ): Promise { const { format = runViteFmt, collectPaths = collectChangedFormatPaths, excludedPaths } = options; try { - const paths = await collectPaths(projectRoot, excludedPaths); + let paths = await collectPaths(projectRoot, excludedPaths); + if (migrationScope()?.patterns.length) { + // Outside Git, enumerate files rather than handing an excluded directory + // to the formatter. Do not follow dependency trees or symlinks. + if (paths === undefined) { + paths = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (isMigrationIgnored(file, entry.isDirectory())) { + continue; + } + if (entry.isDirectory() && !['node_modules', '.git', '.yarn'].includes(entry.name)) { + walk(file); + } else if (entry.isFile()) { + paths!.push(path.relative(projectRoot, file)); + } + } + }; + walk(projectRoot); + } + paths = paths.filter((file) => !isMigrationIgnored(path.resolve(projectRoot, file))); + } if (paths?.length === 0) { return true; } diff --git a/packages/cli/src/migration/migrator/README.md b/packages/cli/src/migration/migrator/README.md index acd19f4eb8..acc7cc7601 100644 --- a/packages/cli/src/migration/migrator/README.md +++ b/packages/cli/src/migration/migrator/README.md @@ -48,6 +48,13 @@ Pick the file by what a function _does_, not by where it happens to be called. ## Rules for adding / changing code +Migration file writes use `../files.ts`, which applies the active exclusion +scope from `../scope.ts`. Keep reads available for shared dependency analysis. +For operations that consume one file to create another, check both paths before +the operation. External tools must receive filtered targets or skip a step whose +writes cannot respect the scope. `vp create` and callers without a migration +scope retain the normal write behavior. + 1. **Add a function to the module that matches its category.** Keep it `export`ed so the barrel surfaces it. If nothing fits, prefer extending an existing module over creating a new one; if you do add a module, add a line diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index cd6b13e998..b8c77e4978 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { coerce, isGreaterThanOrEqual } from 'verkit'; @@ -13,9 +12,10 @@ import { VITE_PLUS_VERSION, isForceOverrideMode, } from '../../utils/constants.ts'; -import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { type NpmWorkspaces } from '../../utils/workspace.ts'; -import { editYamlFile, readYamlFile, scalarString, type YamlDocument } from '../../utils/yaml.ts'; +import fs from '../files.ts'; +import { editJsonFile, readJsonFile } from '../files.ts'; +import { editYamlFile, readYamlFile, scalarString, type YamlDocument } from '../files.ts'; import { dropRemovePackageOverrideKeys, ensurePnpmExoticSubdepsSetting, @@ -31,6 +31,7 @@ import { shouldDropProviderOverrideKey, workspaceUsesWebdriverioProvider, } from '../migrator.ts'; +import { preserveSharedDependencies } from '../scope.ts'; import { type PreparedOxlintConfigs, LEGACY_WRAPPER_FALLBACK_VERSIONS, @@ -132,6 +133,9 @@ export function supportsCatalog( version: string, isBunWorkspace = false, ): boolean { + if (preserveSharedDependencies()) { + return false; + } switch (packageManager) { case PackageManager.pnpm: return pnpmSupportsCatalog(version); @@ -585,7 +589,7 @@ export function getCatalogDependencySpec( ) { return version; } - if (!supportCatalog || version.startsWith('file:')) { + if (!supportCatalog || preserveSharedDependencies() || version.startsWith('file:')) { return version; } return currentValue?.startsWith('catalog:') @@ -867,6 +871,9 @@ export function rewriteCatalog( catalogs?: Record>; } | null; const preferredCatalogSpec = selectPreferredCatalogSpec(parsed?.catalog, parsed?.catalogs); + if (preserveSharedDependencies()) { + return preferredCatalogSpec; + } const preferredCatalogName = preferredCatalogSpec.slice('catalog:'.length); const targetPath: readonly string[] = preferredCatalogName && preferredCatalogName !== 'default' @@ -1051,6 +1058,9 @@ export function rewriteBunCatalog( vitestEcosystemPackages: ReadonlySet, usesWebdriverioProvider = false, ): void { + if (preserveSharedDependencies()) { + return; + } const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return; diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts index d57f2b556a..eadcd558c7 100644 --- a/packages/cli/src/migration/migrator/core-finalization.ts +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -3,8 +3,8 @@ import path from 'node:path'; import { rewriteScripts } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; -import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { rulesDir } from '../../utils/path.ts'; +import { editJsonFile, readJsonFile } from '../files.ts'; import { dropDeadOxlintPluginsDependency, hasTsconfigTypesToRewrite, diff --git a/packages/cli/src/migration/migrator/eslint.ts b/packages/cli/src/migration/migrator/eslint.ts index bf9231d0e1..72f8feb60b 100644 --- a/packages/cli/src/migration/migrator/eslint.ts +++ b/packages/cli/src/migration/migrator/eslint.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { styleText } from 'node:util'; @@ -10,14 +9,16 @@ import { compareReversed, isLessThanOrEqual, normalize } from 'verkit'; import { ResolverFactory, rewriteEslint } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; import { getVpCommand, runCommandSilently } from '../../utils/command.ts'; -import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; import { fetchNpmResource, getNpmRegistry } from '../../utils/npm-config.ts'; import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { hasBaseUrlInTsconfig } from '../../utils/tsconfig.ts'; import { detectConfigs } from '../detector.ts'; +import fs from '../files.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../files.ts'; import { type MigrationReport } from '../report.ts'; +import { isMigrationIgnored, migrationScope } from '../scope.ts'; import { LINT_STAGED_JSON_CONFIG_FILES, LINT_STAGED_OTHER_CONFIG_FILES, @@ -217,6 +218,13 @@ export async function migrateEslintToOxlint( packages?: WorkspacePackage[], options?: { silent?: boolean; report?: MigrationReport }, ): Promise { + if ( + [eslintConfigFile, '.oxlintrc.json', '.oxlintrc.json.bak'].some( + (file) => file && isMigrationIgnored(path.join(projectPath, file)), + ) + ) { + return false; + } const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive); // Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root @@ -252,18 +260,22 @@ export async function migrateEslintToOxlint( warnMigration(migrateResult.skippedRules, options?.report); } - // Step 2: Replace eslint-disable comments with oxlint-disable - spinner.start('Replacing ESLint comments with Oxlint equivalents...'); - const replaceResult = await runOxlintMigrateStep( - projectPath, - migratePackage, - ['--replace-eslint-comments'], - spinner, - 'ESLint comment replacement failed', - `You can run \`vp dlx ${migratePackage} --replace-eslint-comments\` manually later`, - ); - if (replaceResult.ok) { - spinner.stop('ESLint comments replaced'); + // The upstream comment renamer scans the whole tree and cannot consume our + // exclusions. Existing ESLint disable comments already work with Oxlint. + if (!migrationScope()?.patterns.length) { + // Step 2: Replace eslint-disable comments with oxlint-disable + spinner.start('Replacing ESLint comments with Oxlint equivalents...'); + const replaceResult = await runOxlintMigrateStep( + projectPath, + migratePackage, + ['--replace-eslint-comments'], + spinner, + 'ESLint comment replacement failed', + `You can run \`vp dlx ${migratePackage} --replace-eslint-comments\` manually later`, + ); + if (replaceResult.ok) { + spinner.stop('ESLint comments replaced'); + } } // Continue with cleanup regardless — .oxlintrc.json was generated successfully } @@ -285,10 +297,7 @@ export async function migrateEslintToOxlint( // package — delete eslint config files, scrub ESLint-ecosystem deps from // package.json, and rewrite eslint references in any local lint-staged // config. A monorepo running `vp migrate` is treated as adopted as a - // whole; there's no per-package opt-out today. If a workspace package - // publishes a shared ESLint preset that you want to keep intact, exclude - // it from your `pnpm-workspace.yaml` / `workspaces` before running - // `vp migrate`, then add it back afterwards. + // whole, with migration-ignored packages removed from this target list. const cleanupTargets = [ projectPath, ...(packages ?? []).map((p) => path.join(projectPath, p.path)), diff --git a/packages/cli/src/migration/migrator/framework-shim.ts b/packages/cli/src/migration/migrator/framework-shim.ts index 9d2333a517..57acd7b936 100644 --- a/packages/cli/src/migration/migrator/framework-shim.ts +++ b/packages/cli/src/migration/migrator/framework-shim.ts @@ -1,8 +1,9 @@ -import fs from 'node:fs'; import path from 'node:path'; -import { readJsonFile } from '../../utils/json.ts'; +import fs from '../files.ts'; +import { readJsonFile } from '../files.ts'; import { type MigrationReport } from '../report.ts'; +import { isMigrationIgnored } from '../scope.ts'; // .svelte files are handled by @sveltejs/vite-plugin-svelte (transpilation) // and svelte-check / Svelte Language Server (type checking). @@ -87,6 +88,9 @@ export function addFrameworkShim( report?: MigrationReport, ): void { const envDtsPath = getEnvDtsPath(projectPath); + if (isMigrationIgnored(envDtsPath)) { + return; + } const shim = FRAMEWORK_SHIMS[framework]; if (fs.existsSync(envDtsPath)) { const existing = fs.readFileSync(envDtsPath, 'utf-8'); diff --git a/packages/cli/src/migration/migrator/git-hooks.ts b/packages/cli/src/migration/migrator/git-hooks.ts index 7273b40d4c..b82e67b4f9 100644 --- a/packages/cli/src/migration/migrator/git-hooks.ts +++ b/packages/cli/src/migration/migrator/git-hooks.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; @@ -20,7 +19,8 @@ import type { PackageManager, WorkspacePackage } from '../../types/index.ts'; import { getVpCommand } from '../../utils/command.ts'; import { documentationUrl } from '../../utils/documentation.ts'; import { findGitRoot } from '../../utils/git.ts'; -import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; +import fs from '../files.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../files.ts'; import { hasStagedConfigInViteConfig, mergeStagedConfigToViteConfig, @@ -28,6 +28,7 @@ import { removeLintStagedFromPackageJson, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; +import { isMigrationIgnored } from '../scope.ts'; import { getCommandStartIndex, LINT_STAGED_ALL_CONFIG_FILES, @@ -462,6 +463,9 @@ export function installGitHooks( } // Install git hooks via vp config (--no-agent to skip agent setup, handled by migration) + if (isMigrationIgnored(path.join(projectPath, hooksDir, 'pre-commit'))) { + return false; + } const { command, args } = getVpCommand(['config', '--no-agent']); const configResult = spawn.sync(command, args, { cwd: projectPath, @@ -521,8 +525,11 @@ const DEFAULT_STAGED_CONFIG: Record = { '*': 'vp check --fix' }; export function createPreCommitHook(projectPath: string, dir = '.vite-hooks'): void { const huskyDir = path.join(projectPath, dir); - fs.mkdirSync(huskyDir, { recursive: true }); const hookPath = path.join(huskyDir, 'pre-commit'); + if (isMigrationIgnored(hookPath)) { + return; + } + fs.mkdirSync(huskyDir, { recursive: true }); if (fs.existsSync(hookPath)) { return; } diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index dc4a2c5550..e7db9fe80b 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { PackageManager, type WorkspaceInfo, type WorkspacePackage } from '../../types/index.ts'; import { VITE_PLUS_NAME, VITE_PLUS_VERSION, isForceOverrideMode } from '../../utils/constants.ts'; -import { editJsonFile } from '../../utils/json.ts'; +import { editJsonFile } from '../files.ts'; import { applyBuildAllowanceToPackageJsonPnpm, applyYarnWorkspaceHoistingFix, diff --git a/packages/cli/src/migration/migrator/oxlint-config.ts b/packages/cli/src/migration/migrator/oxlint-config.ts index b971d3fd49..7d2ebb1a7f 100644 --- a/packages/cli/src/migration/migrator/oxlint-config.ts +++ b/packages/cli/src/migration/migrator/oxlint-config.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto'; -import fs from 'node:fs'; import path from 'node:path'; import type { Program } from '@oxc-project/types'; @@ -8,6 +7,7 @@ import type { OxlintConfig } from 'oxlint'; import { analyzeMigrationSource, ResolverFactory } from '../../../binding/index.js'; import { detectConfigs } from '../detector.ts'; +import fs from '../files.ts'; import { ensureSvelteRuneGlobals } from '../migrator.ts'; import { OXLINT_OWNER_CONFIG_KEYS, diff --git a/packages/cli/src/migration/migrator/prettier.ts b/packages/cli/src/migration/migrator/prettier.ts index f837621375..a096f01978 100644 --- a/packages/cli/src/migration/migrator/prettier.ts +++ b/packages/cli/src/migration/migrator/prettier.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { styleText } from 'node:util'; @@ -7,13 +6,15 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { rewritePrettier } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; import { getVpCommand, runCommandSilently } from '../../utils/command.ts'; -import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { PRETTIER_CONFIG_FILES, PRETTIER_PACKAGE_JSON_CONFIG, detectConfigs } from '../detector.ts'; +import fs from '../files.ts'; +import { editJsonFile, readJsonFile } from '../files.ts'; import { rewriteToolLintStagedConfigFiles } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; +import { isMigrationIgnored } from '../scope.ts'; import { warnMigration } from './shared.ts'; export function detectPrettierProject( @@ -107,6 +108,13 @@ export async function migratePrettierToOxfmt( packages?: WorkspacePackage[], options?: { silent?: boolean; report?: MigrationReport }, ): Promise<'migrated' | 'skipped' | 'failed'> { + if ( + [prettierConfigFile, '.oxfmtrc.json'].some( + (file) => file && isMigrationIgnored(path.join(projectPath, file)), + ) + ) { + return 'skipped'; + } const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive); // Step 1: Generate .oxfmtrc.json from Prettier config diff --git a/packages/cli/src/migration/migrator/script-runner.ts b/packages/cli/src/migration/migrator/script-runner.ts index 8d68ee0894..351fc693a0 100644 --- a/packages/cli/src/migration/migrator/script-runner.ts +++ b/packages/cli/src/migration/migrator/script-runner.ts @@ -1,14 +1,14 @@ -import fs from 'node:fs'; import path from 'node:path'; import { parse as parseJsonc } from 'jsonc-parser'; import { type LiteralCommandToken, parseLiteralCommands } from '../../../binding/index.js'; import { type WorkspacePackage } from '../../types/index.ts'; -import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; import { readNearestPackageJson } from '../../utils/package.ts'; import { displayRelative } from '../../utils/path.ts'; import { NODE_OPTIONS_WITH_VALUE, SCRIPT_EXTENSIONS } from '../../vpx-script.ts'; +import fs from '../files.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../files.ts'; import { sourceTreeMatches } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; import { diff --git a/packages/cli/src/migration/migrator/setup.ts b/packages/cli/src/migration/migrator/setup.ts index a124ba8f25..1183c89b37 100644 --- a/packages/cli/src/migration/migrator/setup.ts +++ b/packages/cli/src/migration/migrator/setup.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; @@ -8,8 +7,9 @@ import { parse as parseYaml } from 'yaml'; import { type DownloadPackageManagerResult } from '../../../binding/index.js'; import { SETUP_VP_VERSION } from '../../utils/constants.ts'; -import { editJsonFile } from '../../utils/json.ts'; import { detectConfigs } from '../detector.ts'; +import fs from '../files.ts'; +import { editJsonFile } from '../files.ts'; import { type MigrationReport } from '../report.ts'; import { isPlainRecord, warnMigration } from './shared.ts'; diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 5f56f0731b..df44b567e5 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -3,10 +3,11 @@ import path from 'node:path'; import { type WorkspacePackage } from '../../types/index.ts'; import { VITE_PLUS_NAME } from '../../utils/constants.ts'; -import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { detectPackageMetadata } from '../../utils/package.ts'; import { hasVitestTypesInTsconfig } from '../../utils/tsconfig.ts'; +import { editJsonFile, readJsonFile } from '../files.ts'; import { projectUsesVitestDirectly } from '../migrator.ts'; +import { isMigrationIgnored } from '../scope.ts'; import { OPT_IN_BROWSER_PROVIDERS, OXLINT_PLUGINS_PACKAGE, @@ -229,6 +230,9 @@ export function sourceTreeMatches( } for (const entry of entries) { const entryPath = path.join(dir, entry.name); + if (isMigrationIgnored(entryPath, entry.isDirectory())) { + continue; + } if (entry.isDirectory()) { if (skipDirs.has(entry.name)) { continue; @@ -290,6 +294,9 @@ function findPackageTsconfigFiles(projectPath: string): string[] { } for (const entry of entries) { const entryPath = path.join(dir, entry.name); + if (isMigrationIgnored(entryPath, entry.isDirectory())) { + continue; + } if (entry.isDirectory()) { if (!VITEST_SCAN_SKIP_DIRS.has(entry.name)) { scanDir(entryPath, false); diff --git a/packages/cli/src/migration/migrator/tsconfig.ts b/packages/cli/src/migration/migrator/tsconfig.ts index 58782601f8..c1db8452c3 100644 --- a/packages/cli/src/migration/migrator/tsconfig.ts +++ b/packages/cli/src/migration/migrator/tsconfig.ts @@ -8,6 +8,7 @@ import { rewriteTypesInTsconfig, } from '../../utils/tsconfig.ts'; import { type MigrationReport } from '../report.ts'; +import { isMigrationIgnored } from '../scope.ts'; import { warnMigration } from './shared.ts'; export function cleanupDeprecatedTsconfigOptions( @@ -16,7 +17,7 @@ export function cleanupDeprecatedTsconfigOptions( report?: MigrationReport, ): void { const deprecatedOptions = ['esModuleInterop', 'allowSyntheticDefaultImports']; - const files = findTsconfigFiles(projectPath); + const files = findTsconfigFiles(projectPath).filter((file) => !isMigrationIgnored(file)); for (const filePath of files) { for (const name of deprecatedOptions) { if (removeDeprecatedTsconfigFalseOption(filePath, name)) { @@ -41,7 +42,7 @@ export function rewriteTsconfigTypes( report?: MigrationReport, ): boolean { let changed = false; - const files = findTsconfigFiles(projectPath); + const files = findTsconfigFiles(projectPath).filter((file) => !isMigrationIgnored(file)); for (const filePath of files) { if (rewriteTypesInTsconfig(filePath)) { changed = true; @@ -57,5 +58,7 @@ export function rewriteTsconfigTypes( } export function hasTsconfigTypesToRewrite(projectPath: string): boolean { - return findTsconfigFiles(projectPath).some((filePath) => hasTypesToRewriteInTsconfig(filePath)); + return findTsconfigFiles(projectPath) + .filter((file) => !isMigrationIgnored(file)) + .some((filePath) => hasTypesToRewriteInTsconfig(filePath)); } diff --git a/packages/cli/src/migration/migrator/tsup.ts b/packages/cli/src/migration/migrator/tsup.ts index d9e5846fe9..44f68b4479 100644 --- a/packages/cli/src/migration/migrator/tsup.ts +++ b/packages/cli/src/migration/migrator/tsup.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { styleText } from 'node:util'; @@ -8,12 +7,15 @@ import corePkg from 'vite/package.json' with { type: 'json' }; import { PackageManager, type WorkspacePackage } from '../../types/index.ts'; import { getVpCommand, runCommandSilently } from '../../utils/command.ts'; import { TSDOWN_MIGRATION_SKILL_URL } from '../../utils/constants.ts'; -import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; import { cancelAndExit } from '../../utils/prompts.ts'; import { getSilentSpinner, getSpinner } from '../../utils/spinner.ts'; import { detectConfigs, TSUP_CONFIG_FILES, TSUP_PACKAGE_JSON_CONFIG } from '../detector.ts'; +import fs from '../files.ts'; +import { editJsonFile, readJsonFile } from '../files.ts'; import { addMigrationWarning, type MigrationReport } from '../report.ts'; +import { isMigrationIgnored } from '../scope.ts'; +import { warnMigration } from './shared.ts'; function showTsdownMigrationOptions( targetLabel = 'the project root', @@ -428,6 +430,17 @@ export async function migrateTsupToTsdown( const tsupTargets = targets.filter((target) => target === rootPath ? !!tsupConfigFile : !!detectConfigs(target).tsupConfig, ); + if ( + tsupTargets.some((target) => + TSDOWN_MIGRATION_FILES.some((file) => isMigrationIgnored(path.join(target, file))), + ) + ) { + warnMigration( + 'Skipped tsup migration because migrate.ignorePatterns excludes one of its input or output files.', + options?.report, + ); + return false; + } const configCollisions = collectTsupConfigCollisions(tsupTargets); if (configCollisions.length > 0) { prompts.log.warn( diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 96e6e6d46f..2eaf6b5b94 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import type { Program } from '@oxc-project/types'; @@ -24,10 +23,11 @@ import { import { type WorkspacePackage } from '../../types/index.ts'; import { BASEURL_TSCONFIG_WARNING, VITE_PLUS_NAME } from '../../utils/constants.ts'; import { documentationUrl } from '../../utils/documentation.ts'; -import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; import { displayRelative } from '../../utils/path.ts'; import { hasBaseUrlInTsconfig } from '../../utils/tsconfig.ts'; import { detectConfigs, type ConfigFiles } from '../detector.ts'; +import fs from '../files.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../files.ts'; import { collectInstalledPackageNames, oxlintMigrationMarker, @@ -40,6 +40,7 @@ import { sanitizeInheritedJsPlugins, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; +import { isMigrationIgnored, migrationScope } from '../scope.ts'; import { type OxlintConfigMigration, type OxlintModulePlan, @@ -195,6 +196,9 @@ export function mergeTsdownConfigFile( report?: MigrationReport, ): boolean { const configs = detectConfigs(projectPath); + if (isMigrationIgnored(path.join(projectPath, configs.viteConfig ?? 'vite.config.ts'))) { + return false; + } if (!configs.tsdownConfig) { return false; } @@ -203,6 +207,9 @@ export function mergeTsdownConfigFile( const fullViteConfigPath = path.join(projectPath, viteConfig); const fullTsdownConfigPath = path.join(projectPath, configs.tsdownConfig); + if (isMigrationIgnored(fullViteConfigPath) || isMigrationIgnored(fullTsdownConfigPath)) { + return false; + } // For JSON files, merge content directly and delete the file if (configs.tsdownConfig.endsWith('.json')) { @@ -271,6 +278,9 @@ export function mergeViteConfigFiles( preparedOxlintConfigs?: PreparedOxlintConfigs, ): void { const configs = detectConfigs(projectPath); + if (isMigrationIgnored(path.join(projectPath, configs.viteConfig ?? 'vite.config.ts'))) { + return; + } if (!configs.oxfmtConfig && !configs.oxlintConfig) { return; } @@ -587,6 +597,9 @@ function injectConfigDefaults( report?: MigrationReport, ): void { const configs = detectConfigs(projectPath); + if (isMigrationIgnored(path.join(projectPath, configs.viteConfig ?? 'vite.config.ts'))) { + return; + } if (configs.viteConfig && hasConfigKey(path.join(projectPath, configs.viteConfig), configKey)) { return; } @@ -647,6 +660,13 @@ function mergeAndRemoveJsonConfig( ): boolean { const fullViteConfigPath = path.join(projectPath, viteConfigPath); const fullJsonConfigPath = path.join(projectPath, jsonConfigPath); + if ( + isMigrationIgnored(fullViteConfigPath) || + isMigrationIgnored(fullJsonConfigPath) || + [...(modulePlan?.modules.keys() ?? [])].some((file) => isMigrationIgnored(file)) + ) { + return false; + } // Skip merge when the key is already present in vite.config.ts — the Rust // merge step always prepends, so without this guard a template that ships // both an inline `${configKey}:` block and a standalone JSON file (e.g. @@ -760,6 +780,9 @@ export function mergeStagedConfigToViteConfig( report?: MigrationReport, ): boolean { const configs = detectConfigs(projectPath); + if (isMigrationIgnored(path.join(projectPath, configs.viteConfig ?? 'vite.config.ts'))) { + return false; + } const viteConfig = ensureViteConfig(projectPath, configs, silent, report); const fullViteConfigPath = path.join(projectPath, viteConfig); @@ -901,7 +924,13 @@ export function rewriteAllImports( // `collectOxlintOwnerDirs`. oxlintOwnerDirs: string[] = [], ): boolean { - const result = rewriteImportsInDirectory(projectPath, preserveNuxtVitestImports, oxlintOwnerDirs); + const result = rewriteImportsInDirectory( + projectPath, + preserveNuxtVitestImports, + oxlintOwnerDirs, + migrationScope()?.root, + migrationScope()?.patterns, + ); const modified = result.modifiedFiles.length; const preserved = result.preservedVitestFiles.length; const errors = result.errors.length; diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index 5a4105b168..18ff26dc29 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { PackageManager, type WorkspaceInfo, type WorkspacePackage } from '../../types/index.ts'; @@ -9,9 +8,10 @@ import { VITE_PLUS_VERSION, isForceOverrideMode, } from '../../utils/constants.ts'; -import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { type NpmWorkspaces } from '../../utils/workspace.ts'; -import { editYamlFile, readYamlFile, type YamlDocument } from '../../utils/yaml.ts'; +import fs from '../files.ts'; +import { editJsonFile, readJsonFile } from '../files.ts'; +import { editYamlFile, readYamlFile, type YamlDocument } from '../files.ts'; import { alignVitestEcosystemPackages, applyBuildAllowanceToPackageJsonPnpm, @@ -52,6 +52,7 @@ import { yarnrcSatisfiesVitePlus, } from '../migrator.ts'; import { type DependencyVersionChange, type MigrationReport } from '../report.ts'; +import { preserveSharedDependencies } from '../scope.ts'; import { BROWSER_PROVIDER_PEER_DEPS, hasProviderPeerDependency, @@ -152,6 +153,9 @@ export function overridesSatisfyVitePlus( keyStyle: ManagedOverrideKeyStyle = 'bare', usesWebdriverioProvider = false, ): boolean { + if (preserveSharedDependencies()) { + return true; + } const managed = managedOverridePackages(usesVitest, usesWebdriverioProvider); // Common case: a lingering managed `vitest` override is not satisfied. It // must be removed, so the bootstrap stays pending until then. Both key @@ -891,6 +895,9 @@ function ensureOverrideEntries( keyStyle: ManagedOverrideKeyStyle = 'bare', usesWebdriverioProvider = false, ): { overrides: Record; changed: boolean } { + if (preserveSharedDependencies()) { + return { overrides: overrides ?? {}, changed: false }; + } const next = { ...overrides }; let changed = false; // Common case: drop a lingering managed `vitest` override. diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts index ffff16ac75..743d93b19f 100644 --- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts +++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts @@ -10,7 +10,11 @@ import { VITE_PLUS_OVERRIDE_PACKAGES, } from '../../utils/constants.ts'; import { readJsonFile } from '../../utils/json.ts'; -import { extractOverrideTargetName } from '../../utils/package-overrides.ts'; +import { + extractOverrideParentSegments, + extractOverrideTargetName, +} from '../../utils/package-overrides.ts'; +export { extractOverrideParentSegments } from '../../utils/package-overrides.ts'; import { detectPackageMetadata } from '../../utils/package.ts'; import { isAlignableVitestEcosystemPackage } from '../../utils/vitest-ecosystem.ts'; import { @@ -60,16 +64,6 @@ function isRemovePackageOverrideKey(key: string): boolean { ); } -// Strip a trailing `@version`/range from a selector segment and keep its scope. -// Mirrors the version-suffix peeling in `extractOverrideTargetName`: the version -// `@` follows the name (after the `/` of a scoped name); the leading scope `@` -// is never a version separator. -function stripSegmentVersion(segment: string): string { - const nameStart = segment.startsWith('@') ? segment.indexOf('/') + 1 : 0; - const versionAt = segment.indexOf('@', nameStart); - return versionAt > 0 ? segment.slice(0, versionAt) : segment; -} - // True iff a single parent-NAME glob segment matches the given literal package // name. `*` matches any run of characters; all other glob/regex metacharacters // are escaped. Used for the concrete ancestor segments of a selector. @@ -130,64 +124,6 @@ function parentChainReachesVitePlus(segments: string[]): boolean { return true; } -// Extract the ordered PARENT chain of an override/resolution key — the ancestor -// segments above the forced TARGET — or `null` when the key has no parent -// selector (a bare/versioned global pin). Each segment's own `@version`/range is -// stripped and scoped names (`@scope/name`) are kept whole; glob segments (`**`, -// `vite-*`) are preserved verbatim for `parentChainReachesVitePlus`. -// -// Mirrors `extractOverrideTargetName`'s grammar so target and parent stay -// consistent (see that function for the full delimiter rationale): -// - pnpm `a>b>child`: every `>`-separated prefix is a parent level (`a`, `b`); -// pnpm has no globs, so a chain of length > 1 always carries a specific -// ancestor. -// - yarn `from/descriptor`: the descriptor is the trailing 1 (unscoped) or 2 -// (scoped) segments; the remaining leading `/`-segments are the `from` chain, -// with scoped ancestors (`@scope/name`) rejoined. -// - bare/versioned names (`pkg`, `@scope/pkg`, `pkg@4`) have NO parent → `null`. -export function extractOverrideParentSegments(key: string): string[] | null { - let rest = key.trim(); - // Peel every pnpm `>` parent level. pnpm splits at a `>` whose preceding char - // is NOT space, `|`, or `@` (its DELIMITER_REGEX), so semver comparators like - // `pkg@>=4` are not mistaken for a parent selector. - const pnpmParents: string[] = []; - for (let delim = rest.search(/[^ |@]>/); delim !== -1; delim = rest.search(/[^ |@]>/)) { - pnpmParents.push(stripSegmentVersion(rest.slice(0, delim + 1).trim())); - rest = rest.slice(delim + 2).trim(); - } - if (pnpmParents.length > 0) { - return pnpmParents; - } - // No pnpm parent — check for a yarn `from/descriptor` selector. `rest` is the - // child (target) descriptor; only a `/` beyond a single scoped name leaves a - // leading `from` (parent) chain. - if (!rest.includes('/')) { - return null; - } - const segments = rest.split('/'); - // The trailing descriptor occupies the last 2 segments when it is a scoped - // name (second-to-last segment starts with `@`), else the last 1. - const descriptorIsScoped = segments[segments.length - 2]?.startsWith('@') ?? false; - const descriptorSegmentCount = descriptorIsScoped ? 2 : 1; - const rawParents = segments.slice(0, segments.length - descriptorSegmentCount); - if (rawParents.length === 0) { - // The whole key was a bare scoped name (`@scope/pkg`) — no parent selector. - return null; - } - // Rejoin scoped ancestors (`@scope` + `name`) and strip each segment's version. - const parents: string[] = []; - for (let i = 0; i < rawParents.length; i += 1) { - const segment = rawParents[i]; - if (segment.startsWith('@') && i + 1 < rawParents.length) { - parents.push(stripSegmentVersion(`${segment}/${rawParents[i + 1]}`)); - i += 1; - } else { - parents.push(stripSegmentVersion(segment)); - } - } - return parents; -} - // True iff a provider override/resolution key (target ∈ // PROVIDER_OVERRIDE_DROP_NAMES) should be dropped because the pin would affect // vite-plus's OWN direct provider dep. The pin reaches that dep iff its parent diff --git a/packages/cli/src/migration/migrator/vitest-v5.ts b/packages/cli/src/migration/migrator/vitest-v5.ts index 3113afaf90..41907f0515 100644 --- a/packages/cli/src/migration/migrator/vitest-v5.ts +++ b/packages/cli/src/migration/migrator/vitest-v5.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import path from 'node:path'; import { applyEdits, findNodeAtLocation, parse as parseJsonc, parseTree } from 'jsonc-parser'; @@ -22,7 +21,9 @@ import cliPackage from '../../../package.json' with { type: 'json' }; import { PackageManager, type WorkspaceInfoOptional } from '../../types/index.ts'; import { findGitRoot } from '../../utils/git.ts'; import { detectPackageMetadata } from '../../utils/package.ts'; +import fs from '../files.ts'; import { createCatalogDependencyResolver } from '../migrator.ts'; +import { isMigrationIgnored } from '../scope.ts'; import type { RewriteResult, SourceOptions, VitestV5Finding } from '../vitest-v5/ast.ts'; import { migrateVitestV5Command } from '../vitest-v5/commands.ts'; import { @@ -141,6 +142,9 @@ function filesInProject(directory: string): string[] { } for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const file = path.join(current, entry.name); + if (isMigrationIgnored(file, entry.isDirectory())) { + continue; + } // Never follow symlinks into another package or outside the workspace. if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) { walk(file); diff --git a/packages/cli/src/migration/migrator/yarn.ts b/packages/cli/src/migration/migrator/yarn.ts index fb39f76881..d773455258 100644 --- a/packages/cli/src/migration/migrator/yarn.ts +++ b/packages/cli/src/migration/migrator/yarn.ts @@ -1,4 +1,3 @@ -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -11,8 +10,9 @@ import { VITE_PLUS_NAME, VITE_PLUS_VERSION, } from '../../utils/constants.ts'; -import { readJsonFile } from '../../utils/json.ts'; -import { editYamlFile, readYamlFile, scalarString } from '../../utils/yaml.ts'; +import fs from '../files.ts'; +import { readJsonFile } from '../files.ts'; +import { editYamlFile, readYamlFile, scalarString } from '../files.ts'; import { createCatalogDependencyResolverFromCatalogs, overridesSatisfyVitePlus, diff --git a/packages/cli/src/migration/scope.ts b/packages/cli/src/migration/scope.ts new file mode 100644 index 0000000000..6910c1e4c6 --- /dev/null +++ b/packages/cli/src/migration/scope.ts @@ -0,0 +1,218 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { readStaticMigrationConfig } from '../../binding/index.js'; +import { migrationIgnorePatterns } from '../migrate-config.ts'; +import { createMigrationIgnoreMatcher } from '../migration-ignore.ts'; +import { findViteConfig, resolveViteConfig } from '../resolve-vite-config.ts'; +import { PackageManager } from '../types/index.ts'; +import { VITEST_VERSION, VITE_PLUS_OVERRIDE_PACKAGES } from '../utils/constants.ts'; +import { readJsonFile } from '../utils/json.ts'; +import { + extractOverrideParentSegments, + extractOverrideTargetName, +} from '../utils/package-overrides.ts'; +import { isAlignableVitestEcosystemPackage } from '../utils/vitest-ecosystem.ts'; +import { readYamlFile } from '../utils/yaml.ts'; + +export class MigrationScope { + readonly matcher; + readonly ignoredManifests: string[] = []; + + constructor( + readonly root: string, + readonly patterns: string[], + ) { + this.matcher = createMigrationIgnoreMatcher(root, patterns); + if (patterns.length > 0) { + this.collectIgnoredManifests(root); + } + } + + ignores(file: string, isDirectory = false): boolean { + return this.matcher.isIgnored(path.resolve(file), isDirectory); + } + + private collectIgnoredManifests(dir: string): void { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory() && !['node_modules', '.git', '.yarn'].includes(entry.name)) { + this.collectIgnoredManifests(file); + } else if (entry.isFile() && entry.name === 'package.json' && this.ignores(file)) { + this.ignoredManifests.push(file); + } + } + } + + get preserveSharedDependencies(): boolean { + return this.ignoredManifests.length > 0; + } +} + +const scopes = new AsyncLocalStorage(); +export const withMigrationScope = (scope: MigrationScope, run: () => T): T => + scopes.run(scope, run); +export const migrationScope = () => scopes.getStore(); +export const isMigrationIgnored = (file: string, directory = false): boolean => + migrationScope()?.ignores(file, directory) ?? false; +export const preserveSharedDependencies = (): boolean => + migrationScope()?.preserveSharedDependencies ?? false; + +async function readMigrationPatterns(root: string): Promise { + const extracted = readStaticMigrationConfig(root); + const config = + extracted == null ? (await resolveViteConfig(root)).migrate : JSON.parse(extracted); + return migrationIgnorePatterns(config); +} + +// Package-manager discovery treats a non-member example as a standalone project. +// An explicit target inside this repository must still honor the root policy. +// A nested Git repository (for example a cloned template) is independent. +export async function checkEnclosingMigrationPolicy(root: string): Promise { + let dir = root; + while (!fs.existsSync(path.join(dir, '.git'))) { + const parent = path.dirname(dir); + if (parent === dir) { + return; + } + dir = parent; + if (!fs.existsSync(path.join(dir, 'package.json')) || !findViteConfig(dir)) { + continue; + } + const patterns = await readMigrationPatterns(dir); + if (patterns.length > 0) { + const ignored = createMigrationIgnoreMatcher(dir, patterns).isIgnored(root, true); + throw new Error( + ignored + ? `Cannot migrate ${path.relative(dir, root)}: excluded by migrate.ignorePatterns in ${findViteConfig(dir)}. No project files were changed.` + : `Run vp migrate from ${dir} so its migrate.ignorePatterns policy also applies to nested files. No project files were changed.`, + ); + } + } +} + +export async function loadMigrationScope( + root: string, + packageManager?: PackageManager, +): Promise { + const scope = new MigrationScope(root, await readMigrationPatterns(root)); + if (scope.preserveSharedDependencies) { + checkSharedOverrides(scope, packageManager); + } + return scope; +} + +// Catalogs and overrides belong to every consumer, including templates outside +// the workspace package list. Use explicit specs for migrated consumers and +// retain these shared settings in their original locations. +export const sharedDependencyFields = [ + ['catalog'], + ['catalogs'], + ['overrides'], + ['resolutions'], + ['pnpm', 'overrides'], + ['workspaces', 'catalog'], + ['workspaces', 'catalogs'], +] as const; + +function checkSharedOverrides(scope: MigrationScope, packageManager?: PackageManager): void { + const pkg = readJsonFile(path.join(scope.root, 'package.json')); + const settings = [pkg]; + for (const name of ['pnpm-workspace.yaml', '.yarnrc.yml']) { + if ( + packageManager && + name !== + (packageManager === PackageManager.pnpm + ? 'pnpm-workspace.yaml' + : packageManager === PackageManager.yarn + ? '.yarnrc.yml' + : '') + ) { + continue; + } + const file = path.join(scope.root, name); + if (fs.existsSync(file)) { + settings.push(readYamlFile(file) ?? {}); + } + } + const catalogs = + settings.find((value) => value.catalog || value.catalogs) ?? + (pkg.workspaces as Record | undefined) ?? + {}; + const resolveSpec = (spec: string, name: string): unknown => { + if (spec.startsWith('catalog:')) { + const catalogName = spec.slice(8) || 'default'; + const named = catalogs.catalogs as Record> | undefined; + return ( + catalogName === 'default' + ? ((catalogs.catalog as Record | undefined) ?? named?.default) + : named?.[catalogName] + )?.[name]; + } + if (spec.startsWith('$')) { + const dependency = spec.slice(1); + return ( + (pkg.dependencies as Record | undefined)?.[dependency] ?? + (pkg.devDependencies as Record | undefined)?.[dependency] + ); + } + return spec; + }; + const excludedNames = new Set( + scope.ignoredManifests.flatMap((file) => { + try { + return [readJsonFile(file).name]; + } catch { + return []; + } + }), + ); + const check = (overrides: unknown, parents: string[] = []) => { + if (!overrides || typeof overrides !== 'object') { + return; + } + for (const [selector, value] of Object.entries(overrides)) { + const key = selector === '.' ? parents.at(-1)! : selector; + const name = extractOverrideTargetName(key); + const ancestors = selector === '.' ? parents.slice(0, -1) : parents; + const chain = ancestors + .map(extractOverrideTargetName) + .concat(extractOverrideParentSegments(key) ?? []); + // An override scoped exclusively to an excluded package can stay as-is. + // For other selectors we cannot prove that the managed graph is isolated. + const excludedOnly = chain.length > 0 && excludedNames.has(chain[0]); + if ( + typeof value === 'string' && + (name === 'vite' || name === 'vitest' || isAlignableVitestEcosystemPackage(name)) && + !excludedOnly + ) { + const target = name === 'vite' ? VITE_PLUS_OVERRIDE_PACKAGES.vite : VITEST_VERSION; + if (resolveSpec(value, name) !== target) { + throw new Error( + `Cannot migrate while preserving excluded packages: override ${JSON.stringify(selector)} (${JSON.stringify(value)}) conflicts with ${JSON.stringify(target)}. Review the root override or migrate the excluded packages first. No project files were changed.`, + ); + } + } + if (value && typeof value === 'object') { + check(value, [...parents, selector]); + } + } + }; + for (const value of settings) { + if ( + !packageManager || + packageManager === PackageManager.npm || + packageManager === PackageManager.bun || + (packageManager === PackageManager.pnpm && value !== pkg) + ) { + check(value.overrides); + } + if (!packageManager || packageManager === PackageManager.yarn) { + check(value.resolutions); + } + if (!packageManager || packageManager === PackageManager.pnpm) { + check((value.pnpm as Record | undefined)?.overrides); + } + } +} diff --git a/packages/cli/src/oxlint-plugin.ts b/packages/cli/src/oxlint-plugin.ts index 55ab819997..54be443f9a 100644 --- a/packages/cli/src/oxlint-plugin.ts +++ b/packages/cli/src/oxlint-plugin.ts @@ -5,6 +5,7 @@ import { definePlugin, defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; import cliPackage from '../package.json' with { type: 'json' }; +import { createMigrationIgnoreMatcher } from './migration-ignore.ts'; import { PREFER_VITE_PLUS_IMPORTS_RULE_NAME, VITE_PLUS_OXLINT_PLUGIN_NAME, @@ -438,6 +439,8 @@ export const preferVitePlusImportsRule = defineRule({ }, }, createOnce(context: Context) { + const matchers = new Map>(); + let ignored = false; const options: ImportRewriteOptions = { preserveUpstreamVitest: false, fileIsViteConfig: false, @@ -447,6 +450,22 @@ export const preferVitePlusImportsRule = defineRule({ }; return { Program() { + const policy = context.settings['vite-plus/migrate'] as + | { root: string; ignorePatterns: string[] } + | undefined; + let ignore: ReturnType | undefined; + if (policy) { + const key = JSON.stringify(policy); + ignore = matchers.get(key); + if (!ignore) { + ignore = createMigrationIgnoreMatcher(policy.root, policy.ignorePatterns); + matchers.set(key, ignore); + } + } + ignored = ignore?.isIgnored(path.resolve(context.filename), false) ?? false; + if (ignored) { + return; + } options.preserveUpstreamVitest = nearestPackageUsesNuxtTestUtils(context.filename); options.fileIsViteConfig = isViteConfigFile(context.filename); options.ownsOxlintApi = nearestPackageOwnsOxlintApi(context.filename); @@ -454,23 +473,41 @@ export const preferVitePlusImportsRule = defineRule({ options.keepsVitest = nearestPackageKeepsVitest(context.filename); }, ImportDeclaration(node) { + if (ignored) { + return; + } maybeReportLiteral(context, node.source, options); reportLegacyOxlintPluginApiImport(context, node, options.ownsOxlintApi); }, ExportAllDeclaration(node) { + if (ignored) { + return; + } maybeReportLiteral(context, node.source, options); }, ExportNamedDeclaration(node) { + if (ignored) { + return; + } maybeReportLiteral(context, node.source, options); reportLegacyOxlintPluginApiExport(context, node, options.ownsOxlintApi); }, ImportExpression(node) { + if (ignored) { + return; + } maybeReportLiteral(context, node.source, options); }, TSImportType(node) { + if (ignored) { + return; + } maybeReportLiteral(context, node.source, options); }, TSExternalModuleReference(node) { + if (ignored) { + return; + } // Keep import-equals declarations unchanged, matching the migrator's // treatment of require calls. if ( @@ -483,6 +520,9 @@ export const preferVitePlusImportsRule = defineRule({ maybeReportLiteral(context, node.expression, options); }, TSModuleDeclaration(node) { + if (ignored) { + return; + } if (node.global) { return; } diff --git a/packages/cli/src/utils/agent.ts b/packages/cli/src/utils/agent.ts index 4c940f38f6..d3360a2854 100644 --- a/packages/cli/src/utils/agent.ts +++ b/packages/cli/src/utils/agent.ts @@ -418,6 +418,7 @@ export async function writeAgentInstructions({ interactive, conflictDecisions, silent = false, + shouldWriteFile = () => true, }: { projectRoot: string; targetPath?: string; @@ -425,6 +426,7 @@ export async function writeAgentInstructions({ interactive: boolean; conflictDecisions?: Map; silent?: boolean; + shouldWriteFile?: (file: string) => boolean; }) { const paths = [...(targetPaths ?? []), ...(targetPath ? [targetPath] : [])]; if (paths.length === 0) { @@ -445,6 +447,9 @@ export async function writeAgentInstructions({ for (const targetPathToWrite of orderAgentTargetPaths(projectRoot, paths)) { const destinationPath = path.join(projectRoot, targetPathToWrite); + if (!shouldWriteFile(destinationPath)) { + continue; + } await fsPromises.mkdir(path.dirname(destinationPath), { recursive: true }); diff --git a/packages/cli/src/utils/editor.ts b/packages/cli/src/utils/editor.ts index f986fb847f..4606781459 100644 --- a/packages/cli/src/utils/editor.ts +++ b/packages/cli/src/utils/editor.ts @@ -338,6 +338,7 @@ export async function writeEditorConfigs({ silent = false, extraVsCodeSettings, packageManager = PackageManager.pnpm, + shouldWriteFile = () => true, }: { projectRoot: string; editorId: EditorSelection; @@ -346,6 +347,7 @@ export async function writeEditorConfigs({ silent?: boolean; extraVsCodeSettings?: Record; packageManager?: PackageManager; + shouldWriteFile?: (file: string) => boolean; }) { const editorIds = normalizeEditorSelection(editorId); if (editorIds.length === 0) { @@ -361,6 +363,7 @@ export async function writeEditorConfigs({ silent, extraVsCodeSettings, packageManager, + shouldWriteFile, }); } } @@ -373,6 +376,7 @@ async function writeEditorConfig({ silent, extraVsCodeSettings, packageManager, + shouldWriteFile, }: { projectRoot: string; editorId: EditorId; @@ -381,6 +385,7 @@ async function writeEditorConfig({ silent: boolean; extraVsCodeSettings?: Record; packageManager: PackageManager; + shouldWriteFile: (file: string) => boolean; }) { const editorConfig = EDITORS.find((e) => e.id === editorId); if (!editorConfig) { @@ -388,7 +393,6 @@ async function writeEditorConfig({ } const targetDir = path.join(projectRoot, editorConfig.targetDir); - await fsPromises.mkdir(targetDir, { recursive: true }); for (const [fileName, baseIncoming] of Object.entries(editorConfig.files)) { const incoming: EditorConfigValue = @@ -398,6 +402,10 @@ async function writeEditorConfig({ ? jetbrainsWorkspaceConfig(packageManager) : baseIncoming; const filePath = path.join(targetDir, fileName); + if (!shouldWriteFile(filePath)) { + continue; + } + await fsPromises.mkdir(targetDir, { recursive: true }); const jsonFormat = isJsonLikeFile(fileName); if (fs.existsSync(filePath)) { diff --git a/packages/cli/src/utils/package-overrides.ts b/packages/cli/src/utils/package-overrides.ts index f5a7234034..a8dd146540 100644 --- a/packages/cli/src/utils/package-overrides.ts +++ b/packages/cli/src/utils/package-overrides.ts @@ -30,3 +30,71 @@ export function extractOverrideTargetName(key: string): string { const versionAt = target.indexOf('@', nameStart); return versionAt > 0 ? target.slice(0, versionAt) : target; } + +// Strip a trailing `@version`/range from a selector segment and keep its scope. +// Mirrors the version-suffix peeling in `extractOverrideTargetName`: the version +// `@` follows the name (after the `/` of a scoped name); the leading scope `@` +// is never a version separator. +function stripSegmentVersion(segment: string): string { + const nameStart = segment.startsWith('@') ? segment.indexOf('/') + 1 : 0; + const versionAt = segment.indexOf('@', nameStart); + return versionAt > 0 ? segment.slice(0, versionAt) : segment; +} + +// Extract the ordered PARENT chain of an override/resolution key — the ancestor +// segments above the forced TARGET — or `null` when the key has no parent +// selector (a bare/versioned global pin). Each segment's own `@version`/range is +// stripped and scoped names (`@scope/name`) are kept whole; glob segments (`**`, +// `vite-*`) are preserved verbatim for `parentChainReachesVitePlus`. +// +// Mirrors `extractOverrideTargetName`'s grammar so target and parent stay +// consistent (see that function for the full delimiter rationale): +// - pnpm `a>b>child`: every `>`-separated prefix is a parent level (`a`, `b`); +// pnpm has no globs, so a chain of length > 1 always carries a specific +// ancestor. +// - yarn `from/descriptor`: the descriptor is the trailing 1 (unscoped) or 2 +// (scoped) segments; the remaining leading `/`-segments are the `from` chain, +// with scoped ancestors (`@scope/name`) rejoined. +// - bare/versioned names (`pkg`, `@scope/pkg`, `pkg@4`) have NO parent → `null`. +export function extractOverrideParentSegments(key: string): string[] | null { + let rest = key.trim(); + // Peel every pnpm `>` parent level. pnpm splits at a `>` whose preceding char + // is NOT space, `|`, or `@` (its DELIMITER_REGEX), so semver comparators like + // `pkg@>=4` are not mistaken for a parent selector. + const pnpmParents: string[] = []; + for (let delim = rest.search(/[^ |@]>/); delim !== -1; delim = rest.search(/[^ |@]>/)) { + pnpmParents.push(stripSegmentVersion(rest.slice(0, delim + 1).trim())); + rest = rest.slice(delim + 2).trim(); + } + if (pnpmParents.length > 0) { + return pnpmParents; + } + // No pnpm parent — check for a yarn `from/descriptor` selector. `rest` is the + // child (target) descriptor; only a `/` beyond a single scoped name leaves a + // leading `from` (parent) chain. + if (!rest.includes('/')) { + return null; + } + const segments = rest.split('/'); + // The trailing descriptor occupies the last 2 segments when it is a scoped + // name (second-to-last segment starts with `@`), else the last 1. + const descriptorIsScoped = segments[segments.length - 2]?.startsWith('@') ?? false; + const descriptorSegmentCount = descriptorIsScoped ? 2 : 1; + const rawParents = segments.slice(0, segments.length - descriptorSegmentCount); + if (rawParents.length === 0) { + // The whole key was a bare scoped name (`@scope/pkg`) — no parent selector. + return null; + } + // Rejoin scoped ancestors (`@scope` + `name`) and strip each segment's version. + const parents: string[] = []; + for (let i = 0; i < rawParents.length; i += 1) { + const segment = rawParents[i]; + if (segment.startsWith('@') && i + 1 < rawParents.length) { + parents.push(stripSegmentVersion(`${segment}/${rawParents[i + 1]}`)); + i += 1; + } else { + parents.push(stripSegmentVersion(segment)); + } + } + return parents; +}