From 2e5528524c56bee1f1b88e265caedcb89aae8aba Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 4 Sep 2026 10:01:14 +0800 Subject: [PATCH 1/3] fix(cli): recover from stale Vitest aliases --- .../.gitignore | 1 + .../package.json | 18 +++++ .../pnpm-workspace.yaml | 11 +++ .../setup-local.mjs | 33 +++++++++ .../snapshots.toml | 11 +++ .../migration_stale_vitest_alias_startup.md | 73 +++++++++++++++++++ packages/cli/src/bin.ts | 7 +- packages/cli/src/config/bin.ts | 14 +++- .../__tests__/legacy-vitest-alias.spec.ts | 64 ++++++++++++++++ packages/cli/src/utils/legacy-vitest-alias.ts | 64 ++++++++++++++++ 10 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/.gitignore create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/setup-local.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots/migration_stale_vitest_alias_startup.md create mode 100644 packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts create mode 100644 packages/cli/src/utils/legacy-vitest-alias.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/.gitignore b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/package.json new file mode 100644 index 0000000000..8518bded4a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-stale-vitest-alias-startup", + "scripts": { + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "11.9.0", + "onFail": "download" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/pnpm-workspace.yaml new file mode 100644 index 0000000000..2048034313 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - . + +catalog: + vite: npm:@voidzero-dev/vite-plus-core@0.3.0 + vite-plus: 0.3.0 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.24 + +overrides: + vite: 'catalog:' + vitest: 'catalog:' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/setup-local.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/setup-local.mjs new file mode 100644 index 0000000000..5e9dfa8c2d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/setup-local.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const sourcePackageDir = path.dirname(require.resolve('vite-plus/package.json')); +const localPackageDir = path.join('node_modules', 'vite-plus'); + +fs.mkdirSync('node_modules', { recursive: true }); +fs.cpSync(sourcePackageDir, localPackageDir, { recursive: true, dereference: true }); + +const fakeVitestDir = path.join('node_modules', 'vitest'); +fs.mkdirSync(fakeVitestDir, { recursive: true }); +fs.writeFileSync( + path.join(fakeVitestDir, 'package.json'), + JSON.stringify({ + name: 'vitest', + version: '0.1.24', + type: 'module', + exports: { + './config': './config.js', + './package.json': './package.json', + }, + }), +); +fs.writeFileSync( + path.join(fakeVitestDir, 'config.js'), + "throw new Error('stale Vitest alias was loaded before command dispatch');\n", +); + +// This file only prepares node_modules. Remove the staged copy so migration's +// source-import pass measures the project fixture rather than its test harness. +fs.rmSync(new URL(import.meta.url)); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots.toml new file mode 100644 index 0000000000..5de853af23 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots.toml @@ -0,0 +1,11 @@ +[[case]] +name = "migration_stale_vitest_alias_startup" +vp = "global" +link-node-modules = true +steps = [ + { argv = ["node", "setup-local.mjs"], snapshot = false, continue-on-failure = true }, + { argv = ["vp", "config", "--no-hooks", "--no-agent"], envs = [["npm_lifecycle_event", "prepare"]], comment = "prepare should report the stale alias instead of loading its native binding", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive"], envs = [["VP_SKIP_INSTALL", "1"]], comment = "migrate should start without loading the stale Vitest alias", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "the stale Vitest dependency should be removed", continue-on-failure = true }, + { argv = ["vpt", "print-file", "pnpm-workspace.yaml"], comment = "the stale catalog alias and override should be removed", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots/migration_stale_vitest_alias_startup.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots/migration_stale_vitest_alias_startup.md new file mode 100644 index 0000000000..f07bedca24 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_stale_vitest_alias_startup/snapshots/migration_stale_vitest_alias_startup.md @@ -0,0 +1,73 @@ +# migration_stale_vitest_alias_startup + +## `node setup-local.mjs` + + +## `npm_lifecycle_event=prepare vp config --no-hooks --no-agent` + +prepare should report the stale alias instead of loading its native binding + +**Exit code:** 1 + +``` +error: Found a stale Vitest alias in pnpm-workspace.yaml that points to the removed `@voidzero-dev/vite-plus-test` package. Run `vp migrate` to update the project before installing dependencies. +``` + +## `VP_SKIP_INSTALL=1 vp migrate --no-interactive` + +migrate should start without loading the stale Vitest alias + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• Package manager settings configured +``` + +## `vpt print-file package.json` + +the stale Vitest dependency should be removed + +``` +{ + "name": "migration-stale-vitest-alias-startup", + "scripts": { + "prepare": "vp config" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vpt print-file pnpm-workspace.yaml` + +the stale catalog alias and override should be removed + +``` +packages: + - . + +catalog: + vite: npm:@voidzero-dev/vite-plus-core@ + vite-plus: + +overrides: + vite@*: 'catalog:' +peerDependencyRules: + allowAny: + - vite + allowedVersions: + vite: '*' +``` diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 5e7e2075a1..96e5f5a882 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -21,7 +21,6 @@ import { fmt } from './resolve-fmt.ts'; import { lint } from './resolve-lint.ts'; import { pack } from './resolve-pack.ts'; import { test } from './resolve-test.ts'; -import { resolveUniversalViteConfig } from './resolve-vite-config.ts'; import { vite } from './resolve-vite.ts'; import { accent, errorMsg, log } from './utils/terminal.ts'; @@ -130,6 +129,12 @@ if (maybePrintCommandHelp(args)) { } else { // All other commands — delegate to Rust core via NAPI binding try { + // Loading resolve-vite-config also loads define-config, whose runtime + // Vitest helpers import `vitest/config`. Keep that import behind the + // delegated-command branch so global commands such as `migrate` and + // `config` can repair a stale `vitest` package-manager alias before the + // removed wrapper is evaluated. + const { resolveUniversalViteConfig } = await import('./resolve-vite-config.js'); const initInspection = inspectInitCommand(command, args.slice(1)); if ( initInspection.handled && diff --git a/packages/cli/src/config/bin.ts b/packages/cli/src/config/bin.ts index c46d7be1e0..e9920de2f0 100644 --- a/packages/cli/src/config/bin.ts +++ b/packages/cli/src/config/bin.ts @@ -1,11 +1,12 @@ import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { parseConfigArgs } from '../../binding/index.js'; import { updateExistingAgentInstructions } from '../utils/agent.ts'; import { unwrapCliParseOutcome } from '../utils/cli-parse.ts'; +import { findLegacyVitestAliasConfig } from '../utils/legacy-vitest-alias.ts'; import { defaultInteractive, promptGitHooks } from '../utils/prompts.ts'; -import { log } from '../utils/terminal.ts'; +import { errorMsg, log } from '../utils/terminal.ts'; import { install, isGitHooksEnvDisabled, @@ -23,6 +24,15 @@ async function main() { const isLifecycleScript = lifecycleEvent === 'prepare' || lifecycleEvent === 'postinstall'; const root = process.cwd(); + const staleVitestAliasConfig = findLegacyVitestAliasConfig(root); + if (staleVitestAliasConfig) { + const relativeConfigPath = relative(root, staleVitestAliasConfig) || 'package.json'; + errorMsg( + `Found a stale Vitest alias in ${relativeConfigPath} that points to the removed \`@voidzero-dev/vite-plus-test\` package. Run \`vp migrate\` to update the project before installing dependencies.`, + ); + process.exit(1); + } + // --- Step 1: Hooks setup --- // Prefer CLI flag, then last-used dir from local git config, then default. // Check environment opt-outs before the Git-backed location lookup. diff --git a/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts b/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts new file mode 100644 index 0000000000..a4d63bdeda --- /dev/null +++ b/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { findLegacyVitestAliasConfig } from '../legacy-vitest-alias.ts'; + +describe('findLegacyVitestAliasConfig', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-legacy-vitest-alias-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + it('finds a nested package.json override alias', () => { + fs.writeFileSync( + path.join(projectDir, 'package.json'), + JSON.stringify({ + overrides: { + vitest: 'npm:@voidzero-dev/vite-plus-test@0.1.24', + }, + }), + ); + + expect(findLegacyVitestAliasConfig(projectDir)).toBe(path.join(projectDir, 'package.json')); + }); + + it('finds a pnpm catalog alias from a workspace package', () => { + const packageDir = path.join(projectDir, 'packages', 'app'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{}'); + fs.writeFileSync( + path.join(projectDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalog:', + ' vitest: npm:@voidzero-dev/vite-plus-test@0.1.24', + 'overrides:', + " vitest: 'catalog:'", + '', + ].join('\n'), + ); + + expect(findLegacyVitestAliasConfig(packageDir)).toBe( + path.join(projectDir, 'pnpm-workspace.yaml'), + ); + }); + + it('ignores current Vitest pins and malformed config files', () => { + fs.writeFileSync(path.join(projectDir, 'package.json'), '{'); + fs.writeFileSync( + path.join(projectDir, 'pnpm-workspace.yaml'), + ['catalog:', ' vitest: 4.1.11', 'overrides:', ' vitest@*: 4.1.11', ''].join('\n'), + ); + + expect(findLegacyVitestAliasConfig(projectDir)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/legacy-vitest-alias.ts b/packages/cli/src/utils/legacy-vitest-alias.ts new file mode 100644 index 0000000000..71713bd2c9 --- /dev/null +++ b/packages/cli/src/utils/legacy-vitest-alias.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { parse as parseYaml } from 'yaml'; + +const LEGACY_VITEST_PACKAGE = '@voidzero-dev/vite-plus-test'; +const LEGACY_VITEST_ALIAS = `npm:${LEGACY_VITEST_PACKAGE}`; + +function isLegacyVitestAlias(value: unknown): boolean { + return ( + typeof value === 'string' && + (value === LEGACY_VITEST_ALIAS || value.startsWith(`${LEGACY_VITEST_ALIAS}@`)) + ); +} + +function containsLegacyVitestAlias(value: unknown): boolean { + if (isLegacyVitestAlias(value)) { + return true; + } + if (Array.isArray(value)) { + return value.some(containsLegacyVitestAlias); + } + if (value !== null && typeof value === 'object') { + return Object.values(value).some(containsLegacyVitestAlias); + } + return false; +} + +function readConfigFile(filePath: string): unknown { + if (!fs.existsSync(filePath)) { + return undefined; + } + try { + const source = fs.readFileSync(filePath, 'utf8'); + return path.basename(filePath) === 'package.json' ? JSON.parse(source) : parseYaml(source); + } catch { + // Configuration parsing has its own diagnostics. Do not replace them with + // the stale-alias recovery message when the file is malformed. + return undefined; + } +} + +/** + * Find a package-manager setting that still aliases Vitest to the deleted + * `@voidzero-dev/vite-plus-test` wrapper. Walk upward so lifecycle scripts in + * workspace packages also inspect the root `pnpm-workspace.yaml`. + */ +export function findLegacyVitestAliasConfig(startDir: string): string | undefined { + let currentDir = path.resolve(startDir); + while (true) { + for (const fileName of ['package.json', 'pnpm-workspace.yaml']) { + const filePath = path.join(currentDir, fileName); + if (containsLegacyVitestAlias(readConfigFile(filePath))) { + return filePath; + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return undefined; + } + currentDir = parentDir; + } +} From 230ac8ff3a285b588173a42370af167a35788244 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 00:27:54 +0800 Subject: [PATCH 2/3] fix(config): scope stale Vitest alias detection --- .../npm-workspace/package.json | 4 + .../package.json | 6 + .../pnpm-workspace/package.json | 3 + .../pnpm-workspace/pnpm-workspace.yaml | 5 + .../snapshots.toml | 9 ++ ...ig_stale_vitest_alias_boundaries.global.md | 22 ++++ ...fig_stale_vitest_alias_boundaries.local.md | 22 ++++ .../standalone/package.json | 4 + .../__tests__/legacy-vitest-alias.spec.ts | 104 +++++++++++++++++- packages/cli/src/utils/legacy-vitest-alias.ts | 76 ++++++++++--- 10 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/npm-workspace/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/pnpm-workspace.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/standalone/package.json diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/npm-workspace/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/npm-workspace/package.json new file mode 100644 index 0000000000..fde53127fb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/npm-workspace/package.json @@ -0,0 +1,4 @@ +{ + "name": "npm-workspace", + "workspaces": ["packages/*"] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/package.json new file mode 100644 index 0000000000..eb1bdfbb43 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/package.json @@ -0,0 +1,6 @@ +{ + "name": "unrelated-parent", + "devDependencies": { + "vitest": "npm:@voidzero-dev/vite-plus-test@0.1.24" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/package.json new file mode 100644 index 0000000000..a07127694e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/package.json @@ -0,0 +1,3 @@ +{ + "name": "pnpm-workspace" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/pnpm-workspace.yaml new file mode 100644 index 0000000000..eefb9ee0cf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/pnpm-workspace/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - . +metadata: &metadata + self: *metadata + example: npm:@voidzero-dev/vite-plus-test@0.1.24 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots.toml new file mode 100644 index 0000000000..210f203c98 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "config_stale_vitest_alias_boundaries" +vp = ["local", "global"] +env = { npm_lifecycle_event = "prepare" } +steps = [ + { argv = ["vp", "config", "--no-hooks", "--no-agent"], cwd = "standalone", comment = "Ignore metadata and unrelated parent package settings", continue-on-failure = true }, + { argv = ["vp", "config", "--no-hooks", "--no-agent"], cwd = "npm-workspace", comment = "Stop at the npm workspace root", continue-on-failure = true }, + { argv = ["vp", "config", "--no-hooks", "--no-agent"], cwd = "pnpm-workspace", comment = "Stop at the pnpm workspace root and ignore cyclic metadata", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.global.md new file mode 100644 index 0000000000..a71ff6cfcc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.global.md @@ -0,0 +1,22 @@ +# config_stale_vitest_alias_boundaries + +## `cd standalone && vp config --no-hooks --no-agent` + +Ignore metadata and unrelated parent package settings + +``` +``` + +## `cd npm-workspace && vp config --no-hooks --no-agent` + +Stop at the npm workspace root + +``` +``` + +## `cd pnpm-workspace && vp config --no-hooks --no-agent` + +Stop at the pnpm workspace root and ignore cyclic metadata + +``` +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.local.md new file mode 100644 index 0000000000..a71ff6cfcc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/snapshots/config_stale_vitest_alias_boundaries.local.md @@ -0,0 +1,22 @@ +# config_stale_vitest_alias_boundaries + +## `cd standalone && vp config --no-hooks --no-agent` + +Ignore metadata and unrelated parent package settings + +``` +``` + +## `cd npm-workspace && vp config --no-hooks --no-agent` + +Stop at the npm workspace root + +``` +``` + +## `cd pnpm-workspace && vp config --no-hooks --no-agent` + +Stop at the pnpm workspace root and ignore cyclic metadata + +``` +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/standalone/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/standalone/package.json new file mode 100644 index 0000000000..100ec4b172 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/config_stale_vitest_alias_boundaries/standalone/package.json @@ -0,0 +1,4 @@ +{ + "name": "standalone", + "description": "npm:@voidzero-dev/vite-plus-test@0.1.24" +} diff --git a/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts b/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts index a4d63bdeda..0a47c6edd3 100644 --- a/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts +++ b/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { findLegacyVitestAliasConfig } from '../legacy-vitest-alias.ts'; describe('findLegacyVitestAliasConfig', () => { + const legacyAlias = 'npm:@voidzero-dev/vite-plus-test@0.1.24'; let projectDir: string; beforeEach(() => { @@ -22,7 +23,7 @@ describe('findLegacyVitestAliasConfig', () => { path.join(projectDir, 'package.json'), JSON.stringify({ overrides: { - vitest: 'npm:@voidzero-dev/vite-plus-test@0.1.24', + parent: { vitest: legacyAlias }, }, }), ); @@ -30,6 +31,107 @@ describe('findLegacyVitestAliasConfig', () => { expect(findLegacyVitestAliasConfig(projectDir)).toBe(path.join(projectDir, 'package.json')); }); + it.each([ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + 'resolutions', + 'catalog', + ])('finds aliases in package.json %s', (field) => { + fs.writeFileSync( + path.join(projectDir, 'package.json'), + JSON.stringify({ [field]: { vitest: legacyAlias } }), + ); + + expect(findLegacyVitestAliasConfig(projectDir)).toBe(path.join(projectDir, 'package.json')); + }); + + it.each([ + { pnpm: { overrides: { vitest: legacyAlias } } }, + { catalogs: { testing: { vitest: legacyAlias } } }, + { workspaces: { packages: ['packages/*'], catalog: { vitest: legacyAlias } } }, + { workspaces: { packages: ['packages/*'], catalogs: { testing: { vitest: legacyAlias } } } }, + ])('finds aliases in nested package-manager settings: %j', (config) => { + fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify(config)); + + expect(findLegacyVitestAliasConfig(projectDir)).toBe(path.join(projectDir, 'package.json')); + }); + + it.each(['standalone', 'pnpm', 'npm'])('stops at the %s project boundary', (kind) => { + const childDir = path.join(projectDir, 'child'); + const sourceDir = path.join(childDir, 'src'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, 'package.json'), + JSON.stringify({ devDependencies: { vitest: legacyAlias } }), + ); + fs.writeFileSync( + path.join(childDir, 'package.json'), + JSON.stringify({ + devDependencies: { vitest: '4.1.11' }, + ...(kind === 'npm' ? { workspaces: ['packages/*'] } : {}), + }), + ); + if (kind === 'pnpm') { + fs.writeFileSync(path.join(childDir, 'pnpm-workspace.yaml'), 'packages: [.]\n'); + } + + expect(findLegacyVitestAliasConfig(sourceDir)).toBeUndefined(); + }); + + it('finds root npm overrides from a workspace member', () => { + const packageDir = path.join(projectDir, 'packages', 'app'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'package.json'), '{}'); + fs.writeFileSync( + path.join(projectDir, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'], overrides: { vitest: legacyAlias } }), + ); + + expect(findLegacyVitestAliasConfig(packageDir)).toBe(path.join(projectDir, 'package.json')); + }); + + it('ignores alias strings in metadata and unrelated settings', () => { + fs.writeFileSync( + path.join(projectDir, 'package.json'), + JSON.stringify({ + description: legacyAlias, + config: { example: legacyAlias }, + pnpm: { metadata: legacyAlias }, + workspaces: { packages: [], metadata: legacyAlias }, + devDependencies: { vitest: '4.1.11' }, + }), + ); + fs.writeFileSync( + path.join(projectDir, 'pnpm-workspace.yaml'), + `packages: [.]\nmetadata: ${legacyAlias}\ncatalog:\n vitest: 4.1.11\n`, + ); + + expect(findLegacyVitestAliasConfig(projectDir)).toBeUndefined(); + }); + + it.each([false, true])('handles cyclic YAML with a stale alias present: %s', (hasAlias) => { + fs.writeFileSync(path.join(projectDir, 'package.json'), '{}'); + fs.writeFileSync( + path.join(projectDir, 'pnpm-workspace.yaml'), + [ + 'packages: [.]', + 'metadata: &metadata', + ' self: *metadata', + 'overrides: &overrides', + ' self: *overrides', + ' list: &list [*list]', + ` vitest: ${hasAlias ? legacyAlias : '4.1.11'}`, + '', + ].join('\n'), + ); + + expect(findLegacyVitestAliasConfig(projectDir)).toBe( + hasAlias ? path.join(projectDir, 'pnpm-workspace.yaml') : undefined, + ); + }); + it('finds a pnpm catalog alias from a workspace package', () => { const packageDir = path.join(projectDir, 'packages', 'app'); fs.mkdirSync(packageDir, { recursive: true }); diff --git a/packages/cli/src/utils/legacy-vitest-alias.ts b/packages/cli/src/utils/legacy-vitest-alias.ts index 71713bd2c9..e0213c410c 100644 --- a/packages/cli/src/utils/legacy-vitest-alias.ts +++ b/packages/cli/src/utils/legacy-vitest-alias.ts @@ -13,26 +13,57 @@ function isLegacyVitestAlias(value: unknown): boolean { ); } -function containsLegacyVitestAlias(value: unknown): boolean { +function containsLegacyVitestAlias(value: unknown, visited = new Set()): boolean { if (isLegacyVitestAlias(value)) { return true; } - if (Array.isArray(value)) { - return value.some(containsLegacyVitestAlias); - } if (value !== null && typeof value === 'object') { - return Object.values(value).some(containsLegacyVitestAlias); + if (visited.has(value)) { + return false; + } + visited.add(value); + return Object.values(value).some((entry) => containsLegacyVitestAlias(entry, visited)); } return false; } -function readConfigFile(filePath: string): unknown { +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function containsAliasInFields(config: unknown, fields: string[]): boolean { + const record = asRecord(config); + return fields.some((field) => containsLegacyVitestAlias(record?.[field])); +} + +function containsPackageAlias(config: Record | undefined): boolean { + return ( + containsAliasInFields(config, [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + 'overrides', + 'resolutions', + 'catalog', + 'catalogs', + ]) || + containsAliasInFields(config?.pnpm, ['overrides']) || + containsAliasInFields(config?.workspaces, ['catalog', 'catalogs']) + ); +} + +function readConfigFile(filePath: string): Record | undefined { if (!fs.existsSync(filePath)) { return undefined; } try { const source = fs.readFileSync(filePath, 'utf8'); - return path.basename(filePath) === 'package.json' ? JSON.parse(source) : parseYaml(source); + return asRecord( + path.basename(filePath) === 'package.json' ? JSON.parse(source) : parseYaml(source), + ); } catch { // Configuration parsing has its own diagnostics. Do not replace them with // the stale-alias recovery message when the file is malformed. @@ -42,19 +73,38 @@ function readConfigFile(filePath: string): unknown { /** * Find a package-manager setting that still aliases Vitest to the deleted - * `@voidzero-dev/vite-plus-test` wrapper. Walk upward so lifecycle scripts in - * workspace packages also inspect the root `pnpm-workspace.yaml`. + * `@voidzero-dev/vite-plus-test` wrapper. Check the nearest package and its + * workspace root, without inspecting unrelated ancestor package settings. */ export function findLegacyVitestAliasConfig(startDir: string): string | undefined { let currentDir = path.resolve(startDir); + let foundPackage = false; while (true) { - for (const fileName of ['package.json', 'pnpm-workspace.yaml']) { - const filePath = path.join(currentDir, fileName); - if (containsLegacyVitestAlias(readConfigFile(filePath))) { - return filePath; + const packagePath = path.join(currentDir, 'package.json'); + const pkg = readConfigFile(packagePath); + if (!foundPackage && fs.existsSync(packagePath)) { + foundPackage = true; + if (containsPackageAlias(pkg)) { + return packagePath; } } + // Match workspace discovery: the nearest pnpm-workspace.yaml or + // package.json with a workspaces field defines the workspace root. + // If neither exists, only the nearest package's settings apply. + const workspacePath = path.join(currentDir, 'pnpm-workspace.yaml'); + if (fs.existsSync(workspacePath) || (pkg && Object.hasOwn(pkg, 'workspaces'))) { + if (containsPackageAlias(pkg)) { + return packagePath; + } + if ( + containsAliasInFields(readConfigFile(workspacePath), ['catalog', 'catalogs', 'overrides']) + ) { + return workspacePath; + } + return undefined; + } + const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { return undefined; From 3ee2b7dad2c70bd3d1f7c3f95a23e2da28767ff1 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 01:04:09 +0800 Subject: [PATCH 3/3] refactor(cli): simplify stale Vitest alias detection --- packages/cli/src/bin.ts | 7 +- packages/cli/src/config/bin.ts | 2 +- .../__tests__/legacy-vitest-alias.spec.ts | 78 ++++++++----------- packages/cli/src/utils/legacy-vitest-alias.ts | 45 ++++------- 4 files changed, 53 insertions(+), 79 deletions(-) diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 96e5f5a882..8f5865b8ad 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -129,11 +129,8 @@ if (maybePrintCommandHelp(args)) { } else { // All other commands — delegate to Rust core via NAPI binding try { - // Loading resolve-vite-config also loads define-config, whose runtime - // Vitest helpers import `vitest/config`. Keep that import behind the - // delegated-command branch so global commands such as `migrate` and - // `config` can repair a stale `vitest` package-manager alias before the - // removed wrapper is evaluated. + // This module imports `vitest/config` through define-config. Load it here + // so `migrate` and `config` can handle stale aliases before Vitest loads. const { resolveUniversalViteConfig } = await import('./resolve-vite-config.js'); const initInspection = inspectInitCommand(command, args.slice(1)); if ( diff --git a/packages/cli/src/config/bin.ts b/packages/cli/src/config/bin.ts index e9920de2f0..48ca4d1d87 100644 --- a/packages/cli/src/config/bin.ts +++ b/packages/cli/src/config/bin.ts @@ -26,7 +26,7 @@ async function main() { const staleVitestAliasConfig = findLegacyVitestAliasConfig(root); if (staleVitestAliasConfig) { - const relativeConfigPath = relative(root, staleVitestAliasConfig) || 'package.json'; + const relativeConfigPath = relative(root, staleVitestAliasConfig); errorMsg( `Found a stale Vitest alias in ${relativeConfigPath} that points to the removed \`@voidzero-dev/vite-plus-test\` package. Run \`vp migrate\` to update the project before installing dependencies.`, ); diff --git a/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts b/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts index 0a47c6edd3..8d74f59472 100644 --- a/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts +++ b/packages/cli/src/utils/__tests__/legacy-vitest-alias.spec.ts @@ -10,6 +10,14 @@ describe('findLegacyVitestAliasConfig', () => { const legacyAlias = 'npm:@voidzero-dev/vite-plus-test@0.1.24'; let projectDir: string; + function writePackageJson(config: Record, directory?: string): string { + const packageDir = directory ?? projectDir; + const filePath = path.join(packageDir, 'package.json'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(config)); + return filePath; + } + beforeEach(() => { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-legacy-vitest-alias-')); }); @@ -19,16 +27,9 @@ describe('findLegacyVitestAliasConfig', () => { }); it('finds a nested package.json override alias', () => { - fs.writeFileSync( - path.join(projectDir, 'package.json'), - JSON.stringify({ - overrides: { - parent: { vitest: legacyAlias }, - }, - }), - ); + const packagePath = writePackageJson({ overrides: { parent: { vitest: legacyAlias } } }); - expect(findLegacyVitestAliasConfig(projectDir)).toBe(path.join(projectDir, 'package.json')); + expect(findLegacyVitestAliasConfig(projectDir)).toBe(packagePath); }); it.each([ @@ -39,12 +40,9 @@ describe('findLegacyVitestAliasConfig', () => { 'resolutions', 'catalog', ])('finds aliases in package.json %s', (field) => { - fs.writeFileSync( - path.join(projectDir, 'package.json'), - JSON.stringify({ [field]: { vitest: legacyAlias } }), - ); + const packagePath = writePackageJson({ [field]: { vitest: legacyAlias } }); - expect(findLegacyVitestAliasConfig(projectDir)).toBe(path.join(projectDir, 'package.json')); + expect(findLegacyVitestAliasConfig(projectDir)).toBe(packagePath); }); it.each([ @@ -53,25 +51,22 @@ describe('findLegacyVitestAliasConfig', () => { { workspaces: { packages: ['packages/*'], catalog: { vitest: legacyAlias } } }, { workspaces: { packages: ['packages/*'], catalogs: { testing: { vitest: legacyAlias } } } }, ])('finds aliases in nested package-manager settings: %j', (config) => { - fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify(config)); + const packagePath = writePackageJson(config); - expect(findLegacyVitestAliasConfig(projectDir)).toBe(path.join(projectDir, 'package.json')); + expect(findLegacyVitestAliasConfig(projectDir)).toBe(packagePath); }); it.each(['standalone', 'pnpm', 'npm'])('stops at the %s project boundary', (kind) => { const childDir = path.join(projectDir, 'child'); const sourceDir = path.join(childDir, 'src'); fs.mkdirSync(sourceDir, { recursive: true }); - fs.writeFileSync( - path.join(projectDir, 'package.json'), - JSON.stringify({ devDependencies: { vitest: legacyAlias } }), - ); - fs.writeFileSync( - path.join(childDir, 'package.json'), - JSON.stringify({ + writePackageJson({ devDependencies: { vitest: legacyAlias } }); + writePackageJson( + { devDependencies: { vitest: '4.1.11' }, ...(kind === 'npm' ? { workspaces: ['packages/*'] } : {}), - }), + }, + childDir, ); if (kind === 'pnpm') { fs.writeFileSync(path.join(childDir, 'pnpm-workspace.yaml'), 'packages: [.]\n'); @@ -82,27 +77,23 @@ describe('findLegacyVitestAliasConfig', () => { it('finds root npm overrides from a workspace member', () => { const packageDir = path.join(projectDir, 'packages', 'app'); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync(path.join(packageDir, 'package.json'), '{}'); - fs.writeFileSync( - path.join(projectDir, 'package.json'), - JSON.stringify({ workspaces: ['packages/*'], overrides: { vitest: legacyAlias } }), - ); + writePackageJson({}, packageDir); + const packagePath = writePackageJson({ + workspaces: ['packages/*'], + overrides: { vitest: legacyAlias }, + }); - expect(findLegacyVitestAliasConfig(packageDir)).toBe(path.join(projectDir, 'package.json')); + expect(findLegacyVitestAliasConfig(packageDir)).toBe(packagePath); }); it('ignores alias strings in metadata and unrelated settings', () => { - fs.writeFileSync( - path.join(projectDir, 'package.json'), - JSON.stringify({ - description: legacyAlias, - config: { example: legacyAlias }, - pnpm: { metadata: legacyAlias }, - workspaces: { packages: [], metadata: legacyAlias }, - devDependencies: { vitest: '4.1.11' }, - }), - ); + writePackageJson({ + description: legacyAlias, + config: { example: legacyAlias }, + pnpm: { metadata: legacyAlias }, + workspaces: { packages: [], metadata: legacyAlias }, + devDependencies: { vitest: '4.1.11' }, + }); fs.writeFileSync( path.join(projectDir, 'pnpm-workspace.yaml'), `packages: [.]\nmetadata: ${legacyAlias}\ncatalog:\n vitest: 4.1.11\n`, @@ -112,7 +103,7 @@ describe('findLegacyVitestAliasConfig', () => { }); it.each([false, true])('handles cyclic YAML with a stale alias present: %s', (hasAlias) => { - fs.writeFileSync(path.join(projectDir, 'package.json'), '{}'); + writePackageJson({}); fs.writeFileSync( path.join(projectDir, 'pnpm-workspace.yaml'), [ @@ -134,8 +125,7 @@ describe('findLegacyVitestAliasConfig', () => { it('finds a pnpm catalog alias from a workspace package', () => { const packageDir = path.join(projectDir, 'packages', 'app'); - fs.mkdirSync(packageDir, { recursive: true }); - fs.writeFileSync(path.join(packageDir, 'package.json'), '{}'); + writePackageJson({}, packageDir); fs.writeFileSync( path.join(projectDir, 'pnpm-workspace.yaml'), [ diff --git a/packages/cli/src/utils/legacy-vitest-alias.ts b/packages/cli/src/utils/legacy-vitest-alias.ts index e0213c410c..8474f17293 100644 --- a/packages/cli/src/utils/legacy-vitest-alias.ts +++ b/packages/cli/src/utils/legacy-vitest-alias.ts @@ -3,28 +3,17 @@ import path from 'node:path'; import { parse as parseYaml } from 'yaml'; -const LEGACY_VITEST_PACKAGE = '@voidzero-dev/vite-plus-test'; -const LEGACY_VITEST_ALIAS = `npm:${LEGACY_VITEST_PACKAGE}`; - -function isLegacyVitestAlias(value: unknown): boolean { - return ( - typeof value === 'string' && - (value === LEGACY_VITEST_ALIAS || value.startsWith(`${LEGACY_VITEST_ALIAS}@`)) - ); -} +const LEGACY_VITEST_ALIAS = 'npm:@voidzero-dev/vite-plus-test'; function containsLegacyVitestAlias(value: unknown, visited = new Set()): boolean { - if (isLegacyVitestAlias(value)) { - return true; + if (typeof value === 'string') { + return value === LEGACY_VITEST_ALIAS || value.startsWith(`${LEGACY_VITEST_ALIAS}@`); } - if (value !== null && typeof value === 'object') { - if (visited.has(value)) { - return false; - } - visited.add(value); - return Object.values(value).some((entry) => containsLegacyVitestAlias(entry, visited)); + if (value === null || typeof value !== 'object' || visited.has(value)) { + return false; } - return false; + visited.add(value); + return Object.values(value).some((entry) => containsLegacyVitestAlias(entry, visited)); } function asRecord(value: unknown): Record | undefined { @@ -56,9 +45,6 @@ function containsPackageAlias(config: Record | undefined): bool } function readConfigFile(filePath: string): Record | undefined { - if (!fs.existsSync(filePath)) { - return undefined; - } try { const source = fs.readFileSync(filePath, 'utf8'); return asRecord( @@ -82,21 +68,22 @@ export function findLegacyVitestAliasConfig(startDir: string): string | undefine while (true) { const packagePath = path.join(currentDir, 'package.json'); const pkg = readConfigFile(packagePath); - if (!foundPackage && fs.existsSync(packagePath)) { + const isNearestPackage = !foundPackage && fs.existsSync(packagePath); + if (isNearestPackage) { foundPackage = true; - if (containsPackageAlias(pkg)) { - return packagePath; - } } // Match workspace discovery: the nearest pnpm-workspace.yaml or // package.json with a workspaces field defines the workspace root. // If neither exists, only the nearest package's settings apply. const workspacePath = path.join(currentDir, 'pnpm-workspace.yaml'); - if (fs.existsSync(workspacePath) || (pkg && Object.hasOwn(pkg, 'workspaces'))) { - if (containsPackageAlias(pkg)) { - return packagePath; - } + const isWorkspaceRoot = + fs.existsSync(workspacePath) || (pkg !== undefined && Object.hasOwn(pkg, 'workspaces')); + + if ((isNearestPackage || isWorkspaceRoot) && containsPackageAlias(pkg)) { + return packagePath; + } + if (isWorkspaceRoot) { if ( containsAliasInFields(readConfigFile(workspacePath), ['catalog', 'catalogs', 'overrides']) ) {