From 1dd4ead41c0e0bcc34624c68d86f123aecfdb9e4 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Fri, 30 Oct 2026 13:22:43 +0900 Subject: [PATCH 01/24] docs(rfc): align the core version with the bundled Vite version Propose a version scheme for @voidzero-dev/vite-plus-core that encodes the bundled Vite version: the core major and minor equal Vite's, and the core patch is `vitePatch * 100 + n`. Vite+ prereleases use a `vp.` identifier. With this scheme, peer checks against the `vite` alias pass for compatible consumers and still fail for incompatible ones, so migration can drop the broad pnpm `vite` peer rule. Refs #2806, #3277 Co-Authored-By: Claude Opus 5.5 --- rfcs/core-version-alignment.md | 590 +++++++++++++++++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 rfcs/core-version-alignment.md diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md new file mode 100644 index 0000000000..92310f4abc --- /dev/null +++ b/rfcs/core-version-alignment.md @@ -0,0 +1,590 @@ +# RFC: Align the Core Package Version with the Bundled Vite Version + +- Status: Proposed +- Issue: [#2806](https://github.com/voidzero-dev/vite-plus/issues/2806) +- Related: [Q4 2026 plan](https://github.com/voidzero-dev/vite-plus/issues/3277), + [Vite core module identity](./vite-core-module-identity.md), + [Core binding resolution](./core-binding-resolution.md), + [Migration rules](../docs/guide/migrate-rules.md), + [Core package bundling](../packages/core/BUNDLING.md) + +## Proposal + +Derive the version of `@voidzero-dev/vite-plus-core` from the Vite version it +bundles. The core major and minor equal the bundled Vite major and minor. The +core patch encodes the bundled Vite patch and a release counter: + +```text +stable Vite+ release: .. +Vite+ prerelease, stable Vite: -vp. +Vite+ prerelease, Vite pre: .vp. +``` + +`n` counts the stable Vite+ releases that bundle the same Vite patch, from 0 +to 98. `n = 99` is reserved for local and CI builds. `k` counts the Vite+ +prereleases on the same base, from 0. Decoding is exact: the bundled Vite +patch is `floor(core patch / 100)`. + +| Vite+ release | Bundled Vite | Core version | +| ----------------- | -------------- | ------------------- | +| `1.5.0` (first) | `8.3.5` | `8.3.500` | +| `1.5.1` | `8.3.5` | `8.3.501` | +| `1.6.0` | `8.3.6` | `8.3.600` | +| `1.7.0-rc.0` | `8.4.0` | `8.4.0-vp.0` | +| `1.7.0-rc.1` | `8.4.0` | `8.4.0-vp.1` | +| `1.7.0` | `8.4.0` | `8.4.0` | +| `1.7.1` | `8.4.1` | `8.4.100` | +| `2.0.0-beta.0` | `9.0.0-beta.3` | `9.0.0-beta.3.vp.0` | +| `2.0.0-beta.1` | `9.0.0-beta.3` | `9.0.0-beta.3.vp.1` | +| Local or CI build | `8.3.5` | `8.3.599` | + +`vite-plus` keeps its own version line (`1.x` today). The CLI keeps its exact +alias dependency `vite: npm:@voidzero-dev/vite-plus-core@`. +Projects keep the same alias in their manifests, overrides, catalogs, and +resolutions. `vp migrate` stops adding `vite` to pnpm `peerDependencyRules` +and removes the entry that earlier migrations wrote. + +## Problem + +A migrated project aliases `vite` to the core package: + +```json +{ + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@1.4.0", + "vite-plus": "1.4.0" + } +} +``` + +Package managers resolve the alias and read the target's manifest. They +compare its `version` (`1.4.0`) with each consumer's `vite` peer range. Every +current plugin range fails: `vitest@5.0.5` declares +`^6.4.0 || ^7.0.0 || ^8.0.0`, `@vitejs/plugin-react@6.1.2` declares `^8.0.0`, +`@tanstack/react-start` declares `>=8.0.0`. + +pnpm users need `peerDependencyRules.allowAny: [vite]` and +`allowedVersions: { vite: '*' }`. `vp migrate` writes these rules in +`packages/cli/src/migration/migrator/vite-plus-bootstrap.ts` +(`ensurePnpmPeerDependencyRules`), `catalog.ts` (`rewritePnpmWorkspaceYaml`), +and `orchestrators.ts`. The rules also hide real incompatibilities: a plugin +that declares `vite: ^7.0.0` installs without a warning. + +npm has no per-package suppression. The `vite-plus` package alone is +inconsistent from npm's point of view: it depends on +`vite: npm:@voidzero-dev/vite-plus-core@1.4.0` and on `vitest`, whose peer +range rejects `1.4.0`. Every `npm install` prints +`ERESOLVE overriding peer dependency` warnings, and `npm ls vite` marks the +alias `invalid`. The only workaround is `legacy-peer-deps=true`, which changes +peer resolution for the whole tree. + +The release pipeline sets one version on every package. +`.github/workflows/prepare_release.yml` runs `update_json` on +`packages/cli/package.json` and `packages/core/package.json` with the same +`VERSION`. `.github/workflows/release.yml` publishes +`@voidzero-dev/vite-plus-core@${VERSION}`, and +`.github/workflows/reusable-release-build.yml` stamps `${VERSION}` into both +manifests before the build. + +## Goals and scope + +For a project whose bundled Vite satisfies a consumer's `vite` peer range, +the package manager must report the peer as satisfied. For a consumer whose +range excludes the bundled Vite major or minor, the package manager must still +report it. Patch-level floors must resolve correctly up to the bundled patch. + +Migration must stop writing the broad `vite` peer rule and must remove the one +it wrote earlier, without touching rules that users added for other packages. + +The core version must be derivable from the bundled Vite version, and the +bundled Vite version from the core version, without a lookup table. CI must +check that the committed core version encodes `bundledVersions.vite`. + +Out of scope: + +- The `vitest` entries in the same peer rules. `vitest` is pinned to a real + upstream version (`VITEST_VERSION` in `packages/cli/src/utils/constants.ts`), + so peer checks against it are already accurate. The `vitest` rule hides + real incompatibilities in the same way; removing it is a separate change. +- The `vite-plus` version policy. This RFC does not decide whether a Vite + major upgrade is a `vite-plus` major release. +- The registry bridge for preview builds. It assigns `0.0.0-commit.` to + every workspace package and is a separate repository. + +## Design + +### Version scheme + +The scheme must make four range shapes resolve like the bundled Vite: +`^MAJOR.0.0`, `^MAJOR.MINOR.0`, `~MAJOR.MINOR.0`, and `>=MAJOR.MINOR.PATCH`. +Three options were compared against bundled Vite `8.3.5` with `vite-plus` +`1.4.0`: + +- Option A: core major = Vite major; minor and patch follow `vite-plus` + (`8.4.0`). +- Option B: core major and minor = Vite major and minor; patch is a plain + counter per Vite+ release (`8.3.0`, `8.3.1`, ...). +- Option B+ (selected): like B, but the patch encodes the Vite patch + (`8.3.500`, `8.3.501`, ...). +- Option C: core = exact Vite version plus a prerelease tag + (`8.3.5-vp.1.4.0`) or build metadata (`8.3.5+vp.1.4.0`). + +| Consumer range | Bundled `8.3.5` | A `8.4.0` | B `8.3.0` | B+ `8.3.500` | C `8.3.5-vp.1.4.0` (npm) | +| -------------- | --------------- | ------------ | ------------ | ------------ | ------------------------ | +| `^8.0.0` | pass | pass | pass | pass | fail (wrong) | +| `^8.3.0` | pass | pass | pass | pass | fail (wrong) | +| `>=8.3.2` | pass | pass | fail (wrong) | pass | fail (wrong) | +| `~8.3.0` | pass | fail (wrong) | pass | pass | fail (wrong) | +| `^7.0.0` | fail | fail | fail | fail | fail | +| `^8.4.0` | fail | pass (wrong) | fail | fail | fail | +| `>=8.3.6` | fail | pass (wrong) | fail | pass (wrong) | fail | + +Option A fails on minor ranges in both directions. The `vite-plus` minor and +the Vite minor drift apart: `v1.2.0` bundled Vite `8.3.4`, so core `8.2.0` +would reject `^8.3.0` consumers, and a later `vite-plus` `1.9.x` on Vite +`8.5.x` would accept `>=8.6.0` consumers. Both are the failure the RFC exists +to remove. + +Option B resolves minor ranges correctly. Its patch carries no information, so +a `>=8.3.2` consumer fails against `8.3.0` or `8.3.1` although the bundled +`8.3.5` satisfies it. A consumer `>=8.3.6` passes when the counter reaches +the value 6. Patch floors are less common than minor floors, but they exist, +and the counter value is unrelated to the real patch in both directions. + +Option C fails the main goal. npm's semver compares a prerelease only against +comparators with the same `major.minor.patch` tuple, so `8.3.5-vp.1.4.0` +fails `^8.0.0`, `>=8.0.0`, and `^8.3.0`. npm users keep every warning from +#2806. The experiment below confirms this behavior for npm and shows that pnpm +and Yarn are more lenient. Build metadata does not help: npm treats +`8.3.5+a` and `8.3.5+b` as the same version, so a second Vite+ release on the +same Vite patch cannot publish. `v1.2.0` and `v1.3.0` both bundled Vite +`8.3.4`, so that case is real. + +Option B+ is the selected scheme. Its residual false results are limited to +patch floors above the bundled patch inside the same minor (`>=8.3.6` passes +against `8.3.500`) and to exact pins (`8.3.5` fails against `8.3.500`). Both +are rare in `vite` peer ranges. Vite+ follows Vite patch releases within days, +so the window for the first case is short. + +The counter supports 99 stable Vite+ releases per Vite patch (`n` from 0 to +98). `n = 99` is reserved for local and CI builds (see Local and CI builds). +Vite+ shipped five minor releases between 2026-09-28 and 2026-10-23 while Vite +moved from `8.3.1` to `8.3.5`. Two releases on one Vite patch happened once +(`v1.2.0` and `v1.3.0` on `8.3.4`). + +Core versions keep sorting correctly: +`8.3.501 < 8.3.502-vp.0 < 8.3.502 < 8.3.600`. + +### Prereleases and new Vite majors + +A Vite+ prerelease does not copy its own prerelease identifiers into the core +version. A copied suffix has two problems: + +- Two Vite+ prereleases can have the same identifiers, for example + `1.7.0-rc.0` and `1.8.0-rc.0`. If no stable release ships between them on + the same Vite, both map to `8.4.0-rc.0`, and the second publish fails. +- On a stable Vite, a copied suffix looks like a Vite prerelease and can sort + out of order. A Vite+ RC on Vite `9.0.0` would publish `9.0.0-rc.0`, which + sorts below a core built on Vite `9.0.0-rc.1`. + +Every Vite+ prerelease uses the identifier `vp` and a counter `k` instead: + +- On a stable Vite `X.Y.Z`, the base is the next stable core version, which + is the version that the next stable release on that Vite would get. The core + version is that base with `-vp.` appended. `k` counts the published core + prereleases with the same base, from 0. Vite+ `1.7.0-rc.0` and `1.7.0-rc.1` + on Vite `8.4.0` publish `8.4.0-vp.0` and `8.4.0-vp.1`. A later `1.8.0-rc.0` + on the same Vite, with no stable release between them, publishes + `8.4.0-vp.2`. The next stable release publishes `8.4.0`. +- On a Vite prerelease `X.Y.Z-
`, the core version is the Vite version
+  with `.vp.` appended. `k` counts the published core prereleases for that
+  Vite prerelease. Vite `9.0.0-beta.3` gives `9.0.0-beta.3.vp.0`, then
+  `9.0.0-beta.3.vp.1`.
+
+The `vite-plus` prerelease identifiers do not appear in the core version. The
+exact alias in the published CLI manifest links the two versions, as it does
+for stable releases (see Relation to the `vite-plus` version).
+
+The identifier `vp` sorts after `alpha`, `beta`, and `rc`, so every core
+version is unique and in publish order. `semver@7.8.5` gives this order:
+
+```text
+9.0.0-beta.3 < 9.0.0-beta.3.vp.0 < 9.0.0-beta.3.vp.1 < 9.0.0-beta.4
+  < 9.0.0-rc.1.vp.0 < 9.0.0-vp.0 < 9.0.0-vp.1 < 9.0.0
+  < 9.0.100-vp.0 < 9.0.100
+```
+
+With `semver@7.8.5` and npm's strict prerelease rules, `9.0.0-beta.3.vp.0`
+satisfies `^9.0.0-0` and `^9.0.0-beta.1`. This form was not tested in pnpm,
+Yarn, or Bun.
+
+A core prerelease on a stable Vite still fails strict peer checks in npm:
+`8.4.0-vp.0` does not satisfy `^8.0.0`. Prereleases are opt-in, so this RFC
+accepts the warnings for that period. Migration keeps the pnpm peer rule for
+prerelease core versions (see Migration).
+
+A Vite+ stable release bundles a stable Vite. Only a Vite+ prerelease may
+bundle a Vite prerelease. CI rejects a core prerelease on a `vite-plus`
+version without a prerelease identifier, and a stable core on a `vite-plus`
+prerelease.
+
+Since `v1.0.0`, Vite+ published no prereleases. The last ones are
+`v1.0.0-rc.0` and `v1.0.0-rc.1`. The prerelease rule matters mostly for the
+Vite 9 beta period.
+
+A new Vite major gives a new core major (`9.0.0` for Vite `9.0.0`). The
+`vite-plus` version does not follow. A Vite major upgrade is a breaking change
+for user plugins, so a `vite-plus` major release is the expected choice, but
+that decision stays with the release policy.
+
+### Relation to the `vite-plus` version
+
+`vite-plus` stays on its own version line. The two versions are linked by the
+exact alias in the published CLI manifest. pnpm already rewrites
+`vite: workspace:@voidzero-dev/vite-plus-core@*` in
+`packages/cli/package.json` to `npm:@voidzero-dev/vite-plus-core@`
+at pack time, using the core package's own version. That mechanism needs no
+change.
+
+Code that assumes the two versions are equal must change:
+
+| Location                                                | Current behavior                                                      | Required change                                                                                                                                                 |
+| ------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `packages/cli/src/utils/constants.ts`                   | `VITE_PLUS_OVERRIDE_PACKAGES.vite` uses `VITE_PLUS_VERSION`           | Add `VITE_PLUS_CORE_VERSION = process.env.VP_CORE_VERSION \|\| corePkg.version`, read from `packages/core/package.json` at build time, and use it for the alias |
+| `packages/cli/src/resolve-core.ts`                      | Expects the core `version` to equal the installed `vite-plus` version | Parse the alias in the installed `vite-plus/package.json` `dependencies.vite`; skip the version check for a `workspace:` spec (repository layout)               |
+| `packages/cli/src/sync-versions/protocol.ts`, `plan.ts` | Maps `@voidzero-dev/vite-plus-core` to `toolchain.vitePlus`           | Add `vitePlusCore` from the `vite-plus-core` manifest node and map the core name to it                                                                          |
+| `packages/cli/src/migration/migrator/*`                 | Alias written through `VITE_PLUS_OVERRIDE_PACKAGES`                   | No change beyond the constant; the summary in `collectToolchainVersionChanges` already shows the raw Vite version                                               |
+| `ecosystem-ci/patch-project.ts`                         | `VP_OVERRIDE_PACKAGES.vite` uses the `vite-plus` tarball version      | Use the packed core version                                                                                                                                     |
+| `packages/tools/src/install-global-cli.ts`              | Maps the alias version to `voidzero-dev-vite-plus-core-.tgz` | No change; the alias already carries the core version after pack                                                                                                |
+| `crates/vp_global_cli/src/commands/version.rs`          | Reads `bundledVersions` from the installed core manifest              | No change                                                                                                                                                       |
+| `packages/cli/toolchain.config.json`                    | `vite-plus-core` node uses `core-package` as its version source       | No change; `vp toolchain` shows the core version and the bundled Vite version as separate nodes                                                                 |
+
+The `vp upgrade` command in `crates/vp_global_cli/src/commands/upgrade/`
+installs the global CLI from the published `vite-plus` package and its
+lockfile. It does not read the core version.
+
+### Where the version is computed and checked
+
+Add a small module, `packages/tools/src/core-version.ts`, with four pure
+functions and a CLI entry:
+
+- `encodeCoreVersion(vite, n)` returns the stable core version.
+- `decodeCoreVersion(core)` returns `{ vite, n, k }` or an error.
+- `nextCoreVersion({ vite, vitePlus, published })` returns the core version
+  for a release. Its inputs are the bundled Vite version, the `vite-plus`
+  version, and the published core versions. It applies the counter rules
+  below.
+- `assertCoreVersionMatchesVite(pkg, vitePlus)` asserts that `pkg.version`
+  decodes to `pkg.bundledVersions.vite`. It also asserts that the core
+  version is a prerelease only when the `vite-plus` version is a prerelease.
+
+The name `checkCoreVersion` is not used, because
+`packages/cli/src/resolve-core.ts` already defines a function with that name
+for the installed-core check.
+
+Counter rules in `nextCoreVersion`:
+
+- Stable release: `n` is the highest `n` among published stable core versions
+  with the same Vite version, plus 1, or 0 when none exists. If `n` would be
+  higher than 98, the function fails.
+- Prerelease on a stable Vite: the base is the version that the stable rule
+  gives. `k` is the highest `k` among published `-vp.*` versions, plus
+  1, or 0 when none exists.
+- Prerelease on a Vite prerelease: `k` is the highest `k` among published
+  `.vp.*` versions, plus 1, or 0 when none exists.
+
+The committed `packages/core/package.json` must always encode the committed
+`bundledVersions.vite`. Two writers keep it consistent:
+
+1. `.github/scripts/upgrade-deps.ts` already reads
+   `packages/core/package.json` to sync `postcss` and `fsevents`. After a
+   bundled Vite change it sets `version` to `encodeCoreVersion(vite, 0)`.
+   Renovate ignores `vite` (`.github/renovate.json`), so this script is the
+   only automated path that changes the bundled Vite.
+2. `prepare_release.yml` computes the release version. It reads the
+   published core versions
+   (`npm view @voidzero-dev/vite-plus-core versions --json`) and calls
+   `nextCoreVersion` with the input `vite-plus` version. It then writes the
+   result with the existing `update_json` helper and prints both versions in
+   the release PR body.
+
+A unit test in `packages/core/__tests__/` runs `assertCoreVersionMatchesVite`
+against the committed manifest. It skips the check when the version starts
+with `0.0.0-commit.`, which the preview flow stamps. `packages/core/build.ts`
+does not rewrite `version`: it runs after the release stamp and would race
+with it.
+
+### Local and CI builds
+
+`test-vp-create.yml` and `reusable-ecosystem-ci.yml` set both packages to
+`0.0.0` today (`npm pkg set version=0.0.0`), so that a correct install of the
+local build is easy to detect. Core `0.0.0` does not encode a Vite version,
+and peer checks fail against it. These flows would not test the path without a
+`vite` peer rule that users get.
+
+These flows will set core to `encodeCoreVersion(vite, 99)`, for example
+`8.3.599` for Vite `8.3.5`. They keep `0.0.0` for the CLI, which
+`ecosystem-ci/verify-install.ts` and `test-vp-create.yml` assert. The CLI
+reads the core version from `VP_CORE_VERSION` or from the core manifest, so
+`vp create` and `vp migrate` in these flows write the `8.3.599` alias and
+remove the `vite` peer rule.
+
+`nextCoreVersion` never returns `n = 99`, and `release.yml` rejects it.
+Thus a local version never reaches the registry, and the local registry
+cannot mix it up with a published version.
+
+### Release pipeline changes
+
+| File                                                                                                                             | Change                                                                                                                                                                                                                                                                                                                                                                        |
+| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `.github/workflows/prepare_release.yml`                                                                                          | Compute the core version as described above; stop passing `VERSION` to `update_json packages/core/package.json`                                                                                                                                                                                                                                                               |
+| `.github/workflows/release.yml`                                                                                                  | Read `CORE_VERSION` from `packages/core/package.json` at job start, and reject a version with `n = 99`; use it in "Publish core dependency tier", "Wait for core to propagate", the release notes, and the Discord embed. After publish, check that the core `latest` dist-tag equals `CORE_VERSION`. The version check against `unpkg.com/vite-plus@latest` stays on the CLI |
+| `.github/workflows/reusable-release-build.yml`                                                                                   | Stamp `packages/core/package.json` only for preview versions (`0.0.0-commit.*`). A release checkout already carries the encoded version. Update the verify step to the same rule                                                                                                                                                                                              |
+| `packages/core/build.ts`                                                                                                         | Pass `cliPkgJson.version` instead of `pkgJson.version` to `rewriteRolldownBindingRequires`. The platform packages publish at the CLI version, and the guard must compare against that version (`packages/core/build-support/rewrite-rolldown-binding.ts`)                                                                                                                     |
+| `packages/cli/publish-native-addons.ts`                                                                                          | No change: it mirrors the CLI's platform pins into core by name and does not use the core version                                                                                                                                                                                                                                                                             |
+| `.github/workflows/publish-preview.yml`, `publish-preview-register.yml`                                                          | No change: the bridge serves every workspace package as `0.0.0-commit.`, and the stamp before `pnpm pack` keeps the alias on that version. The sticky comment keeps printing one version for both packages                                                                                                                                                               |
+| `.github/workflows/test-vp-create.yml`, `reusable-ecosystem-ci.yml`                                                              | Keep `0.0.0` for the CLI. Set core to `encodeCoreVersion(vite, 99)` and pass it as `VP_CORE_VERSION` where the workflow passes `VP_VERSION` (see Local and CI builds)                                                                                                                                                                                                         |
+| `packages/tools/src/local-npm-registry.ts`, `pack-local-vite-plus.ts`                                                            | No change: both serve the packed tarball's own version                                                                                                                                                                                                                                                                                                                        |
+| `.github/scripts/upgrade-deps.ts`                                                                                                | Reset the core version after a bundled Vite change                                                                                                                                                                                                                                                                                                                            |
+| `.github/renovate.json`                                                                                                          | No change                                                                                                                                                                                                                                                                                                                                                                     |
+| `.claude/skills/release-manager/SKILL.md`                                                                                        | Read both versions from the release branch; publish checks use the core version for core                                                                                                                                                                                                                                                                                      |
+| `packages/core/BUNDLING.md`                                                                                                      | Document the version scheme next to `bundledVersions`                                                                                                                                                                                                                                                                                                                         |
+| `MAINTENANCE.md`                                                                                                                 | Mention the two versions in the release description                                                                                                                                                                                                                                                                                                                           |
+| `README.md`, `packages/cli/README.md`, `docs/guide/local-cli.md`, `docs/guide/upgrade-project.md`, `docs/guide/migrate-rules.md` | Replace `` in alias examples with `` and point to `vp toolchain vite-plus-core`; describe the new pnpm peer rule behavior                                                                                                                                                                                                                    |
+
+### Package-manager behavior
+
+The experiment ran on macOS with pnpm `12.11.2`, npm `10.9.9`, Yarn
+`4.18.1` (`nodeLinker: node-modules`), and Bun `1.4.4`. Each project
+declared `vite` as an npm alias to a published package and local `file:`
+consumers with one `vite` peer range each. `commander@8.3.0`,
+`commander@8.0.0`, and `commander@9.0.0-0` stood in for future core versions;
+`@voidzero-dev/vite-plus-core@1.4.0` is the current core;
+`vite@8.3.0-beta.1` stood in for a prerelease core.
+
+pnpm, npm, and Yarn resolve the alias and compare the target's manifest
+version with the consumer's range. Their results for release versions agree:
+
+| Alias target | `^8.0.0` | `^8.3.0` | `>=8.3.2` | `~8.3.0` | `^7.0.0` | `^8.0.0-0` | `>=8.0.0-0` |
+| ------------ | -------- | -------- | --------- | -------- | -------- | ---------- | ----------- |
+| `8.3.0`      | ok       | ok       | unmet     | ok       | unmet    | ok         | ok          |
+| `8.0.0`      | ok       | unmet    | unmet     | unmet    | unmet    | ok         | ok          |
+| core `1.4.0` | unmet    | unmet    | unmet     | unmet    | unmet    | unmet      | unmet       |
+
+Prerelease targets differ by package manager:
+
+| Alias target   | Manager | `^8.0.0` | `^8.3.0` | `^8.0.0-0` | `^8.3.0-0` | `>=8.0.0-0` | `>=8.0.0` |
+| -------------- | ------- | -------- | -------- | ---------- | ---------- | ----------- | --------- |
+| `8.3.0-beta.1` | npm     | unmet    | unmet    | unmet      | ok         | unmet       | unmet     |
+| `8.3.0-beta.1` | pnpm    | ok       | unmet    | ok         | ok         | ok          | ok        |
+| `8.3.0-beta.1` | Yarn    | ok       | ok       | ok         | ok         | ok          | ok        |
+| `9.0.0-0`      | npm     | unmet    | unmet    | unmet      | unmet      | unmet       | unmet     |
+| `9.0.0-0`      | pnpm    | unmet    | unmet    | unmet      | unmet      | ok          | ok        |
+| `9.0.0-0`      | Yarn    | unmet    | unmet    | unmet      | unmet      | ok          | ok        |
+
+npm follows strict semver prerelease rules. pnpm includes prereleases when
+comparing. Yarn accepted `8.3.0-beta.1` for `^8.3.0`; the RFC records that
+result without a cause.
+
+Severity: pnpm prints `WARN Issues with peer dependencies found` and exits 0
+unless `strict-peer-dependencies` is set; `pnpm peers check` lists each
+consumer. npm prints `ERESOLVE overriding peer dependency` warnings and exited
+0 in every case here. Yarn prints `YN0060` and exits 0. Bun printed no peer
+diagnostic in any case, including a published consumer
+(`@vitejs/plugin-react@6.1.2`, peer `^8.0.0`) against core `1.4.0`. Bun
+resolved the alias and listed it as `vite@1.4.0`. Whether Bun checks peer
+ranges in other layouts is unverified.
+
+The current `vite-plus` manifest triggers the npm warning on its own, because
+`vitest` is a dependency with a `vite` peer range. With core `8.3.500` that
+range is satisfied, so a project that depends only on `vite-plus` installs
+without the warning.
+
+## Migration
+
+### Existing projects
+
+`vp migrate` on a project that already uses Vite+ runs the toolchain upgrade
+path (`docs/guide/upgrade-project.md`). The alias rewrite is unchanged in
+shape: every `npm:@voidzero-dev/vite-plus-core@1.x` spec in dependencies,
+`overrides`, `resolutions`, pnpm and Bun catalogs, and `pnpm-workspace.yaml`
+becomes `npm:@voidzero-dev/vite-plus-core@`. The existing
+`isProtocolPinnedSpec` rules keep `file:`, `link:`, and `workspace:` pins.
+
+Peer rules change only for pnpm:
+
+- `ensurePnpmPeerDependencyRules` in `vite-plus-bootstrap.ts`,
+  `rewritePnpmWorkspaceYaml` in `catalog.ts`, and the writers in
+  `orchestrators.ts` and `catalog.ts` stop adding `vite` to `allowAny` and
+  `allowedVersions` when the target core version is a stable encoded version
+  (major 8 or higher, no prerelease identifier). They remove an existing
+  `vite` entry from both maps. They delete an empty `peerDependencyRules`
+  object or YAML map. Other entries stay, including `vitest`.
+- The writers cannot tell whether an earlier migration or the user wrote a
+  `vite` entry, so they remove every `vite` entry. The only purpose of that
+  entry is to accept the `1.x` alias. `vp migrate` prints one line that names
+  the removed rule, and `docs/guide/migrate-rules.md` describes the removal.
+- When the target core version is not a stable encoded version (a preview
+  `0.0.0-commit.` or a Vite+ prerelease such as `8.4.0-vp.0`), the
+  writers keep the current behavior and write the `vite` rule.
+- `pnpmPeerDependencyRulesSatisfyVitePlus` reports a project as pending when
+  a `vite` entry exists and the target core is a stable encoded version, or
+  when the entry is missing and the target core is not.
+
+npm, Yarn, and Bun have no peer suppression to remove. Their alias handling
+is unchanged: npm keeps the direct dependency and `overrides.vite` on the same
+spec (`EOVERRIDE` rule), `npm-reinstall.ts` keeps moving stale `vite`
+installs aside by package name, Yarn keeps `resolutions.vite`, and Bun keeps
+the mirrored direct `vite` dependency.
+
+The final install step refreshes lockfiles. Every package manager sees the
+alias target version change from `1.x` to `8.x` and re-resolves it; no
+lockfile edit is needed beyond the existing npm stale-install handling.
+
+After migration, a plugin with an incompatible range (`vite: ^7.0.0`) produces
+a peer warning for the first time. This is the intended result. The migrate
+rules document must say so.
+
+### Downgrade
+
+A project on core `8.3.500` can move back to `vite-plus@1.4.0` by running
+that CLI's `vp migrate`. The older CLI rewrites the alias to
+`npm:@voidzero-dev/vite-plus-core@1.4.0` and re-adds the pnpm `vite` peer
+rule, because its logic is self-contained. Its `resolveCore` check compares
+the core version with its own version, so it reports a mismatch until the
+alias is realigned. The newer CLI reports the same for a project still on
+`1.4.0`: "Expected `@voidzero-dev/vite-plus-core@8.3.500`, found `1.4.0`.
+Run `vp migrate`." Exact pins make the registry ordering irrelevant for the
+downgrade.
+
+### Fixtures and documentation
+
+Snapshot fixtures under `crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/`
+that record `peerDependencyRules.allowAny: [vite, vitest]` or the alias
+version need re-recording with `UPDATE_SNAPSHOTS=1`. The redaction in
+`crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs` already masks
+`@voidzero-dev/vite-plus-core@` with a prerelease-aware pattern, so
+local-flavor snapshots do not depend on the chosen encoding.
+
+## Alternatives
+
+### Core major = Vite major, minor and patch from `vite-plus`
+
+Simplest for the pipeline: one `sed` with a different first component. It
+fails minor ranges in both directions, as shown in the comparison table. The
+release dates in this repository already show the drift.
+
+### Plain release counter in the patch
+
+Correct for minor ranges and easier to read (`8.3.7`). It misreports patch
+floors: `>=8.3.2` fails against `8.3.0` although the bundled Vite is `8.3.5`.
+The encoded form keeps patch floors correct up to the bundled patch.
+
+The encoded form does not make every core version look different from a Vite
+version. On Vite patch 0, the core patch is `n` alone (`8.4.0`, `8.4.1`,
+...), and core `8.4.1` looks like Vite `8.4.1`. Decoding still gives the
+correct Vite version (`floor(1 / 100) = 0`). Peer checks stay in the residual
+error class of the selected scheme: `>=8.4.1` passes against core `8.4.1`.
+
+### Exact Vite version with a prerelease tag or build metadata
+
+Fails npm peer checks and cannot publish two Vite+ releases on one Vite
+patch. See the comparison.
+
+### Publish core only when the bundled Vite changes
+
+Core would use the exact Vite version. Core also bundles Rolldown, tsdown,
+and the Vite+ build of the Vite source, so a Vite+ release that changes any of
+these without a Vite change could not publish a new core.
+
+### Keep the version and ask consumers to widen peer ranges
+
+Plugins cannot know about a `1.x` alias target. The npm warning comes from
+`vite-plus`'s own dependency tree, so no consumer change removes it.
+
+### Put the bundled Vite version in `peerDependenciesMeta` or a custom field
+
+Package managers do not read a custom field for peer resolution. Only the
+manifest `version` takes part in the check.
+
+## Risks
+
+- Confusion with upstream Vite. `npm ls vite` prints
+  `vite@npm:@voidzero-dev/vite-plus-core@8.3.500`, and readers may take
+  `8.3.500` for a Vite version. On Vite patch 0, the core version (`8.4.1`)
+  looks exactly like a Vite version. The decoding rule is one division, and
+  `vp toolchain` prints the real Vite version. Documentation must show both.
+- Bundled Vite downgrade. If Vite+ goes back to an older Vite version, the
+  core version goes down, for example from `8.4.0` to `8.3.501`. The counter
+  rules still give a unique version, and exact pins make the lower version
+  install correctly. `release.yml` publishes core with `--tag latest`. How
+  npm sets `latest` when the published version is lower than the current
+  `latest` is unverified. The dist-tag check after publish (see Release
+  pipeline changes) makes a wrong tag visible.
+- Registry ordering. `8.3.500` sorts above every published `1.x` version, so
+  `latest` moves forward as usual and a range on core would jump majors.
+  Vite+ documents exact pins only. The stale `alpha` and `test` dist-tags
+  point at `0.x` versions and can be removed or left alone.
+- Peer warnings appear for real incompatibilities after migration. Projects
+  that relied on the broad rule to hide a `^7.0.0` plugin see a warning or,
+  with `strict-peer-dependencies`, an install failure. The migrate output
+  should name the change.
+- Vite+ prereleases still warn in npm: `8.4.0-vp.0` does not satisfy
+  `^8.0.0`. Accepted; see Prereleases.
+- Preview builds still warn in npm. The bridge forces `0.0.0-commit.`,
+  and the release-manager skill already lists this class of artifact as a
+  non-blocker. Migration keeps the pnpm rule for preview cores. Thus preview
+  builds cannot show the new peer behavior, and the preview check in
+  `.claude/skills/test-pkg-pr-new-migrate/` does not test it. Verification
+  before a release uses the local registry with the `n = 99` local version
+  (see Local and CI builds).
+- Tools that read the `vite` version. Vite's `version` export is rewritten in
+  `packages/core/build.ts` to import `../../package.json` relative to Vite's
+  `src/node/constants.ts`, which is Vite's own manifest, so the export keeps
+  the real Vite version. This was checked by reading the build script; the
+  built output was not inspected in this worktree. Code that reads
+  `node_modules/vite/package.json` directly, such as
+  `readInstalledRawViteVersion` in `vite-plus-bootstrap.ts`, keys on
+  `name === '@voidzero-dev/vite-plus-core'` and reads `bundledVersions.vite`.
+  Third-party code that reads `version` from that file now sees `8.3.500`
+  instead of `1.4.0`, which is closer to the truth; whether any such code
+  gates features on it is unverified.
+- Binding guards. The Rolldown binding guard in core compares the installed
+  platform package version with a baked version. Today it bakes the core
+  version, which equals the CLI version. The build must bake the CLI version
+  after this change, or every release build fails the guard.
+- Counter lookup needs the registry. `prepare_release.yml` already needs the
+  network for `cargo check`. A registry failure must stop the job with a
+  clear error rather than guess `n = 0`.
+- Pipelines that pass one `VERSION` for every package. The list in Release
+  pipeline changes covers the files found by searching for
+  `vite-plus-core`; a missed `${VERSION}` reference would publish or wait for
+  a core version that does not exist. `wait-for-npm-packages.ts` then fails
+  the job, so the error is visible.
+
+## Other aliased packages
+
+Only `vite` is aliased in user projects. `vitest` is pinned to its real
+upstream version, so peer checks against it are accurate. Rolldown and tsdown
+are bundled into core and exposed as `vite/rolldown` and `vite/pack`; they are
+not aliased, and consumers do not declare peers on them through Vite+. The
+removed `@voidzero-dev/vite-plus-test` alias no longer exists. No other
+package has the same problem, and none is in scope.
+
+## Decisions from review
+
+- Local and CI builds use the reserved `n = 99`, and the release counter stops
+  at 98 (see Local and CI builds).
+- Vite+ prereleases use the `vp.` identifier instead of a copy of the
+  `vite-plus` prerelease identifiers (see Prereleases and new Vite majors).
+- The `vitest` peer rule is removed in a follow-up with its own fixture
+  updates. `catalog.ts` already removes it when the project does not use
+  Vitest. The follow-up covers projects that use Vitest.
+- The counter comes from a registry lookup in `prepare_release.yml`, not from
+  a committed counter. The committed manifest only has to encode the bundled
+  Vite version.
+
+## Open questions
+
+1. Whether a Vite major upgrade is a `vite-plus` major release.
+   Recommendation: yes, decided in the release policy, not here.

From 8356c8382737589acdfd25af48549f9d9e535b61 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Fri, 30 Oct 2026 14:04:52 +0900
Subject: [PATCH 02/24] feat(core): align the core version with the bundled
 Vite version

Encode the bundled Vite version in the @voidzero-dev/vite-plus-core
version, as rfcs/core-version-alignment.md describes. The core major and
minor equal Vite's, and the patch is `vitePatch * 100 + n`. Core 8.3.500
bundles Vite 8.3.5. With the `vite` alias, package managers now check
plugin peer ranges against the bundled Vite.

- Add packages/tools/src/core-version.ts to encode, decode, select, and
  check core versions. Vite+ prereleases use a `vp.` identifier, and
  local CI builds use the reserved counter 99.
- Select the core version from the registry in prepare_release, publish
  core at its own version, and check its latest dist-tag after publish.
- Bake the CLI version into the Rolldown binding guards, because the
  platform packages publish at the CLI version.
- Resolve the expected core version from the CLI's `vite` alias, and map
  the core package to its own version in sync-versions.
- Stop writing the pnpm `vite` peer rule for a stable core, remove an
  existing one, and keep it for preview and prerelease cores.
- Reset the core version when upgrade-deps changes the bundled Vite.

Refs #2806, #3277

Co-Authored-By: Claude Opus 5.5 
---
 .claude/skills/release-manager/SKILL.md       |  16 +-
 .../scripts/__tests__/upgrade-deps.spec.ts    |  39 +++
 .github/scripts/upgrade-deps.ts               |  28 ++
 .github/workflows/prepare_release.yml         |  16 +-
 .github/workflows/release.yml                 |  31 +-
 .github/workflows/reusable-ecosystem-ci.yml   |  10 +-
 .github/workflows/reusable-release-build.yml  |  15 +-
 .github/workflows/test-vp-create.yml          |  18 +-
 MAINTENANCE.md                                |   2 +
 README.md                                     |   8 +-
 .../core_module_identity/check-api.mjs        |   7 +-
 .../core_module_identity/check-npm-layout.mjs |   6 +-
 .../core_module_identity/pin-core-alias.mjs   |   4 +-
 docs/guide/local-cli.md                       |  15 +-
 docs/guide/migrate-rules.md                   |   7 +
 docs/guide/upgrade-project.md                 |   2 +-
 ecosystem-ci/patch-project.ts                 |   8 +-
 ecosystem-ci/paths.ts                         |  19 +-
 packages/cli/BUNDLING.md                      |  22 +-
 packages/cli/README.md                        |   8 +-
 .../cli/src/__tests__/resolve-core.spec.ts    |  60 +++-
 .../vite-peer-dependency-rule.spec.ts         | 275 +++++++++++++++++
 packages/cli/src/migration/bin.ts             |   5 +
 .../cli/src/migration/migrator/catalog.ts     | 106 +++++--
 .../src/migration/migrator/orchestrators.ts   |  36 ++-
 packages/cli/src/migration/migrator/shared.ts |  33 ++-
 .../migration/migrator/vite-plus-bootstrap.ts |  59 ++--
 .../migration/migrator/vitest-ecosystem.ts    |  65 ++++
 packages/cli/src/migration/report.ts          |   2 +
 packages/cli/src/resolve-core.ts              |  32 +-
 .../src/sync-versions/__tests__/plan.spec.ts  |   9 +-
 .../sync-versions/__tests__/protocol.spec.ts  |  12 +-
 packages/cli/src/sync-versions/plan.ts        |   7 +-
 packages/cli/src/sync-versions/protocol.ts    |   1 +
 .../cli/src/utils/__tests__/constants.spec.ts |  49 ++-
 packages/cli/src/utils/constants.ts           |  10 +-
 packages/core/BUNDLING.md                     |  24 +-
 packages/core/__tests__/core-version.spec.ts  |  12 +
 .../build-support/rewrite-rolldown-binding.ts |   2 +-
 packages/core/build.ts                        |   4 +-
 packages/core/package.json                    |   2 +-
 .../tools/src/__tests__/core-version.spec.ts  | 149 ++++++++++
 packages/tools/src/core-version.ts            | 278 ++++++++++++++++++
 43 files changed, 1348 insertions(+), 165 deletions(-)
 create mode 100644 packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
 create mode 100644 packages/core/__tests__/core-version.spec.ts
 create mode 100644 packages/tools/src/__tests__/core-version.spec.ts
 create mode 100644 packages/tools/src/core-version.ts

diff --git a/.claude/skills/release-manager/SKILL.md b/.claude/skills/release-manager/SKILL.md
index ef10df2c23..1acf666c73 100644
--- a/.claude/skills/release-manager/SKILL.md
+++ b/.claude/skills/release-manager/SKILL.md
@@ -48,6 +48,8 @@ gh workflow run prepare_release.yml --repo voidzero-dev/vite-plus -f version=X.Y
 
 The workflow bumps `packages/cli/package.json`, `packages/core/package.json`, `packages/prompts/package.json`, `packages/cli/binding/Cargo.toml`, and `crates/vp_global_cli/Cargo.toml`, refreshes `Cargo.lock`, and opens a PR titled `release: vX.Y.Z` from branch `release/vX.Y.Z`. The PR body ends with `Merging this PR will trigger the release workflow.` and that line must survive every later edit.
 
+`@voidzero-dev/vite-plus-core` does not use `X.Y.Z`. Its version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`; see [Core Version](../../../packages/core/BUNDLING.md#core-version)). The workflow selects it from the registry and names it in the first line of the PR body. In the steps below, `` is that version. Read it from the release branch with `node -p "require('./packages/core/package.json').version"`.
+
 ## 2. Sync release versions (required every release)
 
 ### NAPI binding
@@ -421,14 +423,15 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me
 
 **When the `Release` job fails partway, fix the cause, then use "Re-run failed jobs", never "Re-run all jobs".** A full rerun repeats `check`, which now sees the new version on `unpkg.com/vite-plus@latest` and skips the whole release. A failed-jobs rerun reuses `check` and the build artifacts, and every npm publish step is idempotent: `.github/scripts/publish-npm-package.ts` skips versions the registry already serves (`@X.Y.Z is already published; skipping upload.`), so only the unpublished packages upload. Confirm that from the rerun log. The rerun waits on the `release` environment again, and whoever started the rerun cannot approve it.
 
-**A successful publish command does not mean the packages are installable.** `pnpm publish` prints `✅ Published package @X.Y.Z` as soon as the registry accepts the request, and the registry can then take tens of minutes to actually serve that version. This has shipped a broken release: `vite-plus@X.Y.Z` went live on `latest` with an exact dependency on `@voidzero-dev/vite-plus-core@X.Y.Z` that was invisible for about 35 minutes, so every `npm install vite-plus` failed with `ETARGET` and both `publish-docker` and `Deploy docs` failed on `ERR_PNPM_NO_MATCHING_VERSION`. The downstream job failures are the symptom, not the cause; do not re-run them until the registry has the package.
+**A successful publish command does not mean the packages are installable.** `pnpm publish` prints `✅ Published package @X.Y.Z` as soon as the registry accepts the request, and the registry can then take tens of minutes to actually serve that version. This has shipped a broken release: `vite-plus@X.Y.Z` went live on `latest` with an exact dependency on its `@voidzero-dev/vite-plus-core` version that was invisible for about 35 minutes, so every `npm install vite-plus` failed with `ETARGET` and both `publish-docker` and `Deploy docs` failed on `ERR_PNPM_NO_MATCHING_VERSION`. The downstream job failures are the symptom, not the cause; do not re-run them until the registry has the package.
 
 Check visibility directly, not through `npm view`, which caches:
 
 ```bash
-for pkg in '@voidzero-dev%2Fvite-plus-core' 'vite-plus'; do
-  curl -s -H 'Cache-Control: no-cache' "https://registry.npmjs.org/$pkg?t=$(date +%s)" |
-    python3 -c "import json,sys;d=json.load(sys.stdin);print('$pkg', d['dist-tags'].get('latest'), 'X.Y.Z' in d['versions'])"
+for entry in '@voidzero-dev%2Fvite-plus-core ' 'vite-plus X.Y.Z'; do
+  set -- $entry
+  curl -s -H 'Cache-Control: no-cache' "https://registry.npmjs.org/$1?t=$(date +%s)" |
+    python3 -c "import json,sys;d=json.load(sys.stdin);print('$1', d['dist-tags'].get('latest'), '$2' in d['versions'])"
 done
 ```
 
@@ -511,7 +514,8 @@ Local clients can also mislead in the other direction. npm and pnpm cache a look
 
    ```bash
    npm view vite-plus version                       # X.Y.Z
-   npm view @voidzero-dev/vite-plus-core version    # X.Y.Z
+   npm view @voidzero-dev/vite-plus-core version    # , which encodes the bundled Vite version
+   npm view vite-plus@X.Y.Z dependencies.vite       # npm:@voidzero-dev/vite-plus-core@
    npm view create-vite-plus version                # X.Y.Z
    npm view @voidzero-dev/vite-plus-cli-darwin-arm64 version   # X.Y.Z, spot-check a native platform package
    npm view vite-plus dist-tags.latest              # X.Y.Z
@@ -538,7 +542,7 @@ Local clients can also mislead in the other direction. npm and pnpm cache a look
 
    Require the target version directory, the expected `current` link, and `vp --version` output; a success message alone is insufficient. `Already up to date` passes only when the selected installation is already on the target version.
 
-   The Docker check must run `vp --version` inside the image, not just pull it: the output must report `vp vX.Y.Z`. Outside a project that output lists no bundled tools, so inspect the installed image package tree under `~/.vite-plus/X.Y.Z/node_modules/.pnpm` and confirm the bundled tool packages and versions match the changelog's Bundled Versions table. `tsdown` will be absent from that tree because it is bundled into `@voidzero-dev/vite-plus-core`; verify it with `npm view @voidzero-dev/vite-plus-core@X.Y.Z bundledVersions --json` instead. If no local Docker runtime is available, confirm `publish-docker` succeeded and inspect the GHCR manifest for both `linux/amd64` and `linux/arm64`. For the current stable release, confirm the version tag and `latest` have the same digest. Record each architecture's `vp --version` output from the Docker build logs when available (`gh api --allow-escape-sequences repos/voidzero-dev/vite-plus/actions/jobs//logs`; without the flag, `gh` refuses logs containing terminal escape sequences), and distinguish that evidence from a local run:
+   The Docker check must run `vp --version` inside the image, not just pull it: the output must report `vp vX.Y.Z`. Outside a project that output lists no bundled tools, so inspect the installed image package tree under `~/.vite-plus/X.Y.Z/node_modules/.pnpm` and confirm the bundled tool packages and versions match the changelog's Bundled Versions table. `tsdown` will be absent from that tree because it is bundled into `@voidzero-dev/vite-plus-core`; verify it with `npm view @voidzero-dev/vite-plus-core@ bundledVersions --json` instead. If no local Docker runtime is available, confirm `publish-docker` succeeded and inspect the GHCR manifest for both `linux/amd64` and `linux/arm64`. For the current stable release, confirm the version tag and `latest` have the same digest. Record each architecture's `vp --version` output from the Docker build logs when available (`gh api --allow-escape-sequences repos/voidzero-dev/vite-plus/actions/jobs//logs`; without the flag, `gh` refuses logs containing terminal escape sequences), and distinguish that evidence from a local run:
 
    ```bash
    TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:voidzero-dev/vite-plus:pull" \
diff --git a/.github/scripts/__tests__/upgrade-deps.spec.ts b/.github/scripts/__tests__/upgrade-deps.spec.ts
index 1a900a8bc1..3dad49c469 100644
--- a/.github/scripts/__tests__/upgrade-deps.spec.ts
+++ b/.github/scripts/__tests__/upgrade-deps.spec.ts
@@ -240,9 +240,11 @@ function createSyncFixture() {
   const metaDir = join(root, 'meta');
   const versionsPath = join(metaDir, 'versions.json');
   writeJson(corePath, {
+    version: '8.3.501',
     dependencies: { 'yuku-parser': '^0.9.3', postcss: '^8.5.6' },
     optionalDependencies: { fsevents: '~2.3.3' },
     devDependencies: { 'rolldown-plugin-dts': '^0.28.0' },
+    bundledVersions: { vite: '8.3.5' },
   });
   writeJson(join(root, 'store/tsdown/package.json'), { name: 'tsdown', version: '0.23.0' });
   writeJson(pluginPath, {
@@ -268,6 +270,7 @@ function createSyncFixture() {
     dependencies: { 'yuku-codegen': '^0.8.1', 'yuku-parser': '^0.8.1' },
   });
   writeJson(vitePath, {
+    version: '8.3.5',
     dependencies: { postcss: '^8.5.28' },
     optionalDependencies: { fsevents: '~2.4.0' },
   });
@@ -291,9 +294,12 @@ test('pins the bundled parser, syncs Vite ranges, and preserves upgrade metadata
   const { corePath, versionsPath, metaDir, run } = fixture;
   run();
   expect(readJson(corePath)).toEqual({
+    // The bundled Vite did not change, so the release counter stays.
+    version: '8.3.501',
     dependencies: { 'yuku-parser': '0.10.3', postcss: '^8.5.28' },
     optionalDependencies: { fsevents: '~2.4.0' },
     devDependencies: { 'rolldown-plugin-dts': '^0.28.0' },
+    bundledVersions: { vite: '8.3.5' },
   });
   expect(readJson(versionsPath)).toEqual({
     oxlint: { old: '1.86.0', new: '1.87.0' },
@@ -323,6 +329,39 @@ test('pins the bundled parser, syncs Vite ranges, and preserves upgrade metadata
   expect(readFileSync(corePath, 'utf8')).toBe(before);
 });
 
+test('restarts the core version counter when the bundled Vite changes', () => {
+  const fixture = createSyncFixture();
+  const { corePath, versionsPath, metaDir, run } = fixture;
+  writeJson(fixture.vitePath, { ...readJson(fixture.vitePath), version: '8.4.2' });
+  run();
+  expect(readJson(corePath).version).toBe('8.4.200');
+  expect(readJson(corePath).bundledVersions).toEqual({ vite: '8.4.2' });
+  expect(readJson(versionsPath)['@voidzero-dev/vite-plus-core']).toEqual({
+    old: '8.3.501',
+    new: '8.4.200',
+  });
+  expect(readFileSync(join(metaDir, 'pr-body.md'), 'utf8')).toContain(
+    '| `@voidzero-dev/vite-plus-core` | `8.3.501` | `8.4.200` |',
+  );
+
+  // A second pass keeps the recorded old version and the encoded version.
+  run();
+  expect(readJson(corePath).version).toBe('8.4.200');
+  expect(readJson(versionsPath)['@voidzero-dev/vite-plus-core']).toEqual({
+    old: '8.3.501',
+    new: '8.4.200',
+  });
+});
+
+test('fails without writing core when the Vite source has no version', () => {
+  const fixture = createSyncFixture();
+  const { version: _, ...vite } = readJson(fixture.vitePath);
+  writeJson(fixture.vitePath, vite);
+  const before = readFileSync(fixture.corePath, 'utf8');
+  expect(fixture.run).toThrow('Missing version');
+  expect(readFileSync(fixture.corePath, 'utf8')).toBe(before);
+});
+
 test('updates the parser pin when its installed version changes within the same range', () => {
   const { corePath, parserPath, versionsPath, run } = createSyncFixture();
   run();
diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts
index 06a0302111..26bac8d80c 100644
--- a/.github/scripts/upgrade-deps.ts
+++ b/.github/scripts/upgrade-deps.ts
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
 import fs from 'node:fs';
 import path from 'node:path';
 
+import { decodeCoreVersion, encodeCoreVersion } from '../../packages/tools/src/core-version.ts';
 import {
   findLatestStableVersionForMajor,
   getBundledDependencyRanges,
@@ -547,10 +548,37 @@ function syncBundledDependencies(): void {
     recordChange(name, changes.get(name)?.old ?? oldRange, range);
     pkg[field][name] = range;
   }
+  syncCoreVersion(pkg);
   fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\n');
   writeMetaFiles();
 }
 
+// The core version encodes the bundled Vite version (rfcs/core-version-alignment.md).
+// When the bundled Vite changes, restart the release counter at 0. The release
+// workflow selects the next unpublished counter.
+function syncCoreVersion(pkg: { version?: unknown; bundledVersions?: Record }) {
+  const vitePath = path.join(ROOT, 'vite/packages/vite/package.json');
+  const viteVersion = readJsonFile(vitePath).version;
+  if (typeof viteVersion !== 'string' || !viteVersion.trim()) {
+    throw new Error(`Missing version in ${vitePath}`);
+  }
+  pkg.bundledVersions = { ...pkg.bundledVersions, vite: viteVersion };
+  const current = typeof pkg.version === 'string' ? pkg.version : undefined;
+  let currentVite: string | undefined;
+  try {
+    currentVite = current === undefined ? undefined : decodeCoreVersion(current).vite;
+  } catch {
+    currentVite = undefined;
+  }
+  if (currentVite === viteVersion) {
+    return;
+  }
+  const name = '@voidzero-dev/vite-plus-core';
+  const next = encodeCoreVersion(viteVersion, 0);
+  recordChange(name, changes.get(name)?.old ?? current, next);
+  pkg.version = next;
+}
+
 async function upgradeDependencies(): Promise {
   console.log('Fetching latest versions…');
 
diff --git a/.github/workflows/prepare_release.yml b/.github/workflows/prepare_release.yml
index db7fd48e18..4263305ec1 100644
--- a/.github/workflows/prepare_release.yml
+++ b/.github/workflows/prepare_release.yml
@@ -38,14 +38,15 @@ jobs:
       # release.yml updates HomebrewFormula/vp.rb after the release archives and
       # their checksums are published. Do not point the tap at unpublished assets.
       - name: Bump versions
+        id: versions
         env:
           VERSION: ${{ inputs.version }}
         run: |
           set -euo pipefail
           update_json() {
-            local file=$1
-            sed -i -E "s/^(\s*\"version\":\s*)\"[^\"]+\"/\1\"$VERSION\"/" "$file"
-            grep -qF "\"version\": \"$VERSION\"" "$file" || { echo "::error::Failed to update $file"; exit 1; }
+            local file=$1 version=${2:-$VERSION}
+            sed -i -E "s/^(\s*\"version\":\s*)\"[^\"]+\"/\1\"$version\"/" "$file"
+            grep -qF "\"version\": \"$version\"" "$file" || { echo "::error::Failed to update $file"; exit 1; }
             echo "Updated $file"
           }
           update_toml() {
@@ -54,12 +55,17 @@ jobs:
             grep -qF "version = \"$VERSION\"" "$file" || { echo "::error::Failed to update $file"; exit 1; }
             echo "Updated $file"
           }
+          # The core version encodes the bundled Vite version and a release
+          # counter read from the registry (rfcs/core-version-alignment.md).
+          CORE_VERSION=$(node packages/tools/src/core-version.ts next --vite-plus "$VERSION")
+          echo "core-version=$CORE_VERSION" >> "$GITHUB_OUTPUT"
           update_json packages/cli/package.json
-          update_json packages/core/package.json
+          update_json packages/core/package.json "$CORE_VERSION"
           update_json packages/prompts/package.json
           update_json packages/create-vite-plus/package.json
           update_toml packages/cli/binding/Cargo.toml
           update_toml crates/vp_global_cli/Cargo.toml
+          node packages/tools/src/core-version.ts check --release
 
       - name: Refresh Cargo.lock
         run: cargo check
@@ -81,7 +87,7 @@ jobs:
           base: main
           sign-commits: true
           body: |
-            Release vite-plus v${{ inputs.version }}.
+            Release vite-plus v${{ inputs.version }} with @voidzero-dev/vite-plus-core ${{ steps.versions.outputs.core-version }}.
 
             For a stable release, complete the `HomebrewFormula/vp.rb` update after publication by merging the generated `release/homebrew-v${{ inputs.version }}` PR.
 
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0f8d98162a..d671da3207 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,6 +22,7 @@ jobs:
     outputs:
       version_changed: ${{ steps.version.outputs.changed }}
       version: ${{ steps.version.outputs.version }}
+      core_version: ${{ steps.core-version.outputs.version }}
     steps:
       - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2
 
@@ -33,6 +34,12 @@ jobs:
           file-url: https://unpkg.com/vite-plus@latest/package.json
           file-name: packages/cli/package.json
 
+      # The core version encodes the bundled Vite version, so it differs from
+      # the CLI version (rfcs/core-version-alignment.md).
+      - name: Read core version
+        id: core-version
+        run: echo "version=$(node -p "require('./packages/core/package.json').version")" >> "$GITHUB_OUTPUT"
+
   build-rust:
     name: Build bindings and binaries
     needs: check
@@ -66,6 +73,7 @@ jobs:
       id-token: write # Required for OIDC
     env:
       VERSION: ${{ needs.check.outputs.version }}
+      CORE_VERSION: ${{ needs.check.outputs.core_version }}
       # npm publish-time scanning makes a successful upload temporarily
       # unavailable to installs. After each dependency tier becomes visible on
       # this runner's registry edge, allow time for other CDN edges to settle.
@@ -91,6 +99,9 @@ jobs:
       - name: Install dependencies
         run: pnpm install
 
+      - name: Check core version
+        run: node packages/tools/src/core-version.ts check --release
+
       - name: Download cli dist
         uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
         with:
@@ -182,7 +193,7 @@ jobs:
       - name: Publish core dependency tier
         run: >-
           node ./.github/scripts/publish-npm-package.ts
-          "@voidzero-dev/vite-plus-core@${VERSION}"
+          "@voidzero-dev/vite-plus-core@${CORE_VERSION}"
           --
           pnpm publish --filter=./packages/core --tag latest --access public --no-git-checks
 
@@ -191,7 +202,18 @@ jobs:
       - name: Wait for core to propagate
         run: >-
           node ./.github/scripts/wait-for-npm-packages.ts
-          "@voidzero-dev/vite-plus-core@${VERSION}"
+          "@voidzero-dev/vite-plus-core@${CORE_VERSION}"
+
+      # A bundled Vite downgrade publishes a core version below the current
+      # latest. Show a wrong dist-tag without stopping the release.
+      - name: Check the core latest dist-tag
+        continue-on-error: true
+        run: |
+          LATEST=$(npm view @voidzero-dev/vite-plus-core dist-tags.latest)
+          if [ "$LATEST" != "$CORE_VERSION" ]; then
+            echo "::error::@voidzero-dev/vite-plus-core latest is $LATEST, expected $CORE_VERSION. Run: npm dist-tag add @voidzero-dev/vite-plus-core@$CORE_VERSION latest"
+            exit 1
+          fi
 
       # Make the exact local CLI version installable without moving latest.
       # Bootstrap keeps selecting the previous release until its matching global
@@ -288,7 +310,7 @@ jobs:
 
           ### Published Packages
 
-          - \`@voidzero-dev/vite-plus-core@${VERSION}\`
+          - \`@voidzero-dev/vite-plus-core@${CORE_VERSION}\`
           - \`vite-plus@${VERSION}\`
           - \`create-vite-plus@${VERSION}\`
 
@@ -515,6 +537,7 @@ jobs:
       (needs.deploy-docs.result == 'success' || needs.deploy-docs.result == 'skipped')
     env:
       VERSION: ${{ needs.check.outputs.version }}
+      CORE_VERSION: ${{ needs.check.outputs.core_version }}
       IMAGE: ghcr.io/voidzero-dev/vite-plus
     steps:
       # Non-blocking: a webhook failure must not mark the release run as failed
@@ -529,7 +552,7 @@ jobs:
             A new release is available!
 
             **Published Packages:**
-            • @voidzero-dev/vite-plus-core@${{ env.VERSION }}
+            • @voidzero-dev/vite-plus-core@${{ env.CORE_VERSION }}
             • vite-plus@${{ env.VERSION }}
             • create-vite-plus@${{ env.VERSION }}
 
diff --git a/.github/workflows/reusable-ecosystem-ci.yml b/.github/workflows/reusable-ecosystem-ci.yml
index df17337fcd..23f2bfd7c6 100644
--- a/.github/workflows/reusable-ecosystem-ci.yml
+++ b/.github/workflows/reusable-ecosystem-ci.yml
@@ -85,11 +85,15 @@ jobs:
           # and installs them like a real release, with every package manager
           # (including bun) resolving the standard
           # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin
-          # both packages to 0.0.0 so a correctly installed local build is
+          # the CLI to 0.0.0 and core to its local version (release counter
+          # 99, for example 8.3.599) so a correctly installed local build is
           # always distinguishable from any published version
           # (verify-install.ts asserts it), even on a release commit that
-          # leaves packages/{core,cli} at a published version.
-          (cd packages/core && npm pkg set version=0.0.0)
+          # leaves packages/{core,cli} at a published version. The local core
+          # version still encodes the bundled Vite, so peer checks run as they
+          # do for a release (rfcs/core-version-alignment.md).
+          CORE_VERSION=$(node packages/tools/src/core-version.ts local)
+          (cd packages/core && npm pkg set version="$CORE_VERSION")
           (cd packages/cli && npm pkg set version=0.0.0)
           cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
           cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
diff --git a/.github/workflows/reusable-release-build.yml b/.github/workflows/reusable-release-build.yml
index 01d59394c5..b1c6948f6d 100644
--- a/.github/workflows/reusable-release-build.yml
+++ b/.github/workflows/reusable-release-build.yml
@@ -86,14 +86,17 @@ jobs:
           #   cliPkg.version (VITE_PLUS_VERSION). Without this a preview
           #   `vp migrate` pins the build-time release placeholder instead of
           #   the test build, and skips the registry-bridge .npmrc.
-          # - packages/core: the release build bakes ${VERSION} into bundled
-          #   Rolldown's binding version guards, matching the
-          #   @voidzero-dev/vite-plus- packages published as
-          #   ${VERSION}. A release checkout already carries ${VERSION}; this
-          #   only changes preview builds.
+          # - packages/core: only for preview builds, which publish every
+          #   package as 0.0.0-commit.. A release checkout already
+          #   carries the core version, which encodes the bundled Vite
+          #   version (rfcs/core-version-alignment.md). Release builds bake
+          #   the CLI version into bundled Rolldown's binding version guards.
           # The list feeds the verify step below, so a stamped file cannot
           # silently drop out of verification.
-          PKG_JSON_STAMP_FILES="packages/cli/package.json packages/core/package.json"
+          PKG_JSON_STAMP_FILES="packages/cli/package.json"
+          if [[ "$VERSION" == 0.0.0-* ]]; then
+            PKG_JSON_STAMP_FILES="$PKG_JSON_STAMP_FILES packages/core/package.json"
+          fi
           for f in $PKG_JSON_STAMP_FILES; do
             pnpm exec json-edit "$f" '_.version = process.env.VERSION'
           done
diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml
index 68730e89db..5724af3d6c 100644
--- a/.github/workflows/test-vp-create.yml
+++ b/.github/workflows/test-vp-create.yml
@@ -107,11 +107,15 @@ jobs:
           # and installs them like a real release, with every package manager
           # (including bun) resolving the standard
           # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin
-          # both packages to 0.0.0 so a correctly installed local build is
+          # the CLI to 0.0.0 and core to its local version (release counter
+          # 99, for example 8.3.599) so a correctly installed local build is
           # always distinguishable from any published version, even on a
           # release commit that leaves packages/{core,cli} at a published
-          # version.
-          (cd packages/core && npm pkg set version=0.0.0)
+          # version. The local core version still encodes the bundled Vite,
+          # so peer checks run as they do for a release
+          # (rfcs/core-version-alignment.md).
+          CORE_VERSION=$(node packages/tools/src/core-version.ts local)
+          (cd packages/core && npm pkg set version="$CORE_VERSION")
           (cd packages/cli && npm pkg set version=0.0.0)
           cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
           cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
@@ -181,6 +185,7 @@ jobs:
       # The local registry serves the packed 0.0.0 build, so create pins the
       # exact version like a real release. The vp binary was built before the
       # pack step pinned the package versions, so align the version explicitly.
+      # The "Set local core version" step sets VP_CORE_VERSION the same way.
       VP_VERSION: '0.0.0'
       # Force full dependency rewriting so the library template's existing
       # vite-plus dep gets overridden with the local build
@@ -204,6 +209,9 @@ jobs:
           name: vite-plus-packages
           path: tmp/tgz
 
+      - name: Set local core version
+        run: echo "VP_CORE_VERSION=$(node packages/tools/src/core-version.ts local)" >> "$GITHUB_ENV"
+
       - name: Install vp CLI
         run: |
           mkdir -p target/release
@@ -238,8 +246,8 @@ jobs:
           echo "$handshake"
           echo "$handshake" | node -e 'const { env } = JSON.parse(require("node:fs").readFileSync(0, "utf8")); for (const [k, v] of Object.entries(env)) console.log(`${k}=${v}`)' >> "$GITHUB_ENV"
 
-      # No VP_OVERRIDE_PACKAGES: with VP_VERSION=0.0.0 the product default
-      # override map already pins `vite` to npm:@voidzero-dev/vite-plus-core@0.0.0
+      # No VP_OVERRIDE_PACKAGES: with VP_CORE_VERSION set, the product default
+      # override map already pins `vite` to npm:@voidzero-dev/vite-plus-core@
       # and `vitest` to the bundled version (see VITE_PLUS_OVERRIDE_PACKAGES in
       # packages/cli/src/utils/constants.ts), and stays correct across vitest
       # bumps without editing this workflow.
diff --git a/MAINTENANCE.md b/MAINTENANCE.md
index 90fef2796d..6edaf31e23 100644
--- a/MAINTENANCE.md
+++ b/MAINTENANCE.md
@@ -24,6 +24,8 @@ pnpm add vite-plus@0.0.0-commit.
 
 The [release workflow](.github/workflows/release.yml) publishes platform packages before `@voidzero-dev/vite-plus-core`, then publishes `vite-plus`. It checks that the required package versions and tarballs are available before continuing to dependent steps.
 
+`@voidzero-dev/vite-plus-core` has its own version, which encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`). The [prepare release workflow](.github/workflows/prepare_release.yml) selects it from the registry and prints both versions in the release PR. The platform packages, `vite-plus`, and `create-vite-plus` use the `vite-plus` version. See [Core Version](packages/core/BUNDLING.md#core-version).
+
 The [propagation helper](.github/scripts/wait-for-npm-packages.ts) defaults to a 10-minute timeout and an additional 60-second wait for CDN propagation after availability checks pass.
 
 ### Recovering from a propagation timeout
diff --git a/README.md b/README.md
index 5f5db2fddd..9e08310875 100644
--- a/README.md
+++ b/README.md
@@ -203,11 +203,11 @@ If you are manually migrating a project to Vite+, install these dev dependencies
 vp install -D vite-plus
 ```
 
-Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the installed `vite-plus` version. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
+Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the core version that the installed `vite-plus` uses, shown by `vp toolchain vite-plus-core`. The core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`), so package managers check `vite` peer ranges against the bundled Vite. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
 
 ```json
 "overrides": {
-  "vite": "npm:@voidzero-dev/vite-plus-core@",
+  "vite": "npm:@voidzero-dev/vite-plus-core@",
   "vitest": ""
 }
 ```
@@ -216,7 +216,7 @@ If you are using `pnpm`, add this to your `pnpm-workspace.yaml`:
 
 ```yaml
 overrides:
-  vite@*: npm:@voidzero-dev/vite-plus-core@ # Match the installed vite-plus version
+  vite@*: npm:@voidzero-dev/vite-plus-core@ # Match the version from vp toolchain vite-plus-core
   vitest@*:  # Match the version from vp toolchain vitest
 ```
 
@@ -224,7 +224,7 @@ Or, if you are using Yarn:
 
 ```json
 "resolutions": {
-  "vite": "npm:@voidzero-dev/vite-plus-core@",
+  "vite": "npm:@voidzero-dev/vite-plus-core@",
   "vitest": ""
 }
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-api.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-api.mjs
index 573b1212d6..04e308c94f 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-api.mjs
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-api.mjs
@@ -24,6 +24,11 @@ for (const name of [
 assert.equal(ModuleRunner, VpModuleRunner);
 const config = {};
 assert.equal(vite.defineConfig(config), config);
-assert.equal(pkg.dependencies.vite, `npm:@voidzero-dev/vite-plus-core@${pkg.version}`);
+// The core version encodes the bundled Vite version, so it differs from the
+// vite-plus version (rfcs/core-version-alignment.md).
+assert.equal(
+  pkg.dependencies.vite,
+  `npm:@voidzero-dev/vite-plus-core@${require('vite/package.json').version}`,
+);
 assert.equal(pkg.dependencies['@voidzero-dev/vite-plus-core'], undefined);
 console.log('Packed alias, ESM, CommonJS, and module-runner identity passed');
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs
index 7d71f37cc7..c94fb893f9 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs
@@ -10,7 +10,11 @@ assert.equal(cliRequire('vite/package.json').name, '@voidzero-dev/vite-plus-core
 if (project.devDependencies.vite) {
   assert.equal(
     project.devDependencies.vite,
-    `npm:@voidzero-dev/vite-plus-core@${cliRequire('vite-plus/package.json').version}`,
+    cliRequire('vite-plus/package.json').dependencies.vite,
+  );
+  assert.equal(
+    project.devDependencies.vite,
+    `npm:@voidzero-dev/vite-plus-core@${cliRequire('vite/package.json').version}`,
   );
   assert.equal(require.resolve('vite/package.json'), cliRequire.resolve('vite/package.json'));
   assert.equal(vitestRequire.resolve('vite/package.json'), cliRequire.resolve('vite/package.json'));
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/pin-core-alias.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/pin-core-alias.mjs
index b1e29e051d..bc0c30158a 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/pin-core-alias.mjs
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/pin-core-alias.mjs
@@ -3,8 +3,10 @@ import { createRequire } from 'node:module';
 
 // Before installation, resolve the checkout exposed by the snapshot runner.
 // CI can stamp its version, so do not hard-code the committed package version.
+// The core version encodes the bundled Vite version, so it differs from the
+// vite-plus version (rfcs/core-version-alignment.md).
 const require = createRequire(import.meta.url);
-const { version } = require('vite-plus/package.json');
+const { version } = require('@voidzero-dev/vite-plus-core/package.json');
 const project = JSON.parse(readFileSync('package.json', 'utf8'));
 project.devDependencies.vite = `npm:@voidzero-dev/vite-plus-core@${version}`;
 writeFileSync('package.json', `${JSON.stringify(project, null, 2)}\n`);
diff --git a/docs/guide/local-cli.md b/docs/guide/local-cli.md
index c2a76e9b93..5a055eecfd 100644
--- a/docs/guide/local-cli.md
+++ b/docs/guide/local-cli.md
@@ -61,26 +61,26 @@ bun add -D vite-plus
 
 :::
 
-Add overrides to your package manager so that other packages resolve the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the installed `vite-plus` version. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest` so the whole project shares a single Vitest copy with `vp test`. Without the `vitest` pin, a dependency or workspace package can pull a different Vitest than the bundled runner, splitting Vitest's internals (mocks, `expect`, runner state):
+Add overrides to your package manager so that other packages resolve the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the core version that the installed `vite-plus` uses, shown by `vp toolchain vite-plus-core`. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest` so the whole project shares a single Vitest copy with `vp test`. Without the `vitest` pin, a dependency or workspace package can pull a different Vitest than the bundled runner, splitting Vitest's internals (mocks, `expect`, runner state):
 
 ::: code-group
 
 ```yaml [pnpm-workspace.yaml]
 overrides:
-  vite@*: npm:@voidzero-dev/vite-plus-core@ # Match the installed vite-plus version
+  vite@*: npm:@voidzero-dev/vite-plus-core@ # Match the version from vp toolchain vite-plus-core
   vitest@*:  # Match the version from vp toolchain vitest
 ```
 
 ```json [npm / Bun package.json]
 "overrides": {
-  "vite": "npm:@voidzero-dev/vite-plus-core@",
+  "vite": "npm:@voidzero-dev/vite-plus-core@",
   "vitest": ""
 }
 ```
 
 ```json [Yarn package.json]
 "resolutions": {
-  "vite": "npm:@voidzero-dev/vite-plus-core@",
+  "vite": "npm:@voidzero-dev/vite-plus-core@",
   "vitest": ""
 }
 ```
@@ -95,11 +95,11 @@ For example, update an existing `vite` development dependency as follows. Keep t
 ```json [package.json]
 {
   "devDependencies": {
-    "vite": "npm:@voidzero-dev/vite-plus-core@",
+    "vite": "npm:@voidzero-dev/vite-plus-core@",
     "vite-plus": ""
   },
   "overrides": {
-    "vite": "npm:@voidzero-dev/vite-plus-core@",
+    "vite": "npm:@voidzero-dev/vite-plus-core@",
     "vitest": ""
   }
 }
@@ -113,9 +113,10 @@ If you also declare `vitest` directly, set its dependency entry to the exact ver
 Dependencies and plugins can import `vite` or `vitest` directly, even when your own code imports from `vite-plus`. These overrides align their dependencies with the toolchain Vite+ uses:
 
 - The `vite` alias directs those imports to Vite+'s core package. Separate Vite instances can break runtime identity checks: [issue #1391](https://github.com/voidzero-dev/vite-plus/issues/1391) reported TanStack Start returning 404s because an `instanceof` check crossed two copies. [PR #2617](https://github.com/voidzero-dev/vite-plus/pull/2617) addresses the CLI side by sharing Vite through the same alias.
+- The core version encodes the bundled Vite version. Its major and minor are the Vite major and minor, and its patch is the Vite patch times 100 plus a release counter. For example, core `8.3.500` bundles Vite `8.3.5`. Package managers compare this version with the `vite` peer ranges of plugins, so a plugin that supports the bundled Vite passes the check, and a plugin that does not support it still gets a peer warning. [Issue #2806](https://github.com/voidzero-dev/vite-plus/issues/2806) describes the earlier `1.x` versions, which failed every check.
 - The exact `vitest` pin keeps dependencies and `vp test` on the same Vitest version, avoiding separate mocks, `expect` instances, and runner state. [PR #2365](https://github.com/voidzero-dev/vite-plus/pull/2365) documents this requirement for manual installation.
 
-Keep the core alias aligned with your installed `vite-plus` version and update the Vitest pin to match its bundled version when upgrading. [Issue #2356](https://github.com/voidzero-dev/vite-plus/issues/2356) describes how dependency bots can update these packages independently and leave incompatible versions installed together.
+Keep the core alias aligned with the core version of your installed `vite-plus` and update the Vitest pin to match its bundled version when upgrading. [Issue #2356](https://github.com/voidzero-dev/vite-plus/issues/2356) describes how dependency bots can update these packages independently and leave incompatible versions installed together.
 
 :::
 
diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md
index 3101d3f5dd..15b4418bb2 100644
--- a/docs/guide/migrate-rules.md
+++ b/docs/guide/migrate-rules.md
@@ -379,6 +379,13 @@ Migration converts the Volta `volta.node` pin to `.node-version`, because Vite+
 - pnpm accepts the logical default catalog as either top-level `catalog` or `catalogs.default`, but not both. Migration preserves the existing form and never creates the other form beside it.
 - When an existing named catalog already owns `vite-plus`, `vite`, or `vitest`, migration reuses that managed toolchain catalog for newly added dependencies and overrides. It creates a top-level default catalog only when no managed or default catalog can be reused.
 
+**Peer rules.** The core version encodes the bundled Vite version: core `8.3.500` bundles Vite `8.3.5` (see the [RFC](https://github.com/voidzero-dev/vite-plus/blob/main/rfcs/core-version-alignment.md)). pnpm checks `vite` peer ranges against the `vite` alias, so a plugin that supports the bundled Vite passes, and a plugin that does not support it fails.
+
+- Migration does not add `vite` to `peerDependencyRules.allowAny` or `peerDependencyRules.allowedVersions`.
+- Migration removes an existing `vite` entry from both fields, also an entry that you wrote. The only purpose of that entry was to accept the `1.x` core alias. It removes a field, or `peerDependencyRules`, when no entry remains. Other entries stay. The migrate summary names the removed rule.
+- For a preview build (`0.0.0-commit.`) or a Vite+ prerelease core (such as `8.4.0-vp.0`), migration keeps writing the `vite` rule. Package managers do not accept these versions for ranges such as `^8.0.0`.
+- After migration, a plugin whose `vite` peer range excludes the bundled Vite (for example `^7.0.0`) produces a peer warning. With `strictPeerDependencies`, the install fails. Update the plugin, or add a rule scoped to that plugin, such as `allowedVersions: { 'vite-plugin-foo>vite': '*' }`. Migration removes only the plain `vite` key.
+
 **Other rules.**
 
 - Packages that use `vite-plus` without a direct `vite` declaration keep that layout (see [Vite and Overrides](#vite-and-overrides)).
diff --git a/docs/guide/upgrade-project.md b/docs/guide/upgrade-project.md
index 8b32e058fb..efec5a0d4f 100644
--- a/docs/guide/upgrade-project.md
+++ b/docs/guide/upgrade-project.md
@@ -18,7 +18,7 @@ On a project that is already on Vite+, migrate does a toolchain version upgrade
 
 ## Manually Updating
 
-Update `vite-plus` and the `vite` alias to `@voidzero-dev/vite-plus-core` together, keeping the core version aligned with `vite-plus`. Update these entries wherever they are declared in your workspace, including overrides or catalogs, then install dependencies to refresh the lockfile. Also [update the Vitest pin](#updating-the-vitest-pin) to match the bundled version.
+Update `vite-plus` and the `vite` alias to `@voidzero-dev/vite-plus-core` together. The two packages have different versions: the core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`). After you update `vite-plus`, set the alias to the version that `vp toolchain vite-plus-core` shows. Update these entries wherever they are declared in your workspace, including overrides or catalogs, then install dependencies to refresh the lockfile. Also [update the Vitest pin](#updating-the-vitest-pin) to match the bundled version.
 
 Without the global CLI, run the `vp` commands on this page through your package manager, for example `pnpm exec vp toolchain vitest`.
 
diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts
index 6cd4700203..609142db76 100644
--- a/ecosystem-ci/patch-project.ts
+++ b/ecosystem-ci/patch-project.ts
@@ -6,7 +6,7 @@ import { join } from 'node:path';
 import { VITEST_VERSION } from '../packages/cli/src/utils/constants.ts';
 import vitePlusCorePkg from '../packages/core/package.json' with { type: 'json' };
 import { patchNpmxVitestConfig } from './npmx.ts';
-import { ecosystemCiDir, tgzDir, vitePlusTgzVersion } from './paths.ts';
+import { ecosystemCiDir, tgzDir, vitePlusCoreTgzVersion, vitePlusTgzVersion } from './paths.ts';
 import repos from './repo.json' with { type: 'json' };
 import { prepareWebdriverioProject } from './webdriverio.ts';
 
@@ -31,6 +31,7 @@ const cli = process.env.VP_CLI_BIN ?? 'vp';
 // (local-npm-registry.ts), so vp migrate pins and installs the checkout's
 // own version through the standard registry code paths, with no `file:` specs.
 const vitePlusVersion = vitePlusTgzVersion();
+const vitePlusCoreVersion = vitePlusCoreTgzVersion();
 
 const registryScript = join(
   import.meta.dirname,
@@ -319,12 +320,13 @@ const migrateEnv: NodeJS.ProcessEnv = {
   ...registryInfo.env,
   ...(forceFreshMigration ? { VP_FORCE_MIGRATE: '1' } : {}),
   VP_OVERRIDE_PACKAGES: JSON.stringify({
-    vite: `npm:@voidzero-dev/vite-plus-core@${vitePlusVersion}`,
+    vite: `npm:@voidzero-dev/vite-plus-core@${vitePlusCoreVersion}`,
     ...vitestOverrides,
   }),
   // The vp binary was built before the pack step pinned the package versions,
-  // so align the version migrate pins with the tgz the registry serves.
+  // so align the versions migrate pins with the tgz files the registry serves.
   VP_VERSION: vitePlusVersion,
+  VP_CORE_VERSION: vitePlusCoreVersion,
   ...releaseAgeEnv,
 };
 
diff --git a/ecosystem-ci/paths.ts b/ecosystem-ci/paths.ts
index d6e2c3b118..57c504c2b9 100644
--- a/ecosystem-ci/paths.ts
+++ b/ecosystem-ci/paths.ts
@@ -20,11 +20,26 @@ export const tgzDir = join(projectDir, '..', 'tmp', 'tgz');
  * version on a local run.
  */
 export function vitePlusTgzVersion(): string {
+  return tgzVersion(/^vite-plus-(\d.*)\.tgz$/, 'vite-plus-.tgz');
+}
+
+/**
+ * The version of the packed core tgz. It encodes the bundled Vite version, so
+ * it differs from {@link vitePlusTgzVersion} (rfcs/core-version-alignment.md).
+ */
+export function vitePlusCoreTgzVersion(): string {
+  return tgzVersion(
+    /^voidzero-dev-vite-plus-core-(\d.*)\.tgz$/,
+    'voidzero-dev-vite-plus-core-.tgz',
+  );
+}
+
+function tgzVersion(pattern: RegExp, description: string): string {
   const version = readdirSync(tgzDir)
-    .map((entry) => /^vite-plus-(\d.*)\.tgz$/.exec(entry)?.[1])
+    .map((entry) => pattern.exec(entry)?.[1])
     .find(Boolean);
   if (!version) {
-    throw new Error(`No vite-plus-.tgz found in ${tgzDir}`);
+    throw new Error(`No ${description} found in ${tgzDir}`);
   }
   return version;
 }
diff --git a/packages/cli/BUNDLING.md b/packages/cli/BUNDLING.md
index ae3c70c5d5..8dcfa2bcd6 100644
--- a/packages/cli/BUNDLING.md
+++ b/packages/cli/BUNDLING.md
@@ -22,17 +22,21 @@ its subpaths. This gives the CLI and plugins the same dependency name and
 avoids separate core instances under the alias and canonical package name.
 
 `resolve-core.ts` resolves the alias from the selected CLI package and checks
-its core version. It also checks any Vite dependency declared by the command's
-target project. An incidental hoisted peer does not trigger project validation.
-These checks run before Vite or packaging commands start. Keep the canonical
-name in release metadata and alias targets to identify the published package.
+that the core version matches the exact alias in the CLI manifest. The core
+version encodes the bundled Vite version, so it differs from the CLI version
+(see [Core Version](../core/BUNDLING.md#core-version)). It also checks any Vite
+dependency declared by the command's target project. An incidental hoisted peer
+does not trigger project validation. These checks run before Vite or packaging
+commands start. Keep the canonical name in release metadata and alias targets
+to identify the published package.
 
 For npm projects without overrides, pin a declared `vite` alias to the exact
-installed `vite-plus` version. npm can replace a tagged alias such as
-`npm:@voidzero-dev/vite-plus-core@latest` with upstream Vite to satisfy Vitest's
-peer range. The CLI rejects that mismatch and directs users to `vp migrate`,
-which writes an exact alias and the package-manager overrides. The npm fixtures
-check shared core identity with an exact alias and rejection of the tagged layout.
+core version that the installed `vite-plus` uses. npm can replace a tagged
+alias such as `npm:@voidzero-dev/vite-plus-core@latest` with upstream Vite to
+satisfy Vitest's peer range. The CLI rejects that mismatch and directs users to
+`vp migrate`, which writes an exact alias and the package-manager overrides.
+The npm fixtures check shared core identity with an exact alias and rejection
+of the tagged layout.
 
 ## Build Steps
 
diff --git a/packages/cli/README.md b/packages/cli/README.md
index b70e87a3ea..c75fd08e08 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -198,11 +198,11 @@ If you are manually migrating a project to Vite+, install these dev dependencies
 vp install -D vite-plus
 ```
 
-Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the installed `vite-plus` version. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
+Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the core version that the installed `vite-plus` uses, shown by `vp toolchain vite-plus-core`. The core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`), so package managers check `vite` peer ranges against the bundled Vite. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
 
 ```json
 "overrides": {
-  "vite": "npm:@voidzero-dev/vite-plus-core@",
+  "vite": "npm:@voidzero-dev/vite-plus-core@",
   "vitest": ""
 }
 ```
@@ -211,7 +211,7 @@ If you are using `pnpm`, add this to your `pnpm-workspace.yaml`:
 
 ```yaml
 overrides:
-  vite@*: npm:@voidzero-dev/vite-plus-core@ # Match the installed vite-plus version
+  vite@*: npm:@voidzero-dev/vite-plus-core@ # Match the version from vp toolchain vite-plus-core
   vitest@*:  # Match the version from vp toolchain vitest
 ```
 
@@ -219,7 +219,7 @@ Or, if you are using Yarn:
 
 ```json
 "resolutions": {
-  "vite": "npm:@voidzero-dev/vite-plus-core@",
+  "vite": "npm:@voidzero-dev/vite-plus-core@",
   "vitest": ""
 }
 ```
diff --git a/packages/cli/src/__tests__/resolve-core.spec.ts b/packages/cli/src/__tests__/resolve-core.spec.ts
index 8e533913dd..3570e15dfb 100644
--- a/packages/cli/src/__tests__/resolve-core.spec.ts
+++ b/packages/cli/src/__tests__/resolve-core.spec.ts
@@ -5,9 +5,14 @@ import { join } from 'node:path';
 
 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
 
+import corePkg from '../../../core/package.json' with { type: 'json' };
 import cliPkg from '../../package.json' with { type: 'json' };
 import { resolveCore } from '../resolve-core.ts';
 
+// The core version encodes the bundled Vite version, so it differs from the
+// CLI version (rfcs/core-version-alignment.md).
+const CORE_VERSION = corePkg.version;
+
 function writePackageJson(directory: string, manifest: Record): void {
   mkdirSync(directory, { recursive: true });
   writeFileSync(join(directory, 'package.json'), JSON.stringify(manifest));
@@ -16,7 +21,7 @@ function writePackageJson(directory: string, manifest: Record):
 function writeCore(
   directory: string,
   name = '@voidzero-dev/vite-plus-core',
-  version = cliPkg.version,
+  version = CORE_VERSION,
 ): void {
   writePackageJson(directory, {
     name,
@@ -27,10 +32,15 @@ function writeCore(
   writeFileSync(join(directory, 'pack.js'), '');
 }
 
-function writeCli(directory: string, version = cliPkg.version): void {
+function writeCli(
+  directory: string,
+  version = cliPkg.version,
+  viteSpec = `npm:@voidzero-dev/vite-plus-core@${CORE_VERSION}`,
+): void {
   writePackageJson(directory, {
     name: 'vite-plus',
     version,
+    dependencies: { vite: viteSpec },
     exports: { './package.json': './package.json' },
   });
 }
@@ -46,7 +56,7 @@ describe('resolveCore', () => {
     root = realpathSync(mkdtempSync(join(tmpdir(), 'vp-core-resolver-')));
     project = join(root, 'project');
     writePackageJson(project, {
-      devDependencies: { vite: `npm:@voidzero-dev/vite-plus-core@${cliPkg.version}` },
+      devDependencies: { vite: `npm:@voidzero-dev/vite-plus-core@${CORE_VERSION}` },
     });
     cliModule = join(root, 'cli', 'dist', 'bin.js');
     bundled = join(root, 'cli', 'node_modules', 'vite');
@@ -88,29 +98,47 @@ describe('resolveCore', () => {
     expect(resolveCore('', project, cliModule)).toBe(join(bundled, 'index.js'));
   });
 
-  it.each(['0.0.0', '0.0.0-commit.1234567'])(
-    'uses the installed CLI manifest after repacking as %s',
-    (version) => {
-      writeCli(join(root, 'cli'), version);
-      writeCore(bundled, '@voidzero-dev/vite-plus-core', version);
-      writeCore(projectCore, '@voidzero-dev/vite-plus-core', version);
-      expect(resolveCore('', project, cliModule)).toBe(join(bundled, 'index.js'));
-      expect(resolveCore('/pack', project, cliModule)).toBe(join(bundled, 'pack.js'));
-    },
-  );
+  it.each([
+    ['0.0.0', '8.3.599'],
+    ['0.0.0-commit.1234567', '0.0.0-commit.1234567'],
+  ])('uses the installed CLI manifest after repacking as %s', (version, coreVersion) => {
+    writeCli(join(root, 'cli'), version, `npm:@voidzero-dev/vite-plus-core@${coreVersion}`);
+    writeCore(bundled, '@voidzero-dev/vite-plus-core', coreVersion);
+    writeCore(projectCore, '@voidzero-dev/vite-plus-core', coreVersion);
+    expect(resolveCore('', project, cliModule)).toBe(join(bundled, 'index.js'));
+    expect(resolveCore('/pack', project, cliModule)).toBe(join(bundled, 'pack.js'));
+  });
 
   it.each(['project', 'bundled'])(
     'still rejects a mismatched %s core after repacking the CLI',
     (location) => {
-      writeCli(join(root, 'cli'), '0.0.0');
-      writeCore(bundled, '@voidzero-dev/vite-plus-core', '0.0.0');
+      writeCli(join(root, 'cli'), '0.0.0', 'npm:@voidzero-dev/vite-plus-core@8.3.599');
+      writeCore(bundled, '@voidzero-dev/vite-plus-core', '8.3.599');
       writeCore(location === 'project' ? projectCore : bundled);
       expect(() => resolveCore('', project, cliModule)).toThrow(
-        `Expected @voidzero-dev/vite-plus-core@0.0.0, but found @voidzero-dev/vite-plus-core@${cliPkg.version}`,
+        `Expected @voidzero-dev/vite-plus-core@8.3.599, but found @voidzero-dev/vite-plus-core@${CORE_VERSION}`,
       );
     },
   );
 
+  it('rejects a core that matches the CLI version instead of its alias', () => {
+    writeCore(bundled, '@voidzero-dev/vite-plus-core', cliPkg.version);
+    expect(() => resolveCore('', project, cliModule)).toThrow(
+      `Expected @voidzero-dev/vite-plus-core@${CORE_VERSION}, but found @voidzero-dev/vite-plus-core@${cliPkg.version}`,
+    );
+  });
+
+  it('checks only the package name for the repository workspace spec', () => {
+    writeCli(join(root, 'cli'), cliPkg.version, 'workspace:@voidzero-dev/vite-plus-core@*');
+    writeCore(bundled, '@voidzero-dev/vite-plus-core', '0.0.0-any');
+    writeCore(projectCore, '@voidzero-dev/vite-plus-core', '0.0.0-any');
+    expect(resolveCore('', project, cliModule)).toBe(join(bundled, 'index.js'));
+    writeCore(bundled, 'vite', '8.2.2');
+    expect(() => resolveCore('', project, cliModule)).toThrow(
+      'Expected @voidzero-dev/vite-plus-core, but found vite@8.2.2',
+    );
+  });
+
   it('does not fall back to the project when the CLI dependency is missing', () => {
     writeCore(projectCore);
     rmSync(bundled, { recursive: true });
diff --git a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
new file mode 100644
index 0000000000..cf35000c8c
--- /dev/null
+++ b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
@@ -0,0 +1,275 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { parse as parseYaml } from 'yaml';
+
+import { PackageManager, type WorkspaceInfo } from '../../types/index.js';
+import { VITE_PLUS_OVERRIDE_PACKAGES } from '../../utils/constants.js';
+import { createMigrationReport } from '../report.js';
+
+// Keep the vite-plus spec stable; each test selects the core alias it needs.
+vi.mock('../../utils/constants.js', async (importOriginal) => {
+  const mod = await importOriginal();
+  return {
+    ...mod,
+    VITE_PLUS_VERSION: 'latest',
+    VITE_PLUS_OVERRIDE_PACKAGES: { ...mod.VITE_PLUS_OVERRIDE_PACKAGES },
+  };
+});
+
+const {
+  detectVitePlusBootstrapPending,
+  ensureVitePlusBootstrap,
+  rewriteStandaloneProject,
+  vitePeerDependencyRuleRequired,
+} = await import('../migrator.js');
+
+const STABLE_CORE = 'npm:@voidzero-dev/vite-plus-core@8.3.500';
+const PREVIEW_CORE = 'npm:@voidzero-dev/vite-plus-core@0.0.0-commit.0c515e3';
+
+type PeerDependencyRules = { allowAny?: string[]; allowedVersions?: Record };
+
+function makeWorkspaceInfo(rootDir: string, version = '10.33.0'): WorkspaceInfo {
+  return {
+    rootDir,
+    isMonorepo: false,
+    monorepoScope: '',
+    workspacePatterns: [],
+    parentDirs: [],
+    packageManager: PackageManager.pnpm,
+    packageManagerVersion: version,
+    downloadPackageManager: {
+      name: PackageManager.pnpm,
+      installDir: '/tmp',
+      binPrefix: '/tmp/bin',
+      packageName: PackageManager.pnpm,
+      version,
+    },
+    packages: [],
+  };
+}
+
+function readJson(filePath: string): Record {
+  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+}
+
+function readWorkspaceRules(projectPath: string): PeerDependencyRules | undefined {
+  const workspace = parseYaml(
+    fs.readFileSync(path.join(projectPath, 'pnpm-workspace.yaml'), 'utf8'),
+  ) as { peerDependencyRules?: PeerDependencyRules };
+  return workspace.peerDependencyRules;
+}
+
+function readPackageJsonRules(projectPath: string): PeerDependencyRules | undefined {
+  const pkg = readJson(path.join(projectPath, 'package.json')) as {
+    pnpm?: { peerDependencyRules?: PeerDependencyRules };
+  };
+  return pkg.pnpm?.peerDependencyRules;
+}
+
+function writeVitePlusProject(projectPath: string, peerDependencyRules: string[]) {
+  fs.writeFileSync(
+    path.join(projectPath, 'package.json'),
+    JSON.stringify({
+      name: 'test',
+      devDependencies: { 'vite-plus': 'catalog:' },
+      devEngines: {
+        packageManager: { name: 'pnpm', version: '10.33.0', onFail: 'download' },
+      },
+    }),
+  );
+  fs.writeFileSync(
+    path.join(projectPath, 'pnpm-workspace.yaml'),
+    [
+      'catalog:',
+      `  vite: ${VITE_PLUS_OVERRIDE_PACKAGES.vite}`,
+      '  vite-plus: latest',
+      'overrides:',
+      "  vite@*: 'catalog:'",
+      ...peerDependencyRules,
+      '',
+    ].join('\n'),
+  );
+}
+
+describe('vitePeerDependencyRuleRequired', () => {
+  it.each([
+    [STABLE_CORE, false],
+    ['npm:@voidzero-dev/vite-plus-core@9.0.0', false],
+    ['npm:@voidzero-dev/vite-plus-core@8.4.0-vp.0', true],
+    ['npm:@voidzero-dev/vite-plus-core@9.0.0-beta.3.vp.0', true],
+    ['npm:@voidzero-dev/vite-plus-core@0.0.0', true],
+    [PREVIEW_CORE, true],
+    ['npm:@voidzero-dev/vite-plus-core@1.4.0', true],
+    ['npm:@voidzero-dev/vite-plus-core@8.3.500+build.1', true],
+    ['npm:@voidzero-dev/vite-plus-core@latest', true],
+    ['file:../voidzero-dev-vite-plus-core-8.3.599.tgz', true],
+    ['https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891', true],
+  ])('%s requires the rule: %s', (spec, required) => {
+    expect(vitePeerDependencyRuleRequired(spec)).toBe(required);
+  });
+});
+
+describe('pnpm vite peer rule', () => {
+  let tmpDir: string;
+  const savedViteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite;
+
+  beforeEach(() => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-vite-peer-rule-'));
+  });
+
+  afterEach(() => {
+    VITE_PLUS_OVERRIDE_PACKAGES.vite = savedViteOverride;
+    fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  describe('with a stable core version', () => {
+    beforeEach(() => {
+      VITE_PLUS_OVERRIDE_PACKAGES.vite = STABLE_CORE;
+    });
+
+    it('removes the vite rule from pnpm-workspace.yaml and keeps other entries', () => {
+      writeVitePlusProject(tmpDir, [
+        'peerDependencyRules:',
+        '  allowAny:',
+        '    - react',
+        '    - vite',
+        '  allowedVersions:',
+        "    react: '*'",
+        "    vite: '*'",
+      ]);
+      const report = createMigrationReport();
+
+      expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true);
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir), report);
+
+      expect(readWorkspaceRules(tmpDir)).toEqual({
+        allowAny: ['react'],
+        allowedVersions: { react: '*' },
+      });
+      expect(report.vitePeerDependencyRuleRemoved).toBe(true);
+      expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
+      expect(ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir)).changed).toBe(false);
+    });
+
+    it('deletes peerDependencyRules from pnpm-workspace.yaml when no rule remains', () => {
+      writeVitePlusProject(tmpDir, [
+        'peerDependencyRules:',
+        '  allowAny: [vite, vitest]',
+        '  allowedVersions:',
+        "    vite: '*'",
+        "    vitest: '*'",
+      ]);
+      const report = createMigrationReport();
+
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir), report);
+
+      expect(readWorkspaceRules(tmpDir)).toBeUndefined();
+      expect(report.vitePeerDependencyRuleRemoved).toBe(true);
+      expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
+    });
+
+    it('writes no peer rule for a project without one', () => {
+      writeVitePlusProject(tmpDir, []);
+      const report = createMigrationReport();
+
+      expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir), report);
+
+      expect(readWorkspaceRules(tmpDir)).toBeUndefined();
+      expect(report.vitePeerDependencyRuleRemoved).toBe(false);
+    });
+
+    it('removes the vite rule from package.json before pnpm 10.6.2', () => {
+      fs.writeFileSync(
+        path.join(tmpDir, 'package.json'),
+        JSON.stringify({
+          name: 'test',
+          devDependencies: { 'vite-plus': 'latest' },
+          pnpm: {
+            overrides: { react: '18.3.1' },
+            peerDependencyRules: {
+              allowAny: ['react', 'vite'],
+              allowedVersions: { vite: '*' },
+            },
+          },
+        }),
+      );
+      const report = createMigrationReport();
+
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, '10.6.1'), report);
+
+      expect(readPackageJsonRules(tmpDir)).toEqual({ allowAny: ['react'] });
+      expect(report.vitePeerDependencyRuleRemoved).toBe(true);
+    });
+
+    it('deletes package.json peerDependencyRules when no rule remains', () => {
+      fs.writeFileSync(
+        path.join(tmpDir, 'package.json'),
+        JSON.stringify({
+          name: 'test',
+          devDependencies: { 'vite-plus': 'latest' },
+          pnpm: {
+            peerDependencyRules: { allowAny: ['vite'], allowedVersions: { vite: '*' } },
+          },
+        }),
+      );
+
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, '10.6.1'));
+
+      const pkg = readJson(path.join(tmpDir, 'package.json')) as {
+        pnpm: Record;
+      };
+      expect(pkg.pnpm.peerDependencyRules).toBeUndefined();
+      expect(pkg.pnpm.overrides).toEqual({ 'vite@*': STABLE_CORE });
+    });
+
+    it('removes a vite rule during the full migration', () => {
+      fs.writeFileSync(
+        path.join(tmpDir, 'package.json'),
+        JSON.stringify({
+          name: 'app',
+          devDependencies: { vite: '^7.0.0' },
+          pnpm: {
+            peerDependencyRules: {
+              allowAny: ['react', 'vite'],
+              allowedVersions: { react: '*', vite: '*' },
+            },
+          },
+        }),
+      );
+      const report = createMigrationReport();
+
+      rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, '10.6.1'), true, true, report);
+
+      expect(readPackageJsonRules(tmpDir)).toEqual({
+        allowAny: ['react'],
+        allowedVersions: { react: '*' },
+      });
+      expect(report.vitePeerDependencyRuleRemoved).toBe(true);
+    });
+  });
+
+  describe('with a preview core version', () => {
+    beforeEach(() => {
+      VITE_PLUS_OVERRIDE_PACKAGES.vite = PREVIEW_CORE;
+    });
+
+    it('keeps writing the vite rule', () => {
+      writeVitePlusProject(tmpDir, []);
+      const report = createMigrationReport();
+
+      expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(true);
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir), report);
+
+      expect(readWorkspaceRules(tmpDir)).toEqual({
+        allowAny: ['vite'],
+        allowedVersions: { vite: '*' },
+      });
+      expect(report.vitePeerDependencyRuleRemoved).toBe(false);
+      expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
+    });
+  });
+});
diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts
index de19f1893e..cd3828f51b 100644
--- a/packages/cli/src/migration/bin.ts
+++ b/packages/cli/src/migration/bin.ts
@@ -634,6 +634,11 @@ function showMigrationSummary(options: {
   if (report.packageManagerBootstrapConfigured) {
     log(`${styleText('gray', '•')} Package manager settings configured`);
   }
+  if (report.vitePeerDependencyRuleRemoved) {
+    log(
+      `${styleText('gray', '•')} Removed \`vite\` from pnpm peerDependencyRules: the core version now matches the bundled Vite`,
+    );
+  }
   if (suggestFullMigration) {
     log(FULL_MIGRATION_HINT);
   }
diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts
index 76c5e8b932..6b34843da3 100644
--- a/packages/cli/src/migration/migrator/catalog.ts
+++ b/packages/cli/src/migration/migrator/catalog.ts
@@ -23,14 +23,16 @@ import {
   isAlignableVitestEcosystemPackage,
   isLegacyWrapperSpec,
   managedOverridePackages,
+  managedPeerDependencyRuleKeys,
+  mergeManagedPnpmPeerDependencyRules,
   pruneLegacyWrapperAliases,
   removeManagedVitestEntry,
-  removeVitestPeerDependencyRule,
   removeYamlMapVitestEntry,
   rewriteMonorepoProject,
   shouldDropProviderOverrideKey,
   workspaceUsesWebdriverioProvider,
 } from '../migrator.ts';
+import { type MigrationReport } from '../report.ts';
 import {
   type PreparedOxlintConfigs,
   LEGACY_WRAPPER_FALLBACK_VERSIONS,
@@ -39,6 +41,7 @@ import {
   VITEST_IS_MANAGED_OVERRIDE,
   isPlainRecord,
   pnpmOverrideKey,
+  vitePeerDependencyRuleRequired,
   type CatalogDependencyResolver,
   type PackageJsonDependencyField,
   type PnpmPackageJsonSettings,
@@ -407,13 +410,19 @@ export function rewritePnpmWorkspaceYaml(
     if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) {
       allowAny.items = allowAny.items.filter((n) => n.value !== 'vitest');
     }
+    // A `vite` entry only hides real incompatibilities once the core alias
+    // satisfies `vite` peer ranges (see `vitePeerDependencyRuleRequired`).
+    if (!vitePeerDependencyRuleRequired()) {
+      allowAny.items = allowAny.items.filter((n) => n.value !== 'vite');
+    }
+    const peerRuleKeys = managedPeerDependencyRuleKeys(usesVitest);
     const existing = new Set(allowAny.items.map((n) => n.value));
-    for (const key of Object.keys(managedOverridePackages(usesVitest))) {
+    for (const key of peerRuleKeys) {
       if (!existing.has(key)) {
         allowAny.add(scalarString(key));
       }
     }
-    doc.setIn(['peerDependencyRules', 'allowAny'], allowAny);
+    setPeerDependencyRulesField(doc, 'allowAny', allowAny);
 
     // peerDependencyRules.allowedVersions
     // Same guard as above: a malformed non-map `allowedVersions:` value (pnpm
@@ -429,11 +438,14 @@ export function rewritePnpmWorkspaceYaml(
     if (!usesVitest) {
       removeYamlMapVitestEntry(allowedVersions);
     }
-    for (const key of Object.keys(managedOverridePackages(usesVitest))) {
+    if (!vitePeerDependencyRuleRequired()) {
+      allowedVersions.delete('vite');
+    }
+    for (const key of peerRuleKeys) {
       // - vite: '*'
       allowedVersions.set(scalarString(key), scalarString('*'));
     }
-    doc.setIn(['peerDependencyRules', 'allowedVersions'], allowedVersions);
+    setPeerDependencyRulesField(doc, 'allowedVersions', allowedVersions);
 
     // minimumReleaseAgeExclude: exempt the Vite+-managed packages (vite-plus,
     // @voidzero-dev/*, the ox* family, and the vitest family) from the age gate.
@@ -1280,25 +1292,17 @@ export function rewriteRootWorkspacePackageJson(
         // overrides into the new pnpm config.
         dropRemovePackageOverrideKeys(pkg.pnpm?.overrides);
         // Common case: drop a lingering managed `vitest` override before merging.
+        // `mergeManagedPnpmPeerDependencyRules` drops its peer rule.
         if (!workspaceUsesVitest) {
           removeManagedVitestEntry(pkg.pnpm?.overrides, 'pnpm-ranged');
         }
-        if (!workspaceUsesVitest && pkg.pnpm?.peerDependencyRules) {
-          removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules);
-        }
         pkg.pnpm = {
           ...pkg.pnpm,
           overrides: mergeManagedPnpmOverrides(pkg.pnpm?.overrides, managed),
-          peerDependencyRules: {
-            ...pkg.pnpm?.peerDependencyRules,
-            allowAny: [
-              ...new Set([...(pkg.pnpm?.peerDependencyRules?.allowAny ?? []), ...overrideKeys]),
-            ],
-            allowedVersions: {
-              ...pkg.pnpm?.peerDependencyRules?.allowedVersions,
-              ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])),
-            },
-          },
+          peerDependencyRules: mergeManagedPnpmPeerDependencyRules(
+            pkg.pnpm?.peerDependencyRules,
+            workspaceUsesVitest,
+          ),
         };
       } else {
         for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) {
@@ -1406,6 +1410,26 @@ export function pnpmWorkspaceMinimumReleaseAgeExemptionsPending(projectPath: str
   return PNPM_MINIMUM_RELEASE_AGE_EXCLUDES.some((exclude) => !existing.has(exclude));
 }
 
+// Write one pnpm-workspace.yaml `peerDependencyRules` field, or delete it when
+// no entry remains. An emptied `peerDependencyRules` map is deleted too.
+function setPeerDependencyRulesField(
+  doc: YamlDocument,
+  field: 'allowAny' | 'allowedVersions',
+  node: YAMLSeq | YAMLMap,
+): void {
+  if (node.items.length > 0) {
+    doc.setIn(['peerDependencyRules', field], node);
+    return;
+  }
+  if (doc.hasIn(['peerDependencyRules', field])) {
+    doc.deleteIn(['peerDependencyRules', field]);
+  }
+  const rules = doc.get('peerDependencyRules');
+  if (rules instanceof YAMLMap && rules.items.length === 0) {
+    doc.delete('peerDependencyRules');
+  }
+}
+
 export function readPnpmWorkspacePeerDependencyRules(
   projectPath: string,
 ): { allowAny?: string[]; allowedVersions?: Record } | undefined {
@@ -1419,6 +1443,52 @@ export function readPnpmWorkspacePeerDependencyRules(
   return doc?.peerDependencyRules;
 }
 
+function rulesHaveVite(
+  rules: { allowAny?: unknown; allowedVersions?: unknown } | null | undefined,
+): boolean {
+  return (
+    (Array.isArray(rules?.allowAny) && rules.allowAny.includes('vite')) ||
+    (isPlainRecord(rules?.allowedVersions) && rules.allowedVersions.vite !== undefined)
+  );
+}
+
+// Whether the root pnpm config (the package.json `pnpm` field or
+// pnpm-workspace.yaml) holds a `vite` peer rule.
+export function hasVitePeerDependencyRule(projectPath: string): boolean {
+  if (rulesHaveVite(readPnpmWorkspacePeerDependencyRules(projectPath))) {
+    return true;
+  }
+  const packageJsonPath = path.join(projectPath, 'package.json');
+  if (!fs.existsSync(packageJsonPath)) {
+    return false;
+  }
+  const pkg = readJsonFile(packageJsonPath) as { pnpm?: PnpmPackageJsonSettings };
+  return rulesHaveVite(pkg.pnpm?.peerDependencyRules);
+}
+
+// Capture the `vite` peer rule before a migration step. Call the returned
+// function after the step: it records the rule's removal in the report, so the
+// migrate summary names it (see `vitePeerDependencyRuleRequired`).
+export function trackVitePeerDependencyRuleRemoval(
+  projectPath: string,
+  packageManager: PackageManager,
+  report: MigrationReport | undefined,
+): () => void {
+  if (
+    !report ||
+    packageManager !== PackageManager.pnpm ||
+    vitePeerDependencyRuleRequired() ||
+    !hasVitePeerDependencyRule(projectPath)
+  ) {
+    return () => {};
+  }
+  return () => {
+    if (!hasVitePeerDependencyRule(projectPath)) {
+      report.vitePeerDependencyRuleRemoved = true;
+    }
+  };
+}
+
 export function ensurePnpmWorkspacePackages(
   projectPath: string,
   workspacePatterns: string[],
diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts
index 3e515708e5..49b760f66e 100644
--- a/packages/cli/src/migration/migrator/orchestrators.ts
+++ b/packages/cli/src/migration/migrator/orchestrators.ts
@@ -26,6 +26,7 @@ import {
   injectLintTypeCheckDefaults,
   managedOverridePackages,
   mergeManagedPnpmOverrides,
+  mergeManagedPnpmPeerDependencyRules,
   mergeStagedConfigToViteConfig,
   mergeTsdownConfigFile,
   mergeViteConfigFiles,
@@ -42,7 +43,6 @@ import {
   removeLintStagedFromPackageJson,
   removeMigratedOxlintConfigs,
   removeManagedVitestEntry,
-  removeVitestPeerDependencyRule,
   rewriteAllImports,
   rewriteBunCatalog,
   rewriteLintStagedConfigFile,
@@ -54,6 +54,7 @@ import {
   setDirectViteEdge,
   sourceTreeReferencesRetainedVitestModule,
   takePnpmWorkspaceSettings,
+  trackVitePeerDependencyRuleRemoval,
   usesVitestBrowserMode,
   usesWebdriverioProvider,
   workspaceUsesVitestDirectly,
@@ -83,6 +84,11 @@ export function rewriteStandaloneProject(
   }
 
   const packageManager = workspaceInfo.packageManager;
+  const reportVitePeerDependencyRuleRemoval = trackVitePeerDependencyRuleRemoval(
+    projectPath,
+    packageManager,
+    report,
+  );
   const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager);
   const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath);
   // Captured before `rewritePackageJson` strips `oxlint`; the import rewriter
@@ -195,13 +201,10 @@ export function rewriteStandaloneProject(
         // whose target is a removed package, before re-merging the user's
         // overrides into the new pnpm config.
         dropRemovePackageOverrideKeys(pkg.pnpm?.overrides);
-        // Common case: drop a lingering managed `vitest` override + its peer
-        // rules before re-merging.
+        // Common case: drop a lingering managed `vitest` override before
+        // re-merging. `mergeManagedPnpmPeerDependencyRules` drops its peer rule.
         if (!usesVitest) {
           removeManagedVitestEntry(pkg.pnpm?.overrides, 'pnpm-ranged');
-          if (pkg.pnpm?.peerDependencyRules) {
-            removeVitestPeerDependencyRule(pkg.pnpm.peerDependencyRules);
-          }
         }
         // Project already has pnpm config in package.json -- keep using it.
         // `mergeManagedPnpmOverrides` writes the range-qualified keys and drops
@@ -210,16 +213,10 @@ export function rewriteStandaloneProject(
         pkg.pnpm = {
           ...pkg.pnpm,
           overrides: mergeManagedPnpmOverrides(pkg.pnpm?.overrides, managed),
-          peerDependencyRules: {
-            ...pkg.pnpm?.peerDependencyRules,
-            allowAny: [
-              ...new Set([...(pkg.pnpm?.peerDependencyRules?.allowAny ?? []), ...overrideKeys]),
-            ],
-            allowedVersions: {
-              ...pkg.pnpm?.peerDependencyRules?.allowedVersions,
-              ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])),
-            },
-          },
+          peerDependencyRules: mergeManagedPnpmPeerDependencyRules(
+            pkg.pnpm?.peerDependencyRules,
+            usesVitest,
+          ),
         };
       } else {
         movedPnpmSettings = takePnpmWorkspaceSettings(pkg);
@@ -353,6 +350,7 @@ export function rewriteStandaloneProject(
   dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages, originalOxlintDependencies);
   wrapLazyPluginsInViteConfig(projectPath, silent, report);
   migrateTaskCacheConfigInViteConfig(projectPath, silent, report);
+  reportVitePeerDependencyRuleRemoval();
 }
 
 /**
@@ -365,6 +363,11 @@ export function rewriteMonorepo(
   silent = false,
   report?: MigrationReport,
 ): void {
+  const reportVitePeerDependencyRuleRemoval = trackVitePeerDependencyRuleRemoval(
+    workspaceInfo.rootDir,
+    workspaceInfo.packageManager,
+    report,
+  );
   const catalogDependencyResolver = createCatalogDependencyResolver(
     workspaceInfo.rootDir,
     workspaceInfo.packageManager,
@@ -527,6 +530,7 @@ export function rewriteMonorepo(
     wrapLazyPluginsInViteConfig(projectPath, silent, report);
     migrateTaskCacheConfigInViteConfig(projectPath, silent, report);
   }
+  reportVitePeerDependencyRuleRemoval();
 }
 
 /**
diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts
index 03473942b6..c0d8a1fa00 100644
--- a/packages/cli/src/migration/migrator/shared.ts
+++ b/packages/cli/src/migration/migrator/shared.ts
@@ -3,7 +3,7 @@ import path from 'node:path';
 
 import * as prompts from '@voidzero-dev/vite-plus-prompts';
 import type { OxlintConfig } from 'oxlint';
-import { coerce, satisfies } from 'verkit';
+import { coerce, satisfies, tryParse } from 'verkit';
 
 import { VITEST_VERSION, VITE_PLUS_OVERRIDE_PACKAGES } from '../../utils/constants.ts';
 import { readJsonFile } from '../../utils/json.ts';
@@ -211,6 +211,35 @@ export const VITEST_BROWSER_DEP_NAMES = [
 // and must be left untouched.
 export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES;
 
+const CORE_ALIAS_PREFIX = 'npm:@voidzero-dev/vite-plus-core@';
+
+/**
+ * Whether pnpm still needs the managed `vite` peer rule (`allowAny: [vite]`,
+ * `allowedVersions: { vite: '*' }`) for the given core alias.
+ *
+ * A stable core version encodes the bundled Vite version (`8.3.500` bundles
+ * Vite `8.3.5`, see rfcs/core-version-alignment.md). Package managers check
+ * `vite` peer ranges against it correctly, so the rule would only hide real
+ * incompatibilities. Every other spec still needs the rule: a preview
+ * (`0.0.0-commit.`), a Vite+ prerelease (`8.4.0-vp.0` fails `^8.0.0`), a
+ * `1.x` core from before the alignment, a dist-tag, or a `file:` or URL spec
+ * whose version is unknown.
+ */
+export function vitePeerDependencyRuleRequired(
+  viteSpec: string | undefined = VITE_PLUS_OVERRIDE_PACKAGES.vite,
+): boolean {
+  if (!viteSpec?.startsWith(CORE_ALIAS_PREFIX)) {
+    return true;
+  }
+  const version = tryParse(viteSpec.slice(CORE_ALIAS_PREFIX.length));
+  return (
+    !version ||
+    version.major < 8 ||
+    (version.prerelease?.length ?? 0) > 0 ||
+    (version.build?.length ?? 0) > 0
+  );
+}
+
 // Fallback specs used when normalizing a stale wrapper alias. Real user
 // ranges (e.g. `vitest: ^3.0.0`) are preserved — only the wrapper alias is
 // rewritten. For `vitest`, we substitute the vitest version vite-plus
@@ -334,7 +363,7 @@ function checkPackageVersion(projectPath: string, name: string, minVersion: stri
   return true;
 }
 
-type PnpmPeerDependencyRules = {
+export type PnpmPeerDependencyRules = {
   allowAny?: string[];
   allowedVersions?: Record;
   [key: string]: unknown;
diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
index 5a4197d669..63a256d9a8 100644
--- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
+++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
@@ -27,6 +27,8 @@ import {
   isLegacyWrapperSpec,
   isProtocolPinnedSpec,
   managedOverridePackages,
+  managedPeerDependencyRuleKeys,
+  mergeManagedPnpmPeerDependencyRules,
   migratePnpmSettingsToWorkspaceYaml,
   normalizeVitestPeerCatalogSpec,
   pnpmPackageJsonSettingsPending,
@@ -46,6 +48,7 @@ import {
   rewriteYarnrcYml,
   setDirectViteEdge,
   takePnpmWorkspaceSettings,
+  trackVitePeerDependencyRuleRemoval,
   vitestEcosystemCatalogReferencesPending,
   workspaceUsesVitestDirectly,
   workspaceUsesWebdriverio,
@@ -63,6 +66,7 @@ import {
   managedOverrideKey,
   pnpmMajor,
   pnpmOverrideKey,
+  vitePeerDependencyRuleRequired,
   type CatalogDependencyResolver,
   type ManagedOverrideKeyStyle,
   type PnpmPackageJsonSettings,
@@ -224,8 +228,16 @@ function pnpmPeerDependencyRulesSatisfyVitePlus(
   ) {
     return false;
   }
-  const overrideKeys = Object.keys(managedOverridePackages(usesVitest));
-  return overrideKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*');
+  // A stale `vite` peer rule is not satisfied once the core alias satisfies
+  // `vite` peer ranges. It must be removed (see `vitePeerDependencyRuleRequired`).
+  if (
+    !vitePeerDependencyRuleRequired() &&
+    (allowAny.has('vite') || allowedVersions.vite !== undefined)
+  ) {
+    return false;
+  }
+  const ruleKeys = managedPeerDependencyRuleKeys(usesVitest);
+  return ruleKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*');
 }
 
 function npmVitePlusManagedDependenciesPending(
@@ -911,34 +923,19 @@ function ensureOverrideEntries(
 }
 
 function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean {
-  const overrideKeys = Object.keys(managedOverridePackages(usesVitest));
   pkg.pnpm ??= {};
-  // Common case: drop a lingering managed `vitest` peer rule from the source
-  // shape before re-deriving the managed rules.
-  const seed = { ...pkg.pnpm.peerDependencyRules } as {
-    allowAny?: string[];
-    allowedVersions?: Record;
-  };
-  if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) {
-    if (Array.isArray(seed.allowAny)) {
-      seed.allowAny = seed.allowAny.filter((key) => key !== 'vitest');
-    }
-    if (seed.allowedVersions) {
-      seed.allowedVersions = { ...seed.allowedVersions };
-      delete seed.allowedVersions.vitest;
-    }
-  }
-  const peerDependencyRules = {
-    ...seed,
-    allowAny: [...new Set([...(seed.allowAny ?? []), ...overrideKeys])],
-    allowedVersions: {
-      ...seed.allowedVersions,
-      ...Object.fromEntries(overrideKeys.map((key) => [key, '*'])),
-    },
-  };
+  const peerDependencyRules = mergeManagedPnpmPeerDependencyRules(
+    pkg.pnpm.peerDependencyRules,
+    usesVitest,
+  );
   const changed =
-    JSON.stringify(pkg.pnpm.peerDependencyRules ?? {}) !== JSON.stringify(peerDependencyRules);
-  pkg.pnpm.peerDependencyRules = peerDependencyRules;
+    JSON.stringify(pkg.pnpm.peerDependencyRules ?? {}) !==
+    JSON.stringify(peerDependencyRules ?? {});
+  if (peerDependencyRules) {
+    pkg.pnpm.peerDependencyRules = peerDependencyRules;
+  } else {
+    delete pkg.pnpm.peerDependencyRules;
+  }
   return changed;
 }
 
@@ -957,6 +954,11 @@ export function ensureVitePlusBootstrap(
   if (!fs.existsSync(packageJsonPath)) {
     return result;
   }
+  const reportVitePeerDependencyRuleRemoval = trackVitePeerDependencyRuleRemoval(
+    projectPath,
+    workspaceInfo.packageManager,
+    report,
+  );
 
   // Shared override/catalog sinks are workspace-wide, so keep vitest managed
   // when any package needs it. Each package's direct vitest dependency is
@@ -1264,6 +1266,7 @@ export function ensureVitePlusBootstrap(
   if (result.changed && report) {
     report.packageManagerBootstrapConfigured = true;
   }
+  reportVitePeerDependencyRuleRemoval();
   return result;
 }
 
diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
index ffff16ac75..cfa1797b25 100644
--- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts
+++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
@@ -28,9 +28,11 @@ import {
   VITEST_BROWSER_DEP_NAMES,
   VITEST_IS_MANAGED_OVERRIDE,
   pnpmOverrideKey,
+  vitePeerDependencyRuleRequired,
   type CatalogDependencyResolver,
   type ManagedOverrideKeyStyle,
   type PackageJsonDependencyField,
+  type PnpmPeerDependencyRules,
 } from './shared.ts';
 
 // Official packages that do not declare a required `vitest` peer. Keep them
@@ -557,6 +559,69 @@ export function removeVitestPeerDependencyRule(peerDependencyRules: {
   }
 }
 
+// Remove every `vite` entry from pnpm peerDependencyRules, in place. Once the
+// core version encodes the bundled Vite (see `vitePeerDependencyRuleRequired`),
+// the rule only hides real incompatibilities. Migration cannot tell whether an
+// earlier migration or the user wrote the entry; its only purpose was to accept
+// the `1.x` alias, so both go.
+export function removeVitePeerDependencyRule(peerDependencyRules: {
+  allowAny?: string[];
+  allowedVersions?: Record;
+}): void {
+  if (Array.isArray(peerDependencyRules.allowAny)) {
+    peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vite');
+  }
+  if (peerDependencyRules.allowedVersions) {
+    delete peerDependencyRules.allowedVersions.vite;
+  }
+}
+
+// The pnpm peerDependencyRules keys vite-plus manages: the managed override
+// keys, minus `vite` once its core alias satisfies `vite` peer ranges.
+export function managedPeerDependencyRuleKeys(usesVitest: boolean): string[] {
+  const keys = Object.keys(managedOverridePackages(usesVitest));
+  return vitePeerDependencyRuleRequired() ? keys : keys.filter((key) => key !== 'vite');
+}
+
+// Re-derive the managed pnpm peerDependencyRules on top of the package.json
+// `pnpm.peerDependencyRules` shape. Returns the new rules, or `undefined` when
+// no rule remains so the caller can drop the field. Does not mutate `rules`.
+export function mergeManagedPnpmPeerDependencyRules(
+  rules: PnpmPeerDependencyRules | undefined,
+  usesVitest: boolean,
+): PnpmPeerDependencyRules | undefined {
+  const seed: PnpmPeerDependencyRules = { ...rules };
+  if (Array.isArray(seed.allowAny)) {
+    seed.allowAny = [...seed.allowAny];
+  }
+  if (seed.allowedVersions) {
+    seed.allowedVersions = { ...seed.allowedVersions };
+  }
+  // Common case: drop a lingering managed `vitest` peer rule.
+  if (!usesVitest) {
+    removeVitestPeerDependencyRule(seed);
+  }
+  if (!vitePeerDependencyRuleRequired()) {
+    removeVitePeerDependencyRule(seed);
+  }
+  const keys = managedPeerDependencyRuleKeys(usesVitest);
+  const next: PnpmPeerDependencyRules = {
+    ...seed,
+    allowAny: [...new Set([...(seed.allowAny ?? []), ...keys])],
+    allowedVersions: {
+      ...seed.allowedVersions,
+      ...Object.fromEntries(keys.map((key) => [key, '*'])),
+    },
+  };
+  if (next.allowAny?.length === 0) {
+    delete next.allowAny;
+  }
+  if (next.allowedVersions && Object.keys(next.allowedVersions).length === 0) {
+    delete next.allowedVersions;
+  }
+  return Object.keys(next).length > 0 ? next : undefined;
+}
+
 // Legacy wrapper package names that may appear as the target of override
 // aliases left over from earlier vite-plus migrations. `@voidzero-dev/vite-plus-test`
 // was deleted; any catalog/override entry still pointing at it is stale.
diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts
index ab9d75722f..220d5f83a3 100644
--- a/packages/cli/src/migration/report.ts
+++ b/packages/cli/src/migration/report.ts
@@ -33,6 +33,7 @@ export interface MigrationReport {
   gitHooksConfigured: boolean;
   frameworkShimAdded: boolean;
   packageManagerBootstrapConfigured: boolean;
+  vitePeerDependencyRuleRemoved: boolean;
   dependencyUpgrades: DependencyVersionChange[];
   warnings: string[];
   manualSteps: string[];
@@ -62,6 +63,7 @@ export function createMigrationReport(): MigrationReport {
     gitHooksConfigured: false,
     frameworkShimAdded: false,
     packageManagerBootstrapConfigured: false,
+    vitePeerDependencyRuleRemoved: false,
     dependencyUpgrades: [],
     warnings: [],
     manualSteps: [],
diff --git a/packages/cli/src/resolve-core.ts b/packages/cli/src/resolve-core.ts
index c1b9886445..3941c32a41 100644
--- a/packages/cli/src/resolve-core.ts
+++ b/packages/cli/src/resolve-core.ts
@@ -5,24 +5,44 @@ import { join } from 'node:path';
 import { readNearestPackageJson } from './utils/package.ts';
 
 const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
+const CORE_ALIAS_PREFIX = `npm:${CORE_PACKAGE_NAME}@`;
 
-function checkCoreVersion(packageJsonPath: string, expectedVersion: string): void {
+function checkCoreVersion(packageJsonPath: string, expectedVersion: string | undefined): void {
   const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
     name?: string;
     version?: string;
   };
-  if (pkg.name !== CORE_PACKAGE_NAME || pkg.version !== expectedVersion) {
+  if (
+    pkg.name !== CORE_PACKAGE_NAME ||
+    (expectedVersion !== undefined && pkg.version !== expectedVersion)
+  ) {
+    const expected =
+      expectedVersion === undefined ? CORE_PACKAGE_NAME : `${CORE_PACKAGE_NAME}@${expectedVersion}`;
     throw new Error(
-      `Expected ${CORE_PACKAGE_NAME}@${expectedVersion}, but found ${pkg.name}@${pkg.version} at ${packageJsonPath}. ` +
+      `Expected ${expected}, but found ${pkg.name}@${pkg.version} at ${packageJsonPath}. ` +
         'Run `vp migrate` to align the Vite alias, then run `vp install`.',
     );
   }
 }
 
+/**
+ * Read the core version that the selected CLI pins through its `vite` alias.
+ * The core version encodes the bundled Vite version, so it differs from the
+ * CLI version (rfcs/core-version-alignment.md). Packing turns the repository's
+ * `workspace:` spec into the exact alias. Any other spec pins no version.
+ */
+function readExpectedCoreVersion(cliPackageJsonPath: string): string | undefined {
+  const { dependencies } = JSON.parse(readFileSync(cliPackageJsonPath, 'utf8')) as {
+    dependencies?: Record;
+  };
+  const spec = dependencies?.vite;
+  return spec?.startsWith(CORE_ALIAS_PREFIX) ? spec.slice(CORE_ALIAS_PREFIX.length) : undefined;
+}
+
 function checkProjectCoreVersion(
   cwd: string,
   corePackageJsonPath: string,
-  expectedVersion: string,
+  expectedVersion: string | undefined,
 ): void {
   // npm can hoist an upstream Vite peer even when the project only declares
   // vite-plus. Validate Vite only when the nearest package declares it.
@@ -60,9 +80,7 @@ export function resolveCore(
   const cliRequire = createRequire(modulePath);
   // Read the selected CLI's installed manifest. A static JSON import is inlined
   // during the build, before CI and preview packers can stamp a new version.
-  const { version: expectedVersion } = JSON.parse(
-    readFileSync(cliRequire.resolve('vite-plus/package.json'), 'utf8'),
-  ) as { version: string };
+  const expectedVersion = readExpectedCoreVersion(cliRequire.resolve('vite-plus/package.json'));
   let corePackageJsonPath: string;
   try {
     corePackageJsonPath = cliRequire.resolve('vite/package.json');
diff --git a/packages/cli/src/sync-versions/__tests__/plan.spec.ts b/packages/cli/src/sync-versions/__tests__/plan.spec.ts
index dcb9c29956..8b4b4751b1 100644
--- a/packages/cli/src/sync-versions/__tests__/plan.spec.ts
+++ b/packages/cli/src/sync-versions/__tests__/plan.spec.ts
@@ -10,6 +10,7 @@ import {
 
 const toolchain: SyncVersionsToolchain = {
   vitePlus: '0.4.0',
+  vitePlusCore: '8.3.500',
   vitest: '5.0.0',
 };
 
@@ -50,7 +51,7 @@ describe('planSyncVersions', () => {
         '@vitest/coverage-v8': '5.0.0',
         '@vitest/eslint-plugin': '1.6.0',
         '@vitest/coverage-c8': '0.33.0',
-        '@voidzero-dev/vite-plus-core': '0.4.0',
+        '@voidzero-dev/vite-plus-core': '8.3.500',
         'vite-plus': '0.4.0',
         vitest: '5.0.0',
       },
@@ -77,7 +78,7 @@ describe('planSyncVersions', () => {
       devDependencies: {
         '@vitest/ui': 'npm:@vitest/ui@5.0.0',
         'vite-plus': 'npm:@scope/vite-plus-fork@0.3.0',
-        vite: 'npm:@voidzero-dev/vite-plus-core@0.4.0',
+        vite: 'npm:@voidzero-dev/vite-plus-core@8.3.500',
         vitest: 'npm:vitest@5.0.0',
       },
     });
@@ -113,7 +114,7 @@ describe('planSyncVersions', () => {
       catalog: { '@vitest/coverage-v8': '5.0.0' },
       catalogs: {
         toolchain: {
-          vite: 'npm:@voidzero-dev/vite-plus-core@0.4.0',
+          vite: 'npm:@voidzero-dev/vite-plus-core@8.3.500',
           'vite-plus': '0.4.0',
         },
       },
@@ -138,7 +139,7 @@ describe('planSyncVersions', () => {
         .replace("vitest: '^4.1.11'", "vitest: '5.0.0'")
         .replace(
           'vite: "npm:@voidzero-dev/vite-plus-core@0.3.0"',
-          'vite: "npm:@voidzero-dev/vite-plus-core@0.4.0"',
+          'vite: "npm:@voidzero-dev/vite-plus-core@8.3.500"',
         )
         .replace('vite-plus: 0.3.0', 'vite-plus: 0.4.0'),
     );
diff --git a/packages/cli/src/sync-versions/__tests__/protocol.spec.ts b/packages/cli/src/sync-versions/__tests__/protocol.spec.ts
index a7120c3c11..b95c7e61ef 100644
--- a/packages/cli/src/sync-versions/__tests__/protocol.spec.ts
+++ b/packages/cli/src/sync-versions/__tests__/protocol.spec.ts
@@ -6,6 +6,7 @@ const manifest = {
   schemaVersion: 1,
   nodes: [
     { id: 'vite-plus', version: '0.4.0' },
+    { id: 'vite-plus-core', version: '8.3.500' },
     { id: 'vitest', version: '5.0.0' },
   ],
 };
@@ -49,9 +50,10 @@ describe('runSyncVersionsProtocol', () => {
 });
 
 describe('toolchainFromManifest', () => {
-  it('reads the exact Vite+ and Vitest versions', () => {
+  it('reads the exact Vite+, core, and Vitest versions', () => {
     expect(toolchainFromManifest(manifest)).toEqual({
       vitePlus: '0.4.0',
+      vitePlusCore: '8.3.500',
       vitest: '5.0.0',
     });
   });
@@ -64,6 +66,14 @@ describe('toolchainFromManifest', () => {
       schemaVersion: 1,
       nodes: [
         { id: 'vite-plus', version: '0.4.0' },
+        { id: 'vitest', version: '5.0.0' },
+      ],
+    },
+    {
+      schemaVersion: 1,
+      nodes: [
+        { id: 'vite-plus', version: '0.4.0' },
+        { id: 'vite-plus-core', version: '8.3.500' },
         { id: 'vitest', version: '' },
       ],
     },
diff --git a/packages/cli/src/sync-versions/plan.ts b/packages/cli/src/sync-versions/plan.ts
index 72258b163b..b87128598b 100644
--- a/packages/cli/src/sync-versions/plan.ts
+++ b/packages/cli/src/sync-versions/plan.ts
@@ -88,6 +88,8 @@ export type SyncVersionsManifestSnapshot = SyncVersionsRequestV1['manifests'][nu
 
 export interface SyncVersionsToolchain {
   vitePlus: string;
+  /** Encodes the bundled Vite version, so it differs from `vitePlus`. */
+  vitePlusCore: string;
   vitest: string;
 }
 
@@ -141,9 +143,12 @@ function parsePackageJson(contents: string): Record {
 }
 
 function targetVersion(name: string, toolchain: SyncVersionsToolchain): string | undefined {
-  if (name === 'vite-plus' || name === '@voidzero-dev/vite-plus-core') {
+  if (name === 'vite-plus') {
     return toolchain.vitePlus;
   }
+  if (name === '@voidzero-dev/vite-plus-core') {
+    return toolchain.vitePlusCore;
+  }
   if (name === 'vitest' || isAlignableVitestEcosystemPackage(name)) {
     return toolchain.vitest;
   }
diff --git a/packages/cli/src/sync-versions/protocol.ts b/packages/cli/src/sync-versions/protocol.ts
index f6cf1e78f8..d2b1f52357 100644
--- a/packages/cli/src/sync-versions/protocol.ts
+++ b/packages/cli/src/sync-versions/protocol.ts
@@ -35,6 +35,7 @@ export function toolchainFromManifest(input: unknown): SyncVersionsToolchain {
   const manifest = toolchainManifestSchema.parse(input);
   return {
     vitePlus: requiredToolVersion(manifest.nodes, 'vite-plus'),
+    vitePlusCore: requiredToolVersion(manifest.nodes, 'vite-plus-core'),
     vitest: requiredToolVersion(manifest.nodes, 'vitest'),
   };
 }
diff --git a/packages/cli/src/utils/__tests__/constants.spec.ts b/packages/cli/src/utils/__tests__/constants.spec.ts
index 5a2b514ef1..5570a483a0 100644
--- a/packages/cli/src/utils/__tests__/constants.spec.ts
+++ b/packages/cli/src/utils/__tests__/constants.spec.ts
@@ -1,5 +1,6 @@
 import { afterEach, describe, expect, it, vi } from 'vitest';
 
+import corePkg from '../../../../core/package.json' with { type: 'json' };
 import cliPkg from '../../../package.json' with { type: 'json' };
 
 describe('Vite+ dependency versions', () => {
@@ -8,19 +9,61 @@ describe('Vite+ dependency versions', () => {
     vi.resetModules();
   });
 
-  it('uses the concrete CLI version for vite-plus and vite-plus-core by default', async () => {
+  it('uses the concrete CLI and core versions by default', async () => {
     vi.stubEnv('VP_VERSION', '');
+    vi.stubEnv('VP_CORE_VERSION', '');
     vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
     vi.resetModules();
 
-    const { VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } = await import('../constants.js');
+    const { VITE_PLUS_CORE_VERSION, VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } =
+      await import('../constants.js');
 
     expect(VITE_PLUS_VERSION).toBe(cliPkg.version);
+    expect(VITE_PLUS_CORE_VERSION).toBe(corePkg.version);
     expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe(
-      `npm:@voidzero-dev/vite-plus-core@${cliPkg.version}`,
+      `npm:@voidzero-dev/vite-plus-core@${corePkg.version}`,
     );
   });
 
+  it('keeps the core version for a release VP_VERSION', async () => {
+    vi.stubEnv('VP_VERSION', '1.0.1');
+    vi.stubEnv('VP_CORE_VERSION', '');
+    vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
+    vi.resetModules();
+
+    const { VITE_PLUS_CORE_VERSION } = await import('../constants.js');
+
+    expect(VITE_PLUS_CORE_VERSION).toBe(corePkg.version);
+  });
+
+  it.each(['0.0.0', '0.0.0-commit.0c515e3'])(
+    'uses a preview VP_VERSION %s for the core too',
+    async (version) => {
+      vi.stubEnv('VP_VERSION', version);
+      vi.stubEnv('VP_CORE_VERSION', '');
+      vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
+      vi.resetModules();
+
+      const { VITE_PLUS_OVERRIDE_PACKAGES } = await import('../constants.js');
+
+      expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe(`npm:@voidzero-dev/vite-plus-core@${version}`);
+    },
+  );
+
+  it('prefers VP_CORE_VERSION over a preview VP_VERSION', async () => {
+    vi.stubEnv('VP_VERSION', '0.0.0');
+    vi.stubEnv('VP_CORE_VERSION', '8.3.599');
+    vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
+    vi.resetModules();
+
+    const { VITE_PLUS_CORE_VERSION, VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } =
+      await import('../constants.js');
+
+    expect(VITE_PLUS_VERSION).toBe('0.0.0');
+    expect(VITE_PLUS_CORE_VERSION).toBe('8.3.599');
+    expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe('npm:@voidzero-dev/vite-plus-core@8.3.599');
+  });
+
   it('preserves explicit prerelease overrides', async () => {
     const vitePlusUrl = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891';
     const viteCoreUrl =
diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts
index 6338fc0ea6..cb71dcfbe2 100644
--- a/packages/cli/src/utils/constants.ts
+++ b/packages/cli/src/utils/constants.ts
@@ -1,10 +1,18 @@
 import { createRequire } from 'node:module';
 
+import corePkg from '../../../core/package.json' with { type: 'json' };
 import cliPkg from '../../package.json' with { type: 'json' };
 
 export const VITE_PLUS_NAME = 'vite-plus';
 export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version;
 
+// The core version encodes the bundled Vite version (rfcs/core-version-alignment.md),
+// so it differs from the vite-plus version. Preview builds publish every package
+// at one `0.0.0-
` version, so a preview VP_VERSION also selects the core.
+export const VITE_PLUS_CORE_VERSION =
+  process.env.VP_CORE_VERSION ||
+  (process.env.VP_VERSION?.startsWith('0.0.0') ? process.env.VP_VERSION : corePkg.version);
+
 // Mirrors Vite's DEFAULT_CONFIG_FILES order so readers and writers target the same file.
 export const VITE_CONFIG_FILES = [
   'vite.config.js',
@@ -27,7 +35,7 @@ export const TSDOWN_MIGRATION_SKILL_URL =
 export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES
   ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES)
   : {
-      vite: `npm:@voidzero-dev/vite-plus-core@${VITE_PLUS_VERSION}`,
+      vite: `npm:@voidzero-dev/vite-plus-core@${VITE_PLUS_CORE_VERSION}`,
       // The runner inlines its assertion and runner state. Its remaining
       // official dependencies use exact versions; community providers and
       // Istanbul internals have their own version ranges.
diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md
index e946fb9f3a..814b075f8c 100644
--- a/packages/core/BUNDLING.md
+++ b/packages/core/BUNDLING.md
@@ -156,7 +156,7 @@ The `rolldown_webcontainer_fallback` CLI snapshot executes the packed binding lo
 
 ### Release Build: Native Binding Rewriting
 
-During release builds (`RELEASE_BUILD=1`), `bundleRolldown()` rewrites Rolldown's native binding requires through `build-support/rewrite-rolldown-binding.ts`. For every platform in the CLI's `napi.targets` (mapped to napi platform suffixes with `parseTriple`), the loader's `@rolldown/binding-` requires become `@voidzero-dev/vite-plus-`, and that branch's version guard switches from the Rolldown version to core's version, which is what the Vite+ platform packages are published as.
+During release builds (`RELEASE_BUILD=1`), `bundleRolldown()` rewrites Rolldown's native binding requires through `build-support/rewrite-rolldown-binding.ts`. For every platform in the CLI's `napi.targets` (mapped to napi platform suffixes with `parseTriple`), the loader's `@rolldown/binding-` requires become `@voidzero-dev/vite-plus-`, and that branch's version guard switches from the Rolldown version to the CLI version, which is what the Vite+ platform packages are published as.
 
 **Platform-specific binding rewrites**, one per `napi.targets` entry in `packages/cli/package.json` (see the target table in [CLI Package Bundling](../cli/BUNDLING.md#napi-targets)), for example:
 
@@ -171,7 +171,7 @@ Specifiers for platforms Vite+ does not ship (android, freebsd, the `wasm32-wasi
 
 1. **Self-contained distribution** - Users don't need to install separate `@rolldown/binding-*` packages
 2. **Declared dependency graph** - Core resolves the binding through its own `optionalDependencies` (injected during publish by `packages/cli/publish-native-addons.ts`), so pnpm's global virtual store and Yarn PnP work without hidden hoisting, and core no longer requires back into `vite-plus`
-3. **Version alignment** - Each rewritten branch's guard checks the platform package version against core's version; both are published lockstep
+3. **Version alignment** - Each rewritten branch's guard checks the platform package version against the CLI version, which the platform packages use. The core version differs (see [Core Version](#core-version))
 
 **Resolution chain**:
 
@@ -316,7 +316,8 @@ dist/
 1. Update the Vite pin in [`packages/tools/.upstream-versions.json`](../tools/.upstream-versions.json) and review the resolver patch against the new source
 2. Follow the [upstream sync and build instructions](../../CONTRIBUTING.md#pull-upstream-dependencies) to prepare the source, regenerate Vite types, and rebuild the packages
 3. Verify `bundledVersions.vite` in `package.json` is updated
-4. Test with `pnpm test`
+4. Set `version` in `package.json` to the encoded version for the new Vite version, for example `8.4.200` for Vite `8.4.2` (see [Core Version](#core-version)). The upgrade-deps workflow does this step automatically
+5. Test with `pnpm test`
 
 ### Updating Rolldown
 
@@ -405,3 +406,20 @@ The `bundledVersions` field in `package.json` records the exact versions of bund
 ```
 
 This is automatically updated by `mergePackageJson()` during each build.
+
+### Core Version
+
+The core version encodes `bundledVersions.vite`, so it differs from the `vite-plus` version. Package managers compare the version of the `vite` alias target with the `vite` peer ranges of plugins, so the encoded version makes those checks match the bundled Vite. See [the RFC](../../rfcs/core-version-alignment.md) for the design.
+
+| Release                                 | Core version                                       | Example                          |
+| --------------------------------------- | -------------------------------------------------- | -------------------------------- |
+| Stable `vite-plus`                      | `..` | Vite `8.3.5`, `n = 1`: `8.3.501` |
+| `vite-plus` prerelease, stable Vite     | `-vp.`                | `8.4.0-vp.0`                     |
+| `vite-plus` prerelease, Vite prerelease | `.vp.`                            | `9.0.0-beta.3.vp.0`              |
+| Local and CI builds                     | `n = 99`                                           | `8.3.599`                        |
+
+`n` counts the stable releases on one Vite version, from 0 to 98. `k` counts the prereleases on one base. [`packages/tools/src/core-version.ts`](../tools/src/core-version.ts) implements the scheme:
+
+- `prepare_release.yml` runs `core-version.ts next` to select the next unpublished version from the registry.
+- `.github/scripts/upgrade-deps.ts` resets `version` to `n = 0` when the bundled Vite changes.
+- `__tests__/core-version.spec.ts` checks that the committed `version` encodes `bundledVersions.vite`.
diff --git a/packages/core/__tests__/core-version.spec.ts b/packages/core/__tests__/core-version.spec.ts
new file mode 100644
index 0000000000..42714fea4a
--- /dev/null
+++ b/packages/core/__tests__/core-version.spec.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vite-plus/test';
+
+import cliPkg from '../../cli/package.json' with { type: 'json' };
+import { assertCoreVersionMatchesVite } from '../../tools/src/core-version.ts';
+import corePkg from '../package.json' with { type: 'json' };
+
+describe('core version', () => {
+  // The preview flow stamps `0.0.0-commit.` on every package.
+  it.skipIf(corePkg.version.startsWith('0.0.0-commit.'))('encodes the bundled Vite version', () => {
+    expect(() => assertCoreVersionMatchesVite(corePkg, cliPkg.version)).not.toThrow();
+  });
+});
diff --git a/packages/core/build-support/rewrite-rolldown-binding.ts b/packages/core/build-support/rewrite-rolldown-binding.ts
index f5d1bb35ef..998760cff6 100644
--- a/packages/core/build-support/rewrite-rolldown-binding.ts
+++ b/packages/core/build-support/rewrite-rolldown-binding.ts
@@ -21,7 +21,7 @@ export interface RewriteRolldownBindingOptions {
    * are left on `@rolldown/binding-*` and keep their upstream version guards.
    */
   platformSuffixes: ReadonlySet;
-  /** Version the platform packages are published as (core's own version). */
+  /** Version the platform packages are published as (the CLI version). */
   version: string;
 }
 
diff --git a/packages/core/build.ts b/packages/core/build.ts
index c3bf11b33a..44cc6bb168 100644
--- a/packages/core/build.ts
+++ b/packages/core/build.ts
@@ -414,7 +414,9 @@ async function bundleRolldown() {
         const result = rewriteRolldownBindingRequires(source, {
           packageName: cliPkgJson.napi.packageName,
           platformSuffixes: vitePlusPlatformSuffixes,
-          version: pkgJson.version,
+          // The platform packages publish at the CLI version. The core version
+          // encodes the bundled Vite version instead (rfcs/core-version-alignment.md).
+          version: cliPkgJson.version,
         });
         source = result.source;
         for (const suffix of result.rewrittenSuffixes) {
diff --git a/packages/core/package.json b/packages/core/package.json
index efd99320f0..33833f0297 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@voidzero-dev/vite-plus-core",
-  "version": "1.6.0",
+  "version": "8.3.700",
   "description": "The Unified Toolchain for the Web",
   "homepage": "https://viteplus.dev/guide",
   "bugs": {
diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
new file mode 100644
index 0000000000..961aa5c73f
--- /dev/null
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -0,0 +1,149 @@
+import { describe, expect, test } from 'vitest';
+
+import {
+  assertCoreVersionMatchesVite,
+  decodeCoreVersion,
+  encodeCoreVersion,
+  nextCoreVersion,
+} from '../core-version.ts';
+
+const PUBLISHED_BEFORE_ALIGNMENT = ['0.1.21-alpha.7', '0.0.2-test.1', '1.0.0-rc.0', '1.4.0'];
+
+describe('encodeCoreVersion', () => {
+  test('encodes the Vite patch and the release counter in the patch', () => {
+    expect(encodeCoreVersion('8.3.5', 0)).toBe('8.3.500');
+    expect(encodeCoreVersion('8.3.5', 1)).toBe('8.3.501');
+    expect(encodeCoreVersion('8.4.0', 0)).toBe('8.4.0');
+    expect(encodeCoreVersion('8.4.1', 0)).toBe('8.4.100');
+    expect(encodeCoreVersion('8.3.5', 99)).toBe('8.3.599');
+  });
+
+  test('rejects a Vite prerelease and an out-of-range counter', () => {
+    expect(() => encodeCoreVersion('9.0.0-beta.3', 0)).toThrow('Vite prerelease');
+    expect(() => encodeCoreVersion('8.3.5', 100)).toThrow('from 0 to 99');
+    expect(() => encodeCoreVersion('8.3.5', -1)).toThrow('from 0 to 99');
+  });
+});
+
+describe('decodeCoreVersion', () => {
+  test('decodes stable and prerelease core versions', () => {
+    expect(decodeCoreVersion('8.3.500')).toEqual({ vite: '8.3.5', n: 0 });
+    expect(decodeCoreVersion('8.3.501')).toEqual({ vite: '8.3.5', n: 1 });
+    expect(decodeCoreVersion('8.4.1')).toEqual({ vite: '8.4.0', n: 1 });
+    expect(decodeCoreVersion('8.4.0-vp.2')).toEqual({ vite: '8.4.0', n: 0, k: 2 });
+    expect(decodeCoreVersion('9.0.0-beta.3.vp.1')).toEqual({ vite: '9.0.0-beta.3', k: 1 });
+  });
+
+  test('rejects prereleases without the vp counter', () => {
+    expect(() => decodeCoreVersion('0.0.0-commit.0c515e3')).toThrow('not an encoded');
+    expect(() => decodeCoreVersion('8.4.0-rc.0')).toThrow('not an encoded');
+    expect(() => decodeCoreVersion('8.4.0-vp.x')).toThrow('not an encoded');
+    expect(() => decodeCoreVersion('latest')).toThrow('Invalid version');
+  });
+});
+
+describe('nextCoreVersion', () => {
+  test('starts the counter at 0 for a new Vite version', () => {
+    expect(
+      nextCoreVersion({ vite: '8.3.5', vitePlus: '1.5.0', published: PUBLISHED_BEFORE_ALIGNMENT }),
+    ).toBe('8.3.500');
+  });
+
+  test('increments the counter for each stable release on one Vite version', () => {
+    const published = [...PUBLISHED_BEFORE_ALIGNMENT, '8.3.500', '8.3.501', '8.3.600'];
+    expect(nextCoreVersion({ vite: '8.3.5', vitePlus: '1.5.2', published })).toBe('8.3.502');
+    expect(nextCoreVersion({ vite: '8.3.6', vitePlus: '1.6.1', published })).toBe('8.3.601');
+    expect(nextCoreVersion({ vite: '8.4.0', vitePlus: '1.7.0', published })).toBe('8.4.0');
+  });
+
+  test('ignores prereleases and local versions for the stable counter', () => {
+    const published = ['8.4.0-vp.0', '8.4.0-vp.1', '8.4.99'];
+    expect(nextCoreVersion({ vite: '8.4.0', vitePlus: '1.7.0', published })).toBe('8.4.0');
+  });
+
+  test('gives each Vite+ prerelease on a stable Vite a unique vp counter', () => {
+    expect(nextCoreVersion({ vite: '8.4.0', vitePlus: '1.7.0-rc.0', published: [] })).toBe(
+      '8.4.0-vp.0',
+    );
+    expect(
+      nextCoreVersion({ vite: '8.4.0', vitePlus: '1.8.0-rc.0', published: ['8.4.0-vp.0'] }),
+    ).toBe('8.4.0-vp.1');
+    expect(
+      nextCoreVersion({
+        vite: '8.4.0',
+        vitePlus: '1.8.0-rc.0',
+        published: ['8.4.0', '8.4.0-vp.0', '8.4.0-vp.1'],
+      }),
+    ).toBe('8.4.1-vp.0');
+  });
+
+  test('appends the vp counter to a bundled Vite prerelease', () => {
+    expect(nextCoreVersion({ vite: '9.0.0-beta.3', vitePlus: '2.0.0-beta.0', published: [] })).toBe(
+      '9.0.0-beta.3.vp.0',
+    );
+    expect(
+      nextCoreVersion({
+        vite: '9.0.0-beta.3',
+        vitePlus: '2.0.0-beta.1',
+        published: ['9.0.0-beta.2.vp.4', '9.0.0-beta.3.vp.0'],
+      }),
+    ).toBe('9.0.0-beta.3.vp.1');
+  });
+
+  test('rejects a stable vite-plus release on a Vite prerelease', () => {
+    expect(() =>
+      nextCoreVersion({ vite: '9.0.0-beta.3', vitePlus: '2.0.0', published: [] }),
+    ).toThrow('cannot bundle the Vite prerelease');
+  });
+
+  test('fails when the release counter is exhausted', () => {
+    const published = Array.from({ length: 99 }, (_, n) => encodeCoreVersion('8.3.5', n));
+    expect(() => nextCoreVersion({ vite: '8.3.5', vitePlus: '1.9.0', published })).toThrow(
+      'the maximum',
+    );
+  });
+});
+
+function manifest(version: string, vite = '8.3.5') {
+  return { version, bundledVersions: { vite } };
+}
+
+describe('assertCoreVersionMatchesVite', () => {
+  test('accepts a core version that encodes the bundled Vite', () => {
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.500'), '1.5.0')).not.toThrow();
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.599'), '0.0.0')).not.toThrow();
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('8.4.0-vp.1', '8.4.0'), '1.7.0-rc.1'),
+    ).not.toThrow();
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('9.0.0-beta.3.vp.0', '9.0.0-beta.3'), '2.0.0-beta.0'),
+    ).not.toThrow();
+  });
+
+  test('rejects a core version that encodes another Vite version', () => {
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.400'), '1.5.0')).toThrow(
+      'Set its version to 8.3.500',
+    );
+    expect(() => assertCoreVersionMatchesVite(manifest('1.4.0'), '1.4.0')).toThrow(
+      'encodes Vite 1.4.0',
+    );
+  });
+
+  test('rejects mixed prerelease and stable versions', () => {
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.500'), '1.5.0-rc.0')).toThrow(
+      'both be prereleases',
+    );
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.500-vp.0'), '1.5.0')).toThrow(
+      'both be prereleases',
+    );
+  });
+
+  test('rejects the local build counter for a release', () => {
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('8.3.599'), '1.5.0', { release: true }),
+    ).toThrow('reserved for local builds');
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('8.3.598'), '1.5.0', { release: true }),
+    ).not.toThrow();
+  });
+});
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
new file mode 100644
index 0000000000..6b59de36ef
--- /dev/null
+++ b/packages/tools/src/core-version.ts
@@ -0,0 +1,278 @@
+/**
+ * Version scheme of `@voidzero-dev/vite-plus-core` (rfcs/core-version-alignment.md).
+ *
+ * The core version encodes the bundled Vite version, so package managers check
+ * `vite` peer ranges against the alias `vite: npm:@voidzero-dev/vite-plus-core@...`
+ * correctly:
+ *
+ *   stable Vite+ release:          ..
+ *   Vite+ prerelease, stable Vite: -vp.
+ *   Vite+ prerelease, Vite pre:    .vp.
+ *
+ * `n` counts the stable Vite+ releases on one Vite version (0 to 98). `n = 99`
+ * is reserved for local and CI builds, so they never collide with a published
+ * version. `k` counts the Vite+ prereleases on one base.
+ *
+ * Usage:
+ *   node packages/tools/src/core-version.ts next --vite-plus 
+ *   node packages/tools/src/core-version.ts local
+ *   node packages/tools/src/core-version.ts check [--release]
+ */
+
+import { execFileSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { parseArgs } from 'node:util';
+
+export const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
+export const MAX_RELEASE_COUNTER = 98;
+export const LOCAL_BUILD_COUNTER = 99;
+
+const PRERELEASE_TAG = 'vp';
+const VERSION_RE =
+  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?$/;
+
+interface ParsedVersion {
+  major: number;
+  minor: number;
+  patch: number;
+  prerelease: string[];
+}
+
+export interface DecodedCoreVersion {
+  /** The bundled Vite version. */
+  vite: string;
+  /** The stable release counter. Undefined on a Vite prerelease. */
+  n?: number;
+  /** The Vite+ prerelease counter. Undefined on a stable core version. */
+  k?: number;
+}
+
+function parseVersion(version: string): ParsedVersion {
+  const match = VERSION_RE.exec(version);
+  if (!match) {
+    throw new Error(`Invalid version: ${version}`);
+  }
+  return {
+    major: Number(match[1]),
+    minor: Number(match[2]),
+    patch: Number(match[3]),
+    prerelease: match[4] ? match[4].split('.') : [],
+  };
+}
+
+export function isPrereleaseVersion(version: string): boolean {
+  return parseVersion(version).prerelease.length > 0;
+}
+
+/** Returns the stable core version for release counter `n` on a stable Vite. */
+export function encodeCoreVersion(vite: string, n: number): string {
+  const { major, minor, patch, prerelease } = parseVersion(vite);
+  if (prerelease.length > 0) {
+    throw new Error(`A stable core version cannot encode the Vite prerelease ${vite}`);
+  }
+  if (!Number.isInteger(n) || n < 0 || n > LOCAL_BUILD_COUNTER) {
+    throw new Error(`The release counter must be an integer from 0 to ${LOCAL_BUILD_COUNTER}`);
+  }
+  return `${major}.${minor}.${patch * 100 + n}`;
+}
+
+/** Decodes a core version into the bundled Vite version and its counters. */
+export function decodeCoreVersion(core: string): DecodedCoreVersion {
+  const { major, minor, patch, prerelease } = parseVersion(core);
+  const stableVite = `${major}.${minor}.${Math.floor(patch / 100)}`;
+  if (prerelease.length === 0) {
+    return { vite: stableVite, n: patch % 100 };
+  }
+  const k = prerelease.at(-1)!;
+  if (prerelease.at(-2) !== PRERELEASE_TAG || !/^(?:0|[1-9]\d*)$/.test(k)) {
+    throw new Error(`${core} is not an encoded core version`);
+  }
+  if (prerelease.length === 2) {
+    return { vite: stableVite, n: patch % 100, k: Number(k) };
+  }
+  return {
+    vite: `${major}.${minor}.${patch}-${prerelease.slice(0, -2).join('.')}`,
+    k: Number(k),
+  };
+}
+
+function tryDecodeCoreVersion(core: string): DecodedCoreVersion | undefined {
+  try {
+    return decodeCoreVersion(core);
+  } catch {
+    return undefined;
+  }
+}
+
+function nextCounter(values: Iterable): number {
+  let next = 0;
+  for (const value of values) {
+    next = Math.max(next, value + 1);
+  }
+  return next;
+}
+
+/**
+ * Returns the core version for a release.
+ *
+ * @param vite - The bundled Vite version.
+ * @param vitePlus - The `vite-plus` version of the release.
+ * @param published - Every published core version. Versions that do not decode
+ *   (the `0.x` and `1.x` lines) are ignored.
+ */
+export function nextCoreVersion({
+  vite,
+  vitePlus,
+  published,
+}: {
+  vite: string;
+  vitePlus: string;
+  published: readonly string[];
+}): string {
+  const isVitePlusPrerelease = isPrereleaseVersion(vitePlus);
+  const decoded = published.flatMap((version) => {
+    const result = tryDecodeCoreVersion(version);
+    return result ? [result] : [];
+  });
+
+  if (isPrereleaseVersion(vite)) {
+    if (!isVitePlusPrerelease) {
+      throw new Error(`The stable vite-plus ${vitePlus} cannot bundle the Vite prerelease ${vite}`);
+    }
+    const k = nextCounter(
+      decoded.flatMap((entry) => (entry.vite === vite && entry.k !== undefined ? [entry.k] : [])),
+    );
+    return `${vite}.${PRERELEASE_TAG}.${k}`;
+  }
+
+  const n = nextCounter(
+    decoded.flatMap((entry) =>
+      entry.vite === vite &&
+      entry.k === undefined &&
+      entry.n !== undefined &&
+      entry.n <= MAX_RELEASE_COUNTER
+        ? [entry.n]
+        : [],
+    ),
+  );
+  if (n > MAX_RELEASE_COUNTER) {
+    throw new Error(
+      `Vite ${vite} already has ${MAX_RELEASE_COUNTER + 1} core releases, the maximum`,
+    );
+  }
+  const base = encodeCoreVersion(vite, n);
+  if (!isVitePlusPrerelease) {
+    return base;
+  }
+  const k = nextCounter(
+    published.flatMap((version) => {
+      const match = version.startsWith(`${base}-${PRERELEASE_TAG}.`)
+        ? tryDecodeCoreVersion(version)
+        : undefined;
+      return match?.k === undefined ? [] : [match.k];
+    }),
+  );
+  return `${base}-${PRERELEASE_TAG}.${k}`;
+}
+
+/**
+ * Asserts that a core manifest encodes its bundled Vite version, and that the
+ * core version is a prerelease exactly when the `vite-plus` version is one.
+ * With `release`, also rejects the local build counter.
+ */
+export function assertCoreVersionMatchesVite(
+  pkg: { version: string; bundledVersions?: { vite?: string } },
+  vitePlus: string,
+  { release = false }: { release?: boolean } = {},
+): void {
+  const vite = pkg.bundledVersions?.vite;
+  if (!vite) {
+    throw new Error(`${CORE_PACKAGE_NAME} has no bundledVersions.vite`);
+  }
+  const decoded = decodeCoreVersion(pkg.version);
+  if (decoded.vite !== vite) {
+    throw new Error(
+      `${CORE_PACKAGE_NAME}@${pkg.version} encodes Vite ${decoded.vite}, but it bundles Vite ${vite}. ` +
+        `Set its version to ${isPrereleaseVersion(vite) ? `${vite}.${PRERELEASE_TAG}.0` : encodeCoreVersion(vite, 0)}.`,
+    );
+  }
+  const isCorePrerelease = decoded.k !== undefined;
+  if (isCorePrerelease !== isPrereleaseVersion(vitePlus)) {
+    throw new Error(
+      `${CORE_PACKAGE_NAME}@${pkg.version} and vite-plus@${vitePlus} must both be prereleases or both be stable`,
+    );
+  }
+  if (release && decoded.n === LOCAL_BUILD_COUNTER) {
+    throw new Error(
+      `${CORE_PACKAGE_NAME}@${pkg.version} uses the counter ${LOCAL_BUILD_COUNTER}, which is reserved for local builds`,
+    );
+  }
+}
+
+function readManifest(relativePath: string): {
+  version: string;
+  bundledVersions?: { vite?: string };
+} {
+  return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8'));
+}
+
+function readPublishedCoreVersions(): string[] {
+  const output = execFileSync('npm', ['view', CORE_PACKAGE_NAME, 'versions', '--json'], {
+    encoding: 'utf8',
+  });
+  const versions: unknown = JSON.parse(output);
+  if (typeof versions === 'string') {
+    return [versions];
+  }
+  if (!Array.isArray(versions) || versions.some((version) => typeof version !== 'string')) {
+    throw new Error(`Unexpected output from npm view ${CORE_PACKAGE_NAME} versions`);
+  }
+  return versions;
+}
+
+function main(argv: string[]): void {
+  const { positionals, values } = parseArgs({
+    args: argv,
+    allowPositionals: true,
+    options: {
+      'vite-plus': { type: 'string' },
+      release: { type: 'boolean', default: false },
+    },
+  });
+  const core = readManifest('../../core/package.json');
+  const vite = core.bundledVersions?.vite;
+  if (!vite) {
+    throw new Error(`packages/core/package.json has no bundledVersions.vite`);
+  }
+
+  switch (positionals[0]) {
+    case 'next': {
+      const vitePlus = values['vite-plus'];
+      if (!vitePlus) {
+        throw new Error('next requires --vite-plus ');
+      }
+      console.log(nextCoreVersion({ vite, vitePlus, published: readPublishedCoreVersions() }));
+      break;
+    }
+    case 'local':
+      console.log(encodeCoreVersion(vite, LOCAL_BUILD_COUNTER));
+      break;
+    case 'check': {
+      const cli = readManifest('../../cli/package.json');
+      assertCoreVersionMatchesVite(core, cli.version, { release: values.release });
+      console.log(`${CORE_PACKAGE_NAME}@${core.version} encodes Vite ${vite}`);
+      break;
+    }
+    default:
+      throw new Error(`Unknown command: ${positionals[0] ?? ''}. Use next, local, or check.`);
+  }
+}
+
+if (import.meta.main) {
+  try {
+    main(process.argv.slice(2));
+  } catch (error) {
+    console.error(`[core-version] ${(error as Error).message}`);
+    process.exit(1);
+  }
+}

From 15d208846a770dfb69b4f1395e1f337d5b89b4ff Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Fri, 30 Oct 2026 15:21:49 +0900
Subject: [PATCH 03/24] fix(cli): keep the stale core check for the workspace
 spec

The repository CLI declares `vite: workspace:@voidzero-dev/vite-plus-core@*`,
which has no exact version. The core check then compared only the package
name, so a project core with a stale version passed and `vp dev` started.
Fall back to the version of the core that the CLI resolves.

Co-Authored-By: Claude Opus 5.5 
---
 ...dule_identity_npm_tagged_alias_rejected.md | 15 ---------
 .../cli/src/__tests__/resolve-core.spec.ts    | 33 ++++++++++++++-----
 packages/cli/src/resolve-core.ts              | 27 +++++++++++----
 3 files changed, 45 insertions(+), 30 deletions(-)
 delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias_rejected.md

diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias_rejected.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias_rejected.md
deleted file mode 100644
index 8f3b5c664c..0000000000
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias_rejected.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# core_module_identity_npm_tagged_alias_rejected
-
-## `vpt write-file package.json '{"name":"core-module-identity","private":true,"type":"module","packageManager":"npm@11.11.0","devDependencies":{"vite-plus":"latest","vite":"npm:@voidzero-dev/vite-plus-core@latest"}}
-'`
-
-
-## `vp install --ignore-scripts`
-
-
-## `node check-tagged-alias.mjs`
-
-```
-vp dev rejects the replaced npm alias and reports the repair command
-vp pack rejects the replaced npm alias and reports the repair command
-```
diff --git a/packages/cli/src/__tests__/resolve-core.spec.ts b/packages/cli/src/__tests__/resolve-core.spec.ts
index 3570e15dfb..86cb6dfc2a 100644
--- a/packages/cli/src/__tests__/resolve-core.spec.ts
+++ b/packages/cli/src/__tests__/resolve-core.spec.ts
@@ -128,15 +128,30 @@ describe('resolveCore', () => {
     );
   });
 
-  it('checks only the package name for the repository workspace spec', () => {
-    writeCli(join(root, 'cli'), cliPkg.version, 'workspace:@voidzero-dev/vite-plus-core@*');
-    writeCore(bundled, '@voidzero-dev/vite-plus-core', '0.0.0-any');
-    writeCore(projectCore, '@voidzero-dev/vite-plus-core', '0.0.0-any');
-    expect(resolveCore('', project, cliModule)).toBe(join(bundled, 'index.js'));
-    writeCore(bundled, 'vite', '8.2.2');
-    expect(() => resolveCore('', project, cliModule)).toThrow(
-      'Expected @voidzero-dev/vite-plus-core, but found vite@8.2.2',
-    );
+  describe('with the repository workspace spec', () => {
+    beforeEach(() => {
+      writeCli(join(root, 'cli'), cliPkg.version, 'workspace:@voidzero-dev/vite-plus-core@*');
+      writeCore(bundled, '@voidzero-dev/vite-plus-core', '0.0.0-any');
+    });
+
+    it('compares the project core with the core that the CLI resolves', () => {
+      writeCore(projectCore, '@voidzero-dev/vite-plus-core', '0.0.0-any');
+      expect(resolveCore('', project, cliModule)).toBe(join(bundled, 'index.js'));
+    });
+
+    it('rejects a stale project core', () => {
+      writeCore(projectCore, '@voidzero-dev/vite-plus-core', '0.0.0-stale');
+      expect(() => resolveCore('', project, cliModule)).toThrow(
+        'Expected @voidzero-dev/vite-plus-core@0.0.0-any, but found @voidzero-dev/vite-plus-core@0.0.0-stale',
+      );
+    });
+
+    it('rejects upstream Vite in the CLI dependency', () => {
+      writeCore(bundled, 'vite', '8.2.2');
+      expect(() => resolveCore('', project, cliModule)).toThrow(
+        'Expected @voidzero-dev/vite-plus-core, but found vite@8.2.2',
+      );
+    });
   });
 
   it('does not fall back to the project when the CLI dependency is missing', () => {
diff --git a/packages/cli/src/resolve-core.ts b/packages/cli/src/resolve-core.ts
index 3941c32a41..61b511851f 100644
--- a/packages/cli/src/resolve-core.ts
+++ b/packages/cli/src/resolve-core.ts
@@ -29,14 +29,26 @@ function checkCoreVersion(packageJsonPath: string, expectedVersion: string | und
  * Read the core version that the selected CLI pins through its `vite` alias.
  * The core version encodes the bundled Vite version, so it differs from the
  * CLI version (rfcs/core-version-alignment.md). Packing turns the repository's
- * `workspace:` spec into the exact alias. Any other spec pins no version.
+ * `workspace:` spec into the exact alias. A spec without an exact version
+ * falls back to the version of the core that the CLI resolves, so a stale
+ * project core still fails in the repository layout.
  */
-function readExpectedCoreVersion(cliPackageJsonPath: string): string | undefined {
+function readExpectedCoreVersion(
+  cliPackageJsonPath: string,
+  corePackageJsonPath: string,
+): string | undefined {
   const { dependencies } = JSON.parse(readFileSync(cliPackageJsonPath, 'utf8')) as {
     dependencies?: Record;
   };
   const spec = dependencies?.vite;
-  return spec?.startsWith(CORE_ALIAS_PREFIX) ? spec.slice(CORE_ALIAS_PREFIX.length) : undefined;
+  if (spec?.startsWith(CORE_ALIAS_PREFIX)) {
+    return spec.slice(CORE_ALIAS_PREFIX.length);
+  }
+  const core = JSON.parse(readFileSync(corePackageJsonPath, 'utf8')) as {
+    name?: string;
+    version?: string;
+  };
+  return core.name === CORE_PACKAGE_NAME ? core.version : undefined;
 }
 
 function checkProjectCoreVersion(
@@ -78,15 +90,18 @@ export function resolveCore(
   modulePath = import.meta.url,
 ): string {
   const cliRequire = createRequire(modulePath);
-  // Read the selected CLI's installed manifest. A static JSON import is inlined
-  // during the build, before CI and preview packers can stamp a new version.
-  const expectedVersion = readExpectedCoreVersion(cliRequire.resolve('vite-plus/package.json'));
   let corePackageJsonPath: string;
   try {
     corePackageJsonPath = cliRequire.resolve('vite/package.json');
   } catch (cause) {
     throw new Error('Could not resolve the bundled Vite dependency. Run `vp install`.', { cause });
   }
+  // Read the selected CLI's installed manifest. A static JSON import is inlined
+  // during the build, before CI and preview packers can stamp a new version.
+  const expectedVersion = readExpectedCoreVersion(
+    cliRequire.resolve('vite-plus/package.json'),
+    corePackageJsonPath,
+  );
   checkCoreVersion(corePackageJsonPath, expectedVersion);
   checkProjectCoreVersion(cwd, corePackageJsonPath, expectedVersion);
 

From 50035c9f7aee8864b57e506a84b964e550955b5a Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Fri, 30 Oct 2026 15:21:58 +0900
Subject: [PATCH 04/24] test(snapshots): re-record for the encoded core version

- Remove the pnpm `vite` peer rule from migration snapshots, and record the
  summary line for projects whose rule migration removes.
- npm now keeps a tagged core alias and resolves Vitest's `vite` peer to the
  CLI's core, because the core version satisfies that range. Replace the
  tagged-alias rejection case with a case that checks the shared core, and
  update the CLI-only layout check.
- Read the core version from the core manifest or the CLI alias in the core
  identity fixtures, not from the vite-plus version.

Co-Authored-By: Claude Opus 5.5 
---
 ...mand_update_catalog_protocol_legacy_key.md | 10 -------
 .../command_update_catalog_protocol_pnpm.md   |  5 ----
 .../command_update_catalog_protocol_pnpm12.md |  5 ----
 .../core_module_identity/check-npm-layout.mjs | 13 ++++-----
 .../check-tagged-alias.mjs                    | 25 ++++++-----------
 .../core_module_identity/snapshots.toml       |  4 ++-
 ...odule_identity_npm_cli_only_no_override.md |  2 +-
 .../core_module_identity_npm_tagged_alias.md  | 28 +++++++++++++++++++
 .../create_approve_builds_migrate_pnpm11.md   | 15 ----------
 .../snapshots/create_approve_builds_pnpm11.md | 15 ----------
 .../snapshots/create_org_bundled_monorepo.md  |  5 ----
 .../snapshots/migration_add_git_hooks.md      |  5 ----
 .../migration_auto_create_vite_config.md      |  5 ----
 .../snapshots/migration_baseurl_tsconfig.md   |  5 ----
 ...igration_chained_lint_staged_pre_commit.md |  5 ----
 .../migration_composed_husky_custom_dir.md    |  5 ----
 .../migration_composed_husky_prepare.md       |  5 ----
 .../migration_env_prefix_lint_staged.md       |  5 ----
 .../snapshots/migration_eslint.md             |  5 ----
 .../snapshots/migration_eslint_lint_staged.md |  5 ----
 .../migration_eslint_lintstagedrc.md          |  5 ----
 .../snapshots/migration_eslint_npx_wrapper.md |  5 ----
 .../snapshots/migration_existing_husky.md     |  5 ----
 .../migration_existing_husky_lint_staged.md   |  5 ----
 .../migration_existing_husky_v8_hooks.md      |  5 ----
 ...migration_existing_husky_v8_multi_hooks.md |  5 ----
 .../migration_existing_lint_staged_config.md  |  5 ----
 ...igration_existing_pnpm_exec_lint_staged.md |  5 ----
 .../migration_existing_prepare_script.md      |  5 ----
 .../snapshots/migration_from_tsdown.md        |  5 ----
 .../migration_from_tsdown_json_config.md      |  5 ----
 .../snapshots/migration_from_vitest_config.md |  2 --
 .../snapshots/migration_from_vitest_files.md  |  2 --
 ...ration_hooks_skip_on_existing_hookspath.md |  5 ----
 .../migration_husky_catalog_version.md        |  5 ----
 .../migration_husky_latest_dist_tag.md        |  5 ----
 ...tion_husky_latest_dist_tag_v9_installed.md |  5 ----
 .../snapshots/migration_husky_or_prepare.md   |  5 ----
 .../migration_husky_semicolon_prepare.md      |  5 ----
 ...igration_husky_v8_preserves_lint_staged.md |  5 ----
 .../snapshots/migration_lazy_plugins_await.md |  5 ----
 .../migration_lint_staged_in_scripts.md       |  5 ----
 .../migration_lint_staged_merge_fail.md       |  5 ----
 .../migration_lint_staged_ts_config.md        |  5 ----
 .../snapshots/migration_lintstagedrc_json.md  |  5 ----
 .../migration_lintstagedrc_merge_fail.md      |  5 ----
 .../migration_lintstagedrc_not_support.md     |  5 ----
 .../migration_lintstagedrc_staged_exists.md   |  5 ----
 .../migration_merge_vite_config_js.md         |  5 ----
 .../migration_merge_vite_config_ts.md         |  2 --
 .../snapshots/migration_monorepo_pnpm.md      |  2 --
 ...repo_pnpm_overrides_dependency_selector.md |  5 ----
 .../snapshots/migration_no_git_repo.md        |  5 ----
 .../snapshots/migration_no_hooks.md           |  5 ----
 .../migration_no_hooks_with_husky.md          |  5 ----
 .../snapshots/migration_npmx_dev.md           |  3 +-
 .../snapshots/migration_other_hook_tool.md    |  5 ----
 .../migration_oxlintrc_json_with_comments.md  |  5 ----
 .../snapshots/migration_oxlintrc_jsonc.md     |  5 ----
 ...migration_partially_installed_vite_plus.md |  5 ----
 ...migration_partially_migrated_pre_commit.md |  5 ----
 .../snapshots/migration_playwright_test.md    |  3 +-
 ...gration_preserve_below_policy_node_pins.md |  5 ----
 .../snapshots/migration_prettier.md           |  5 ----
 .../migration_prettier_eslint_combo.md        |  5 ----
 .../migration_prettier_ignore_unknown.md      |  5 ----
 .../migration_prettier_lint_staged.md         |  5 ----
 .../snapshots/migration_prettier_pkg_json.md  |  5 ----
 .../migration_rewrite_declare_module.md       |  2 --
 .../migration_skip_vite_dependency.md         |  5 ----
 .../migration_skip_vite_peer_dependency.md    |  5 ----
 .../migration_stale_vitest_alias_startup.md   |  5 ----
 .../snapshots/migration_standalone_pnpm.md    |  5 ----
 .../snapshots/migration_subpath.md            |  5 ----
 .../migration_tsconfig_esmoduleinterop.md     |  5 ----
 ...igration_upgrade_browser_peer_only_pnpm.md |  3 +-
 ...ration_upgrade_browser_source_only_pnpm.md |  3 +-
 .../migrate_vitest4_community_provider.md     |  1 +
 ...ration_upgrade_browser_webdriverio_pnpm.md |  3 +-
 .../preserve_community_provider_version.md    |  1 +
 .../upgrade_community_provider_catalog.md     |  2 --
 .../migration_upgrade_hooks_flag_pnpm.md      |  1 +
 ..._upgrade_monorepo_vitest_localized_pnpm.md |  3 +-
 ...ration_upgrade_nuxt_test_utils_monorepo.md |  2 --
 ...ration_upgrade_peer_vitest_catalog_pnpm.md |  6 +---
 ...ration_upgrade_pnpm_bundled_catalog_dep.md |  5 ----
 ...migration_upgrade_pnpm_catalogs_default.md | 10 -------
 .../migration_upgrade_setup_full_pnpm.md      |  1 +
 ...tion_upgrade_setup_skipped_default_pnpm.md |  6 +---
 .../migration_upgrade_stale_local_pnpm.md     |  6 +---
 ...pgrade_vitest_reference_whitespace_pnpm.md |  6 +---
 ...igration_vite_plus_in_dependencies_pnpm.md |  5 ----
 .../snapshots/migration_vite_version.md       |  5 ----
 .../snapshots/migration_vitest_import_only.md |  5 ----
 .../snapshots/migration_vitest_peer_dep.md    |  2 --
 .../snapshots/migration_workspace_members.md  |  5 ----
 .../snapshots/new_vite_monorepo.md            |  5 ----
 packages/cli/BUNDLING.md                      | 14 ++++++----
 98 files changed, 69 insertions(+), 459 deletions(-)
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias.md

diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_legacy_key/snapshots/command_update_catalog_protocol_legacy_key.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_legacy_key/snapshots/command_update_catalog_protocol_legacy_key.md
index ad34531d72..27da3c19a3 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_legacy_key/snapshots/command_update_catalog_protocol_legacy_key.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_legacy_key/snapshots/command_update_catalog_protocol_legacy_key.md
@@ -32,11 +32,6 @@ catalog:
   vite-plus: 
 overrides:
   vite: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vp up`
@@ -85,11 +80,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vp up`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm/snapshots/command_update_catalog_protocol_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm/snapshots/command_update_catalog_protocol_pnpm.md
index 7de98a9cee..72b25fe774 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm/snapshots/command_update_catalog_protocol_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm/snapshots/command_update_catalog_protocol_pnpm.md
@@ -60,9 +60,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm12/snapshots/command_update_catalog_protocol_pnpm12.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm12/snapshots/command_update_catalog_protocol_pnpm12.md
index 96f01d9165..9b738a2952 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm12/snapshots/command_update_catalog_protocol_pnpm12.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_catalog_protocol_pnpm12/snapshots/command_update_catalog_protocol_pnpm12.md
@@ -27,11 +27,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vp up`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs
index c94fb893f9..f984cfd3d4 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-npm-layout.mjs
@@ -23,11 +23,10 @@ if (project.devDependencies.vite) {
   );
 } else {
   assert.deepEqual(Object.keys(project.devDependencies), ['vite-plus']);
-  assert.equal(require('vite/package.json').name, 'vite');
-  assert.equal(vitestRequire('vite/package.json').name, 'vite');
-  assert.notEqual(
-    cliRequire.resolve('vite/package.json'),
-    vitestRequire.resolve('vite/package.json'),
-  );
-  console.log('npm installed separate CLI core and upstream Vitest peer without overrides');
+  // The core version encodes the bundled Vite version, so it satisfies Vitest's
+  // `vite` peer range. npm resolves that peer to the CLI's core instead of
+  // installing upstream Vite (rfcs/core-version-alignment.md).
+  assert.equal(require('vite/package.json').name, '@voidzero-dev/vite-plus-core');
+  assert.equal(vitestRequire.resolve('vite/package.json'), cliRequire.resolve('vite/package.json'));
+  console.log('npm shares the CLI core with the Vitest peer without a project alias or overrides');
 }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-tagged-alias.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-tagged-alias.mjs
index 40e971754c..5b22621d02 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-tagged-alias.mjs
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/check-tagged-alias.mjs
@@ -1,24 +1,17 @@
 import assert from 'node:assert/strict';
-import { spawnSync } from 'node:child_process';
 import { createRequire } from 'node:module';
 
 const require = createRequire(import.meta.url);
 const cliRequire = createRequire(require.resolve('vite-plus/package.json'));
+const vitestRequire = createRequire(cliRequire.resolve('vitest/package.json'));
 const project = require('./package.json');
 assert.equal(project.overrides, undefined);
 assert.equal(project.devDependencies.vite, 'npm:@voidzero-dev/vite-plus-core@latest');
-// npm 11.11 accepts a peer's upstream Vite as satisfying the tagged alias.
-assert.equal(require('vite/package.json').name, 'vite');
-assert.equal(cliRequire('vite/package.json').name, '@voidzero-dev/vite-plus-core');
-assert.notEqual(require.resolve('vite/package.json'), cliRequire.resolve('vite/package.json'));
-
-for (const args of [['dev'], ['pack', 'entry.js']]) {
-  const result = spawnSync('vp', args, { encoding: 'utf8', timeout: 30_000 });
-  assert.ifError(result.error);
-  const output = result.stdout + result.stderr;
-  assert.equal(result.status, 1, output);
-  assert.match(output, /Expected @voidzero-dev\/vite-plus-core@/);
-  assert.match(output, /but found vite@/);
-  assert.match(output, /Run `vp migrate` to align the Vite alias, then run `vp install`\./);
-  console.log(`vp ${args[0]} rejects the replaced npm alias and reports the repair command`);
-}
+// The core version encodes the bundled Vite version, so it satisfies Vitest's
+// `vite` peer range and npm keeps the tagged alias on the core
+// (rfcs/core-version-alignment.md). A `1.x` core made npm replace the alias
+// with upstream Vite.
+assert.equal(require('vite/package.json').name, '@voidzero-dev/vite-plus-core');
+assert.equal(require.resolve('vite/package.json'), cliRequire.resolve('vite/package.json'));
+assert.equal(vitestRequire.resolve('vite/package.json'), cliRequire.resolve('vite/package.json'));
+console.log('npm keeps the tagged core alias and shares it between the project, CLI, and Vitest');
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots.toml
index d0be4f3f72..9de89704ba 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots.toml
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots.toml
@@ -156,11 +156,13 @@ steps = [
 ]
 
 [[case]]
-name = "core_module_identity_npm_tagged_alias_rejected"
+name = "core_module_identity_npm_tagged_alias"
 vp = "global"
 local-registry = true
+comment = "The core version satisfies Vitest's vite peer range, so npm keeps a tagged core alias instead of replacing it with upstream Vite."
 steps = [
   { argv = ["vpt", "write-file", "package.json", "{\"name\":\"core-module-identity\",\"private\":true,\"type\":\"module\",\"packageManager\":\"npm@11.11.0\",\"devDependencies\":{\"vite-plus\":\"latest\",\"vite\":\"npm:@voidzero-dev/vite-plus-core@latest\"}}\n"], snapshot = false },
   { argv = ["vp", "install", "--ignore-scripts"], timeout = 120000, snapshot = false },
   ["node", "check-tagged-alias.mjs"],
+  ["vp", "pack", "entry.js"],
 ]
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_cli_only_no_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_cli_only_no_override.md
index 0571631515..3c9c60ff20 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_cli_only_no_override.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_cli_only_no_override.md
@@ -13,7 +13,7 @@
 ## `node check-npm-layout.mjs`
 
 ```
-npm installed separate CLI core and upstream Vitest peer without overrides
+npm shares the CLI core with the Vitest peer without a project alias or overrides
 ```
 
 ## `node check-bundled.mjs`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias.md
new file mode 100644
index 0000000000..d47cbe8631
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_module_identity/snapshots/core_module_identity_npm_tagged_alias.md
@@ -0,0 +1,28 @@
+# core_module_identity_npm_tagged_alias
+
+The core version satisfies Vitest's vite peer range, so npm keeps a tagged core alias instead of replacing it with upstream Vite.
+
+## `vpt write-file package.json '{"name":"core-module-identity","private":true,"type":"module","packageManager":"npm@11.11.0","devDependencies":{"vite-plus":"latest","vite":"npm:@voidzero-dev/vite-plus-core@latest"}}
+'`
+
+
+## `vp install --ignore-scripts`
+
+
+## `node check-tagged-alias.mjs`
+
+```
+npm keeps the tagged core alias and shares it between the project, CLI, and Vitest
+```
+
+## `vp pack entry.js`
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+ℹ entry: entry.js
+ℹ Build start
+ℹ dist/entry.mjs   kB │ gzip:  kB
+ℹ 1 files, total:  kB
+✔ Build complete in 
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_migrate_pnpm11/snapshots/create_approve_builds_migrate_pnpm11.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_migrate_pnpm11/snapshots/create_approve_builds_migrate_pnpm11.md
index 7121581f7a..4741c59d57 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_migrate_pnpm11/snapshots/create_approve_builds_migrate_pnpm11.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_migrate_pnpm11/snapshots/create_approve_builds_migrate_pnpm11.md
@@ -24,11 +24,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vp create @your-org:with-build-dep --no-interactive --directory default-app`
@@ -57,11 +52,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `cd default-app && vp pm approve-builds core-js`
@@ -84,9 +74,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_pnpm11/snapshots/create_approve_builds_pnpm11.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_pnpm11/snapshots/create_approve_builds_pnpm11.md
index 6c8ca152cb..ab6923f9a7 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_pnpm11/snapshots/create_approve_builds_pnpm11.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_approve_builds_pnpm11/snapshots/create_approve_builds_pnpm11.md
@@ -23,11 +23,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vp create @your-org:with-build-dep --no-interactive --directory default-app`
@@ -55,11 +50,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `cd default-app && vp pm approve-builds core-js`
@@ -82,9 +72,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_org_bundled_monorepo/snapshots/create_org_bundled_monorepo.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_org_bundled_monorepo/snapshots/create_org_bundled_monorepo.md
index 5c05d098d5..ff75554728 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_org_bundled_monorepo/snapshots/create_org_bundled_monorepo.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_org_bundled_monorepo/snapshots/create_org_bundled_monorepo.md
@@ -46,11 +46,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vpt stat-file my-mono/.git --assert dir`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md
index 54af3c6544..70d08e0758 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md
@@ -43,11 +43,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .vite-hooks/pre-commit`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_auto_create_vite_config/snapshots/migration_auto_create_vite_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_auto_create_vite_config/snapshots/migration_auto_create_vite_config.md
index d206896401..cba60fec73 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_auto_create_vite_config/snapshots/migration_auto_create_vite_config.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_auto_create_vite_config/snapshots/migration_auto_create_vite_config.md
@@ -90,9 +90,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_baseurl_tsconfig/snapshots/migration_baseurl_tsconfig.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_baseurl_tsconfig/snapshots/migration_baseurl_tsconfig.md
index 786b036a0d..30052873dc 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_baseurl_tsconfig/snapshots/migration_baseurl_tsconfig.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_baseurl_tsconfig/snapshots/migration_baseurl_tsconfig.md
@@ -97,9 +97,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_chained_lint_staged_pre_commit/snapshots/migration_chained_lint_staged_pre_commit.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_chained_lint_staged_pre_commit/snapshots/migration_chained_lint_staged_pre_commit.md
index 41101ccc53..01c8723581 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_chained_lint_staged_pre_commit/snapshots/migration_chained_lint_staged_pre_commit.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_chained_lint_staged_pre_commit/snapshots/migration_chained_lint_staged_pre_commit.md
@@ -48,11 +48,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_custom_dir/snapshots/migration_composed_husky_custom_dir.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_custom_dir/snapshots/migration_composed_husky_custom_dir.md
index 8f9a9de90b..1480398795 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_custom_dir/snapshots/migration_composed_husky_custom_dir.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_custom_dir/snapshots/migration_composed_husky_custom_dir.md
@@ -57,11 +57,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .config/husky/pre-commit`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_prepare/snapshots/migration_composed_husky_prepare.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_prepare/snapshots/migration_composed_husky_prepare.md
index f3126ccd6e..aa852bfc34 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_prepare/snapshots/migration_composed_husky_prepare.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_composed_husky_prepare/snapshots/migration_composed_husky_prepare.md
@@ -44,9 +44,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_env_prefix_lint_staged/snapshots/migration_env_prefix_lint_staged.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_env_prefix_lint_staged/snapshots/migration_env_prefix_lint_staged.md
index 8448cf35d6..a352df32bd 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_env_prefix_lint_staged/snapshots/migration_env_prefix_lint_staged.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_env_prefix_lint_staged/snapshots/migration_env_prefix_lint_staged.md
@@ -48,11 +48,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint/snapshots/migration_eslint.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint/snapshots/migration_eslint.md
index 6fd722d9e2..4124dc07f3 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint/snapshots/migration_eslint.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint/snapshots/migration_eslint.md
@@ -44,11 +44,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file eslint.config.mjs --assert-not file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lint_staged/snapshots/migration_eslint_lint_staged.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lint_staged/snapshots/migration_eslint_lint_staged.md
index 6e787aae18..ded31805d7 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lint_staged/snapshots/migration_eslint_lint_staged.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lint_staged/snapshots/migration_eslint_lint_staged.md
@@ -41,11 +41,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lintstagedrc/snapshots/migration_eslint_lintstagedrc.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lintstagedrc/snapshots/migration_eslint_lintstagedrc.md
index 6bb47ada0e..38b2c8732d 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lintstagedrc/snapshots/migration_eslint_lintstagedrc.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_lintstagedrc/snapshots/migration_eslint_lintstagedrc.md
@@ -41,11 +41,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .lintstagedrc.json --assert-not file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_npx_wrapper/snapshots/migration_eslint_npx_wrapper.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_npx_wrapper/snapshots/migration_eslint_npx_wrapper.md
index cb125d95b5..c5600fa1cc 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_npx_wrapper/snapshots/migration_eslint_npx_wrapper.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_npx_wrapper/snapshots/migration_eslint_npx_wrapper.md
@@ -46,11 +46,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file eslint.config.mjs --assert-not file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky/snapshots/migration_existing_husky.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky/snapshots/migration_existing_husky.md
index 38647dd192..f07e9d6bf3 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky/snapshots/migration_existing_husky.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky/snapshots/migration_existing_husky.md
@@ -44,11 +44,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .husky/pre-commit`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_lint_staged/snapshots/migration_existing_husky_lint_staged.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_lint_staged/snapshots/migration_existing_husky_lint_staged.md
index fc0951692c..e560ba94cd 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_lint_staged/snapshots/migration_existing_husky_lint_staged.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_lint_staged/snapshots/migration_existing_husky_lint_staged.md
@@ -48,11 +48,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_hooks/snapshots/migration_existing_husky_v8_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_hooks/snapshots/migration_existing_husky_v8_hooks.md
index 45966828d4..464cce89a5 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_hooks/snapshots/migration_existing_husky_v8_hooks.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_hooks/snapshots/migration_existing_husky_v8_hooks.md
@@ -45,11 +45,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .husky/pre-commit`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_multi_hooks/snapshots/migration_existing_husky_v8_multi_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_multi_hooks/snapshots/migration_existing_husky_v8_multi_hooks.md
index a6511ad5c9..29ffba5760 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_multi_hooks/snapshots/migration_existing_husky_v8_multi_hooks.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_husky_v8_multi_hooks/snapshots/migration_existing_husky_v8_multi_hooks.md
@@ -45,11 +45,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .husky/pre-commit`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_lint_staged_config/snapshots/migration_existing_lint_staged_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_lint_staged_config/snapshots/migration_existing_lint_staged_config.md
index 2729823751..4dc9ce6272 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_lint_staged_config/snapshots/migration_existing_lint_staged_config.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_lint_staged_config/snapshots/migration_existing_lint_staged_config.md
@@ -43,11 +43,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .lintstagedrc.json --assert-not file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_pnpm_exec_lint_staged/snapshots/migration_existing_pnpm_exec_lint_staged.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_pnpm_exec_lint_staged/snapshots/migration_existing_pnpm_exec_lint_staged.md
index a40a4a8bb7..601711ff4d 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_pnpm_exec_lint_staged/snapshots/migration_existing_pnpm_exec_lint_staged.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_pnpm_exec_lint_staged/snapshots/migration_existing_pnpm_exec_lint_staged.md
@@ -48,11 +48,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_prepare_script/snapshots/migration_existing_prepare_script.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_prepare_script/snapshots/migration_existing_prepare_script.md
index 4de69944ad..eb973a231e 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_prepare_script/snapshots/migration_existing_prepare_script.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_prepare_script/snapshots/migration_existing_prepare_script.md
@@ -44,11 +44,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .vite-hooks/pre-commit`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md
index e715a94cef..7c3acb7133 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md
@@ -85,11 +85,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vp migrate --no-interactive`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md
index 7e226727f1..919b5e2bff 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md
@@ -86,11 +86,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vp migrate --no-interactive`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_config/snapshots/migration_from_vitest_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_config/snapshots/migration_from_vitest_config.md
index bcd8c684ae..54cb5e435b 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_config/snapshots/migration_from_vitest_config.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_config/snapshots/migration_from_vitest_config.md
@@ -108,9 +108,7 @@ overrides:
   '@vitest/browser@*': 
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_files/snapshots/migration_from_vitest_files.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_files/snapshots/migration_from_vitest_files.md
index 41978da470..f3a6324d81 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_files/snapshots/migration_from_vitest_files.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_vitest_files/snapshots/migration_from_vitest_files.md
@@ -52,10 +52,8 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
 
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_hooks_skip_on_existing_hookspath/snapshots/migration_hooks_skip_on_existing_hookspath.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_hooks_skip_on_existing_hookspath/snapshots/migration_hooks_skip_on_existing_hookspath.md
index ef9b150f19..b18f3b41cd 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_hooks_skip_on_existing_hookspath/snapshots/migration_hooks_skip_on_existing_hookspath.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_hooks_skip_on_existing_hookspath/snapshots/migration_hooks_skip_on_existing_hookspath.md
@@ -43,11 +43,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `git config --local core.hooksPath`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_catalog_version/snapshots/migration_husky_catalog_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_catalog_version/snapshots/migration_husky_catalog_version.md
index 2d866471f8..13be0b56a2 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_catalog_version/snapshots/migration_husky_catalog_version.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_catalog_version/snapshots/migration_husky_catalog_version.md
@@ -54,11 +54,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag/snapshots/migration_husky_latest_dist_tag.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag/snapshots/migration_husky_latest_dist_tag.md
index 70af6b9a4b..e86d5eb535 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag/snapshots/migration_husky_latest_dist_tag.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag/snapshots/migration_husky_latest_dist_tag.md
@@ -44,9 +44,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag_v9_installed/snapshots/migration_husky_latest_dist_tag_v9_installed.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag_v9_installed/snapshots/migration_husky_latest_dist_tag_v9_installed.md
index 2e5b05206c..1e8ec36dbe 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag_v9_installed/snapshots/migration_husky_latest_dist_tag_v9_installed.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_latest_dist_tag_v9_installed/snapshots/migration_husky_latest_dist_tag_v9_installed.md
@@ -48,9 +48,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_or_prepare/snapshots/migration_husky_or_prepare.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_or_prepare/snapshots/migration_husky_or_prepare.md
index 9b895203cb..a8b696566c 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_or_prepare/snapshots/migration_husky_or_prepare.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_or_prepare/snapshots/migration_husky_or_prepare.md
@@ -48,9 +48,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_semicolon_prepare/snapshots/migration_husky_semicolon_prepare.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_semicolon_prepare/snapshots/migration_husky_semicolon_prepare.md
index 1cca97e5b1..eac86d09ef 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_semicolon_prepare/snapshots/migration_husky_semicolon_prepare.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_semicolon_prepare/snapshots/migration_husky_semicolon_prepare.md
@@ -48,9 +48,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_v8_preserves_lint_staged/snapshots/migration_husky_v8_preserves_lint_staged.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_v8_preserves_lint_staged/snapshots/migration_husky_v8_preserves_lint_staged.md
index b775701d03..c0460fd0b8 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_v8_preserves_lint_staged/snapshots/migration_husky_v8_preserves_lint_staged.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_husky_v8_preserves_lint_staged/snapshots/migration_husky_v8_preserves_lint_staged.md
@@ -48,9 +48,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lazy_plugins_await/snapshots/migration_lazy_plugins_await.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lazy_plugins_await/snapshots/migration_lazy_plugins_await.md
index 0f2a1951b1..236b092660 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lazy_plugins_await/snapshots/migration_lazy_plugins_await.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lazy_plugins_await/snapshots/migration_lazy_plugins_await.md
@@ -62,9 +62,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_in_scripts/snapshots/migration_lint_staged_in_scripts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_in_scripts/snapshots/migration_lint_staged_in_scripts.md
index 467fd26f76..4775b5160b 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_in_scripts/snapshots/migration_lint_staged_in_scripts.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_in_scripts/snapshots/migration_lint_staged_in_scripts.md
@@ -49,11 +49,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_merge_fail/snapshots/migration_lint_staged_merge_fail.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_merge_fail/snapshots/migration_lint_staged_merge_fail.md
index 23fb3a8a00..b83bdbda3e 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_merge_fail/snapshots/migration_lint_staged_merge_fail.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_merge_fail/snapshots/migration_lint_staged_merge_fail.md
@@ -50,11 +50,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_ts_config/snapshots/migration_lint_staged_ts_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_ts_config/snapshots/migration_lint_staged_ts_config.md
index e13263d45d..4b03c89791 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_ts_config/snapshots/migration_lint_staged_ts_config.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lint_staged_ts_config/snapshots/migration_lint_staged_ts_config.md
@@ -46,11 +46,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file lint-staged.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_json/snapshots/migration_lintstagedrc_json.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_json/snapshots/migration_lintstagedrc_json.md
index 4134ba0d43..b40631cf68 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_json/snapshots/migration_lintstagedrc_json.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_json/snapshots/migration_lintstagedrc_json.md
@@ -98,11 +98,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_merge_fail/snapshots/migration_lintstagedrc_merge_fail.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_merge_fail/snapshots/migration_lintstagedrc_merge_fail.md
index fe4904d1e5..686ce20a82 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_merge_fail/snapshots/migration_lintstagedrc_merge_fail.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_merge_fail/snapshots/migration_lintstagedrc_merge_fail.md
@@ -47,11 +47,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .lintstagedrc.json`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_not_support/snapshots/migration_lintstagedrc_not_support.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_not_support/snapshots/migration_lintstagedrc_not_support.md
index beff4d8795..ea662e0ec4 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_not_support/snapshots/migration_lintstagedrc_not_support.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_not_support/snapshots/migration_lintstagedrc_not_support.md
@@ -74,9 +74,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_staged_exists/snapshots/migration_lintstagedrc_staged_exists.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_staged_exists/snapshots/migration_lintstagedrc_staged_exists.md
index e3662eebe4..b768812354 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_staged_exists/snapshots/migration_lintstagedrc_staged_exists.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_lintstagedrc_staged_exists/snapshots/migration_lintstagedrc_staged_exists.md
@@ -44,11 +44,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .lintstagedrc.json --assert file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_js/snapshots/migration_merge_vite_config_js.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_js/snapshots/migration_merge_vite_config_js.md
index 165f3445bf..029771df29 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_js/snapshots/migration_merge_vite_config_js.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_js/snapshots/migration_merge_vite_config_js.md
@@ -84,9 +84,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_vite_config_ts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_vite_config_ts.md
index 64ac0df171..097d14b181 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_vite_config_ts.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_vite_config_ts.md
@@ -164,9 +164,7 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md
index 9b74a136c7..6294fb9da8 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm/snapshots/migration_monorepo_pnpm.md
@@ -135,10 +135,8 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 minimumReleaseAgeExclude:
   - vite-plus
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm_overrides_dependency_selector/snapshots/migration_monorepo_pnpm_overrides_dependency_selector.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm_overrides_dependency_selector/snapshots/migration_monorepo_pnpm_overrides_dependency_selector.md
index ab8dc31e0a..56cea89ad3 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm_overrides_dependency_selector/snapshots/migration_monorepo_pnpm_overrides_dependency_selector.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_monorepo_pnpm_overrides_dependency_selector/snapshots/migration_monorepo_pnpm_overrides_dependency_selector.md
@@ -68,11 +68,6 @@ overrides:
   supertest>superagent: 9.0.2
   react-click-away-listener>react: 0.0.0-experimental-7dc903cd-20251203
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file packages/app/package.json`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_git_repo/snapshots/migration_no_git_repo.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_git_repo/snapshots/migration_no_git_repo.md
index cb103d8e0f..53df88e954 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_git_repo/snapshots/migration_no_git_repo.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_git_repo/snapshots/migration_no_git_repo.md
@@ -39,11 +39,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .vite-hooks --assert dir`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks/snapshots/migration_no_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks/snapshots/migration_no_hooks.md
index 830b4912ea..d7d4cb3ee7 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks/snapshots/migration_no_hooks.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks/snapshots/migration_no_hooks.md
@@ -39,11 +39,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .vite-hooks --assert-not dir`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks_with_husky/snapshots/migration_no_hooks_with_husky.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks_with_husky/snapshots/migration_no_hooks_with_husky.md
index 4d0cd34d8b..1405b4955a 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks_with_husky/snapshots/migration_no_hooks_with_husky.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_no_hooks_with_husky/snapshots/migration_no_hooks_with_husky.md
@@ -48,11 +48,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .husky --assert-not dir`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_npmx_dev/snapshots/migration_npmx_dev.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_npmx_dev/snapshots/migration_npmx_dev.md
index 47b7ab55d9..5e1ad05ab6 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_npmx_dev/snapshots/migration_npmx_dev.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_npmx_dev/snapshots/migration_npmx_dev.md
@@ -16,6 +16,7 @@ VITE+ - The Unified Toolchain for the Web
     @vitest/browser-playwright  4.1.10 → 
     @vitest/coverage-v8         4.1.10 → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -62,9 +63,7 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_other_hook_tool/snapshots/migration_other_hook_tool.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_other_hook_tool/snapshots/migration_other_hook_tool.md
index cade69a2f6..22c25bdfb1 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_other_hook_tool/snapshots/migration_other_hook_tool.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_other_hook_tool/snapshots/migration_other_hook_tool.md
@@ -48,9 +48,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_json_with_comments/snapshots/migration_oxlintrc_json_with_comments.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_json_with_comments/snapshots/migration_oxlintrc_json_with_comments.md
index 0bce7e8eea..bff0813631 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_json_with_comments/snapshots/migration_oxlintrc_json_with_comments.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_json_with_comments/snapshots/migration_oxlintrc_json_with_comments.md
@@ -81,9 +81,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_jsonc/snapshots/migration_oxlintrc_jsonc.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_jsonc/snapshots/migration_oxlintrc_jsonc.md
index 494f0ccc1d..424171ff34 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_jsonc/snapshots/migration_oxlintrc_jsonc.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxlintrc_jsonc/snapshots/migration_oxlintrc_jsonc.md
@@ -90,9 +90,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_installed_vite_plus/snapshots/migration_partially_installed_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_installed_vite_plus/snapshots/migration_partially_installed_vite_plus.md
index 97dd757d01..ea063b0541 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_installed_vite_plus/snapshots/migration_partially_installed_vite_plus.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_installed_vite_plus/snapshots/migration_partially_installed_vite_plus.md
@@ -60,11 +60,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_migrated_pre_commit/snapshots/migration_partially_migrated_pre_commit.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_migrated_pre_commit/snapshots/migration_partially_migrated_pre_commit.md
index 5bb81236bb..3b9ae80ad5 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_migrated_pre_commit/snapshots/migration_partially_migrated_pre_commit.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_partially_migrated_pre_commit/snapshots/migration_partially_migrated_pre_commit.md
@@ -45,11 +45,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file .husky/pre-commit`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_playwright_test/snapshots/migration_playwright_test.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_playwright_test/snapshots/migration_playwright_test.md
index 983c329bc3..7140b67dc1 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_playwright_test/snapshots/migration_playwright_test.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_playwright_test/snapshots/migration_playwright_test.md
@@ -16,6 +16,7 @@ VITE+ - The Unified Toolchain for the Web
     @vitest/browser-playwright  4.1.10 → 
     @vitest/coverage-v8         4.1.10 → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -62,9 +63,7 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_preserve_below_policy_node_pins/snapshots/migration_preserve_below_policy_node_pins.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_preserve_below_policy_node_pins/snapshots/migration_preserve_below_policy_node_pins.md
index 2ad8880f7e..db43ba69cc 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_preserve_below_policy_node_pins/snapshots/migration_preserve_below_policy_node_pins.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_preserve_below_policy_node_pins/snapshots/migration_preserve_below_policy_node_pins.md
@@ -67,11 +67,6 @@ catalogs:
     vite-plus: 
 overrides:
   vite@*: catalog:vite-stack
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vp migrate --no-interactive`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier/snapshots/migration_prettier.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier/snapshots/migration_prettier.md
index bd736ea504..310f8c21df 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier/snapshots/migration_prettier.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier/snapshots/migration_prettier.md
@@ -45,11 +45,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .prettierrc.json --assert-not file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_eslint_combo/snapshots/migration_prettier_eslint_combo.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_eslint_combo/snapshots/migration_prettier_eslint_combo.md
index 53f8b36cda..fbeab78a28 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_eslint_combo/snapshots/migration_prettier_eslint_combo.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_eslint_combo/snapshots/migration_prettier_eslint_combo.md
@@ -47,11 +47,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file eslint.config.mjs --assert-not file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_ignore_unknown/snapshots/migration_prettier_ignore_unknown.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_ignore_unknown/snapshots/migration_prettier_ignore_unknown.md
index 69ad496ca8..f4c973cccf 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_ignore_unknown/snapshots/migration_prettier_ignore_unknown.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_ignore_unknown/snapshots/migration_prettier_ignore_unknown.md
@@ -45,11 +45,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt stat-file .prettierrc.json --assert-not file`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_lint_staged/snapshots/migration_prettier_lint_staged.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_lint_staged/snapshots/migration_prettier_lint_staged.md
index 09e6ef9c66..3d436e89e1 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_lint_staged/snapshots/migration_prettier_lint_staged.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_lint_staged/snapshots/migration_prettier_lint_staged.md
@@ -42,11 +42,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_pkg_json/snapshots/migration_prettier_pkg_json.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_pkg_json/snapshots/migration_prettier_pkg_json.md
index b0dd73997e..cf6f814751 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_pkg_json/snapshots/migration_prettier_pkg_json.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_prettier_pkg_json/snapshots/migration_prettier_pkg_json.md
@@ -43,11 +43,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vpt print-file vite.config.ts`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_rewrite_declare_module/snapshots/migration_rewrite_declare_module.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_rewrite_declare_module/snapshots/migration_rewrite_declare_module.md
index 16d65ed4e1..de54e90796 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_rewrite_declare_module/snapshots/migration_rewrite_declare_module.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_rewrite_declare_module/snapshots/migration_rewrite_declare_module.md
@@ -84,9 +84,7 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_dependency/snapshots/migration_skip_vite_dependency.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_dependency/snapshots/migration_skip_vite_dependency.md
index 6681cd8c83..c963c637ec 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_dependency/snapshots/migration_skip_vite_dependency.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_dependency/snapshots/migration_skip_vite_dependency.md
@@ -74,9 +74,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_peer_dependency/snapshots/migration_skip_vite_peer_dependency.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_peer_dependency/snapshots/migration_skip_vite_peer_dependency.md
index 3e2ad38e8c..c6df351109 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_peer_dependency/snapshots/migration_skip_vite_peer_dependency.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_skip_vite_peer_dependency/snapshots/migration_skip_vite_peer_dependency.md
@@ -74,9 +74,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
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
index f07bedca24..21eb2332fe 100644
--- 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
@@ -65,9 +65,4 @@ catalog:
 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_standalone_pnpm/snapshots/migration_standalone_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_standalone_pnpm/snapshots/migration_standalone_pnpm.md
index 190803a2df..7f20f7781c 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_standalone_pnpm/snapshots/migration_standalone_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_standalone_pnpm/snapshots/migration_standalone_pnpm.md
@@ -40,9 +40,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_subpath/snapshots/migration_subpath.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_subpath/snapshots/migration_subpath.md
index bea45e89e0..f8872f5d07 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_subpath/snapshots/migration_subpath.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_subpath/snapshots/migration_subpath.md
@@ -69,9 +69,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsconfig_esmoduleinterop/snapshots/migration_tsconfig_esmoduleinterop.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsconfig_esmoduleinterop/snapshots/migration_tsconfig_esmoduleinterop.md
index 02ac9a1233..317e458361 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsconfig_esmoduleinterop/snapshots/migration_tsconfig_esmoduleinterop.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsconfig_esmoduleinterop/snapshots/migration_tsconfig_esmoduleinterop.md
@@ -71,9 +71,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_peer_only_pnpm/snapshots/migration_upgrade_browser_peer_only_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_peer_only_pnpm/snapshots/migration_upgrade_browser_peer_only_pnpm.md
index e2e816ce40..1ee7aa7d57 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_peer_only_pnpm/snapshots/migration_upgrade_browser_peer_only_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_peer_only_pnpm/snapshots/migration_upgrade_browser_peer_only_pnpm.md
@@ -13,6 +13,7 @@ VITE+ - The Unified Toolchain for the Web
     vite-plus  latest → 
     vite              → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -56,10 +57,8 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
 
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_source_only_pnpm/snapshots/migration_upgrade_browser_source_only_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_source_only_pnpm/snapshots/migration_upgrade_browser_source_only_pnpm.md
index bf85dc3a16..be418e27f8 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_source_only_pnpm/snapshots/migration_upgrade_browser_source_only_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_source_only_pnpm/snapshots/migration_upgrade_browser_source_only_pnpm.md
@@ -19,6 +19,7 @@ VITE+ - The Unified Toolchain for the Web
     vite                    → 
     @vitest/browser  4.1.8  → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -59,9 +60,7 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migrate_vitest4_community_provider.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migrate_vitest4_community_provider.md
index e118fff0f5..e0afce59c0 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migrate_vitest4_community_provider.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migrate_vitest4_community_provider.md
@@ -17,6 +17,7 @@ VITE+ - The Unified Toolchain for the Web
 ◇ Migrated . to Vite+ 
 • Node   pnpm 
 • 1 file had imports rewritten
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migration_upgrade_browser_webdriverio_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migration_upgrade_browser_webdriverio_pnpm.md
index f6184c755f..2264e3d0cf 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migration_upgrade_browser_webdriverio_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/migration_upgrade_browser_webdriverio_pnpm.md
@@ -19,6 +19,7 @@ VITE+ - The Unified Toolchain for the Web
     vite              → 
 • 1 file had imports rewritten
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -77,10 +78,8 @@ overrides:
   '@vitest/browser@*': 
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 allowBuilds:
   edgedriver: true
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/preserve_community_provider_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/preserve_community_provider_version.md
index 621f76c9c2..0eff0f5412 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/preserve_community_provider_version.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/preserve_community_provider_version.md
@@ -18,6 +18,7 @@ VITE+ - The Unified Toolchain for the Web
     vite              → 
 • 1 file had imports rewritten
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/upgrade_community_provider_catalog.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/upgrade_community_provider_catalog.md
index 69a21053b2..f93ad80924 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/upgrade_community_provider_catalog.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_browser_webdriverio_pnpm/snapshots/upgrade_community_provider_catalog.md
@@ -75,10 +75,8 @@ allowBuilds:
   geckodriver: true
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
 
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_hooks_flag_pnpm/snapshots/migration_upgrade_hooks_flag_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_hooks_flag_pnpm/snapshots/migration_upgrade_hooks_flag_pnpm.md
index 6974febf41..8e1a3d1342 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_hooks_flag_pnpm/snapshots/migration_upgrade_hooks_flag_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_hooks_flag_pnpm/snapshots/migration_upgrade_hooks_flag_pnpm.md
@@ -18,6 +18,7 @@ VITE+ - The Unified Toolchain for the Web
 • 2 config updates applied
 • Git hooks configured
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `git config --local core.hooksPath`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_monorepo_vitest_localized_pnpm/snapshots/migration_upgrade_monorepo_vitest_localized_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_monorepo_vitest_localized_pnpm/snapshots/migration_upgrade_monorepo_vitest_localized_pnpm.md
index a095fef1db..28c87514c2 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_monorepo_vitest_localized_pnpm/snapshots/migration_upgrade_monorepo_vitest_localized_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_monorepo_vitest_localized_pnpm/snapshots/migration_upgrade_monorepo_vitest_localized_pnpm.md
@@ -13,6 +13,7 @@ VITE+ - The Unified Toolchain for the Web
     vite-plus  latest → 
     vite              → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -68,9 +69,7 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_nuxt_test_utils_monorepo/snapshots/migration_upgrade_nuxt_test_utils_monorepo.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_nuxt_test_utils_monorepo/snapshots/migration_upgrade_nuxt_test_utils_monorepo.md
index 465554a573..fe95d68337 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_nuxt_test_utils_monorepo/snapshots/migration_upgrade_nuxt_test_utils_monorepo.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_nuxt_test_utils_monorepo/snapshots/migration_upgrade_nuxt_test_utils_monorepo.md
@@ -62,10 +62,8 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
 
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_peer_vitest_catalog_pnpm/snapshots/migration_upgrade_peer_vitest_catalog_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_peer_vitest_catalog_pnpm/snapshots/migration_upgrade_peer_vitest_catalog_pnpm.md
index 15698124cc..a3a367b082 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_peer_vitest_catalog_pnpm/snapshots/migration_upgrade_peer_vitest_catalog_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_peer_vitest_catalog_pnpm/snapshots/migration_upgrade_peer_vitest_catalog_pnpm.md
@@ -13,6 +13,7 @@ VITE+ - The Unified Toolchain for the Web
     vite-plus  latest → 
     vite              → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -50,11 +51,6 @@ catalogs:
   test: {}
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vp migrate --no-interactive`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_bundled_catalog_dep/snapshots/migration_upgrade_pnpm_bundled_catalog_dep.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_bundled_catalog_dep/snapshots/migration_upgrade_pnpm_bundled_catalog_dep.md
index 9e63d36f5e..ee30c7c0aa 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_bundled_catalog_dep/snapshots/migration_upgrade_pnpm_bundled_catalog_dep.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_bundled_catalog_dep/snapshots/migration_upgrade_pnpm_bundled_catalog_dep.md
@@ -49,9 +49,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_catalogs_default/snapshots/migration_upgrade_pnpm_catalogs_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_catalogs_default/snapshots/migration_upgrade_pnpm_catalogs_default.md
index 1cb71f44bd..32b831b67c 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_catalogs_default/snapshots/migration_upgrade_pnpm_catalogs_default.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_pnpm_catalogs_default/snapshots/migration_upgrade_pnpm_catalogs_default.md
@@ -52,11 +52,6 @@ catalogs:
     rari: ^0.14.12
 overrides:
   vite@*: catalog:build
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vp migrate --no-interactive`
@@ -106,9 +101,4 @@ catalogs:
     rari: ^0.14.12
 overrides:
   vite@*: catalog:build
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_full_pnpm/snapshots/migration_upgrade_setup_full_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_full_pnpm/snapshots/migration_upgrade_setup_full_pnpm.md
index a8618adbf1..145033fe63 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_full_pnpm/snapshots/migration_upgrade_setup_full_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_full_pnpm/snapshots/migration_upgrade_setup_full_pnpm.md
@@ -22,6 +22,7 @@ VITE+ - The Unified Toolchain for the Web
 • 2 config updates applied
 • Node version manager file migrated to .node-version
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 → Manual follow-up:
   - Remove the "volta" field from package.json
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_skipped_default_pnpm/snapshots/migration_upgrade_setup_skipped_default_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_skipped_default_pnpm/snapshots/migration_upgrade_setup_skipped_default_pnpm.md
index 227c579723..69513ff898 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_skipped_default_pnpm/snapshots/migration_upgrade_setup_skipped_default_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_setup_skipped_default_pnpm/snapshots/migration_upgrade_setup_skipped_default_pnpm.md
@@ -13,6 +13,7 @@ VITE+ - The Unified Toolchain for the Web
     vite-plus  0.1.21 → 
     vite              → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 • Skipped editor, hooks, and lint setup. Run `vp migrate --full` to apply them.
 ```
 
@@ -47,11 +48,6 @@ pnpm settings consolidated by the version upgrade
 ```
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 catalog:
   vite: npm:@voidzero-dev/vite-plus-core@
   vite-plus: 
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_stale_local_pnpm/snapshots/migration_upgrade_stale_local_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_stale_local_pnpm/snapshots/migration_upgrade_stale_local_pnpm.md
index e02b10c9fb..9e2e00f978 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_stale_local_pnpm/snapshots/migration_upgrade_stale_local_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_stale_local_pnpm/snapshots/migration_upgrade_stale_local_pnpm.md
@@ -16,6 +16,7 @@ VITE+ - The Unified Toolchain for the Web
     vite-plus  0.1.24 → 
     vite              → 
 • Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -46,11 +47,6 @@ pnpm settings should be consolidated here
 ```
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 catalog:
   vite: npm:@voidzero-dev/vite-plus-core@
   vite-plus: 
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_vitest_reference_whitespace_pnpm/snapshots/migration_upgrade_vitest_reference_whitespace_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_vitest_reference_whitespace_pnpm/snapshots/migration_upgrade_vitest_reference_whitespace_pnpm.md
index 3ace8f239c..f5ba43cf8c 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_vitest_reference_whitespace_pnpm/snapshots/migration_upgrade_vitest_reference_whitespace_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_vitest_reference_whitespace_pnpm/snapshots/migration_upgrade_vitest_reference_whitespace_pnpm.md
@@ -10,6 +10,7 @@ VITE+ - The Unified Toolchain for the Web
 ◇ Migrated . to Vite+ 
 • Node   pnpm 
 • 2 config updates applied, 1 file had imports rewritten
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
 ```
 
 ## `vpt print-file package.json`
@@ -54,11 +55,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
 
 ## `vp migrate --no-interactive`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_plus_in_dependencies_pnpm/snapshots/migration_vite_plus_in_dependencies_pnpm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_plus_in_dependencies_pnpm/snapshots/migration_vite_plus_in_dependencies_pnpm.md
index d038952dc4..16c8fcc19a 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_plus_in_dependencies_pnpm/snapshots/migration_vite_plus_in_dependencies_pnpm.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_plus_in_dependencies_pnpm/snapshots/migration_vite_plus_in_dependencies_pnpm.md
@@ -45,9 +45,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_version/snapshots/migration_vite_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_version/snapshots/migration_vite_version.md
index 2e5cc07b7f..6f965f0493 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_version/snapshots/migration_vite_version.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vite_version/snapshots/migration_vite_version.md
@@ -41,9 +41,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_import_only/snapshots/migration_vitest_import_only.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_import_only/snapshots/migration_vitest_import_only.md
index d20d676a0a..85efefa5b0 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_import_only/snapshots/migration_vitest_import_only.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_import_only/snapshots/migration_vitest_import_only.md
@@ -50,9 +50,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: 'catalog:'
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_peer_dep/snapshots/migration_vitest_peer_dep.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_peer_dep/snapshots/migration_vitest_peer_dep.md
index f67ce74fd3..56e0db8748 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_peer_dep/snapshots/migration_vitest_peer_dep.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_vitest_peer_dep/snapshots/migration_vitest_peer_dep.md
@@ -51,9 +51,7 @@ overrides:
   vitest@*: 'catalog:'
 peerDependencyRules:
   allowAny:
-    - vite
     - vitest
   allowedVersions:
-    vite: '*'
     vitest: '*'
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_members/snapshots/migration_workspace_members.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_members/snapshots/migration_workspace_members.md
index 8add5c6b23..63182370c6 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_members/snapshots/migration_workspace_members.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_workspace_members/snapshots/migration_workspace_members.md
@@ -27,11 +27,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vp run -r build`
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/new_vite_monorepo/snapshots/new_vite_monorepo.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/new_vite_monorepo/snapshots/new_vite_monorepo.md
index bb258b1685..8057d0a2e4 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/new_vite_monorepo/snapshots/new_vite_monorepo.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/new_vite_monorepo/snapshots/new_vite_monorepo.md
@@ -93,11 +93,6 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
 
 ## `vpt stat-file vite-plus-monorepo/.gitignore --assert file`
diff --git a/packages/cli/BUNDLING.md b/packages/cli/BUNDLING.md
index 8dcfa2bcd6..49bb00e8f6 100644
--- a/packages/cli/BUNDLING.md
+++ b/packages/cli/BUNDLING.md
@@ -31,12 +31,14 @@ commands start. Keep the canonical name in release metadata and alias targets
 to identify the published package.
 
 For npm projects without overrides, pin a declared `vite` alias to the exact
-core version that the installed `vite-plus` uses. npm can replace a tagged
-alias such as `npm:@voidzero-dev/vite-plus-core@latest` with upstream Vite to
-satisfy Vitest's peer range. The CLI rejects that mismatch and directs users to
-`vp migrate`, which writes an exact alias and the package-manager overrides.
-The npm fixtures check shared core identity with an exact alias and rejection
-of the tagged layout.
+core version that the installed `vite-plus` uses. With a `1.x` core, npm
+replaced a tagged alias such as `npm:@voidzero-dev/vite-plus-core@latest` with
+upstream Vite to satisfy Vitest's peer range. The encoded core version
+satisfies that range, so npm now keeps the alias on the core. The CLI still
+rejects upstream Vite and a core version that differs from its alias, and
+directs users to `vp migrate`, which writes an exact alias and the
+package-manager overrides. The npm fixtures check shared core identity with an
+exact alias, a tagged alias, and no project alias.
 
 ## Build Steps
 

From e491882d95318694198607c98cd087099e5f7445 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Fri, 6 Nov 2026 16:26:10 +0800
Subject: [PATCH 05/24] fix(release): wait for core at its own version in the
 Homebrew update

The Homebrew updater waited for @voidzero-dev/vite-plus-core at the CLI
version. The core version now encodes the bundled Vite version, so that
wait could never succeed. Read the core version from the `vite` alias of
the published vite-plus manifest instead. The manual recovery command keeps
its single version argument.

Co-Authored-By: Claude Opus 5.5 
---
 .../scripts/__tests__/homebrew-formula.mjs    | 23 +++++++++++++
 .github/scripts/update-homebrew-formula.mjs   | 32 ++++++++++++++++---
 rfcs/core-version-alignment.md                |  1 +
 3 files changed, 51 insertions(+), 5 deletions(-)

diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs
index 3b280a0c1a..b78520b545 100644
--- a/.github/scripts/__tests__/homebrew-formula.mjs
+++ b/.github/scripts/__tests__/homebrew-formula.mjs
@@ -6,6 +6,7 @@ import { test } from 'node:test';
 import { fileURLToPath } from 'node:url';
 
 import {
+  publishedCoreVersion,
   publishedFormulaVersion,
   releaseAssets,
   targets,
@@ -143,3 +144,25 @@ await test('updates preserve recipe changes and reset only a previous version re
     /Unsafe formula URL/,
   );
 });
+
+await test('reads the core version from the published CLI alias', async () => {
+  const manifest = (dependencies) => async (url) => {
+    assert.equal(url, `https://registry.npmjs.org/vite-plus/${version}`);
+    return Response.json({ dependencies });
+  };
+  assert.equal(
+    await publishedCoreVersion(
+      version,
+      manifest({ vite: 'npm:@voidzero-dev/vite-plus-core@8.3.501' }),
+    ),
+    '8.3.501',
+  );
+  await assert.rejects(
+    publishedCoreVersion(version, manifest({ vite: '^8.0.0' })),
+    /does not alias vite to @voidzero-dev\/vite-plus-core/,
+  );
+  await assert.rejects(
+    publishedCoreVersion(version, async () => new Response('', { status: 404 })),
+    /HTTP 404/,
+  );
+});
diff --git a/.github/scripts/update-homebrew-formula.mjs b/.github/scripts/update-homebrew-formula.mjs
index ba7195d22f..afce258973 100644
--- a/.github/scripts/update-homebrew-formula.mjs
+++ b/.github/scripts/update-homebrew-formula.mjs
@@ -137,6 +137,24 @@ export async function verifyRelease(version, release, fetchImpl = fetch) {
   return assets;
 }
 
+const CORE_ALIAS_PREFIX = 'npm:@voidzero-dev/vite-plus-core@';
+
+// The core version encodes the bundled Vite version, so it differs from the
+// vite-plus version (rfcs/core-version-alignment.md). Read it from the `vite`
+// alias of the published CLI. The release job publishes the CLI before this
+// script runs.
+export async function publishedCoreVersion(version, fetchImpl = fetch) {
+  const url = `https://registry.npmjs.org/vite-plus/${version}`;
+  const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
+  assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`);
+  const spec = (await response.json()).dependencies?.vite;
+  assert.ok(
+    typeof spec === 'string' && spec.startsWith(CORE_ALIAS_PREFIX),
+    `vite-plus@${version} does not alias vite to @voidzero-dev/vite-plus-core: ${spec}`,
+  );
+  return spec.slice(CORE_ALIAS_PREFIX.length);
+}
+
 async function main() {
   const [version] = process.argv.slice(2);
   stableVersion(version ?? '');
@@ -146,13 +164,17 @@ async function main() {
     }),
   );
   const assets = await verifyRelease(version, release);
+  const coreVersion = await publishedCoreVersion(version);
   const packages = [
-    'vite-plus',
-    '@voidzero-dev/vite-plus-core',
-    '@voidzero-dev/vite-plus-prompts',
-    ...targets.map(([, , , platform]) => `@voidzero-dev/vite-plus-${platform}`),
+    { name: 'vite-plus', version },
+    { name: '@voidzero-dev/vite-plus-core', version: coreVersion },
+    { name: '@voidzero-dev/vite-plus-prompts', version },
+    ...targets.map(([, , , platform]) => ({
+      name: `@voidzero-dev/vite-plus-${platform}`,
+      version,
+    })),
   ];
-  await waitForNpmPackagesFromEnv(packages.map((name) => ({ name, version })));
+  await waitForNpmPackagesFromEnv(packages);
   const file = new URL('../../HomebrewFormula/vp.rb', import.meta.url);
   const source = await readFile(file, 'utf8');
   const result = updateFormula(source, version, assets);
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 92310f4abc..f50d9883aa 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -347,6 +347,7 @@ cannot mix it up with a published version.
 | `packages/tools/src/local-npm-registry.ts`, `pack-local-vite-plus.ts`                                                            | No change: both serve the packed tarball's own version                                                                                                                                                                                                                                                                                                                        |
 | `.github/scripts/upgrade-deps.ts`                                                                                                | Reset the core version after a bundled Vite change                                                                                                                                                                                                                                                                                                                            |
 | `.github/renovate.json`                                                                                                          | No change                                                                                                                                                                                                                                                                                                                                                                     |
+| `.github/scripts/update-homebrew-formula.mjs`                                                                                    | Read the core version from the `vite` alias of the published `vite-plus` manifest, and wait for core at that version                                                                                                                                                                                                                                                          |
 | `.claude/skills/release-manager/SKILL.md`                                                                                        | Read both versions from the release branch; publish checks use the core version for core                                                                                                                                                                                                                                                                                      |
 | `packages/core/BUNDLING.md`                                                                                                      | Document the version scheme next to `bundledVersions`                                                                                                                                                                                                                                                                                                                         |
 | `MAINTENANCE.md`                                                                                                                 | Mention the two versions in the release description                                                                                                                                                                                                                                                                                                                           |

From 9131d2ba46101826c5da35162d1cec1ac5c5cbeb Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Mon, 9 Nov 2026 18:28:56 +0800
Subject: [PATCH 06/24] refactor: simplify the core version and peer-rule code

- Decide the stale pnpm peer-rule keys (`vitest` and `vite`) in one
  function, and check and remove them with shared helpers in the YAML
  writer, the package.json merge, and the satisfied check.
- Define the core alias prefix once for the CLI and once for the scripts.
- Read the bundled core manifest once in resolve-core, and compare the
  project's core with the core that the CLI resolves.
- Read the core version only from VP_CORE_VERSION or the core manifest.
- Reuse the core-version helpers in upgrade-deps, the download helper in
  the Homebrew updater, and readPackageJsonIfExists in the migrator.
- Remove the unused VP_CORE_VERSION from the ecosystem CI migrate env.

Co-Authored-By: Claude Opus 5.5 
---
 .github/scripts/update-homebrew-formula.mjs   | 35 ++++---
 .github/scripts/upgrade-deps.ts               | 17 ++--
 ecosystem-ci/patch-project.ts                 |  3 +-
 ecosystem-ci/paths.ts                         | 12 +--
 .../cli/src/migration/migrator/catalog.ts     | 46 +++------
 packages/cli/src/migration/migrator/shared.ts | 12 ++-
 .../migration/migrator/vite-plus-bootstrap.ts | 23 ++---
 .../migration/migrator/vitest-ecosystem.ts    | 95 ++++++++-----------
 packages/cli/src/resolve-core.ts              | 49 +++++-----
 .../cli/src/utils/__tests__/constants.spec.ts | 21 +---
 packages/cli/src/utils/constants.ts           | 13 +--
 packages/tools/src/core-version.ts            | 24 ++---
 12 files changed, 150 insertions(+), 200 deletions(-)

diff --git a/.github/scripts/update-homebrew-formula.mjs b/.github/scripts/update-homebrew-formula.mjs
index afce258973..edd97dcd00 100644
--- a/.github/scripts/update-homebrew-formula.mjs
+++ b/.github/scripts/update-homebrew-formula.mjs
@@ -5,7 +5,8 @@ import { readFile, writeFile } from 'node:fs/promises';
 import { resolve } from 'node:path';
 import { pathToFileURL } from 'node:url';
 
-import { waitForNpmPackagesFromEnv } from './wait-for-npm-packages.ts';
+import { CORE_ALIAS_PREFIX } from '../../packages/tools/src/core-version.ts';
+import { DEFAULT_NPM_REGISTRY, waitForNpmPackagesFromEnv } from './wait-for-npm-packages.ts';
 
 export const targets = [
   ['macos', 'arm', 'aarch64-apple-darwin', 'darwin-arm64'],
@@ -105,15 +106,17 @@ export function updateFormula(source, version, assets) {
   return updated;
 }
 
+async function download(url, fetchImpl) {
+  const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
+  assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`);
+  return response;
+}
+
 export async function verifyRelease(version, release, fetchImpl = fetch) {
   stableVersion(version);
   assert.equal(release.tag_name, `v${version}`);
   assert.ok(!release.draft && !release.prerelease, 'Release must be published and stable');
-  const get = async (url) => {
-    const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
-    assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`);
-    return response;
-  };
+  const get = (url) => download(url, fetchImpl);
   // The formula is added with the per-user Homebrew runtime. Check the release
   // tag, never main, before enabling installation.
   await get(`https://raw.githubusercontent.com/${repository}/v${version}/HomebrewFormula/vp.rb`);
@@ -137,17 +140,17 @@ export async function verifyRelease(version, release, fetchImpl = fetch) {
   return assets;
 }
 
-const CORE_ALIAS_PREFIX = 'npm:@voidzero-dev/vite-plus-core@';
-
 // The core version encodes the bundled Vite version, so it differs from the
 // vite-plus version (rfcs/core-version-alignment.md). Read it from the `vite`
 // alias of the published CLI. The release job publishes the CLI before this
 // script runs.
-export async function publishedCoreVersion(version, fetchImpl = fetch) {
-  const url = `https://registry.npmjs.org/vite-plus/${version}`;
-  const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
-  assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`);
-  const spec = (await response.json()).dependencies?.vite;
+export async function publishedCoreVersion(
+  version,
+  fetchImpl = fetch,
+  registry = process.env.PUBLISH_REGISTRY ?? DEFAULT_NPM_REGISTRY,
+) {
+  const url = `${registry.replace(/\/+$/, '')}/vite-plus/${version}`;
+  const spec = (await (await download(url, fetchImpl)).json()).dependencies?.vite;
   assert.ok(
     typeof spec === 'string' && spec.startsWith(CORE_ALIAS_PREFIX),
     `vite-plus@${version} does not alias vite to @voidzero-dev/vite-plus-core: ${spec}`,
@@ -163,8 +166,10 @@ async function main() {
       encoding: 'utf8',
     }),
   );
-  const assets = await verifyRelease(version, release);
-  const coreVersion = await publishedCoreVersion(version);
+  const [assets, coreVersion] = await Promise.all([
+    verifyRelease(version, release),
+    publishedCoreVersion(version),
+  ]);
   const packages = [
     { name: 'vite-plus', version },
     { name: '@voidzero-dev/vite-plus-core', version: coreVersion },
diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts
index 26bac8d80c..86fd080e65 100644
--- a/.github/scripts/upgrade-deps.ts
+++ b/.github/scripts/upgrade-deps.ts
@@ -2,7 +2,11 @@ import { execFileSync } from 'node:child_process';
 import fs from 'node:fs';
 import path from 'node:path';
 
-import { decodeCoreVersion, encodeCoreVersion } from '../../packages/tools/src/core-version.ts';
+import {
+  CORE_PACKAGE_NAME,
+  encodeCoreVersion,
+  tryDecodeCoreVersion,
+} from '../../packages/tools/src/core-version.ts';
 import {
   findLatestStableVersionForMajor,
   getBundledDependencyRanges,
@@ -564,18 +568,11 @@ function syncCoreVersion(pkg: { version?: unknown; bundledVersions?: Record.tgz');
+  return tgzVersion('vite-plus');
 }
 
 /**
@@ -28,18 +28,16 @@ export function vitePlusTgzVersion(): string {
  * it differs from {@link vitePlusTgzVersion} (rfcs/core-version-alignment.md).
  */
 export function vitePlusCoreTgzVersion(): string {
-  return tgzVersion(
-    /^voidzero-dev-vite-plus-core-(\d.*)\.tgz$/,
-    'voidzero-dev-vite-plus-core-.tgz',
-  );
+  return tgzVersion('voidzero-dev-vite-plus-core');
 }
 
-function tgzVersion(pattern: RegExp, description: string): string {
+function tgzVersion(prefix: string): string {
+  const pattern = new RegExp(`^${prefix}-(\\d.*)\\.tgz$`);
   const version = readdirSync(tgzDir)
     .map((entry) => pattern.exec(entry)?.[1])
     .find(Boolean);
   if (!version) {
-    throw new Error(`No ${description} found in ${tgzDir}`);
+    throw new Error(`No ${prefix}-.tgz found in ${tgzDir}`);
   }
   return version;
 }
diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts
index 6b34843da3..b754a32c9d 100644
--- a/packages/cli/src/migration/migrator/catalog.ts
+++ b/packages/cli/src/migration/migrator/catalog.ts
@@ -25,11 +25,13 @@ import {
   managedOverridePackages,
   managedPeerDependencyRuleKeys,
   mergeManagedPnpmPeerDependencyRules,
+  peerDependencyRulesHaveKey,
   pruneLegacyWrapperAliases,
   removeManagedVitestEntry,
   removeYamlMapVitestEntry,
   rewriteMonorepoProject,
   shouldDropProviderOverrideKey,
+  stalePeerDependencyRuleKeys,
   workspaceUsesWebdriverioProvider,
 } from '../migrator.ts';
 import { type MigrationReport } from '../report.ts';
@@ -41,6 +43,7 @@ import {
   VITEST_IS_MANAGED_OVERRIDE,
   isPlainRecord,
   pnpmOverrideKey,
+  readPackageJsonIfExists,
   vitePeerDependencyRuleRequired,
   type CatalogDependencyResolver,
   type PackageJsonDependencyField,
@@ -406,15 +409,9 @@ export function rewritePnpmWorkspaceYaml(
         allowAny.add(scalarString(allowAnyNode));
       }
     }
-    // Common case: drop any lingering managed `vitest` allowAny entry.
-    if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) {
-      allowAny.items = allowAny.items.filter((n) => n.value !== 'vitest');
-    }
-    // A `vite` entry only hides real incompatibilities once the core alias
-    // satisfies `vite` peer ranges (see `vitePeerDependencyRuleRequired`).
-    if (!vitePeerDependencyRuleRequired()) {
-      allowAny.items = allowAny.items.filter((n) => n.value !== 'vite');
-    }
+    // Drop lingering managed `vitest` and stale `vite` entries.
+    const stalePeerRuleKeys = stalePeerDependencyRuleKeys(usesVitest);
+    allowAny.items = allowAny.items.filter((n) => !stalePeerRuleKeys.includes(n.value));
     const peerRuleKeys = managedPeerDependencyRuleKeys(usesVitest);
     const existing = new Set(allowAny.items.map((n) => n.value));
     for (const key of peerRuleKeys) {
@@ -434,12 +431,8 @@ export function rewritePnpmWorkspaceYaml(
     } else {
       allowedVersions = new YAMLMap, Scalar>();
     }
-    // Common case: drop any lingering managed `vitest` allowedVersions entry.
-    if (!usesVitest) {
-      removeYamlMapVitestEntry(allowedVersions);
-    }
-    if (!vitePeerDependencyRuleRequired()) {
-      allowedVersions.delete('vite');
+    for (const key of stalePeerRuleKeys) {
+      allowedVersions.delete(key);
     }
     for (const key of peerRuleKeys) {
       // - vite: '*'
@@ -1284,7 +1277,6 @@ export function rewriteRootWorkspacePackageJson(
       // See https://github.com/oven-sh/bun/issues/8406.
       setDirectViteEdge(pkg, supportCatalog, catalogDependencyResolver);
     } else if (packageManager === PackageManager.pnpm) {
-      const overrideKeys = Object.keys(managedOverridePackages(workspaceUsesVitest));
       const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings(pnpmVersion ?? '');
       if (!usePnpmWorkspaceSettings) {
         // Strip selector-shaped overrides (e.g. `parent>@vitest/browser-playwright`)
@@ -1305,6 +1297,7 @@ export function rewriteRootWorkspacePackageJson(
           ),
         };
       } else {
+        const overrideKeys = Object.keys(managedOverridePackages(workspaceUsesVitest));
         for (const key of [...overrideKeys, ...PROVIDER_OVERRIDE_DROP_NAMES]) {
           if (pkg.resolutions?.[key]) {
             delete pkg.resolutions[key];
@@ -1443,27 +1436,16 @@ export function readPnpmWorkspacePeerDependencyRules(
   return doc?.peerDependencyRules;
 }
 
-function rulesHaveVite(
-  rules: { allowAny?: unknown; allowedVersions?: unknown } | null | undefined,
-): boolean {
-  return (
-    (Array.isArray(rules?.allowAny) && rules.allowAny.includes('vite')) ||
-    (isPlainRecord(rules?.allowedVersions) && rules.allowedVersions.vite !== undefined)
-  );
-}
-
 // Whether the root pnpm config (the package.json `pnpm` field or
 // pnpm-workspace.yaml) holds a `vite` peer rule.
 export function hasVitePeerDependencyRule(projectPath: string): boolean {
-  if (rulesHaveVite(readPnpmWorkspacePeerDependencyRules(projectPath))) {
+  if (peerDependencyRulesHaveKey(readPnpmWorkspacePeerDependencyRules(projectPath), ['vite'])) {
     return true;
   }
-  const packageJsonPath = path.join(projectPath, 'package.json');
-  if (!fs.existsSync(packageJsonPath)) {
-    return false;
-  }
-  const pkg = readJsonFile(packageJsonPath) as { pnpm?: PnpmPackageJsonSettings };
-  return rulesHaveVite(pkg.pnpm?.peerDependencyRules);
+  const pkg = readPackageJsonIfExists(path.join(projectPath, 'package.json')) as
+    | { pnpm?: PnpmPackageJsonSettings }
+    | undefined;
+  return peerDependencyRulesHaveKey(pkg?.pnpm?.peerDependencyRules, ['vite']);
 }
 
 // Capture the `vite` peer rule before a migration step. Call the returned
diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts
index c0d8a1fa00..17ba284ad7 100644
--- a/packages/cli/src/migration/migrator/shared.ts
+++ b/packages/cli/src/migration/migrator/shared.ts
@@ -5,7 +5,11 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts';
 import type { OxlintConfig } from 'oxlint';
 import { coerce, satisfies, tryParse } from 'verkit';
 
-import { VITEST_VERSION, VITE_PLUS_OVERRIDE_PACKAGES } from '../../utils/constants.ts';
+import {
+  VITEST_VERSION,
+  VITE_PLUS_CORE_ALIAS_PREFIX,
+  VITE_PLUS_OVERRIDE_PACKAGES,
+} from '../../utils/constants.ts';
 import { readJsonFile } from '../../utils/json.ts';
 import { detectPackageMetadata } from '../../utils/package.ts';
 import { displayRelative } from '../../utils/path.ts';
@@ -211,8 +215,6 @@ export const VITEST_BROWSER_DEP_NAMES = [
 // and must be left untouched.
 export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES;
 
-const CORE_ALIAS_PREFIX = 'npm:@voidzero-dev/vite-plus-core@';
-
 /**
  * Whether pnpm still needs the managed `vite` peer rule (`allowAny: [vite]`,
  * `allowedVersions: { vite: '*' }`) for the given core alias.
@@ -228,10 +230,10 @@ const CORE_ALIAS_PREFIX = 'npm:@voidzero-dev/vite-plus-core@';
 export function vitePeerDependencyRuleRequired(
   viteSpec: string | undefined = VITE_PLUS_OVERRIDE_PACKAGES.vite,
 ): boolean {
-  if (!viteSpec?.startsWith(CORE_ALIAS_PREFIX)) {
+  if (!viteSpec?.startsWith(VITE_PLUS_CORE_ALIAS_PREFIX)) {
     return true;
   }
-  const version = tryParse(viteSpec.slice(CORE_ALIAS_PREFIX.length));
+  const version = tryParse(viteSpec.slice(VITE_PLUS_CORE_ALIAS_PREFIX.length));
   return (
     !version ||
     version.major < 8 ||
diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
index 63a256d9a8..8e9262813a 100644
--- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
+++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
@@ -29,6 +29,7 @@ import {
   managedOverridePackages,
   managedPeerDependencyRuleKeys,
   mergeManagedPnpmPeerDependencyRules,
+  peerDependencyRulesHaveKey,
   migratePnpmSettingsToWorkspaceYaml,
   normalizeVitestPeerCatalogSpec,
   pnpmPackageJsonSettingsPending,
@@ -47,6 +48,7 @@ import {
   rewritePnpmWorkspaceYaml,
   rewriteYarnrcYml,
   setDirectViteEdge,
+  stalePeerDependencyRuleKeys,
   takePnpmWorkspaceSettings,
   trackVitePeerDependencyRuleRemoval,
   vitestEcosystemCatalogReferencesPending,
@@ -66,7 +68,6 @@ import {
   managedOverrideKey,
   pnpmMajor,
   pnpmOverrideKey,
-  vitePeerDependencyRuleRequired,
   type CatalogDependencyResolver,
   type ManagedOverrideKeyStyle,
   type PnpmPackageJsonSettings,
@@ -218,24 +219,12 @@ function pnpmPeerDependencyRulesSatisfyVitePlus(
     | undefined,
   usesVitest: boolean,
 ): boolean {
-  const allowAny = new Set(peerDependencyRules?.allowAny ?? []);
-  const allowedVersions = peerDependencyRules?.allowedVersions ?? {};
-  // Common case: a lingering managed `vitest` peer rule is NOT satisfied.
-  if (
-    !usesVitest &&
-    VITEST_IS_MANAGED_OVERRIDE &&
-    (allowAny.has('vitest') || allowedVersions.vitest !== undefined)
-  ) {
-    return false;
-  }
-  // A stale `vite` peer rule is not satisfied once the core alias satisfies
-  // `vite` peer ranges. It must be removed (see `vitePeerDependencyRuleRequired`).
-  if (
-    !vitePeerDependencyRuleRequired() &&
-    (allowAny.has('vite') || allowedVersions.vite !== undefined)
-  ) {
+  // A lingering managed `vitest` rule or a stale `vite` rule is NOT satisfied.
+  if (peerDependencyRulesHaveKey(peerDependencyRules, stalePeerDependencyRuleKeys(usesVitest))) {
     return false;
   }
+  const allowAny = new Set(peerDependencyRules?.allowAny ?? []);
+  const allowedVersions = peerDependencyRules?.allowedVersions ?? {};
   const ruleKeys = managedPeerDependencyRuleKeys(usesVitest);
   return ruleKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*');
 }
diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
index cfa1797b25..35824336a7 100644
--- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts
+++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
@@ -27,6 +27,7 @@ import {
   REMOVE_PACKAGES,
   VITEST_BROWSER_DEP_NAMES,
   VITEST_IS_MANAGED_OVERRIDE,
+  isPlainRecord,
   pnpmOverrideKey,
   vitePeerDependencyRuleRequired,
   type CatalogDependencyResolver,
@@ -540,40 +541,36 @@ function managedVitestOverrideKeys(keyStyle: ManagedOverrideKeyStyle): string[]
   return keyStyle === 'pnpm-ranged' ? ['vitest', pnpmOverrideKey('vitest')] : ['vitest'];
 }
 
-// Remove the managed `vitest` entry from pnpm peerDependencyRules (its
-// `allowAny` array entry and `allowedVersions.vitest`), in place. Works on both
-// the package.json `pnpm.peerDependencyRules` JSON shape and the same shape read
-// back from pnpm-workspace.yaml.
-export function removeVitestPeerDependencyRule(peerDependencyRules: {
-  allowAny?: string[];
-  allowedVersions?: Record;
-}): void {
-  if (!VITEST_IS_MANAGED_OVERRIDE) {
-    return;
+// The pnpm peerDependencyRules keys that migration removes:
+// - `vitest`, a lingering managed rule, when the workspace does not use Vitest.
+// - `vite`, once its core alias satisfies `vite` peer ranges (see
+//   `vitePeerDependencyRuleRequired`). Then the rule only hides real
+//   incompatibilities. Migration cannot tell whether an earlier migration or the
+//   user wrote the entry; its only purpose was to accept the `1.x` alias, so
+//   both go.
+export function stalePeerDependencyRuleKeys(usesVitest: boolean): string[] {
+  const keys: string[] = [];
+  if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) {
+    keys.push('vitest');
   }
-  if (Array.isArray(peerDependencyRules.allowAny)) {
-    peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vitest');
-  }
-  if (peerDependencyRules.allowedVersions) {
-    delete peerDependencyRules.allowedVersions.vitest;
+  if (!vitePeerDependencyRuleRequired()) {
+    keys.push('vite');
   }
+  return keys;
 }
 
-// Remove every `vite` entry from pnpm peerDependencyRules, in place. Once the
-// core version encodes the bundled Vite (see `vitePeerDependencyRuleRequired`),
-// the rule only hides real incompatibilities. Migration cannot tell whether an
-// earlier migration or the user wrote the entry; its only purpose was to accept
-// the `1.x` alias, so both go.
-export function removeVitePeerDependencyRule(peerDependencyRules: {
-  allowAny?: string[];
-  allowedVersions?: Record;
-}): void {
-  if (Array.isArray(peerDependencyRules.allowAny)) {
-    peerDependencyRules.allowAny = peerDependencyRules.allowAny.filter((key) => key !== 'vite');
-  }
-  if (peerDependencyRules.allowedVersions) {
-    delete peerDependencyRules.allowedVersions.vite;
-  }
+// Whether pnpm peerDependencyRules hold an `allowAny` or `allowedVersions` entry
+// for one of `keys`. Works on both the package.json `pnpm.peerDependencyRules`
+// JSON shape and the same shape read back from pnpm-workspace.yaml.
+export function peerDependencyRulesHaveKey(
+  rules: { allowAny?: unknown; allowedVersions?: unknown } | null | undefined,
+  keys: readonly string[],
+): boolean {
+  return keys.some(
+    (key) =>
+      (Array.isArray(rules?.allowAny) && rules.allowAny.includes(key)) ||
+      (isPlainRecord(rules?.allowedVersions) && rules.allowedVersions[key] !== undefined),
+  );
 }
 
 // The pnpm peerDependencyRules keys vite-plus manages: the managed override
@@ -590,33 +587,25 @@ export function mergeManagedPnpmPeerDependencyRules(
   rules: PnpmPeerDependencyRules | undefined,
   usesVitest: boolean,
 ): PnpmPeerDependencyRules | undefined {
-  const seed: PnpmPeerDependencyRules = { ...rules };
-  if (Array.isArray(seed.allowAny)) {
-    seed.allowAny = [...seed.allowAny];
-  }
-  if (seed.allowedVersions) {
-    seed.allowedVersions = { ...seed.allowedVersions };
-  }
-  // Common case: drop a lingering managed `vitest` peer rule.
-  if (!usesVitest) {
-    removeVitestPeerDependencyRule(seed);
-  }
-  if (!vitePeerDependencyRuleRequired()) {
-    removeVitePeerDependencyRule(seed);
-  }
+  const stale = new Set(stalePeerDependencyRuleKeys(usesVitest));
   const keys = managedPeerDependencyRuleKeys(usesVitest);
-  const next: PnpmPeerDependencyRules = {
-    ...seed,
-    allowAny: [...new Set([...(seed.allowAny ?? []), ...keys])],
-    allowedVersions: {
-      ...seed.allowedVersions,
-      ...Object.fromEntries(keys.map((key) => [key, '*'])),
-    },
+  const allowAny = [
+    ...new Set([
+      ...(Array.isArray(rules?.allowAny) ? rules.allowAny : []).filter((key) => !stale.has(key)),
+      ...keys,
+    ]),
+  ];
+  const allowedVersions = {
+    ...Object.fromEntries(
+      Object.entries(rules?.allowedVersions ?? {}).filter(([key]) => !stale.has(key)),
+    ),
+    ...Object.fromEntries(keys.map((key) => [key, '*'])),
   };
-  if (next.allowAny?.length === 0) {
+  const next: PnpmPeerDependencyRules = { ...rules, allowAny, allowedVersions };
+  if (allowAny.length === 0) {
     delete next.allowAny;
   }
-  if (next.allowedVersions && Object.keys(next.allowedVersions).length === 0) {
+  if (Object.keys(allowedVersions).length === 0) {
     delete next.allowedVersions;
   }
   return Object.keys(next).length > 0 ? next : undefined;
diff --git a/packages/cli/src/resolve-core.ts b/packages/cli/src/resolve-core.ts
index 61b511851f..92d20fcbed 100644
--- a/packages/cli/src/resolve-core.ts
+++ b/packages/cli/src/resolve-core.ts
@@ -2,53 +2,51 @@ import { readFileSync } from 'node:fs';
 import { createRequire } from 'node:module';
 import { join } from 'node:path';
 
+import { VITE_PLUS_CORE_ALIAS_PREFIX, VITE_PLUS_CORE_NAME } from './utils/constants.ts';
 import { readNearestPackageJson } from './utils/package.ts';
 
-const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
-const CORE_ALIAS_PREFIX = `npm:${CORE_PACKAGE_NAME}@`;
-
-function checkCoreVersion(packageJsonPath: string, expectedVersion: string | undefined): void {
+/**
+ * Check that a resolved `vite` is the core package, at `expectedVersion` when
+ * one is given. Returns the core version.
+ */
+function checkCoreVersion(
+  packageJsonPath: string,
+  expectedVersion: string | undefined,
+): string | undefined {
   const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
     name?: string;
     version?: string;
   };
   if (
-    pkg.name !== CORE_PACKAGE_NAME ||
+    pkg.name !== VITE_PLUS_CORE_NAME ||
     (expectedVersion !== undefined && pkg.version !== expectedVersion)
   ) {
     const expected =
-      expectedVersion === undefined ? CORE_PACKAGE_NAME : `${CORE_PACKAGE_NAME}@${expectedVersion}`;
+      expectedVersion === undefined
+        ? VITE_PLUS_CORE_NAME
+        : `${VITE_PLUS_CORE_NAME}@${expectedVersion}`;
     throw new Error(
       `Expected ${expected}, but found ${pkg.name}@${pkg.version} at ${packageJsonPath}. ` +
         'Run `vp migrate` to align the Vite alias, then run `vp install`.',
     );
   }
+  return pkg.version;
 }
 
 /**
  * Read the core version that the selected CLI pins through its `vite` alias.
  * The core version encodes the bundled Vite version, so it differs from the
  * CLI version (rfcs/core-version-alignment.md). Packing turns the repository's
- * `workspace:` spec into the exact alias. A spec without an exact version
- * falls back to the version of the core that the CLI resolves, so a stale
- * project core still fails in the repository layout.
+ * `workspace:` spec into the exact alias. Any other spec pins no version.
  */
-function readExpectedCoreVersion(
-  cliPackageJsonPath: string,
-  corePackageJsonPath: string,
-): string | undefined {
+function readAliasCoreVersion(cliPackageJsonPath: string): string | undefined {
   const { dependencies } = JSON.parse(readFileSync(cliPackageJsonPath, 'utf8')) as {
     dependencies?: Record;
   };
   const spec = dependencies?.vite;
-  if (spec?.startsWith(CORE_ALIAS_PREFIX)) {
-    return spec.slice(CORE_ALIAS_PREFIX.length);
-  }
-  const core = JSON.parse(readFileSync(corePackageJsonPath, 'utf8')) as {
-    name?: string;
-    version?: string;
-  };
-  return core.name === CORE_PACKAGE_NAME ? core.version : undefined;
+  return spec?.startsWith(VITE_PLUS_CORE_ALIAS_PREFIX)
+    ? spec.slice(VITE_PLUS_CORE_ALIAS_PREFIX.length)
+    : undefined;
 }
 
 function checkProjectCoreVersion(
@@ -98,12 +96,13 @@ export function resolveCore(
   }
   // Read the selected CLI's installed manifest. A static JSON import is inlined
   // during the build, before CI and preview packers can stamp a new version.
-  const expectedVersion = readExpectedCoreVersion(
-    cliRequire.resolve('vite-plus/package.json'),
+  // The project's core must match the core that the CLI resolves, so a stale
+  // project core also fails when the CLI alias pins no version.
+  const coreVersion = checkCoreVersion(
     corePackageJsonPath,
+    readAliasCoreVersion(cliRequire.resolve('vite-plus/package.json')),
   );
-  checkCoreVersion(corePackageJsonPath, expectedVersion);
-  checkProjectCoreVersion(cwd, corePackageJsonPath, expectedVersion);
+  checkProjectCoreVersion(cwd, corePackageJsonPath, coreVersion);
 
   return cliRequire.resolve(`vite${subpath}`);
 }
diff --git a/packages/cli/src/utils/__tests__/constants.spec.ts b/packages/cli/src/utils/__tests__/constants.spec.ts
index 5570a483a0..2c4e4b4b1d 100644
--- a/packages/cli/src/utils/__tests__/constants.spec.ts
+++ b/packages/cli/src/utils/__tests__/constants.spec.ts
@@ -25,32 +25,21 @@ describe('Vite+ dependency versions', () => {
     );
   });
 
-  it('keeps the core version for a release VP_VERSION', async () => {
-    vi.stubEnv('VP_VERSION', '1.0.1');
-    vi.stubEnv('VP_CORE_VERSION', '');
-    vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
-    vi.resetModules();
-
-    const { VITE_PLUS_CORE_VERSION } = await import('../constants.js');
-
-    expect(VITE_PLUS_CORE_VERSION).toBe(corePkg.version);
-  });
-
-  it.each(['0.0.0', '0.0.0-commit.0c515e3'])(
-    'uses a preview VP_VERSION %s for the core too',
+  it.each(['1.0.1', '0.0.0', '0.0.0-commit.0c515e3'])(
+    'keeps the core manifest version for VP_VERSION %s',
     async (version) => {
       vi.stubEnv('VP_VERSION', version);
       vi.stubEnv('VP_CORE_VERSION', '');
       vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
       vi.resetModules();
 
-      const { VITE_PLUS_OVERRIDE_PACKAGES } = await import('../constants.js');
+      const { VITE_PLUS_CORE_VERSION } = await import('../constants.js');
 
-      expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe(`npm:@voidzero-dev/vite-plus-core@${version}`);
+      expect(VITE_PLUS_CORE_VERSION).toBe(corePkg.version);
     },
   );
 
-  it('prefers VP_CORE_VERSION over a preview VP_VERSION', async () => {
+  it('uses VP_CORE_VERSION for the core alias', async () => {
     vi.stubEnv('VP_VERSION', '0.0.0');
     vi.stubEnv('VP_CORE_VERSION', '8.3.599');
     vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts
index cb71dcfbe2..9c0191b224 100644
--- a/packages/cli/src/utils/constants.ts
+++ b/packages/cli/src/utils/constants.ts
@@ -5,13 +5,14 @@ import cliPkg from '../../package.json' with { type: 'json' };
 
 export const VITE_PLUS_NAME = 'vite-plus';
 export const VITE_PLUS_VERSION = process.env.VP_VERSION || cliPkg.version;
+export const VITE_PLUS_CORE_NAME = '@voidzero-dev/vite-plus-core';
+// Projects and the CLI alias `vite` to the core: `vite: npm:@voidzero-dev/vite-plus-core@`.
+export const VITE_PLUS_CORE_ALIAS_PREFIX = `npm:${VITE_PLUS_CORE_NAME}@`;
 
 // The core version encodes the bundled Vite version (rfcs/core-version-alignment.md),
-// so it differs from the vite-plus version. Preview builds publish every package
-// at one `0.0.0-
` version, so a preview VP_VERSION also selects the core.
-export const VITE_PLUS_CORE_VERSION =
-  process.env.VP_CORE_VERSION ||
-  (process.env.VP_VERSION?.startsWith('0.0.0') ? process.env.VP_VERSION : corePkg.version);
+// so it differs from the vite-plus version. Preview builds stamp the core manifest
+// before the build, so `corePkg.version` is already the preview version.
+export const VITE_PLUS_CORE_VERSION = process.env.VP_CORE_VERSION || corePkg.version;
 
 // Mirrors Vite's DEFAULT_CONFIG_FILES order so readers and writers target the same file.
 export const VITE_CONFIG_FILES = [
@@ -35,7 +36,7 @@ export const TSDOWN_MIGRATION_SKILL_URL =
 export const VITE_PLUS_OVERRIDE_PACKAGES: Record = process.env.VP_OVERRIDE_PACKAGES
   ? JSON.parse(process.env.VP_OVERRIDE_PACKAGES)
   : {
-      vite: `npm:@voidzero-dev/vite-plus-core@${VITE_PLUS_CORE_VERSION}`,
+      vite: `${VITE_PLUS_CORE_ALIAS_PREFIX}${VITE_PLUS_CORE_VERSION}`,
       // The runner inlines its assertion and runner state. Its remaining
       // official dependencies use exact versions; community providers and
       // Istanbul internals have their own version ranges.
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
index 6b59de36ef..4917b71c2f 100644
--- a/packages/tools/src/core-version.ts
+++ b/packages/tools/src/core-version.ts
@@ -24,8 +24,10 @@ import { readFileSync } from 'node:fs';
 import { parseArgs } from 'node:util';
 
 export const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
-export const MAX_RELEASE_COUNTER = 98;
-export const LOCAL_BUILD_COUNTER = 99;
+/** The `vite` alias to the core: `npm:@voidzero-dev/vite-plus-core@`. */
+export const CORE_ALIAS_PREFIX = `npm:${CORE_PACKAGE_NAME}@`;
+const MAX_RELEASE_COUNTER = 98;
+const LOCAL_BUILD_COUNTER = 99;
 
 const PRERELEASE_TAG = 'vp';
 const VERSION_RE =
@@ -60,7 +62,7 @@ function parseVersion(version: string): ParsedVersion {
   };
 }
 
-export function isPrereleaseVersion(version: string): boolean {
+function isPrereleaseVersion(version: string): boolean {
   return parseVersion(version).prerelease.length > 0;
 }
 
@@ -96,7 +98,7 @@ export function decodeCoreVersion(core: string): DecodedCoreVersion {
   };
 }
 
-function tryDecodeCoreVersion(core: string): DecodedCoreVersion | undefined {
+export function tryDecodeCoreVersion(core: string): DecodedCoreVersion | undefined {
   try {
     return decodeCoreVersion(core);
   } catch {
@@ -117,8 +119,9 @@ function nextCounter(values: Iterable): number {
  *
  * @param vite - The bundled Vite version.
  * @param vitePlus - The `vite-plus` version of the release.
- * @param published - Every published core version. Versions that do not decode
- *   (the `0.x` and `1.x` lines) are ignored.
+ * @param published - Every published core version. Versions from before the
+ *   alignment (the `0.x` and `1.x` lines) never match a bundled Vite version,
+ *   so they do not affect the counters.
  */
 export function nextCoreVersion({
   vite,
@@ -165,12 +168,9 @@ export function nextCoreVersion({
     return base;
   }
   const k = nextCounter(
-    published.flatMap((version) => {
-      const match = version.startsWith(`${base}-${PRERELEASE_TAG}.`)
-        ? tryDecodeCoreVersion(version)
-        : undefined;
-      return match?.k === undefined ? [] : [match.k];
-    }),
+    decoded.flatMap((entry) =>
+      entry.vite === vite && entry.n === n && entry.k !== undefined ? [entry.k] : [],
+    ),
   );
   return `${base}-${PRERELEASE_TAG}.${k}`;
 }

From 9d92f9e5aa94b638fa29cc60e6bc912cb2df1059 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 10:09:24 +0800
Subject: [PATCH 07/24] fix(release): reject a core version that another
 release published

- Record the vite-plus version in the core manifest (`vitePlusVersion`).
  `core-version.ts prepare` writes it with the selected core version, and
  `check --release` fails unless the published core version is absent or
  belongs to the same vite-plus release. This covers two release PRs that
  select the same counter and a manual vite-plus bump.
- Start the counter at `.vp.0` when the bundled Vite is a prerelease,
  and check the prerelease pairing only for a release.
- Keep a user `allowedVersions.vite` range during migration. Migration only
  removes the managed `'*'` value and `allowAny: [vite]`.
- Add a strict pnpm peer check case: a Vite 8 plugin installs through the
  core alias, and a Vite 7 plugin still fails.

Co-Authored-By: Claude Opus 5.5 
---
 .../scripts/__tests__/upgrade-deps.spec.ts    |   8 ++
 .github/scripts/upgrade-deps.ts               |   4 +-
 .github/workflows/prepare_release.yml         |  11 +-
 .../check-peers.mjs                           |  27 ++++
 .../core_peer_dependency_checks/package.json  |  11 ++
 .../plugins/current/package.json              |   7 ++
 .../plugins/legacy/package.json               |   7 ++
 .../snapshots.toml                            |  10 ++
 ...core_peer_dependency_checks_pnpm_strict.md |  18 +++
 docs/guide/migrate-rules.md                   |   2 +-
 .../vite-peer-dependency-rule.spec.ts         |  37 ++++++
 .../cli/src/migration/migrator/catalog.ts     |  13 +-
 .../migration/migrator/vite-plus-bootstrap.ts |   6 +-
 .../migration/migrator/vitest-ecosystem.ts    |  39 ++++--
 packages/core/BUNDLING.md                     |   3 +-
 .../tools/src/__tests__/core-version.spec.ts  |  74 +++++++++--
 packages/tools/src/core-version.ts            | 118 ++++++++++++++----
 rfcs/core-version-alignment.md                |  62 ++++++---
 18 files changed, 379 insertions(+), 78 deletions(-)
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/check-peers.mjs
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/package.json
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/current/package.json
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/legacy/package.json
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict.md

diff --git a/.github/scripts/__tests__/upgrade-deps.spec.ts b/.github/scripts/__tests__/upgrade-deps.spec.ts
index 3dad49c469..f3ab32fc49 100644
--- a/.github/scripts/__tests__/upgrade-deps.spec.ts
+++ b/.github/scripts/__tests__/upgrade-deps.spec.ts
@@ -353,6 +353,14 @@ test('restarts the core version counter when the bundled Vite changes', () => {
   });
 });
 
+test('starts the vp counter when the bundled Vite is a prerelease', () => {
+  const fixture = createSyncFixture();
+  writeJson(fixture.vitePath, { ...readJson(fixture.vitePath), version: '9.0.0-beta.3' });
+  fixture.run();
+  expect(readJson(fixture.corePath).version).toBe('9.0.0-beta.3.vp.0');
+  expect(readJson(fixture.corePath).bundledVersions).toEqual({ vite: '9.0.0-beta.3' });
+});
+
 test('fails without writing core when the Vite source has no version', () => {
   const fixture = createSyncFixture();
   const { version: _, ...vite } = readJson(fixture.vitePath);
diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts
index 86fd080e65..d965bee2d6 100644
--- a/.github/scripts/upgrade-deps.ts
+++ b/.github/scripts/upgrade-deps.ts
@@ -4,7 +4,7 @@ import path from 'node:path';
 
 import {
   CORE_PACKAGE_NAME,
-  encodeCoreVersion,
+  initialCoreVersion,
   tryDecodeCoreVersion,
 } from '../../packages/tools/src/core-version.ts';
 import {
@@ -571,7 +571,7 @@ function syncCoreVersion(pkg: { version?: unknown; bundledVersions?: Record> "$GITHUB_OUTPUT"
+          echo "Updated packages/core/package.json"
           update_json packages/cli/package.json
-          update_json packages/core/package.json "$CORE_VERSION"
           update_json packages/prompts/package.json
           update_json packages/create-vite-plus/package.json
           update_toml packages/cli/binding/Cargo.toml
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/check-peers.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/check-peers.mjs
new file mode 100644
index 0000000000..320cb0010b
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/check-peers.mjs
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+
+// The core version encodes the bundled Vite version, so pnpm checks `vite`
+// peer ranges against the bundled Vite (rfcs/core-version-alignment.md).
+const require = createRequire(import.meta.url);
+assert.equal(require('vite/package.json').name, '@voidzero-dev/vite-plus-core');
+assert.ok(require.resolve('vite-plugin-current/package.json'));
+console.log('pnpm strict peer check accepts a plugin that supports the bundled Vite');
+
+// A plugin that requires an older Vite major must still fail the check.
+const project = JSON.parse(readFileSync('package.json', 'utf8'));
+project.devDependencies['vite-plugin-legacy'] = 'file:./plugins/legacy';
+writeFileSync('package.json', `${JSON.stringify(project, null, 2)}\n`);
+const result = spawnSync('vp', ['install', '--ignore-scripts'], {
+  encoding: 'utf8',
+  timeout: 110_000,
+});
+assert.ifError(result.error);
+const output = result.stdout + result.stderr;
+assert.notEqual(result.status, 0, output);
+assert.match(output, /ERR_PNPM_PEER_DEP_ISSUES/);
+assert.match(output, /vite-plugin-legacy/);
+assert.doesNotMatch(output, /vite-plugin-current/);
+console.log('pnpm strict peer check rejects a plugin that requires Vite 7');
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/package.json
new file mode 100644
index 0000000000..30a092e9fd
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "core-peer-dependency-checks",
+  "private": true,
+  "type": "module",
+  "packageManager": "pnpm@11.24.0",
+  "devDependencies": {
+    "vite": "npm:@voidzero-dev/vite-plus-core@latest",
+    "vite-plugin-current": "file:./plugins/current",
+    "vite-plus": "latest"
+  }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/current/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/current/package.json
new file mode 100644
index 0000000000..792b751a6c
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/current/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "vite-plugin-current",
+  "version": "1.0.0",
+  "peerDependencies": {
+    "vite": "^8.0.0"
+  }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/legacy/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/legacy/package.json
new file mode 100644
index 0000000000..849e171e3d
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/plugins/legacy/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "vite-plugin-legacy",
+  "version": "1.0.0",
+  "peerDependencies": {
+    "vite": "^7.0.0"
+  }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml
new file mode 100644
index 0000000000..d99c3de477
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml
@@ -0,0 +1,10 @@
+[[case]]
+name = "core_peer_dependency_checks_pnpm_strict"
+vp = "global"
+local-registry = true
+comment = "The core version encodes the bundled Vite version, so strict pnpm peer checks accept a Vite 8 plugin through the vite alias and still reject a Vite 7 plugin."
+steps = [
+  { argv = ["vpt", "write-file", "pnpm-workspace.yaml", "strictPeerDependencies: true\nminimumReleaseAge: 0\n"], snapshot = false },
+  { argv = ["vp", "install", "--ignore-scripts"], timeout = 120000, snapshot = false },
+  { argv = ["node", "check-peers.mjs"], timeout = 120000 },
+]
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict.md
new file mode 100644
index 0000000000..ac25240c2e
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict.md
@@ -0,0 +1,18 @@
+# core_peer_dependency_checks_pnpm_strict
+
+The core version encodes the bundled Vite version, so strict pnpm peer checks accept a Vite 8 plugin through the vite alias and still reject a Vite 7 plugin.
+
+## `vpt write-file pnpm-workspace.yaml 'strictPeerDependencies: true
+minimumReleaseAge: 0
+'`
+
+
+## `vp install --ignore-scripts`
+
+
+## `node check-peers.mjs`
+
+```
+pnpm strict peer check accepts a plugin that supports the bundled Vite
+pnpm strict peer check rejects a plugin that requires Vite 7
+```
diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md
index 15b4418bb2..c938c2770d 100644
--- a/docs/guide/migrate-rules.md
+++ b/docs/guide/migrate-rules.md
@@ -382,7 +382,7 @@ Migration converts the Volta `volta.node` pin to `.node-version`, because Vite+
 **Peer rules.** The core version encodes the bundled Vite version: core `8.3.500` bundles Vite `8.3.5` (see the [RFC](https://github.com/voidzero-dev/vite-plus/blob/main/rfcs/core-version-alignment.md)). pnpm checks `vite` peer ranges against the `vite` alias, so a plugin that supports the bundled Vite passes, and a plugin that does not support it fails.
 
 - Migration does not add `vite` to `peerDependencyRules.allowAny` or `peerDependencyRules.allowedVersions`.
-- Migration removes an existing `vite` entry from both fields, also an entry that you wrote. The only purpose of that entry was to accept the `1.x` core alias. It removes a field, or `peerDependencyRules`, when no entry remains. Other entries stay. The migrate summary names the removed rule.
+- Migration removes `vite` from `allowAny` and removes `allowedVersions.vite` when its value is `'*'`, also when you wrote the entry. The only purpose of those entries was to accept the `1.x` core alias. Another `allowedVersions.vite` range, such as `'7 || 8'`, stays. Migration removes a field, or `peerDependencyRules`, when no entry remains. Other entries stay. The migrate summary names the removed rule.
 - For a preview build (`0.0.0-commit.`) or a Vite+ prerelease core (such as `8.4.0-vp.0`), migration keeps writing the `vite` rule. Package managers do not accept these versions for ranges such as `^8.0.0`.
 - After migration, a plugin whose `vite` peer range excludes the bundled Vite (for example `^7.0.0`) produces a peer warning. With `strictPeerDependencies`, the install fails. Update the plugin, or add a rule scoped to that plugin, such as `allowedVersions: { 'vite-plugin-foo>vite': '*' }`. Migration removes only the plain `vite` key.
 
diff --git a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
index cf35000c8c..a840367953 100644
--- a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
+++ b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
@@ -182,6 +182,43 @@ describe('pnpm vite peer rule', () => {
       expect(report.vitePeerDependencyRuleRemoved).toBe(false);
     });
 
+    it('keeps a user vite range in pnpm-workspace.yaml', () => {
+      writeVitePlusProject(tmpDir, [
+        'peerDependencyRules:',
+        '  allowAny:',
+        '    - vite',
+        '  allowedVersions:',
+        "    vite: '7 || 8'",
+      ]);
+      const report = createMigrationReport();
+
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir), report);
+
+      expect(readWorkspaceRules(tmpDir)).toEqual({ allowedVersions: { vite: '7 || 8' } });
+      expect(report.vitePeerDependencyRuleRemoved).toBe(true);
+      expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm)).toBe(false);
+    });
+
+    it('keeps a user vite range in package.json before pnpm 10.6.2', () => {
+      fs.writeFileSync(
+        path.join(tmpDir, 'package.json'),
+        JSON.stringify({
+          name: 'test',
+          devDependencies: { 'vite-plus': 'latest' },
+          pnpm: {
+            overrides: { react: '18.3.1' },
+            peerDependencyRules: { allowedVersions: { vite: '^8.0.0' } },
+          },
+        }),
+      );
+      const report = createMigrationReport();
+
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, '10.6.1'), report);
+
+      expect(readPackageJsonRules(tmpDir)).toEqual({ allowedVersions: { vite: '^8.0.0' } });
+      expect(report.vitePeerDependencyRuleRemoved).toBe(false);
+    });
+
     it('removes the vite rule from package.json before pnpm 10.6.2', () => {
       fs.writeFileSync(
         path.join(tmpDir, 'package.json'),
diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts
index b754a32c9d..74b99a732a 100644
--- a/packages/cli/src/migration/migrator/catalog.ts
+++ b/packages/cli/src/migration/migrator/catalog.ts
@@ -25,7 +25,8 @@ import {
   managedOverridePackages,
   managedPeerDependencyRuleKeys,
   mergeManagedPnpmPeerDependencyRules,
-  peerDependencyRulesHaveKey,
+  isStaleAllowedVersion,
+  peerDependencyRulesHaveStaleEntry,
   pruneLegacyWrapperAliases,
   removeManagedVitestEntry,
   removeYamlMapVitestEntry,
@@ -432,7 +433,9 @@ export function rewritePnpmWorkspaceYaml(
       allowedVersions = new YAMLMap, Scalar>();
     }
     for (const key of stalePeerRuleKeys) {
-      allowedVersions.delete(key);
+      if (isStaleAllowedVersion(key, allowedVersions.get(key), stalePeerRuleKeys)) {
+        allowedVersions.delete(key);
+      }
     }
     for (const key of peerRuleKeys) {
       // - vite: '*'
@@ -1439,13 +1442,15 @@ export function readPnpmWorkspacePeerDependencyRules(
 // Whether the root pnpm config (the package.json `pnpm` field or
 // pnpm-workspace.yaml) holds a `vite` peer rule.
 export function hasVitePeerDependencyRule(projectPath: string): boolean {
-  if (peerDependencyRulesHaveKey(readPnpmWorkspacePeerDependencyRules(projectPath), ['vite'])) {
+  if (
+    peerDependencyRulesHaveStaleEntry(readPnpmWorkspacePeerDependencyRules(projectPath), ['vite'])
+  ) {
     return true;
   }
   const pkg = readPackageJsonIfExists(path.join(projectPath, 'package.json')) as
     | { pnpm?: PnpmPackageJsonSettings }
     | undefined;
-  return peerDependencyRulesHaveKey(pkg?.pnpm?.peerDependencyRules, ['vite']);
+  return peerDependencyRulesHaveStaleEntry(pkg?.pnpm?.peerDependencyRules, ['vite']);
 }
 
 // Capture the `vite` peer rule before a migration step. Call the returned
diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
index 8e9262813a..8df7cf1bb7 100644
--- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
+++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
@@ -29,7 +29,7 @@ import {
   managedOverridePackages,
   managedPeerDependencyRuleKeys,
   mergeManagedPnpmPeerDependencyRules,
-  peerDependencyRulesHaveKey,
+  peerDependencyRulesHaveStaleEntry,
   migratePnpmSettingsToWorkspaceYaml,
   normalizeVitestPeerCatalogSpec,
   pnpmPackageJsonSettingsPending,
@@ -220,7 +220,9 @@ function pnpmPeerDependencyRulesSatisfyVitePlus(
   usesVitest: boolean,
 ): boolean {
   // A lingering managed `vitest` rule or a stale `vite` rule is NOT satisfied.
-  if (peerDependencyRulesHaveKey(peerDependencyRules, stalePeerDependencyRuleKeys(usesVitest))) {
+  if (
+    peerDependencyRulesHaveStaleEntry(peerDependencyRules, stalePeerDependencyRuleKeys(usesVitest))
+  ) {
     return false;
   }
   const allowAny = new Set(peerDependencyRules?.allowAny ?? []);
diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
index 35824336a7..70cd8df17c 100644
--- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts
+++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
@@ -559,17 +559,30 @@ export function stalePeerDependencyRuleKeys(usesVitest: boolean): string[] {
   return keys;
 }
 
-// Whether pnpm peerDependencyRules hold an `allowAny` or `allowedVersions` entry
-// for one of `keys`. Works on both the package.json `pnpm.peerDependencyRules`
-// JSON shape and the same shape read back from pnpm-workspace.yaml.
-export function peerDependencyRulesHaveKey(
+// Whether an `allowedVersions` entry is stale. Migration only ever wrote `'*'`
+// for `vite`, so another `vite` range is the user's own constraint and stays.
+export function isStaleAllowedVersion(
+  key: string,
+  value: unknown,
+  staleKeys: readonly string[],
+): boolean {
+  return staleKeys.includes(key) && (key !== 'vite' || value === '*');
+}
+
+// Whether pnpm peerDependencyRules hold a stale `allowAny` or `allowedVersions`
+// entry for one of `keys`. Works on both the package.json
+// `pnpm.peerDependencyRules` JSON shape and the same shape read back from
+// pnpm-workspace.yaml.
+export function peerDependencyRulesHaveStaleEntry(
   rules: { allowAny?: unknown; allowedVersions?: unknown } | null | undefined,
   keys: readonly string[],
 ): boolean {
-  return keys.some(
-    (key) =>
-      (Array.isArray(rules?.allowAny) && rules.allowAny.includes(key)) ||
-      (isPlainRecord(rules?.allowedVersions) && rules.allowedVersions[key] !== undefined),
+  return (
+    (Array.isArray(rules?.allowAny) && rules.allowAny.some((key) => keys.includes(key))) ||
+    (isPlainRecord(rules?.allowedVersions) &&
+      Object.entries(rules.allowedVersions).some(([key, value]) =>
+        isStaleAllowedVersion(key, value, keys),
+      ))
   );
 }
 
@@ -587,17 +600,21 @@ export function mergeManagedPnpmPeerDependencyRules(
   rules: PnpmPeerDependencyRules | undefined,
   usesVitest: boolean,
 ): PnpmPeerDependencyRules | undefined {
-  const stale = new Set(stalePeerDependencyRuleKeys(usesVitest));
+  const stale = stalePeerDependencyRuleKeys(usesVitest);
   const keys = managedPeerDependencyRuleKeys(usesVitest);
   const allowAny = [
     ...new Set([
-      ...(Array.isArray(rules?.allowAny) ? rules.allowAny : []).filter((key) => !stale.has(key)),
+      ...(Array.isArray(rules?.allowAny) ? rules.allowAny : []).filter(
+        (key) => !stale.includes(key),
+      ),
       ...keys,
     ]),
   ];
   const allowedVersions = {
     ...Object.fromEntries(
-      Object.entries(rules?.allowedVersions ?? {}).filter(([key]) => !stale.has(key)),
+      Object.entries(rules?.allowedVersions ?? {}).filter(
+        ([key, value]) => !isStaleAllowedVersion(key, value, stale),
+      ),
     ),
     ...Object.fromEntries(keys.map((key) => [key, '*'])),
   };
diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md
index 814b075f8c..6ab1d13899 100644
--- a/packages/core/BUNDLING.md
+++ b/packages/core/BUNDLING.md
@@ -420,6 +420,7 @@ The core version encodes `bundledVersions.vite`, so it differs from the `vite-pl
 
 `n` counts the stable releases on one Vite version, from 0 to 98. `k` counts the prereleases on one base. [`packages/tools/src/core-version.ts`](../tools/src/core-version.ts) implements the scheme:
 
-- `prepare_release.yml` runs `core-version.ts next` to select the next unpublished version from the registry.
+- `prepare_release.yml` runs `core-version.ts prepare` to select the next unpublished version from the registry. It writes that version and the `vite-plus` version (`vitePlusVersion`) to `package.json`.
+- `release.yml` runs `core-version.ts check --release`. It fails when the registry already holds the core version for a different `vite-plus` release.
 - `.github/scripts/upgrade-deps.ts` resets `version` to `n = 0` when the bundled Vite changes.
 - `__tests__/core-version.spec.ts` checks that the committed `version` encodes `bundledVersions.vite`.
diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
index 961aa5c73f..71a9ce9282 100644
--- a/packages/tools/src/__tests__/core-version.spec.ts
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'vitest';
 
 import {
   assertCoreVersionMatchesVite,
+  assertCoreVersionUnclaimed,
   decodeCoreVersion,
   encodeCoreVersion,
+  initialCoreVersion,
   nextCoreVersion,
 } from '../core-version.ts';
 
@@ -104,8 +106,15 @@ describe('nextCoreVersion', () => {
   });
 });
 
-function manifest(version: string, vite = '8.3.5') {
-  return { version, bundledVersions: { vite } };
+describe('initialCoreVersion', () => {
+  test('starts the counter for a stable and a prerelease Vite', () => {
+    expect(initialCoreVersion('8.4.2')).toBe('8.4.200');
+    expect(initialCoreVersion('9.0.0-beta.3')).toBe('9.0.0-beta.3.vp.0');
+  });
+});
+
+function manifest(version: string, vite = '8.3.5', vitePlusVersion?: string) {
+  return { version, bundledVersions: { vite }, vitePlusVersion };
 }
 
 describe('assertCoreVersionMatchesVite', () => {
@@ -129,21 +138,64 @@ describe('assertCoreVersionMatchesVite', () => {
     );
   });
 
-  test('rejects mixed prerelease and stable versions', () => {
-    expect(() => assertCoreVersionMatchesVite(manifest('8.3.500'), '1.5.0-rc.0')).toThrow(
-      'both be prereleases',
-    );
-    expect(() => assertCoreVersionMatchesVite(manifest('8.3.500-vp.0'), '1.5.0')).toThrow(
-      'both be prereleases',
-    );
+  test('allows a Vite prerelease with a stable vite-plus between releases', () => {
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('9.0.0-beta.3.vp.0', '9.0.0-beta.3'), '1.9.0'),
+    ).not.toThrow();
+  });
+
+  test('rejects mixed prerelease and stable versions for a release', () => {
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('8.3.500', '8.3.5', '1.5.0-rc.0'), '1.5.0-rc.0', {
+        release: true,
+      }),
+    ).toThrow('both be prereleases');
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('8.3.500-vp.0', '8.3.5', '1.5.0'), '1.5.0', {
+        release: true,
+      }),
+    ).toThrow('both be prereleases');
   });
 
   test('rejects the local build counter for a release', () => {
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.599'), '1.5.0', { release: true }),
+      assertCoreVersionMatchesVite(manifest('8.3.599', '8.3.5', '1.5.0'), '1.5.0', {
+        release: true,
+      }),
     ).toThrow('reserved for local builds');
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.598'), '1.5.0', { release: true }),
+      assertCoreVersionMatchesVite(manifest('8.3.598', '8.3.5', '1.5.0'), '1.5.0', {
+        release: true,
+      }),
     ).not.toThrow();
   });
+
+  test('requires the manifest to record the released vite-plus version', () => {
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('8.3.500'), '1.5.0', { release: true }),
+    ).toThrow('records vite-plus (none)');
+    expect(() =>
+      assertCoreVersionMatchesVite(manifest('8.3.500', '8.3.5', '1.4.0'), '1.5.0', {
+        release: true,
+      }),
+    ).toThrow('records vite-plus 1.4.0, but the release is vite-plus 1.5.0');
+  });
+});
+
+describe('assertCoreVersionUnclaimed', () => {
+  test('accepts an unpublished core version and a retry of the same release', () => {
+    expect(() => assertCoreVersionUnclaimed(undefined, '8.3.501', '1.5.1')).not.toThrow();
+    expect(() =>
+      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.501', '1.5.1'),
+    ).not.toThrow();
+  });
+
+  test('rejects a core version that another release published', () => {
+    expect(() =>
+      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.501', '1.6.0'),
+    ).toThrow('already published for vite-plus 1.5.1');
+    expect(() => assertCoreVersionUnclaimed({}, '8.3.500', '1.6.0')).toThrow(
+      'already published for vite-plus (unknown)',
+    );
+  });
 });
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
index 4917b71c2f..93635e5484 100644
--- a/packages/tools/src/core-version.ts
+++ b/packages/tools/src/core-version.ts
@@ -13,14 +13,18 @@
  * is reserved for local and CI builds, so they never collide with a published
  * version. `k` counts the Vite+ prereleases on one base.
  *
+ * A release records its `vite-plus` version in the core manifest
+ * (`vitePlusVersion`). The release check uses it to tell a retry of the same
+ * release from a core version that another release already published.
+ *
  * Usage:
- *   node packages/tools/src/core-version.ts next --vite-plus 
+ *   node packages/tools/src/core-version.ts prepare --vite-plus 
  *   node packages/tools/src/core-version.ts local
  *   node packages/tools/src/core-version.ts check [--release]
  */
 
 import { execFileSync } from 'node:child_process';
-import { readFileSync } from 'node:fs';
+import { readFileSync, writeFileSync } from 'node:fs';
 import { parseArgs } from 'node:util';
 
 export const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
@@ -78,6 +82,14 @@ export function encodeCoreVersion(vite: string, n: number): string {
   return `${major}.${minor}.${patch * 100 + n}`;
 }
 
+/**
+ * Returns the first core version for a bundled Vite version: counter 0 on a
+ * stable Vite, `.vp.0` on a Vite prerelease.
+ */
+export function initialCoreVersion(vite: string): string {
+  return isPrereleaseVersion(vite) ? `${vite}.${PRERELEASE_TAG}.0` : encodeCoreVersion(vite, 0);
+}
+
 /** Decodes a core version into the bundled Vite version and its counters. */
 export function decodeCoreVersion(core: string): DecodedCoreVersion {
   const { major, minor, patch, prerelease } = parseVersion(core);
@@ -175,13 +187,23 @@ export function nextCoreVersion({
   return `${base}-${PRERELEASE_TAG}.${k}`;
 }
 
+interface CoreManifest {
+  version: string;
+  bundledVersions?: { vite?: string };
+  vitePlusVersion?: string;
+}
+
 /**
- * Asserts that a core manifest encodes its bundled Vite version, and that the
- * core version is a prerelease exactly when the `vite-plus` version is one.
- * With `release`, also rejects the local build counter.
+ * Asserts that a core manifest encodes its bundled Vite version. With
+ * `release`, also asserts that:
+ * - the core version is a prerelease exactly when the `vite-plus` version is
+ *   one. Between releases, main can bundle a Vite prerelease with a stable
+ *   `vite-plus`.
+ * - the core version does not use the local build counter.
+ * - the manifest records this `vite-plus` version. `prepare` writes it.
  */
 export function assertCoreVersionMatchesVite(
-  pkg: { version: string; bundledVersions?: { vite?: string } },
+  pkg: CoreManifest,
   vitePlus: string,
   { release = false }: { release?: boolean } = {},
 ): void {
@@ -193,27 +215,53 @@ export function assertCoreVersionMatchesVite(
   if (decoded.vite !== vite) {
     throw new Error(
       `${CORE_PACKAGE_NAME}@${pkg.version} encodes Vite ${decoded.vite}, but it bundles Vite ${vite}. ` +
-        `Set its version to ${isPrereleaseVersion(vite) ? `${vite}.${PRERELEASE_TAG}.0` : encodeCoreVersion(vite, 0)}.`,
+        `Set its version to ${initialCoreVersion(vite)}.`,
     );
   }
+  if (!release) {
+    return;
+  }
   const isCorePrerelease = decoded.k !== undefined;
   if (isCorePrerelease !== isPrereleaseVersion(vitePlus)) {
     throw new Error(
       `${CORE_PACKAGE_NAME}@${pkg.version} and vite-plus@${vitePlus} must both be prereleases or both be stable`,
     );
   }
-  if (release && decoded.n === LOCAL_BUILD_COUNTER) {
+  if (decoded.n === LOCAL_BUILD_COUNTER) {
     throw new Error(
       `${CORE_PACKAGE_NAME}@${pkg.version} uses the counter ${LOCAL_BUILD_COUNTER}, which is reserved for local builds`,
     );
   }
+  if (pkg.vitePlusVersion !== vitePlus) {
+    throw new Error(
+      `${CORE_PACKAGE_NAME}@${pkg.version} records vite-plus ${pkg.vitePlusVersion ?? '(none)'}, but the release is vite-plus ${vitePlus}. ` +
+        'Run the Prepare Release workflow to select the core version.',
+    );
+  }
 }
 
-function readManifest(relativePath: string): {
-  version: string;
-  bundledVersions?: { vite?: string };
-} {
-  return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8'));
+/**
+ * Asserts that the registry does not already hold the core version for a
+ * different release. `published` is the published manifest of that version, or
+ * `undefined` when it is not published. A retry of the same release passes.
+ */
+export function assertCoreVersionUnclaimed(
+  published: { vitePlusVersion?: string } | undefined,
+  core: string,
+  vitePlus: string,
+): void {
+  if (published && published.vitePlusVersion !== vitePlus) {
+    throw new Error(
+      `${CORE_PACKAGE_NAME}@${core} is already published for vite-plus ${published.vitePlusVersion ?? '(unknown)'}. ` +
+        'Run the Prepare Release workflow again to select an unpublished core version.',
+    );
+  }
+}
+
+const CORE_MANIFEST_URL = new URL('../../core/package.json', import.meta.url);
+
+function readManifest(url: URL): CoreManifest {
+  return JSON.parse(readFileSync(url, 'utf8'));
 }
 
 function readPublishedCoreVersions(): string[] {
@@ -230,7 +278,25 @@ function readPublishedCoreVersions(): string[] {
   return versions;
 }
 
-function main(argv: string[]): void {
+async function readPublishedCoreManifest(
+  core: string,
+): Promise<{ vitePlusVersion?: string } | undefined> {
+  const registry = (process.env.PUBLISH_REGISTRY ?? 'https://registry.npmjs.org').replace(
+    /\/+$/,
+    '',
+  );
+  const url = `${registry}/${CORE_PACKAGE_NAME.replace('/', '%2f')}/${core}`;
+  const response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
+  if (response.status === 404) {
+    return undefined;
+  }
+  if (!response.ok) {
+    throw new Error(`Registry returned HTTP ${response.status} for ${url}`);
+  }
+  return (await response.json()) as { vitePlusVersion?: string };
+}
+
+async function main(argv: string[]): Promise {
   const { positionals, values } = parseArgs({
     args: argv,
     allowPositionals: true,
@@ -239,38 +305,48 @@ function main(argv: string[]): void {
       release: { type: 'boolean', default: false },
     },
   });
-  const core = readManifest('../../core/package.json');
+  const core = readManifest(CORE_MANIFEST_URL);
   const vite = core.bundledVersions?.vite;
   if (!vite) {
     throw new Error(`packages/core/package.json has no bundledVersions.vite`);
   }
 
   switch (positionals[0]) {
-    case 'next': {
+    case 'prepare': {
       const vitePlus = values['vite-plus'];
       if (!vitePlus) {
-        throw new Error('next requires --vite-plus ');
+        throw new Error('prepare requires --vite-plus ');
       }
-      console.log(nextCoreVersion({ vite, vitePlus, published: readPublishedCoreVersions() }));
+      core.version = nextCoreVersion({ vite, vitePlus, published: readPublishedCoreVersions() });
+      core.vitePlusVersion = vitePlus;
+      writeFileSync(CORE_MANIFEST_URL, `${JSON.stringify(core, null, 2)}\n`);
+      console.log(core.version);
       break;
     }
     case 'local':
       console.log(encodeCoreVersion(vite, LOCAL_BUILD_COUNTER));
       break;
     case 'check': {
-      const cli = readManifest('../../cli/package.json');
+      const cli = readManifest(new URL('../../cli/package.json', import.meta.url));
       assertCoreVersionMatchesVite(core, cli.version, { release: values.release });
+      if (values.release) {
+        assertCoreVersionUnclaimed(
+          await readPublishedCoreManifest(core.version),
+          core.version,
+          cli.version,
+        );
+      }
       console.log(`${CORE_PACKAGE_NAME}@${core.version} encodes Vite ${vite}`);
       break;
     }
     default:
-      throw new Error(`Unknown command: ${positionals[0] ?? ''}. Use next, local, or check.`);
+      throw new Error(`Unknown command: ${positionals[0] ?? ''}. Use prepare, local, or check.`);
   }
 }
 
 if (import.meta.main) {
   try {
-    main(process.argv.slice(2));
+    await main(process.argv.slice(2));
   } catch (error) {
     console.error(`[core-version] ${(error as Error).message}`);
     process.exit(1);
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index f50d9883aa..25728d0377 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -224,9 +224,11 @@ accepts the warnings for that period. Migration keeps the pnpm peer rule for
 prerelease core versions (see Migration).
 
 A Vite+ stable release bundles a stable Vite. Only a Vite+ prerelease may
-bundle a Vite prerelease. CI rejects a core prerelease on a `vite-plus`
-version without a prerelease identifier, and a stable core on a `vite-plus`
-prerelease.
+bundle a Vite prerelease. The release check rejects a core prerelease on a
+`vite-plus` version without a prerelease identifier, and a stable core on a
+`vite-plus` prerelease. Between releases, main can bundle a Vite prerelease
+with a stable `vite-plus` version (for example core `9.0.0-beta.3.vp.0` with
+`vite-plus` `1.9.0`), so this pairing check runs only for a release.
 
 Since `v1.0.0`, Vite+ published no prereleases. The last ones are
 `v1.0.0-rc.0` and `v1.0.0-rc.1`. The prerelease rule matters mostly for the
@@ -265,18 +267,24 @@ lockfile. It does not read the core version.
 
 ### Where the version is computed and checked
 
-Add a small module, `packages/tools/src/core-version.ts`, with four pure
+Add a small module, `packages/tools/src/core-version.ts`, with pure
 functions and a CLI entry:
 
 - `encodeCoreVersion(vite, n)` returns the stable core version.
+- `initialCoreVersion(vite)` returns the first core version for a bundled
+  Vite: `n = 0` on a stable Vite, `.vp.0` on a Vite prerelease.
 - `decodeCoreVersion(core)` returns `{ vite, n, k }` or an error.
 - `nextCoreVersion({ vite, vitePlus, published })` returns the core version
   for a release. Its inputs are the bundled Vite version, the `vite-plus`
   version, and the published core versions. It applies the counter rules
   below.
-- `assertCoreVersionMatchesVite(pkg, vitePlus)` asserts that `pkg.version`
-  decodes to `pkg.bundledVersions.vite`. It also asserts that the core
-  version is a prerelease only when the `vite-plus` version is a prerelease.
+- `assertCoreVersionMatchesVite(pkg, vitePlus, { release })` asserts that
+  `pkg.version` decodes to `pkg.bundledVersions.vite`. With `release`, it also
+  asserts the prerelease pairing, rejects `n = 99`, and requires
+  `pkg.vitePlusVersion` to equal the released `vite-plus` version.
+- `assertCoreVersionUnclaimed(published, core, vitePlus)` rejects a core
+  version that the registry already holds for a different `vite-plus`
+  version. A retry of the same release passes.
 
 The name `checkCoreVersion` is not used, because
 `packages/cli/src/resolve-core.ts` already defines a function with that name
@@ -298,15 +306,26 @@ The committed `packages/core/package.json` must always encode the committed
 
 1. `.github/scripts/upgrade-deps.ts` already reads
    `packages/core/package.json` to sync `postcss` and `fsevents`. After a
-   bundled Vite change it sets `version` to `encodeCoreVersion(vite, 0)`.
+   bundled Vite change it sets `version` to `initialCoreVersion(vite)`.
    Renovate ignores `vite` (`.github/renovate.json`), so this script is the
    only automated path that changes the bundled Vite.
-2. `prepare_release.yml` computes the release version. It reads the
+2. `prepare_release.yml` runs `core-version.ts prepare`. It reads the
    published core versions
-   (`npm view @voidzero-dev/vite-plus-core versions --json`) and calls
-   `nextCoreVersion` with the input `vite-plus` version. It then writes the
-   result with the existing `update_json` helper and prints both versions in
-   the release PR body.
+   (`npm view @voidzero-dev/vite-plus-core versions --json`), calls
+   `nextCoreVersion` with the input `vite-plus` version, and writes the
+   result and `vitePlusVersion` to the core manifest. The workflow prints
+   both versions in the release PR body.
+
+The counter comes from the registry when the release PR is prepared, so it
+can be stale at publish time. Two release PRs prepared before either
+publishes get the same core version, and a manual `vite-plus` bump skips the
+selection. The publish step skips a version that is already published, so
+the CLI could then pin a core tarball from another commit. The
+`vitePlusVersion` field closes this gap: `release.yml` runs
+`core-version.ts check --release`, which reads the published manifest of the
+core version and fails unless the version is unpublished or was published for
+the same `vite-plus` version. The field also maps a published core back to
+its `vite-plus` release.
 
 A unit test in `packages/core/__tests__/` runs `assertCoreVersionMatchesVite`
 against the committed manifest. It skips the check when the version starts
@@ -418,13 +437,16 @@ Peer rules change only for pnpm:
   `rewritePnpmWorkspaceYaml` in `catalog.ts`, and the writers in
   `orchestrators.ts` and `catalog.ts` stop adding `vite` to `allowAny` and
   `allowedVersions` when the target core version is a stable encoded version
-  (major 8 or higher, no prerelease identifier). They remove an existing
-  `vite` entry from both maps. They delete an empty `peerDependencyRules`
-  object or YAML map. Other entries stay, including `vitest`.
-- The writers cannot tell whether an earlier migration or the user wrote a
-  `vite` entry, so they remove every `vite` entry. The only purpose of that
-  entry is to accept the `1.x` alias. `vp migrate` prints one line that names
-  the removed rule, and `docs/guide/migrate-rules.md` describes the removal.
+  (major 8 or higher, no prerelease identifier). They remove `vite` from
+  `allowAny` and remove `allowedVersions.vite` when its value is `'*'`. They
+  delete an empty `peerDependencyRules` object or YAML map. Other entries
+  stay, including `vitest`.
+- The writers cannot tell whether an earlier migration or the user wrote
+  `allowAny: [vite]` or `allowedVersions.vite: '*'`, so they remove both. The
+  only purpose of those entries is to accept the `1.x` alias. Migration only
+  ever wrote `'*'`, so another `allowedVersions.vite` range is the user's own
+  constraint and stays. `vp migrate` prints one line that names the removed
+  rule, and `docs/guide/migrate-rules.md` describes the removal.
 - When the target core version is not a stable encoded version (a preview
   `0.0.0-commit.` or a Vite+ prerelease such as `8.4.0-vp.0`), the
   writers keep the current behavior and write the `vite` rule.

From 394a7809c7f96837579d96c0af53df5e17f0ac30 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 10:11:18 +0800
Subject: [PATCH 08/24] docs: cover follow-ups from the core version review

- Read the matching core alias with `npm view vite-plus@
  dependencies.vite` when updating manually, and explain how to handle
  dependency bot updates that change only one of the two packages.
- Run the core release check in the release check job too, so a stale or
  duplicate core version fails before the build.
- Note that main can hold an unpublished core version, and link the CLI's
  stable-core rule to the version scheme module.

Co-Authored-By: Claude Opus 5.5 
---
 .github/workflows/release.yml                 | 13 +++++++++++++
 CONTRIBUTING.md                               |  2 ++
 docs/guide/upgrade-project.md                 | 13 ++++++++++++-
 packages/cli/src/migration/migrator/shared.ts |  3 +++
 4 files changed, 30 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d671da3207..29343b0246 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -40,6 +40,19 @@ jobs:
         id: core-version
         run: echo "version=$(node -p "require('./packages/core/package.json').version")" >> "$GITHUB_OUTPUT"
 
+      # Fail before the build and the approval wait when the core version is
+      # stale or another release already published it. The Release job checks
+      # again before it publishes.
+      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+        if: steps.version.outputs.changed == 'true'
+        with:
+          node-version-file: .node-version
+          package-manager-cache: false
+
+      - name: Check core version
+        if: steps.version.outputs.changed == 'true'
+        run: node packages/tools/src/core-version.ts check --release
+
   build-rust:
     name: Build bindings and binaries
     needs: check
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7ef0bb5d8b..7261ed3774 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -184,6 +184,8 @@ pnpm local-registry --pack --serve
 
 `pnpm local-registry:ps` lists any registry processes still running; `pnpm local-registry:kill` stops them all and removes their leftover temp caches. See the [tool documentation](packages/tools/README.md#local-npm-registry) for implementation details and test integration.
 
+The core package has its own version, which encodes the bundled Vite version (see [Core Version](packages/core/BUNDLING.md#core-version)). Between releases, `packages/core/package.json` can hold a version that npm does not have yet, for example after a bundled Vite upgrade. A locally built `vp create` or `vp migrate` without the local registry then writes a `vite` alias that npm cannot install. Use the local registry, which serves the packed core at that version.
+
 ### Global CLI (Rust) changes
 
 `pnpm link` only swaps the JS side; the `vp` binary on `PATH` (and the Rust-backed commands it handles directly, such as package-manager commands) is still the installed binary. For changes to the Rust global CLI (`crates/`), follow [the source installation steps](#install-the-vite-global-cli-from-source-code), and combine with `pnpm link` when the change spans both layers.
diff --git a/docs/guide/upgrade-project.md b/docs/guide/upgrade-project.md
index efec5a0d4f..969bf6e6fe 100644
--- a/docs/guide/upgrade-project.md
+++ b/docs/guide/upgrade-project.md
@@ -18,7 +18,18 @@ On a project that is already on Vite+, migrate does a toolchain version upgrade
 
 ## Manually Updating
 
-Update `vite-plus` and the `vite` alias to `@voidzero-dev/vite-plus-core` together. The two packages have different versions: the core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`). After you update `vite-plus`, set the alias to the version that `vp toolchain vite-plus-core` shows. Update these entries wherever they are declared in your workspace, including overrides or catalogs, then install dependencies to refresh the lockfile. Also [update the Vitest pin](#updating-the-vitest-pin) to match the bundled version.
+Update `vite-plus` and the `vite` alias to `@voidzero-dev/vite-plus-core` together. The two packages have different versions: the core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`). Read the alias that a `vite-plus` version uses before you install it:
+
+```bash
+npm view vite-plus@ dependencies.vite
+# npm:@voidzero-dev/vite-plus-core@
+```
+
+Set the `vite` alias to that value. Update these entries wherever they are declared in your workspace, including overrides or catalogs, then install dependencies to refresh the lockfile. Also [update the Vitest pin](#updating-the-vitest-pin) to match the bundled version. After the install, `vp toolchain vite-plus-core` shows the same core version.
+
+### Dependency Bots
+
+Dependabot and Renovate see `vite-plus` and the `vite` alias as two separate dependencies with different versions. A bot can update one without the other. Vite+ then stops before it runs a Vite command and asks you to run `vp migrate`. Before you merge a bot update, check that the `vite` alias matches the output of `npm view vite-plus@ dependencies.vite`. If it does not match, run `vp migrate` on the bot's branch. Migrate pins the matching alias, the overrides, and the Vitest version.
 
 Without the global CLI, run the `vp` commands on this page through your package manager, for example `pnpm exec vp toolchain vitest`.
 
diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts
index 17ba284ad7..2f64783922 100644
--- a/packages/cli/src/migration/migrator/shared.ts
+++ b/packages/cli/src/migration/migrator/shared.ts
@@ -226,6 +226,9 @@ export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGE
  * (`0.0.0-commit.`), a Vite+ prerelease (`8.4.0-vp.0` fails `^8.0.0`), a
  * `1.x` core from before the alignment, a dist-tag, or a `file:` or URL spec
  * whose version is unknown.
+ *
+ * Major 8 is the first aligned core major. `packages/tools/src/core-version.ts`
+ * owns the version scheme; the CLI cannot import it, so keep the two in sync.
  */
 export function vitePeerDependencyRuleRequired(
   viteSpec: string | undefined = VITE_PLUS_OVERRIDE_PACKAGES.vite,

From bc9bf3c7ee5bc6c6cb2e8d38591e6f428ed091e7 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 10:32:48 +0800
Subject: [PATCH 09/24] refactor(core): use a patch factor of 1000 for the core
 version

Follow the oxlint-tsgolint convention, which Vite+ bundles: the core patch
is `vitePatch * 1000 + n`, with `n` from 0. Core 8.3.5000 is the first
release on Vite 8.3.5, and local and CI builds use counter 999 (8.3.5999).
The counter now allows 999 stable releases per Vite patch.

The RFC also compares the `.vp.` prerelease form with encoding the
counter in the Vite prerelease number (`9.0.0-beta.3000`). The encoded form
passes prerelease floors such as `^9.0.0-beta.5` by mistake, so the RFC
keeps `.vp.`.

Co-Authored-By: Claude Opus 5.5 
---
 .claude/skills/release-manager/SKILL.md       |   2 +-
 .../scripts/__tests__/homebrew-formula.mjs    |   4 +-
 .../scripts/__tests__/upgrade-deps.spec.ts    |  18 +-
 .github/workflows/reusable-ecosystem-ci.yml   |   2 +-
 .github/workflows/test-vp-create.yml          |   2 +-
 MAINTENANCE.md                                |   2 +-
 README.md                                     |   2 +-
 docs/guide/local-cli.md                       |   2 +-
 docs/guide/migrate-rules.md                   |   2 +-
 docs/guide/upgrade-project.md                 |   2 +-
 packages/cli/README.md                        |   2 +-
 .../cli/src/__tests__/resolve-core.spec.ts    |   8 +-
 .../vite-peer-dependency-rule.spec.ts         |   6 +-
 packages/cli/src/migration/migrator/shared.ts |   2 +-
 .../src/sync-versions/__tests__/plan.spec.ts  |  10 +-
 .../sync-versions/__tests__/protocol.spec.ts  |   6 +-
 .../cli/src/utils/__tests__/constants.spec.ts |   6 +-
 packages/core/BUNDLING.md                     |  16 +-
 packages/core/package.json                    |   2 +-
 .../tools/src/__tests__/core-version.spec.ts  |  58 +++----
 packages/tools/src/core-version.ts            |  20 ++-
 rfcs/core-version-alignment.md                | 159 +++++++++++-------
 22 files changed, 190 insertions(+), 143 deletions(-)

diff --git a/.claude/skills/release-manager/SKILL.md b/.claude/skills/release-manager/SKILL.md
index 1acf666c73..88da19434c 100644
--- a/.claude/skills/release-manager/SKILL.md
+++ b/.claude/skills/release-manager/SKILL.md
@@ -48,7 +48,7 @@ gh workflow run prepare_release.yml --repo voidzero-dev/vite-plus -f version=X.Y
 
 The workflow bumps `packages/cli/package.json`, `packages/core/package.json`, `packages/prompts/package.json`, `packages/cli/binding/Cargo.toml`, and `crates/vp_global_cli/Cargo.toml`, refreshes `Cargo.lock`, and opens a PR titled `release: vX.Y.Z` from branch `release/vX.Y.Z`. The PR body ends with `Merging this PR will trigger the release workflow.` and that line must survive every later edit.
 
-`@voidzero-dev/vite-plus-core` does not use `X.Y.Z`. Its version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`; see [Core Version](../../../packages/core/BUNDLING.md#core-version)). The workflow selects it from the registry and names it in the first line of the PR body. In the steps below, `` is that version. Read it from the release branch with `node -p "require('./packages/core/package.json').version"`.
+`@voidzero-dev/vite-plus-core` does not use `X.Y.Z`. Its version encodes the bundled Vite version (for example, `8.3.5000` for Vite `8.3.5`; see [Core Version](../../../packages/core/BUNDLING.md#core-version)). The workflow selects it from the registry and names it in the first line of the PR body. In the steps below, `` is that version. Read it from the release branch with `node -p "require('./packages/core/package.json').version"`.
 
 ## 2. Sync release versions (required every release)
 
diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs
index b78520b545..784eab93c2 100644
--- a/.github/scripts/__tests__/homebrew-formula.mjs
+++ b/.github/scripts/__tests__/homebrew-formula.mjs
@@ -153,9 +153,9 @@ await test('reads the core version from the published CLI alias', async () => {
   assert.equal(
     await publishedCoreVersion(
       version,
-      manifest({ vite: 'npm:@voidzero-dev/vite-plus-core@8.3.501' }),
+      manifest({ vite: 'npm:@voidzero-dev/vite-plus-core@8.3.5001' }),
     ),
-    '8.3.501',
+    '8.3.5001',
   );
   await assert.rejects(
     publishedCoreVersion(version, manifest({ vite: '^8.0.0' })),
diff --git a/.github/scripts/__tests__/upgrade-deps.spec.ts b/.github/scripts/__tests__/upgrade-deps.spec.ts
index f3ab32fc49..64d506942c 100644
--- a/.github/scripts/__tests__/upgrade-deps.spec.ts
+++ b/.github/scripts/__tests__/upgrade-deps.spec.ts
@@ -240,7 +240,7 @@ function createSyncFixture() {
   const metaDir = join(root, 'meta');
   const versionsPath = join(metaDir, 'versions.json');
   writeJson(corePath, {
-    version: '8.3.501',
+    version: '8.3.5001',
     dependencies: { 'yuku-parser': '^0.9.3', postcss: '^8.5.6' },
     optionalDependencies: { fsevents: '~2.3.3' },
     devDependencies: { 'rolldown-plugin-dts': '^0.28.0' },
@@ -295,7 +295,7 @@ test('pins the bundled parser, syncs Vite ranges, and preserves upgrade metadata
   run();
   expect(readJson(corePath)).toEqual({
     // The bundled Vite did not change, so the release counter stays.
-    version: '8.3.501',
+    version: '8.3.5001',
     dependencies: { 'yuku-parser': '0.10.3', postcss: '^8.5.28' },
     optionalDependencies: { fsevents: '~2.4.0' },
     devDependencies: { 'rolldown-plugin-dts': '^0.28.0' },
@@ -334,22 +334,22 @@ test('restarts the core version counter when the bundled Vite changes', () => {
   const { corePath, versionsPath, metaDir, run } = fixture;
   writeJson(fixture.vitePath, { ...readJson(fixture.vitePath), version: '8.4.2' });
   run();
-  expect(readJson(corePath).version).toBe('8.4.200');
+  expect(readJson(corePath).version).toBe('8.4.2000');
   expect(readJson(corePath).bundledVersions).toEqual({ vite: '8.4.2' });
   expect(readJson(versionsPath)['@voidzero-dev/vite-plus-core']).toEqual({
-    old: '8.3.501',
-    new: '8.4.200',
+    old: '8.3.5001',
+    new: '8.4.2000',
   });
   expect(readFileSync(join(metaDir, 'pr-body.md'), 'utf8')).toContain(
-    '| `@voidzero-dev/vite-plus-core` | `8.3.501` | `8.4.200` |',
+    '| `@voidzero-dev/vite-plus-core` | `8.3.5001` | `8.4.2000` |',
   );
 
   // A second pass keeps the recorded old version and the encoded version.
   run();
-  expect(readJson(corePath).version).toBe('8.4.200');
+  expect(readJson(corePath).version).toBe('8.4.2000');
   expect(readJson(versionsPath)['@voidzero-dev/vite-plus-core']).toEqual({
-    old: '8.3.501',
-    new: '8.4.200',
+    old: '8.3.5001',
+    new: '8.4.2000',
   });
 });
 
diff --git a/.github/workflows/reusable-ecosystem-ci.yml b/.github/workflows/reusable-ecosystem-ci.yml
index 23f2bfd7c6..743a956650 100644
--- a/.github/workflows/reusable-ecosystem-ci.yml
+++ b/.github/workflows/reusable-ecosystem-ci.yml
@@ -86,7 +86,7 @@ jobs:
           # (including bun) resolving the standard
           # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin
           # the CLI to 0.0.0 and core to its local version (release counter
-          # 99, for example 8.3.599) so a correctly installed local build is
+          # 999, for example 8.3.5999) so a correctly installed local build is
           # always distinguishable from any published version
           # (verify-install.ts asserts it), even on a release commit that
           # leaves packages/{core,cli} at a published version. The local core
diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml
index 5724af3d6c..bdd562a0f3 100644
--- a/.github/workflows/test-vp-create.yml
+++ b/.github/workflows/test-vp-create.yml
@@ -108,7 +108,7 @@ jobs:
           # (including bun) resolving the standard
           # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin
           # the CLI to 0.0.0 and core to its local version (release counter
-          # 99, for example 8.3.599) so a correctly installed local build is
+          # 999, for example 8.3.5999) so a correctly installed local build is
           # always distinguishable from any published version, even on a
           # release commit that leaves packages/{core,cli} at a published
           # version. The local core version still encodes the bundled Vite,
diff --git a/MAINTENANCE.md b/MAINTENANCE.md
index 6edaf31e23..037ed4986c 100644
--- a/MAINTENANCE.md
+++ b/MAINTENANCE.md
@@ -24,7 +24,7 @@ pnpm add vite-plus@0.0.0-commit.
 
 The [release workflow](.github/workflows/release.yml) publishes platform packages before `@voidzero-dev/vite-plus-core`, then publishes `vite-plus`. It checks that the required package versions and tarballs are available before continuing to dependent steps.
 
-`@voidzero-dev/vite-plus-core` has its own version, which encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`). The [prepare release workflow](.github/workflows/prepare_release.yml) selects it from the registry and prints both versions in the release PR. The platform packages, `vite-plus`, and `create-vite-plus` use the `vite-plus` version. See [Core Version](packages/core/BUNDLING.md#core-version).
+`@voidzero-dev/vite-plus-core` has its own version, which encodes the bundled Vite version (for example, `8.3.5000` for Vite `8.3.5`). The [prepare release workflow](.github/workflows/prepare_release.yml) selects it from the registry and prints both versions in the release PR. The platform packages, `vite-plus`, and `create-vite-plus` use the `vite-plus` version. See [Core Version](packages/core/BUNDLING.md#core-version).
 
 The [propagation helper](.github/scripts/wait-for-npm-packages.ts) defaults to a 10-minute timeout and an additional 60-second wait for CDN propagation after availability checks pass.
 
diff --git a/README.md b/README.md
index 9e08310875..ec0f5e7a82 100644
--- a/README.md
+++ b/README.md
@@ -203,7 +203,7 @@ If you are manually migrating a project to Vite+, install these dev dependencies
 vp install -D vite-plus
 ```
 
-Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the core version that the installed `vite-plus` uses, shown by `vp toolchain vite-plus-core`. The core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`), so package managers check `vite` peer ranges against the bundled Vite. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
+Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the core version that the installed `vite-plus` uses, shown by `vp toolchain vite-plus-core`. The core version encodes the bundled Vite version (for example, `8.3.5000` for Vite `8.3.5`), so package managers check `vite` peer ranges against the bundled Vite. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
 
 ```json
 "overrides": {
diff --git a/docs/guide/local-cli.md b/docs/guide/local-cli.md
index 5a055eecfd..532a1e3b23 100644
--- a/docs/guide/local-cli.md
+++ b/docs/guide/local-cli.md
@@ -113,7 +113,7 @@ If you also declare `vitest` directly, set its dependency entry to the exact ver
 Dependencies and plugins can import `vite` or `vitest` directly, even when your own code imports from `vite-plus`. These overrides align their dependencies with the toolchain Vite+ uses:
 
 - The `vite` alias directs those imports to Vite+'s core package. Separate Vite instances can break runtime identity checks: [issue #1391](https://github.com/voidzero-dev/vite-plus/issues/1391) reported TanStack Start returning 404s because an `instanceof` check crossed two copies. [PR #2617](https://github.com/voidzero-dev/vite-plus/pull/2617) addresses the CLI side by sharing Vite through the same alias.
-- The core version encodes the bundled Vite version. Its major and minor are the Vite major and minor, and its patch is the Vite patch times 100 plus a release counter. For example, core `8.3.500` bundles Vite `8.3.5`. Package managers compare this version with the `vite` peer ranges of plugins, so a plugin that supports the bundled Vite passes the check, and a plugin that does not support it still gets a peer warning. [Issue #2806](https://github.com/voidzero-dev/vite-plus/issues/2806) describes the earlier `1.x` versions, which failed every check.
+- The core version encodes the bundled Vite version. Its major and minor are the Vite major and minor, and its patch is the Vite patch times 1000 plus a release counter, the same convention that `oxlint-tsgolint` uses for TypeScript. For example, core `8.3.5000` bundles Vite `8.3.5`. Package managers compare this version with the `vite` peer ranges of plugins, so a plugin that supports the bundled Vite passes the check, and a plugin that does not support it still gets a peer warning. [Issue #2806](https://github.com/voidzero-dev/vite-plus/issues/2806) describes the earlier `1.x` versions, which failed every check.
 - The exact `vitest` pin keeps dependencies and `vp test` on the same Vitest version, avoiding separate mocks, `expect` instances, and runner state. [PR #2365](https://github.com/voidzero-dev/vite-plus/pull/2365) documents this requirement for manual installation.
 
 Keep the core alias aligned with the core version of your installed `vite-plus` and update the Vitest pin to match its bundled version when upgrading. [Issue #2356](https://github.com/voidzero-dev/vite-plus/issues/2356) describes how dependency bots can update these packages independently and leave incompatible versions installed together.
diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md
index c938c2770d..8d9d785717 100644
--- a/docs/guide/migrate-rules.md
+++ b/docs/guide/migrate-rules.md
@@ -379,7 +379,7 @@ Migration converts the Volta `volta.node` pin to `.node-version`, because Vite+
 - pnpm accepts the logical default catalog as either top-level `catalog` or `catalogs.default`, but not both. Migration preserves the existing form and never creates the other form beside it.
 - When an existing named catalog already owns `vite-plus`, `vite`, or `vitest`, migration reuses that managed toolchain catalog for newly added dependencies and overrides. It creates a top-level default catalog only when no managed or default catalog can be reused.
 
-**Peer rules.** The core version encodes the bundled Vite version: core `8.3.500` bundles Vite `8.3.5` (see the [RFC](https://github.com/voidzero-dev/vite-plus/blob/main/rfcs/core-version-alignment.md)). pnpm checks `vite` peer ranges against the `vite` alias, so a plugin that supports the bundled Vite passes, and a plugin that does not support it fails.
+**Peer rules.** The core version encodes the bundled Vite version: core `8.3.5000` bundles Vite `8.3.5` (see the [RFC](https://github.com/voidzero-dev/vite-plus/blob/main/rfcs/core-version-alignment.md)). pnpm checks `vite` peer ranges against the `vite` alias, so a plugin that supports the bundled Vite passes, and a plugin that does not support it fails.
 
 - Migration does not add `vite` to `peerDependencyRules.allowAny` or `peerDependencyRules.allowedVersions`.
 - Migration removes `vite` from `allowAny` and removes `allowedVersions.vite` when its value is `'*'`, also when you wrote the entry. The only purpose of those entries was to accept the `1.x` core alias. Another `allowedVersions.vite` range, such as `'7 || 8'`, stays. Migration removes a field, or `peerDependencyRules`, when no entry remains. Other entries stay. The migrate summary names the removed rule.
diff --git a/docs/guide/upgrade-project.md b/docs/guide/upgrade-project.md
index 969bf6e6fe..22ec3523d2 100644
--- a/docs/guide/upgrade-project.md
+++ b/docs/guide/upgrade-project.md
@@ -18,7 +18,7 @@ On a project that is already on Vite+, migrate does a toolchain version upgrade
 
 ## Manually Updating
 
-Update `vite-plus` and the `vite` alias to `@voidzero-dev/vite-plus-core` together. The two packages have different versions: the core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`). Read the alias that a `vite-plus` version uses before you install it:
+Update `vite-plus` and the `vite` alias to `@voidzero-dev/vite-plus-core` together. The two packages have different versions: the core version encodes the bundled Vite version (for example, `8.3.5000` for Vite `8.3.5`). Read the alias that a `vite-plus` version uses before you install it:
 
 ```bash
 npm view vite-plus@ dependencies.vite
diff --git a/packages/cli/README.md b/packages/cli/README.md
index c75fd08e08..9ae8bdd73a 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -198,7 +198,7 @@ If you are manually migrating a project to Vite+, install these dev dependencies
 vp install -D vite-plus
 ```
 
-Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the core version that the installed `vite-plus` uses, shown by `vp toolchain vite-plus-core`. The core version encodes the bundled Vite version (for example, `8.3.500` for Vite `8.3.5`), so package managers check `vite` peer ranges against the bundled Vite. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
+Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`, setting `` to the core version that the installed `vite-plus` uses, shown by `vp toolchain vite-plus-core`. The core version encodes the bundled Vite version (for example, `8.3.5000` for Vite `8.3.5`), so package managers check `vite` peer ranges against the bundled Vite. Pin `vitest` to ``, using the bundled version shown by `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states:
 
 ```json
 "overrides": {
diff --git a/packages/cli/src/__tests__/resolve-core.spec.ts b/packages/cli/src/__tests__/resolve-core.spec.ts
index 86cb6dfc2a..948938944a 100644
--- a/packages/cli/src/__tests__/resolve-core.spec.ts
+++ b/packages/cli/src/__tests__/resolve-core.spec.ts
@@ -99,7 +99,7 @@ describe('resolveCore', () => {
   });
 
   it.each([
-    ['0.0.0', '8.3.599'],
+    ['0.0.0', '8.3.5999'],
     ['0.0.0-commit.1234567', '0.0.0-commit.1234567'],
   ])('uses the installed CLI manifest after repacking as %s', (version, coreVersion) => {
     writeCli(join(root, 'cli'), version, `npm:@voidzero-dev/vite-plus-core@${coreVersion}`);
@@ -112,11 +112,11 @@ describe('resolveCore', () => {
   it.each(['project', 'bundled'])(
     'still rejects a mismatched %s core after repacking the CLI',
     (location) => {
-      writeCli(join(root, 'cli'), '0.0.0', 'npm:@voidzero-dev/vite-plus-core@8.3.599');
-      writeCore(bundled, '@voidzero-dev/vite-plus-core', '8.3.599');
+      writeCli(join(root, 'cli'), '0.0.0', 'npm:@voidzero-dev/vite-plus-core@8.3.5999');
+      writeCore(bundled, '@voidzero-dev/vite-plus-core', '8.3.5999');
       writeCore(location === 'project' ? projectCore : bundled);
       expect(() => resolveCore('', project, cliModule)).toThrow(
-        `Expected @voidzero-dev/vite-plus-core@8.3.599, but found @voidzero-dev/vite-plus-core@${CORE_VERSION}`,
+        `Expected @voidzero-dev/vite-plus-core@8.3.5999, but found @voidzero-dev/vite-plus-core@${CORE_VERSION}`,
       );
     },
   );
diff --git a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
index a840367953..ba9207575a 100644
--- a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
+++ b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
@@ -26,7 +26,7 @@ const {
   vitePeerDependencyRuleRequired,
 } = await import('../migrator.js');
 
-const STABLE_CORE = 'npm:@voidzero-dev/vite-plus-core@8.3.500';
+const STABLE_CORE = 'npm:@voidzero-dev/vite-plus-core@8.3.5000';
 const PREVIEW_CORE = 'npm:@voidzero-dev/vite-plus-core@0.0.0-commit.0c515e3';
 
 type PeerDependencyRules = { allowAny?: string[]; allowedVersions?: Record };
@@ -103,9 +103,9 @@ describe('vitePeerDependencyRuleRequired', () => {
     ['npm:@voidzero-dev/vite-plus-core@0.0.0', true],
     [PREVIEW_CORE, true],
     ['npm:@voidzero-dev/vite-plus-core@1.4.0', true],
-    ['npm:@voidzero-dev/vite-plus-core@8.3.500+build.1', true],
+    ['npm:@voidzero-dev/vite-plus-core@8.3.5000+build.1', true],
     ['npm:@voidzero-dev/vite-plus-core@latest', true],
-    ['file:../voidzero-dev-vite-plus-core-8.3.599.tgz', true],
+    ['file:../voidzero-dev-vite-plus-core-8.3.5999.tgz', true],
     ['https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891', true],
   ])('%s requires the rule: %s', (spec, required) => {
     expect(vitePeerDependencyRuleRequired(spec)).toBe(required);
diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts
index 2f64783922..6d45114212 100644
--- a/packages/cli/src/migration/migrator/shared.ts
+++ b/packages/cli/src/migration/migrator/shared.ts
@@ -219,7 +219,7 @@ export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGE
  * Whether pnpm still needs the managed `vite` peer rule (`allowAny: [vite]`,
  * `allowedVersions: { vite: '*' }`) for the given core alias.
  *
- * A stable core version encodes the bundled Vite version (`8.3.500` bundles
+ * A stable core version encodes the bundled Vite version (`8.3.5000` bundles
  * Vite `8.3.5`, see rfcs/core-version-alignment.md). Package managers check
  * `vite` peer ranges against it correctly, so the rule would only hide real
  * incompatibilities. Every other spec still needs the rule: a preview
diff --git a/packages/cli/src/sync-versions/__tests__/plan.spec.ts b/packages/cli/src/sync-versions/__tests__/plan.spec.ts
index 8b4b4751b1..4bdd99da93 100644
--- a/packages/cli/src/sync-versions/__tests__/plan.spec.ts
+++ b/packages/cli/src/sync-versions/__tests__/plan.spec.ts
@@ -10,7 +10,7 @@ import {
 
 const toolchain: SyncVersionsToolchain = {
   vitePlus: '0.4.0',
-  vitePlusCore: '8.3.500',
+  vitePlusCore: '8.3.5000',
   vitest: '5.0.0',
 };
 
@@ -51,7 +51,7 @@ describe('planSyncVersions', () => {
         '@vitest/coverage-v8': '5.0.0',
         '@vitest/eslint-plugin': '1.6.0',
         '@vitest/coverage-c8': '0.33.0',
-        '@voidzero-dev/vite-plus-core': '8.3.500',
+        '@voidzero-dev/vite-plus-core': '8.3.5000',
         'vite-plus': '0.4.0',
         vitest: '5.0.0',
       },
@@ -78,7 +78,7 @@ describe('planSyncVersions', () => {
       devDependencies: {
         '@vitest/ui': 'npm:@vitest/ui@5.0.0',
         'vite-plus': 'npm:@scope/vite-plus-fork@0.3.0',
-        vite: 'npm:@voidzero-dev/vite-plus-core@8.3.500',
+        vite: 'npm:@voidzero-dev/vite-plus-core@8.3.5000',
         vitest: 'npm:vitest@5.0.0',
       },
     });
@@ -114,7 +114,7 @@ describe('planSyncVersions', () => {
       catalog: { '@vitest/coverage-v8': '5.0.0' },
       catalogs: {
         toolchain: {
-          vite: 'npm:@voidzero-dev/vite-plus-core@8.3.500',
+          vite: 'npm:@voidzero-dev/vite-plus-core@8.3.5000',
           'vite-plus': '0.4.0',
         },
       },
@@ -139,7 +139,7 @@ describe('planSyncVersions', () => {
         .replace("vitest: '^4.1.11'", "vitest: '5.0.0'")
         .replace(
           'vite: "npm:@voidzero-dev/vite-plus-core@0.3.0"',
-          'vite: "npm:@voidzero-dev/vite-plus-core@8.3.500"',
+          'vite: "npm:@voidzero-dev/vite-plus-core@8.3.5000"',
         )
         .replace('vite-plus: 0.3.0', 'vite-plus: 0.4.0'),
     );
diff --git a/packages/cli/src/sync-versions/__tests__/protocol.spec.ts b/packages/cli/src/sync-versions/__tests__/protocol.spec.ts
index b95c7e61ef..9cbf0828fb 100644
--- a/packages/cli/src/sync-versions/__tests__/protocol.spec.ts
+++ b/packages/cli/src/sync-versions/__tests__/protocol.spec.ts
@@ -6,7 +6,7 @@ const manifest = {
   schemaVersion: 1,
   nodes: [
     { id: 'vite-plus', version: '0.4.0' },
-    { id: 'vite-plus-core', version: '8.3.500' },
+    { id: 'vite-plus-core', version: '8.3.5000' },
     { id: 'vitest', version: '5.0.0' },
   ],
 };
@@ -53,7 +53,7 @@ describe('toolchainFromManifest', () => {
   it('reads the exact Vite+, core, and Vitest versions', () => {
     expect(toolchainFromManifest(manifest)).toEqual({
       vitePlus: '0.4.0',
-      vitePlusCore: '8.3.500',
+      vitePlusCore: '8.3.5000',
       vitest: '5.0.0',
     });
   });
@@ -73,7 +73,7 @@ describe('toolchainFromManifest', () => {
       schemaVersion: 1,
       nodes: [
         { id: 'vite-plus', version: '0.4.0' },
-        { id: 'vite-plus-core', version: '8.3.500' },
+        { id: 'vite-plus-core', version: '8.3.5000' },
         { id: 'vitest', version: '' },
       ],
     },
diff --git a/packages/cli/src/utils/__tests__/constants.spec.ts b/packages/cli/src/utils/__tests__/constants.spec.ts
index 2c4e4b4b1d..42768cb67f 100644
--- a/packages/cli/src/utils/__tests__/constants.spec.ts
+++ b/packages/cli/src/utils/__tests__/constants.spec.ts
@@ -41,7 +41,7 @@ describe('Vite+ dependency versions', () => {
 
   it('uses VP_CORE_VERSION for the core alias', async () => {
     vi.stubEnv('VP_VERSION', '0.0.0');
-    vi.stubEnv('VP_CORE_VERSION', '8.3.599');
+    vi.stubEnv('VP_CORE_VERSION', '8.3.5999');
     vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
     vi.resetModules();
 
@@ -49,8 +49,8 @@ describe('Vite+ dependency versions', () => {
       await import('../constants.js');
 
     expect(VITE_PLUS_VERSION).toBe('0.0.0');
-    expect(VITE_PLUS_CORE_VERSION).toBe('8.3.599');
-    expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe('npm:@voidzero-dev/vite-plus-core@8.3.599');
+    expect(VITE_PLUS_CORE_VERSION).toBe('8.3.5999');
+    expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe('npm:@voidzero-dev/vite-plus-core@8.3.5999');
   });
 
   it('preserves explicit prerelease overrides', async () => {
diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md
index 6ab1d13899..1e0607fca3 100644
--- a/packages/core/BUNDLING.md
+++ b/packages/core/BUNDLING.md
@@ -316,7 +316,7 @@ dist/
 1. Update the Vite pin in [`packages/tools/.upstream-versions.json`](../tools/.upstream-versions.json) and review the resolver patch against the new source
 2. Follow the [upstream sync and build instructions](../../CONTRIBUTING.md#pull-upstream-dependencies) to prepare the source, regenerate Vite types, and rebuild the packages
 3. Verify `bundledVersions.vite` in `package.json` is updated
-4. Set `version` in `package.json` to the encoded version for the new Vite version, for example `8.4.200` for Vite `8.4.2` (see [Core Version](#core-version)). The upgrade-deps workflow does this step automatically
+4. Set `version` in `package.json` to the encoded version for the new Vite version, for example `8.4.2000` for Vite `8.4.2` (see [Core Version](#core-version)). The upgrade-deps workflow does this step automatically
 5. Test with `pnpm test`
 
 ### Updating Rolldown
@@ -411,14 +411,14 @@ This is automatically updated by `mergePackageJson()` during each build.
 
 The core version encodes `bundledVersions.vite`, so it differs from the `vite-plus` version. Package managers compare the version of the `vite` alias target with the `vite` peer ranges of plugins, so the encoded version makes those checks match the bundled Vite. See [the RFC](../../rfcs/core-version-alignment.md) for the design.
 
-| Release                                 | Core version                                       | Example                          |
-| --------------------------------------- | -------------------------------------------------- | -------------------------------- |
-| Stable `vite-plus`                      | `..` | Vite `8.3.5`, `n = 1`: `8.3.501` |
-| `vite-plus` prerelease, stable Vite     | `-vp.`                | `8.4.0-vp.0`                     |
-| `vite-plus` prerelease, Vite prerelease | `.vp.`                            | `9.0.0-beta.3.vp.0`              |
-| Local and CI builds                     | `n = 99`                                           | `8.3.599`                        |
+| Release                                 | Core version                                        | Example                           |
+| --------------------------------------- | --------------------------------------------------- | --------------------------------- |
+| Stable `vite-plus`                      | `..` | Vite `8.3.5`, `n = 1`: `8.3.5001` |
+| `vite-plus` prerelease, stable Vite     | `-vp.`                 | `8.4.0-vp.0`                      |
+| `vite-plus` prerelease, Vite prerelease | `.vp.`                             | `9.0.0-beta.3.vp.0`               |
+| Local and CI builds                     | `n = 999`                                           | `8.3.5999`                        |
 
-`n` counts the stable releases on one Vite version, from 0 to 98. `k` counts the prereleases on one base. [`packages/tools/src/core-version.ts`](../tools/src/core-version.ts) implements the scheme:
+`n` counts the stable releases on one Vite version, from 0 to 998. `k` counts the prereleases on one base. This follows the [`oxlint-tsgolint` versioning](https://github.com/oxc-project/tsgolint#versioning), where `7.0.2000` is the first release on TypeScript `7.0.2`. [`packages/tools/src/core-version.ts`](../tools/src/core-version.ts) implements the scheme:
 
 - `prepare_release.yml` runs `core-version.ts prepare` to select the next unpublished version from the registry. It writes that version and the `vite-plus` version (`vitePlusVersion`) to `package.json`.
 - `release.yml` runs `core-version.ts check --release`. It fails when the registry already holds the core version for a different `vite-plus` release.
diff --git a/packages/core/package.json b/packages/core/package.json
index 33833f0297..40a5db5b3e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@voidzero-dev/vite-plus-core",
-  "version": "8.3.700",
+  "version": "8.3.7000",
   "description": "The Unified Toolchain for the Web",
   "homepage": "https://viteplus.dev/guide",
   "bugs": {
diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
index 71a9ce9282..a0f7dff6e3 100644
--- a/packages/tools/src/__tests__/core-version.spec.ts
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -13,24 +13,24 @@ const PUBLISHED_BEFORE_ALIGNMENT = ['0.1.21-alpha.7', '0.0.2-test.1', '1.0.0-rc.
 
 describe('encodeCoreVersion', () => {
   test('encodes the Vite patch and the release counter in the patch', () => {
-    expect(encodeCoreVersion('8.3.5', 0)).toBe('8.3.500');
-    expect(encodeCoreVersion('8.3.5', 1)).toBe('8.3.501');
+    expect(encodeCoreVersion('8.3.5', 0)).toBe('8.3.5000');
+    expect(encodeCoreVersion('8.3.5', 1)).toBe('8.3.5001');
     expect(encodeCoreVersion('8.4.0', 0)).toBe('8.4.0');
-    expect(encodeCoreVersion('8.4.1', 0)).toBe('8.4.100');
-    expect(encodeCoreVersion('8.3.5', 99)).toBe('8.3.599');
+    expect(encodeCoreVersion('8.4.1', 0)).toBe('8.4.1000');
+    expect(encodeCoreVersion('8.3.5', 999)).toBe('8.3.5999');
   });
 
   test('rejects a Vite prerelease and an out-of-range counter', () => {
     expect(() => encodeCoreVersion('9.0.0-beta.3', 0)).toThrow('Vite prerelease');
-    expect(() => encodeCoreVersion('8.3.5', 100)).toThrow('from 0 to 99');
-    expect(() => encodeCoreVersion('8.3.5', -1)).toThrow('from 0 to 99');
+    expect(() => encodeCoreVersion('8.3.5', 1000)).toThrow('from 0 to 999');
+    expect(() => encodeCoreVersion('8.3.5', -1)).toThrow('from 0 to 999');
   });
 });
 
 describe('decodeCoreVersion', () => {
   test('decodes stable and prerelease core versions', () => {
-    expect(decodeCoreVersion('8.3.500')).toEqual({ vite: '8.3.5', n: 0 });
-    expect(decodeCoreVersion('8.3.501')).toEqual({ vite: '8.3.5', n: 1 });
+    expect(decodeCoreVersion('8.3.5000')).toEqual({ vite: '8.3.5', n: 0 });
+    expect(decodeCoreVersion('8.3.5001')).toEqual({ vite: '8.3.5', n: 1 });
     expect(decodeCoreVersion('8.4.1')).toEqual({ vite: '8.4.0', n: 1 });
     expect(decodeCoreVersion('8.4.0-vp.2')).toEqual({ vite: '8.4.0', n: 0, k: 2 });
     expect(decodeCoreVersion('9.0.0-beta.3.vp.1')).toEqual({ vite: '9.0.0-beta.3', k: 1 });
@@ -48,18 +48,18 @@ describe('nextCoreVersion', () => {
   test('starts the counter at 0 for a new Vite version', () => {
     expect(
       nextCoreVersion({ vite: '8.3.5', vitePlus: '1.5.0', published: PUBLISHED_BEFORE_ALIGNMENT }),
-    ).toBe('8.3.500');
+    ).toBe('8.3.5000');
   });
 
   test('increments the counter for each stable release on one Vite version', () => {
-    const published = [...PUBLISHED_BEFORE_ALIGNMENT, '8.3.500', '8.3.501', '8.3.600'];
-    expect(nextCoreVersion({ vite: '8.3.5', vitePlus: '1.5.2', published })).toBe('8.3.502');
-    expect(nextCoreVersion({ vite: '8.3.6', vitePlus: '1.6.1', published })).toBe('8.3.601');
+    const published = [...PUBLISHED_BEFORE_ALIGNMENT, '8.3.5000', '8.3.5001', '8.3.6000'];
+    expect(nextCoreVersion({ vite: '8.3.5', vitePlus: '1.5.2', published })).toBe('8.3.5002');
+    expect(nextCoreVersion({ vite: '8.3.6', vitePlus: '1.6.1', published })).toBe('8.3.6001');
     expect(nextCoreVersion({ vite: '8.4.0', vitePlus: '1.7.0', published })).toBe('8.4.0');
   });
 
   test('ignores prereleases and local versions for the stable counter', () => {
-    const published = ['8.4.0-vp.0', '8.4.0-vp.1', '8.4.99'];
+    const published = ['8.4.0-vp.0', '8.4.0-vp.1', '8.4.999'];
     expect(nextCoreVersion({ vite: '8.4.0', vitePlus: '1.7.0', published })).toBe('8.4.0');
   });
 
@@ -99,7 +99,7 @@ describe('nextCoreVersion', () => {
   });
 
   test('fails when the release counter is exhausted', () => {
-    const published = Array.from({ length: 99 }, (_, n) => encodeCoreVersion('8.3.5', n));
+    const published = Array.from({ length: 999 }, (_, n) => encodeCoreVersion('8.3.5', n));
     expect(() => nextCoreVersion({ vite: '8.3.5', vitePlus: '1.9.0', published })).toThrow(
       'the maximum',
     );
@@ -108,7 +108,7 @@ describe('nextCoreVersion', () => {
 
 describe('initialCoreVersion', () => {
   test('starts the counter for a stable and a prerelease Vite', () => {
-    expect(initialCoreVersion('8.4.2')).toBe('8.4.200');
+    expect(initialCoreVersion('8.4.2')).toBe('8.4.2000');
     expect(initialCoreVersion('9.0.0-beta.3')).toBe('9.0.0-beta.3.vp.0');
   });
 });
@@ -119,8 +119,8 @@ function manifest(version: string, vite = '8.3.5', vitePlusVersion?: string) {
 
 describe('assertCoreVersionMatchesVite', () => {
   test('accepts a core version that encodes the bundled Vite', () => {
-    expect(() => assertCoreVersionMatchesVite(manifest('8.3.500'), '1.5.0')).not.toThrow();
-    expect(() => assertCoreVersionMatchesVite(manifest('8.3.599'), '0.0.0')).not.toThrow();
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.5000'), '1.5.0')).not.toThrow();
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.5999'), '0.0.0')).not.toThrow();
     expect(() =>
       assertCoreVersionMatchesVite(manifest('8.4.0-vp.1', '8.4.0'), '1.7.0-rc.1'),
     ).not.toThrow();
@@ -130,8 +130,8 @@ describe('assertCoreVersionMatchesVite', () => {
   });
 
   test('rejects a core version that encodes another Vite version', () => {
-    expect(() => assertCoreVersionMatchesVite(manifest('8.3.400'), '1.5.0')).toThrow(
-      'Set its version to 8.3.500',
+    expect(() => assertCoreVersionMatchesVite(manifest('8.3.4000'), '1.5.0')).toThrow(
+      'Set its version to 8.3.5000',
     );
     expect(() => assertCoreVersionMatchesVite(manifest('1.4.0'), '1.4.0')).toThrow(
       'encodes Vite 1.4.0',
@@ -146,12 +146,12 @@ describe('assertCoreVersionMatchesVite', () => {
 
   test('rejects mixed prerelease and stable versions for a release', () => {
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.500', '8.3.5', '1.5.0-rc.0'), '1.5.0-rc.0', {
+      assertCoreVersionMatchesVite(manifest('8.3.5000', '8.3.5', '1.5.0-rc.0'), '1.5.0-rc.0', {
         release: true,
       }),
     ).toThrow('both be prereleases');
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.500-vp.0', '8.3.5', '1.5.0'), '1.5.0', {
+      assertCoreVersionMatchesVite(manifest('8.3.5000-vp.0', '8.3.5', '1.5.0'), '1.5.0', {
         release: true,
       }),
     ).toThrow('both be prereleases');
@@ -159,12 +159,12 @@ describe('assertCoreVersionMatchesVite', () => {
 
   test('rejects the local build counter for a release', () => {
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.599', '8.3.5', '1.5.0'), '1.5.0', {
+      assertCoreVersionMatchesVite(manifest('8.3.5999', '8.3.5', '1.5.0'), '1.5.0', {
         release: true,
       }),
     ).toThrow('reserved for local builds');
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.598', '8.3.5', '1.5.0'), '1.5.0', {
+      assertCoreVersionMatchesVite(manifest('8.3.5998', '8.3.5', '1.5.0'), '1.5.0', {
         release: true,
       }),
     ).not.toThrow();
@@ -172,10 +172,10 @@ describe('assertCoreVersionMatchesVite', () => {
 
   test('requires the manifest to record the released vite-plus version', () => {
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.500'), '1.5.0', { release: true }),
+      assertCoreVersionMatchesVite(manifest('8.3.5000'), '1.5.0', { release: true }),
     ).toThrow('records vite-plus (none)');
     expect(() =>
-      assertCoreVersionMatchesVite(manifest('8.3.500', '8.3.5', '1.4.0'), '1.5.0', {
+      assertCoreVersionMatchesVite(manifest('8.3.5000', '8.3.5', '1.4.0'), '1.5.0', {
         release: true,
       }),
     ).toThrow('records vite-plus 1.4.0, but the release is vite-plus 1.5.0');
@@ -184,17 +184,17 @@ describe('assertCoreVersionMatchesVite', () => {
 
 describe('assertCoreVersionUnclaimed', () => {
   test('accepts an unpublished core version and a retry of the same release', () => {
-    expect(() => assertCoreVersionUnclaimed(undefined, '8.3.501', '1.5.1')).not.toThrow();
+    expect(() => assertCoreVersionUnclaimed(undefined, '8.3.5001', '1.5.1')).not.toThrow();
     expect(() =>
-      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.501', '1.5.1'),
+      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.5001', '1.5.1'),
     ).not.toThrow();
   });
 
   test('rejects a core version that another release published', () => {
     expect(() =>
-      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.501', '1.6.0'),
+      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.5001', '1.6.0'),
     ).toThrow('already published for vite-plus 1.5.1');
-    expect(() => assertCoreVersionUnclaimed({}, '8.3.500', '1.6.0')).toThrow(
+    expect(() => assertCoreVersionUnclaimed({}, '8.3.5000', '1.6.0')).toThrow(
       'already published for vite-plus (unknown)',
     );
   });
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
index 93635e5484..7922c7ba96 100644
--- a/packages/tools/src/core-version.ts
+++ b/packages/tools/src/core-version.ts
@@ -5,11 +5,11 @@
  * `vite` peer ranges against the alias `vite: npm:@voidzero-dev/vite-plus-core@...`
  * correctly:
  *
- *   stable Vite+ release:          ..
+ *   stable Vite+ release:          ..
  *   Vite+ prerelease, stable Vite: -vp.
  *   Vite+ prerelease, Vite pre:    .vp.
  *
- * `n` counts the stable Vite+ releases on one Vite version (0 to 98). `n = 99`
+ * `n` counts the stable Vite+ releases on one Vite version (0 to 998). `n = 999`
  * is reserved for local and CI builds, so they never collide with a published
  * version. `k` counts the Vite+ prereleases on one base.
  *
@@ -30,8 +30,12 @@ import { parseArgs } from 'node:util';
 export const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';
 /** The `vite` alias to the core: `npm:@voidzero-dev/vite-plus-core@`. */
 export const CORE_ALIAS_PREFIX = `npm:${CORE_PACKAGE_NAME}@`;
-const MAX_RELEASE_COUNTER = 98;
-const LOCAL_BUILD_COUNTER = 99;
+// Same convention as oxlint-tsgolint, which Vite+ bundles: `7.0.2000` is its
+// first release on TypeScript `7.0.2`, and `7.0.2001` the next one
+// (https://github.com/oxc-project/tsgolint#versioning).
+const PATCH_FACTOR = 1000;
+const MAX_RELEASE_COUNTER = 998;
+const LOCAL_BUILD_COUNTER = 999;
 
 const PRERELEASE_TAG = 'vp';
 const VERSION_RE =
@@ -79,7 +83,7 @@ export function encodeCoreVersion(vite: string, n: number): string {
   if (!Number.isInteger(n) || n < 0 || n > LOCAL_BUILD_COUNTER) {
     throw new Error(`The release counter must be an integer from 0 to ${LOCAL_BUILD_COUNTER}`);
   }
-  return `${major}.${minor}.${patch * 100 + n}`;
+  return `${major}.${minor}.${patch * PATCH_FACTOR + n}`;
 }
 
 /**
@@ -93,16 +97,16 @@ export function initialCoreVersion(vite: string): string {
 /** Decodes a core version into the bundled Vite version and its counters. */
 export function decodeCoreVersion(core: string): DecodedCoreVersion {
   const { major, minor, patch, prerelease } = parseVersion(core);
-  const stableVite = `${major}.${minor}.${Math.floor(patch / 100)}`;
+  const stableVite = `${major}.${minor}.${Math.floor(patch / PATCH_FACTOR)}`;
   if (prerelease.length === 0) {
-    return { vite: stableVite, n: patch % 100 };
+    return { vite: stableVite, n: patch % PATCH_FACTOR };
   }
   const k = prerelease.at(-1)!;
   if (prerelease.at(-2) !== PRERELEASE_TAG || !/^(?:0|[1-9]\d*)$/.test(k)) {
     throw new Error(`${core} is not an encoded core version`);
   }
   if (prerelease.length === 2) {
-    return { vite: stableVite, n: patch % 100, k: Number(k) };
+    return { vite: stableVite, n: patch % PATCH_FACTOR, k: Number(k) };
   }
   return {
     vite: `${major}.${minor}.${patch}-${prerelease.slice(0, -2).join('.')}`,
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 25728d0377..41ac5f5d18 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -15,28 +15,34 @@ bundles. The core major and minor equal the bundled Vite major and minor. The
 core patch encodes the bundled Vite patch and a release counter:
 
 ```text
-stable Vite+ release:          ..
+stable Vite+ release:          ..
 Vite+ prerelease, stable Vite: -vp.
 Vite+ prerelease, Vite pre:    .vp.
 ```
 
 `n` counts the stable Vite+ releases that bundle the same Vite patch, from 0
-to 98. `n = 99` is reserved for local and CI builds. `k` counts the Vite+
+to 998. `n = 999` is reserved for local and CI builds. `k` counts the Vite+
 prereleases on the same base, from 0. Decoding is exact: the bundled Vite
-patch is `floor(core patch / 100)`.
+patch is `floor(core patch / 1000)`.
+
+This is the convention of `oxlint-tsgolint`, which Vite+ bundles. Its first
+release on TypeScript `7.0.2` is `7.0.2000`, and the next ones are `7.0.2001`,
+`7.0.2002`, and so on
+([tsgolint versioning](https://github.com/oxc-project/tsgolint#versioning)).
+One rule then applies to the versions that `vp --version` shows.
 
 | Vite+ release     | Bundled Vite   | Core version        |
 | ----------------- | -------------- | ------------------- |
-| `1.5.0` (first)   | `8.3.5`        | `8.3.500`           |
-| `1.5.1`           | `8.3.5`        | `8.3.501`           |
-| `1.6.0`           | `8.3.6`        | `8.3.600`           |
+| `1.5.0` (first)   | `8.3.5`        | `8.3.5000`          |
+| `1.5.1`           | `8.3.5`        | `8.3.5001`          |
+| `1.6.0`           | `8.3.6`        | `8.3.6000`          |
 | `1.7.0-rc.0`      | `8.4.0`        | `8.4.0-vp.0`        |
 | `1.7.0-rc.1`      | `8.4.0`        | `8.4.0-vp.1`        |
 | `1.7.0`           | `8.4.0`        | `8.4.0`             |
-| `1.7.1`           | `8.4.1`        | `8.4.100`           |
+| `1.7.1`           | `8.4.1`        | `8.4.1000`          |
 | `2.0.0-beta.0`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.0` |
 | `2.0.0-beta.1`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.1` |
-| Local or CI build | `8.3.5`        | `8.3.599`           |
+| Local or CI build | `8.3.5`        | `8.3.5999`          |
 
 `vite-plus` keeps its own version line (`1.x` today). The CLI keeps its exact
 alias dependency `vite: npm:@voidzero-dev/vite-plus-core@`.
@@ -125,19 +131,19 @@ Three options were compared against bundled Vite `8.3.5` with `vite-plus`
 - Option B: core major and minor = Vite major and minor; patch is a plain
   counter per Vite+ release (`8.3.0`, `8.3.1`, ...).
 - Option B+ (selected): like B, but the patch encodes the Vite patch
-  (`8.3.500`, `8.3.501`, ...).
+  (`8.3.5000`, `8.3.5001`, ...).
 - Option C: core = exact Vite version plus a prerelease tag
   (`8.3.5-vp.1.4.0`) or build metadata (`8.3.5+vp.1.4.0`).
 
-| Consumer range | Bundled `8.3.5` | A `8.4.0`    | B `8.3.0`    | B+ `8.3.500` | C `8.3.5-vp.1.4.0` (npm) |
-| -------------- | --------------- | ------------ | ------------ | ------------ | ------------------------ |
-| `^8.0.0`       | pass            | pass         | pass         | pass         | fail (wrong)             |
-| `^8.3.0`       | pass            | pass         | pass         | pass         | fail (wrong)             |
-| `>=8.3.2`      | pass            | pass         | fail (wrong) | pass         | fail (wrong)             |
-| `~8.3.0`       | pass            | fail (wrong) | pass         | pass         | fail (wrong)             |
-| `^7.0.0`       | fail            | fail         | fail         | fail         | fail                     |
-| `^8.4.0`       | fail            | pass (wrong) | fail         | fail         | fail                     |
-| `>=8.3.6`      | fail            | pass (wrong) | fail         | pass (wrong) | fail                     |
+| Consumer range | Bundled `8.3.5` | A `8.4.0`    | B `8.3.0`    | B+ `8.3.5000` | C `8.3.5-vp.1.4.0` (npm) |
+| -------------- | --------------- | ------------ | ------------ | ------------- | ------------------------ |
+| `^8.0.0`       | pass            | pass         | pass         | pass          | fail (wrong)             |
+| `^8.3.0`       | pass            | pass         | pass         | pass          | fail (wrong)             |
+| `>=8.3.2`      | pass            | pass         | fail (wrong) | pass          | fail (wrong)             |
+| `~8.3.0`       | pass            | fail (wrong) | pass         | pass          | fail (wrong)             |
+| `^7.0.0`       | fail            | fail         | fail         | fail          | fail                     |
+| `^8.4.0`       | fail            | pass (wrong) | fail         | fail          | fail                     |
+| `>=8.3.6`      | fail            | pass (wrong) | fail         | pass (wrong)  | fail                     |
 
 Option A fails on minor ranges in both directions. The `vite-plus` minor and
 the Vite minor drift apart: `v1.2.0` bundled Vite `8.3.4`, so core `8.2.0`
@@ -162,18 +168,18 @@ same Vite patch cannot publish. `v1.2.0` and `v1.3.0` both bundled Vite
 
 Option B+ is the selected scheme. Its residual false results are limited to
 patch floors above the bundled patch inside the same minor (`>=8.3.6` passes
-against `8.3.500`) and to exact pins (`8.3.5` fails against `8.3.500`). Both
+against `8.3.5000`) and to exact pins (`8.3.5` fails against `8.3.5000`). Both
 are rare in `vite` peer ranges. Vite+ follows Vite patch releases within days,
 so the window for the first case is short.
 
-The counter supports 99 stable Vite+ releases per Vite patch (`n` from 0 to
-98). `n = 99` is reserved for local and CI builds (see Local and CI builds).
+The counter supports 999 stable Vite+ releases per Vite patch (`n` from 0 to
+998). `n = 999` is reserved for local and CI builds (see Local and CI builds).
 Vite+ shipped five minor releases between 2026-09-28 and 2026-10-23 while Vite
 moved from `8.3.1` to `8.3.5`. Two releases on one Vite patch happened once
 (`v1.2.0` and `v1.3.0` on `8.3.4`).
 
 Core versions keep sorting correctly:
-`8.3.501 < 8.3.502-vp.0 < 8.3.502 < 8.3.600`.
+`8.3.5001 < 8.3.5002-vp.0 < 8.3.5002 < 8.3.6000`.
 
 ### Prereleases and new Vite majors
 
@@ -211,7 +217,7 @@ version is unique and in publish order. `semver@7.8.5` gives this order:
 ```text
 9.0.0-beta.3 < 9.0.0-beta.3.vp.0 < 9.0.0-beta.3.vp.1 < 9.0.0-beta.4
   < 9.0.0-rc.1.vp.0 < 9.0.0-vp.0 < 9.0.0-vp.1 < 9.0.0
-  < 9.0.100-vp.0 < 9.0.100
+  < 9.0.1000-vp.0 < 9.0.1000
 ```
 
 With `semver@7.8.5` and npm's strict prerelease rules, `9.0.0-beta.3.vp.0`
@@ -280,7 +286,7 @@ functions and a CLI entry:
   below.
 - `assertCoreVersionMatchesVite(pkg, vitePlus, { release })` asserts that
   `pkg.version` decodes to `pkg.bundledVersions.vite`. With `release`, it also
-  asserts the prerelease pairing, rejects `n = 99`, and requires
+  asserts the prerelease pairing, rejects `n = 999`, and requires
   `pkg.vitePlusVersion` to equal the released `vite-plus` version.
 - `assertCoreVersionUnclaimed(published, core, vitePlus)` rejects a core
   version that the registry already holds for a different `vite-plus`
@@ -294,7 +300,7 @@ Counter rules in `nextCoreVersion`:
 
 - Stable release: `n` is the highest `n` among published stable core versions
   with the same Vite version, plus 1, or 0 when none exists. If `n` would be
-  higher than 98, the function fails.
+  higher than 998, the function fails.
 - Prerelease on a stable Vite: the base is the version that the stable rule
   gives. `k` is the highest `k` among published `-vp.*` versions, plus
   1, or 0 when none exists.
@@ -341,36 +347,36 @@ local build is easy to detect. Core `0.0.0` does not encode a Vite version,
 and peer checks fail against it. These flows would not test the path without a
 `vite` peer rule that users get.
 
-These flows will set core to `encodeCoreVersion(vite, 99)`, for example
-`8.3.599` for Vite `8.3.5`. They keep `0.0.0` for the CLI, which
+These flows will set core to `encodeCoreVersion(vite, 999)`, for example
+`8.3.5999` for Vite `8.3.5`. They keep `0.0.0` for the CLI, which
 `ecosystem-ci/verify-install.ts` and `test-vp-create.yml` assert. The CLI
 reads the core version from `VP_CORE_VERSION` or from the core manifest, so
-`vp create` and `vp migrate` in these flows write the `8.3.599` alias and
+`vp create` and `vp migrate` in these flows write the `8.3.5999` alias and
 remove the `vite` peer rule.
 
-`nextCoreVersion` never returns `n = 99`, and `release.yml` rejects it.
+`nextCoreVersion` never returns `n = 999`, and `release.yml` rejects it.
 Thus a local version never reaches the registry, and the local registry
 cannot mix it up with a published version.
 
 ### Release pipeline changes
 
-| File                                                                                                                             | Change                                                                                                                                                                                                                                                                                                                                                                        |
-| -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `.github/workflows/prepare_release.yml`                                                                                          | Compute the core version as described above; stop passing `VERSION` to `update_json packages/core/package.json`                                                                                                                                                                                                                                                               |
-| `.github/workflows/release.yml`                                                                                                  | Read `CORE_VERSION` from `packages/core/package.json` at job start, and reject a version with `n = 99`; use it in "Publish core dependency tier", "Wait for core to propagate", the release notes, and the Discord embed. After publish, check that the core `latest` dist-tag equals `CORE_VERSION`. The version check against `unpkg.com/vite-plus@latest` stays on the CLI |
-| `.github/workflows/reusable-release-build.yml`                                                                                   | Stamp `packages/core/package.json` only for preview versions (`0.0.0-commit.*`). A release checkout already carries the encoded version. Update the verify step to the same rule                                                                                                                                                                                              |
-| `packages/core/build.ts`                                                                                                         | Pass `cliPkgJson.version` instead of `pkgJson.version` to `rewriteRolldownBindingRequires`. The platform packages publish at the CLI version, and the guard must compare against that version (`packages/core/build-support/rewrite-rolldown-binding.ts`)                                                                                                                     |
-| `packages/cli/publish-native-addons.ts`                                                                                          | No change: it mirrors the CLI's platform pins into core by name and does not use the core version                                                                                                                                                                                                                                                                             |
-| `.github/workflows/publish-preview.yml`, `publish-preview-register.yml`                                                          | No change: the bridge serves every workspace package as `0.0.0-commit.`, and the stamp before `pnpm pack` keeps the alias on that version. The sticky comment keeps printing one version for both packages                                                                                                                                                               |
-| `.github/workflows/test-vp-create.yml`, `reusable-ecosystem-ci.yml`                                                              | Keep `0.0.0` for the CLI. Set core to `encodeCoreVersion(vite, 99)` and pass it as `VP_CORE_VERSION` where the workflow passes `VP_VERSION` (see Local and CI builds)                                                                                                                                                                                                         |
-| `packages/tools/src/local-npm-registry.ts`, `pack-local-vite-plus.ts`                                                            | No change: both serve the packed tarball's own version                                                                                                                                                                                                                                                                                                                        |
-| `.github/scripts/upgrade-deps.ts`                                                                                                | Reset the core version after a bundled Vite change                                                                                                                                                                                                                                                                                                                            |
-| `.github/renovate.json`                                                                                                          | No change                                                                                                                                                                                                                                                                                                                                                                     |
-| `.github/scripts/update-homebrew-formula.mjs`                                                                                    | Read the core version from the `vite` alias of the published `vite-plus` manifest, and wait for core at that version                                                                                                                                                                                                                                                          |
-| `.claude/skills/release-manager/SKILL.md`                                                                                        | Read both versions from the release branch; publish checks use the core version for core                                                                                                                                                                                                                                                                                      |
-| `packages/core/BUNDLING.md`                                                                                                      | Document the version scheme next to `bundledVersions`                                                                                                                                                                                                                                                                                                                         |
-| `MAINTENANCE.md`                                                                                                                 | Mention the two versions in the release description                                                                                                                                                                                                                                                                                                                           |
-| `README.md`, `packages/cli/README.md`, `docs/guide/local-cli.md`, `docs/guide/upgrade-project.md`, `docs/guide/migrate-rules.md` | Replace `` in alias examples with `` and point to `vp toolchain vite-plus-core`; describe the new pnpm peer rule behavior                                                                                                                                                                                                                    |
+| File                                                                                                                             | Change                                                                                                                                                                                                                                                                                                                                                                         |
+| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `.github/workflows/prepare_release.yml`                                                                                          | Compute the core version as described above; stop passing `VERSION` to `update_json packages/core/package.json`                                                                                                                                                                                                                                                                |
+| `.github/workflows/release.yml`                                                                                                  | Read `CORE_VERSION` from `packages/core/package.json` at job start, and reject a version with `n = 999`; use it in "Publish core dependency tier", "Wait for core to propagate", the release notes, and the Discord embed. After publish, check that the core `latest` dist-tag equals `CORE_VERSION`. The version check against `unpkg.com/vite-plus@latest` stays on the CLI |
+| `.github/workflows/reusable-release-build.yml`                                                                                   | Stamp `packages/core/package.json` only for preview versions (`0.0.0-commit.*`). A release checkout already carries the encoded version. Update the verify step to the same rule                                                                                                                                                                                               |
+| `packages/core/build.ts`                                                                                                         | Pass `cliPkgJson.version` instead of `pkgJson.version` to `rewriteRolldownBindingRequires`. The platform packages publish at the CLI version, and the guard must compare against that version (`packages/core/build-support/rewrite-rolldown-binding.ts`)                                                                                                                      |
+| `packages/cli/publish-native-addons.ts`                                                                                          | No change: it mirrors the CLI's platform pins into core by name and does not use the core version                                                                                                                                                                                                                                                                              |
+| `.github/workflows/publish-preview.yml`, `publish-preview-register.yml`                                                          | No change: the bridge serves every workspace package as `0.0.0-commit.`, and the stamp before `pnpm pack` keeps the alias on that version. The sticky comment keeps printing one version for both packages                                                                                                                                                                |
+| `.github/workflows/test-vp-create.yml`, `reusable-ecosystem-ci.yml`                                                              | Keep `0.0.0` for the CLI. Set core to `encodeCoreVersion(vite, 999)` and pass it as `VP_CORE_VERSION` where the workflow passes `VP_VERSION` (see Local and CI builds)                                                                                                                                                                                                         |
+| `packages/tools/src/local-npm-registry.ts`, `pack-local-vite-plus.ts`                                                            | No change: both serve the packed tarball's own version                                                                                                                                                                                                                                                                                                                         |
+| `.github/scripts/upgrade-deps.ts`                                                                                                | Reset the core version after a bundled Vite change                                                                                                                                                                                                                                                                                                                             |
+| `.github/renovate.json`                                                                                                          | No change                                                                                                                                                                                                                                                                                                                                                                      |
+| `.github/scripts/update-homebrew-formula.mjs`                                                                                    | Read the core version from the `vite` alias of the published `vite-plus` manifest, and wait for core at that version                                                                                                                                                                                                                                                           |
+| `.claude/skills/release-manager/SKILL.md`                                                                                        | Read both versions from the release branch; publish checks use the core version for core                                                                                                                                                                                                                                                                                       |
+| `packages/core/BUNDLING.md`                                                                                                      | Document the version scheme next to `bundledVersions`                                                                                                                                                                                                                                                                                                                          |
+| `MAINTENANCE.md`                                                                                                                 | Mention the two versions in the release description                                                                                                                                                                                                                                                                                                                            |
+| `README.md`, `packages/cli/README.md`, `docs/guide/local-cli.md`, `docs/guide/upgrade-project.md`, `docs/guide/migrate-rules.md` | Replace `` in alias examples with `` and point to `vp toolchain vite-plus-core`; describe the new pnpm peer rule behavior                                                                                                                                                                                                                     |
 
 ### Package-manager behavior
 
@@ -416,7 +422,7 @@ resolved the alias and listed it as `vite@1.4.0`. Whether Bun checks peer
 ranges in other layouts is unverified.
 
 The current `vite-plus` manifest triggers the npm warning on its own, because
-`vitest` is a dependency with a `vite` peer range. With core `8.3.500` that
+`vitest` is a dependency with a `vite` peer range. With core `8.3.5000` that
 range is satisfied, so a project that depends only on `vite-plus` installs
 without the warning.
 
@@ -470,13 +476,13 @@ rules document must say so.
 
 ### Downgrade
 
-A project on core `8.3.500` can move back to `vite-plus@1.4.0` by running
+A project on core `8.3.5000` can move back to `vite-plus@1.4.0` by running
 that CLI's `vp migrate`. The older CLI rewrites the alias to
 `npm:@voidzero-dev/vite-plus-core@1.4.0` and re-adds the pnpm `vite` peer
 rule, because its logic is self-contained. Its `resolveCore` check compares
 the core version with its own version, so it reports a mismatch until the
 alias is realigned. The newer CLI reports the same for a project still on
-`1.4.0`: "Expected `@voidzero-dev/vite-plus-core@8.3.500`, found `1.4.0`.
+`1.4.0`: "Expected `@voidzero-dev/vite-plus-core@8.3.5000`, found `1.4.0`.
 Run `vp migrate`." Exact pins make the registry ordering irrelevant for the
 downgrade.
 
@@ -506,7 +512,7 @@ The encoded form keeps patch floors correct up to the bundled patch.
 The encoded form does not make every core version look different from a Vite
 version. On Vite patch 0, the core patch is `n` alone (`8.4.0`, `8.4.1`,
 ...), and core `8.4.1` looks like Vite `8.4.1`. Decoding still gives the
-correct Vite version (`floor(1 / 100) = 0`). Peer checks stay in the residual
+correct Vite version (`floor(1 / 1000) = 0`). Peer checks stay in the residual
 error class of the selected scheme: `>=8.4.1` passes against core `8.4.1`.
 
 ### Exact Vite version with a prerelease tag or build metadata
@@ -514,6 +520,43 @@ error class of the selected scheme: `>=8.4.1` passes against core `8.4.1`.
 Fails npm peer checks and cannot publish two Vite+ releases on one Vite
 patch. See the comparison.
 
+### Encode the counter in the Vite prerelease number
+
+A core on a Vite prerelease could use the same `* 1000` rule as a stable
+core: Vite `9.0.0-beta.3` would give `9.0.0-beta.3000`, then
+`9.0.0-beta.3001`. This gives one shape for every core version and one
+identifier less.
+
+The stable form needs the counter in the patch, because an extra identifier
+would make the version a prerelease, and npm does not match a prerelease
+against ranges such as `^8.0.0`. A Vite prerelease is already a prerelease,
+so the counter can follow it as `.vp.`. That version sorts directly after
+the Vite version and before the next one. The encoded number sorts after
+later Vite prereleases:
+
+```text
+9.0.0-beta.3 < 9.0.0-beta.3.vp.0 < 9.0.0-beta.4
+9.0.0-beta.3 < 9.0.0-beta.4 < 9.0.0-beta.3000
+```
+
+`semver@7.8.6` gives these peer results:
+
+| Consumer range   | `9.0.0-beta.3.vp.0` | `9.0.0-beta.3000` |
+| ---------------- | ------------------- | ----------------- |
+| `^9.0.0-beta.1`  | pass                | pass              |
+| `^9.0.0-beta.3`  | pass                | pass              |
+| `^9.0.0-beta.5`  | fail                | pass (wrong)      |
+| `>=9.0.0-beta.5` | fail                | pass (wrong)      |
+| `^9.0.0-rc.0`    | fail                | fail              |
+
+The `.vp.` form has no residual false result for prerelease floors. The
+encoded form adds the stable scheme's residual error to them. Plugins use such
+floors during a Vite beta: `@sveltejs/vite-plugin-svelte` declares
+`^8.0.0-beta.7 || ^8.0.0`. A core that bundles Vite `8.0.0-beta.3` as
+`8.0.0-beta.3000` would pass that check although its Vite is too old.
+`oxlint-tsgolint` publishes no prerelease versions, so its convention does
+not decide this case.
+
 ### Publish core only when the bundled Vite changes
 
 Core would use the exact Vite version. Core also bundles Rolldown, tsdown,
@@ -533,18 +576,18 @@ manifest `version` takes part in the check.
 ## Risks
 
 - Confusion with upstream Vite. `npm ls vite` prints
-  `vite@npm:@voidzero-dev/vite-plus-core@8.3.500`, and readers may take
-  `8.3.500` for a Vite version. On Vite patch 0, the core version (`8.4.1`)
+  `vite@npm:@voidzero-dev/vite-plus-core@8.3.5000`, and readers may take
+  `8.3.5000` for a Vite version. On Vite patch 0, the core version (`8.4.1`)
   looks exactly like a Vite version. The decoding rule is one division, and
   `vp toolchain` prints the real Vite version. Documentation must show both.
 - Bundled Vite downgrade. If Vite+ goes back to an older Vite version, the
-  core version goes down, for example from `8.4.0` to `8.3.501`. The counter
+  core version goes down, for example from `8.4.0` to `8.3.5001`. The counter
   rules still give a unique version, and exact pins make the lower version
   install correctly. `release.yml` publishes core with `--tag latest`. How
   npm sets `latest` when the published version is lower than the current
   `latest` is unverified. The dist-tag check after publish (see Release
   pipeline changes) makes a wrong tag visible.
-- Registry ordering. `8.3.500` sorts above every published `1.x` version, so
+- Registry ordering. `8.3.5000` sorts above every published `1.x` version, so
   `latest` moves forward as usual and a range on core would jump majors.
   Vite+ documents exact pins only. The stale `alpha` and `test` dist-tags
   point at `0.x` versions and can be removed or left alone.
@@ -559,7 +602,7 @@ manifest `version` takes part in the check.
   non-blocker. Migration keeps the pnpm rule for preview cores. Thus preview
   builds cannot show the new peer behavior, and the preview check in
   `.claude/skills/test-pkg-pr-new-migrate/` does not test it. Verification
-  before a release uses the local registry with the `n = 99` local version
+  before a release uses the local registry with the `n = 999` local version
   (see Local and CI builds).
 - Tools that read the `vite` version. Vite's `version` export is rewritten in
   `packages/core/build.ts` to import `../../package.json` relative to Vite's
@@ -569,7 +612,7 @@ manifest `version` takes part in the check.
   `node_modules/vite/package.json` directly, such as
   `readInstalledRawViteVersion` in `vite-plus-bootstrap.ts`, keys on
   `name === '@voidzero-dev/vite-plus-core'` and reads `bundledVersions.vite`.
-  Third-party code that reads `version` from that file now sees `8.3.500`
+  Third-party code that reads `version` from that file now sees `8.3.5000`
   instead of `1.4.0`, which is closer to the truth; whether any such code
   gates features on it is unverified.
 - Binding guards. The Rolldown binding guard in core compares the installed
@@ -596,8 +639,8 @@ package has the same problem, and none is in scope.
 
 ## Decisions from review
 
-- Local and CI builds use the reserved `n = 99`, and the release counter stops
-  at 98 (see Local and CI builds).
+- Local and CI builds use the reserved `n = 999`, and the release counter stops
+  at 998 (see Local and CI builds).
 - Vite+ prereleases use the `vp.` identifier instead of a copy of the
   `vite-plus` prerelease identifiers (see Prereleases and new Vite majors).
 - The `vitest` peer rule is removed in a follow-up with its own fixture

From fd343a3d3b9c8e997cef2f1255fc96cf88499181 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 11:08:44 +0800
Subject: [PATCH 10/24] ci: read the local core version from the core manifest
 only

The local CI builds set the core version after the CLI build, so the CLI
constant and its toolchain manifest kept the committed version and
VP_CORE_VERSION had to correct the alias. Stamp the local core version
before the build in test-vp-create and ecosystem CI instead. The CLI build,
its toolchain manifest, and the packed `vite` alias then all read
packages/core/package.json, and VP_CORE_VERSION is removed. The native
build cache key does not include the core manifest.

Co-Authored-By: Claude Opus 5.5 
---
 .github/workflows/reusable-ecosystem-ci.yml   | 19 +++++++----
 .github/workflows/test-vp-create.yml          | 29 ++++++++--------
 .../cli/src/utils/__tests__/constants.spec.ts | 16 ---------
 packages/cli/src/utils/constants.ts           |  6 ++--
 rfcs/core-version-alignment.md                | 33 ++++++++++---------
 5 files changed, 48 insertions(+), 55 deletions(-)

diff --git a/.github/workflows/reusable-ecosystem-ci.yml b/.github/workflows/reusable-ecosystem-ci.yml
index 743a956650..36e943060c 100644
--- a/.github/workflows/reusable-ecosystem-ci.yml
+++ b/.github/workflows/reusable-ecosystem-ci.yml
@@ -68,6 +68,15 @@ jobs:
         with:
           name: windows-cli-binaries
 
+      # Stamp the local core version (release counter 999, for example
+      # 8.3.5999) before the build, so the CLI build, its toolchain manifest,
+      # and the packed `vite` alias all read it from packages/core/package.json
+      # (rfcs/core-version-alignment.md). The native build cache key does not
+      # include that file. The local version still encodes the bundled Vite,
+      # so peer checks run as they do for a release.
+      - name: Set local core version
+        run: (cd packages/core && npm pkg set version="$(node ../tools/src/core-version.ts local)")
+
       - name: Build with upstream
         uses: ./.github/actions/build-upstream
         with:
@@ -85,15 +94,11 @@ jobs:
           # and installs them like a real release, with every package manager
           # (including bun) resolving the standard
           # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin
-          # the CLI to 0.0.0 and core to its local version (release counter
-          # 999, for example 8.3.5999) so a correctly installed local build is
+          # the CLI to 0.0.0 (core already has its local version, see "Set
+          # local core version") so a correctly installed local build is
           # always distinguishable from any published version
           # (verify-install.ts asserts it), even on a release commit that
-          # leaves packages/{core,cli} at a published version. The local core
-          # version still encodes the bundled Vite, so peer checks run as they
-          # do for a release (rfcs/core-version-alignment.md).
-          CORE_VERSION=$(node packages/tools/src/core-version.ts local)
-          (cd packages/core && npm pkg set version="$CORE_VERSION")
+          # leaves packages/{core,cli} at a published version.
           (cd packages/cli && npm pkg set version=0.0.0)
           cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
           cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml
index bdd562a0f3..108dc46f0c 100644
--- a/.github/workflows/test-vp-create.yml
+++ b/.github/workflows/test-vp-create.yml
@@ -93,6 +93,15 @@ jobs:
           path: ./rolldown/packages/rolldown/src
           merge-multiple: true
 
+      # Stamp the local core version (release counter 999, for example
+      # 8.3.5999) before the build, so the CLI build, its toolchain manifest,
+      # and the packed `vite` alias all read it from packages/core/package.json
+      # (rfcs/core-version-alignment.md). The native build cache key does not
+      # include that file. The local version still encodes the bundled Vite,
+      # so peer checks run as they do for a release.
+      - name: Set local core version
+        run: (cd packages/core && npm pkg set version="$(node ../tools/src/core-version.ts local)")
+
       - name: Build with upstream
         uses: ./.github/actions/build-upstream
         with:
@@ -107,15 +116,11 @@ jobs:
           # and installs them like a real release, with every package manager
           # (including bun) resolving the standard
           # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin
-          # the CLI to 0.0.0 and core to its local version (release counter
-          # 999, for example 8.3.5999) so a correctly installed local build is
+          # the CLI to 0.0.0 (core already has its local version, see "Set
+          # local core version") so a correctly installed local build is
           # always distinguishable from any published version, even on a
           # release commit that leaves packages/{core,cli} at a published
-          # version. The local core version still encodes the bundled Vite,
-          # so peer checks run as they do for a release
-          # (rfcs/core-version-alignment.md).
-          CORE_VERSION=$(node packages/tools/src/core-version.ts local)
-          (cd packages/core && npm pkg set version="$CORE_VERSION")
+          # version.
           (cd packages/cli && npm pkg set version=0.0.0)
           cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
           cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../..
@@ -184,8 +189,7 @@ jobs:
     env:
       # The local registry serves the packed 0.0.0 build, so create pins the
       # exact version like a real release. The vp binary was built before the
-      # pack step pinned the package versions, so align the version explicitly.
-      # The "Set local core version" step sets VP_CORE_VERSION the same way.
+      # pack step pinned the CLI version, so align the version explicitly.
       VP_VERSION: '0.0.0'
       # Force full dependency rewriting so the library template's existing
       # vite-plus dep gets overridden with the local build
@@ -209,9 +213,6 @@ jobs:
           name: vite-plus-packages
           path: tmp/tgz
 
-      - name: Set local core version
-        run: echo "VP_CORE_VERSION=$(node packages/tools/src/core-version.ts local)" >> "$GITHUB_ENV"
-
       - name: Install vp CLI
         run: |
           mkdir -p target/release
@@ -246,8 +247,8 @@ jobs:
           echo "$handshake"
           echo "$handshake" | node -e 'const { env } = JSON.parse(require("node:fs").readFileSync(0, "utf8")); for (const [k, v] of Object.entries(env)) console.log(`${k}=${v}`)' >> "$GITHUB_ENV"
 
-      # No VP_OVERRIDE_PACKAGES: with VP_CORE_VERSION set, the product default
-      # override map already pins `vite` to npm:@voidzero-dev/vite-plus-core@
+      # No VP_OVERRIDE_PACKAGES: the build read the local core version, so the
+      # product default override map already pins `vite` to npm:@voidzero-dev/vite-plus-core@
       # and `vitest` to the bundled version (see VITE_PLUS_OVERRIDE_PACKAGES in
       # packages/cli/src/utils/constants.ts), and stays correct across vitest
       # bumps without editing this workflow.
diff --git a/packages/cli/src/utils/__tests__/constants.spec.ts b/packages/cli/src/utils/__tests__/constants.spec.ts
index 42768cb67f..8ee08f7152 100644
--- a/packages/cli/src/utils/__tests__/constants.spec.ts
+++ b/packages/cli/src/utils/__tests__/constants.spec.ts
@@ -11,7 +11,6 @@ describe('Vite+ dependency versions', () => {
 
   it('uses the concrete CLI and core versions by default', async () => {
     vi.stubEnv('VP_VERSION', '');
-    vi.stubEnv('VP_CORE_VERSION', '');
     vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
     vi.resetModules();
 
@@ -29,7 +28,6 @@ describe('Vite+ dependency versions', () => {
     'keeps the core manifest version for VP_VERSION %s',
     async (version) => {
       vi.stubEnv('VP_VERSION', version);
-      vi.stubEnv('VP_CORE_VERSION', '');
       vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
       vi.resetModules();
 
@@ -39,20 +37,6 @@ describe('Vite+ dependency versions', () => {
     },
   );
 
-  it('uses VP_CORE_VERSION for the core alias', async () => {
-    vi.stubEnv('VP_VERSION', '0.0.0');
-    vi.stubEnv('VP_CORE_VERSION', '8.3.5999');
-    vi.stubEnv('VP_OVERRIDE_PACKAGES', '');
-    vi.resetModules();
-
-    const { VITE_PLUS_CORE_VERSION, VITE_PLUS_OVERRIDE_PACKAGES, VITE_PLUS_VERSION } =
-      await import('../constants.js');
-
-    expect(VITE_PLUS_VERSION).toBe('0.0.0');
-    expect(VITE_PLUS_CORE_VERSION).toBe('8.3.5999');
-    expect(VITE_PLUS_OVERRIDE_PACKAGES.vite).toBe('npm:@voidzero-dev/vite-plus-core@8.3.5999');
-  });
-
   it('preserves explicit prerelease overrides', async () => {
     const vitePlusUrl = 'https://pkg.pr.new/voidzero-dev/vite-plus@1891';
     const viteCoreUrl =
diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts
index 9c0191b224..9c09edbcc3 100644
--- a/packages/cli/src/utils/constants.ts
+++ b/packages/cli/src/utils/constants.ts
@@ -10,9 +10,9 @@ export const VITE_PLUS_CORE_NAME = '@voidzero-dev/vite-plus-core';
 export const VITE_PLUS_CORE_ALIAS_PREFIX = `npm:${VITE_PLUS_CORE_NAME}@`;
 
 // The core version encodes the bundled Vite version (rfcs/core-version-alignment.md),
-// so it differs from the vite-plus version. Preview builds stamp the core manifest
-// before the build, so `corePkg.version` is already the preview version.
-export const VITE_PLUS_CORE_VERSION = process.env.VP_CORE_VERSION || corePkg.version;
+// so it differs from the vite-plus version. Release, preview, and local CI builds
+// set the core manifest version before the build, so this is the packed version.
+export const VITE_PLUS_CORE_VERSION = corePkg.version;
 
 // Mirrors Vite's DEFAULT_CONFIG_FILES order so readers and writers target the same file.
 export const VITE_CONFIG_FILES = [
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 41ac5f5d18..6e642d717b 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -256,16 +256,16 @@ change.
 
 Code that assumes the two versions are equal must change:
 
-| Location                                                | Current behavior                                                      | Required change                                                                                                                                                 |
-| ------------------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `packages/cli/src/utils/constants.ts`                   | `VITE_PLUS_OVERRIDE_PACKAGES.vite` uses `VITE_PLUS_VERSION`           | Add `VITE_PLUS_CORE_VERSION = process.env.VP_CORE_VERSION \|\| corePkg.version`, read from `packages/core/package.json` at build time, and use it for the alias |
-| `packages/cli/src/resolve-core.ts`                      | Expects the core `version` to equal the installed `vite-plus` version | Parse the alias in the installed `vite-plus/package.json` `dependencies.vite`; skip the version check for a `workspace:` spec (repository layout)               |
-| `packages/cli/src/sync-versions/protocol.ts`, `plan.ts` | Maps `@voidzero-dev/vite-plus-core` to `toolchain.vitePlus`           | Add `vitePlusCore` from the `vite-plus-core` manifest node and map the core name to it                                                                          |
-| `packages/cli/src/migration/migrator/*`                 | Alias written through `VITE_PLUS_OVERRIDE_PACKAGES`                   | No change beyond the constant; the summary in `collectToolchainVersionChanges` already shows the raw Vite version                                               |
-| `ecosystem-ci/patch-project.ts`                         | `VP_OVERRIDE_PACKAGES.vite` uses the `vite-plus` tarball version      | Use the packed core version                                                                                                                                     |
-| `packages/tools/src/install-global-cli.ts`              | Maps the alias version to `voidzero-dev-vite-plus-core-.tgz` | No change; the alias already carries the core version after pack                                                                                                |
-| `crates/vp_global_cli/src/commands/version.rs`          | Reads `bundledVersions` from the installed core manifest              | No change                                                                                                                                                       |
-| `packages/cli/toolchain.config.json`                    | `vite-plus-core` node uses `core-package` as its version source       | No change; `vp toolchain` shows the core version and the bundled Vite version as separate nodes                                                                 |
+| Location                                                | Current behavior                                                      | Required change                                                                                                                                   |
+| ------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `packages/cli/src/utils/constants.ts`                   | `VITE_PLUS_OVERRIDE_PACKAGES.vite` uses `VITE_PLUS_VERSION`           | Add `VITE_PLUS_CORE_VERSION = corePkg.version`, read from `packages/core/package.json` at build time, and use it for the alias                    |
+| `packages/cli/src/resolve-core.ts`                      | Expects the core `version` to equal the installed `vite-plus` version | Parse the alias in the installed `vite-plus/package.json` `dependencies.vite`; skip the version check for a `workspace:` spec (repository layout) |
+| `packages/cli/src/sync-versions/protocol.ts`, `plan.ts` | Maps `@voidzero-dev/vite-plus-core` to `toolchain.vitePlus`           | Add `vitePlusCore` from the `vite-plus-core` manifest node and map the core name to it                                                            |
+| `packages/cli/src/migration/migrator/*`                 | Alias written through `VITE_PLUS_OVERRIDE_PACKAGES`                   | No change beyond the constant; the summary in `collectToolchainVersionChanges` already shows the raw Vite version                                 |
+| `ecosystem-ci/patch-project.ts`                         | `VP_OVERRIDE_PACKAGES.vite` uses the `vite-plus` tarball version      | Use the packed core version                                                                                                                       |
+| `packages/tools/src/install-global-cli.ts`              | Maps the alias version to `voidzero-dev-vite-plus-core-.tgz` | No change; the alias already carries the core version after pack                                                                                  |
+| `crates/vp_global_cli/src/commands/version.rs`          | Reads `bundledVersions` from the installed core manifest              | No change                                                                                                                                         |
+| `packages/cli/toolchain.config.json`                    | `vite-plus-core` node uses `core-package` as its version source       | No change; `vp toolchain` shows the core version and the bundled Vite version as separate nodes                                                   |
 
 The `vp upgrade` command in `crates/vp_global_cli/src/commands/upgrade/`
 installs the global CLI from the published `vite-plus` package and its
@@ -349,10 +349,13 @@ and peer checks fail against it. These flows would not test the path without a
 
 These flows will set core to `encodeCoreVersion(vite, 999)`, for example
 `8.3.5999` for Vite `8.3.5`. They keep `0.0.0` for the CLI, which
-`ecosystem-ci/verify-install.ts` and `test-vp-create.yml` assert. The CLI
-reads the core version from `VP_CORE_VERSION` or from the core manifest, so
-`vp create` and `vp migrate` in these flows write the `8.3.5999` alias and
-remove the `vite` peer rule.
+`ecosystem-ci/verify-install.ts` and `test-vp-create.yml` assert. They set the
+core version before the build, so the CLI build, its toolchain manifest, and
+the packed `vite` alias all read it from `packages/core/package.json`. The
+native build cache key does not include that file. `vp create` and
+`vp migrate` in these flows write the `8.3.5999` alias and remove the `vite`
+peer rule. No environment variable overrides the core version: the core
+manifest is the only source.
 
 `nextCoreVersion` never returns `n = 999`, and `release.yml` rejects it.
 Thus a local version never reaches the registry, and the local registry
@@ -368,7 +371,7 @@ cannot mix it up with a published version.
 | `packages/core/build.ts`                                                                                                         | Pass `cliPkgJson.version` instead of `pkgJson.version` to `rewriteRolldownBindingRequires`. The platform packages publish at the CLI version, and the guard must compare against that version (`packages/core/build-support/rewrite-rolldown-binding.ts`)                                                                                                                      |
 | `packages/cli/publish-native-addons.ts`                                                                                          | No change: it mirrors the CLI's platform pins into core by name and does not use the core version                                                                                                                                                                                                                                                                              |
 | `.github/workflows/publish-preview.yml`, `publish-preview-register.yml`                                                          | No change: the bridge serves every workspace package as `0.0.0-commit.`, and the stamp before `pnpm pack` keeps the alias on that version. The sticky comment keeps printing one version for both packages                                                                                                                                                                |
-| `.github/workflows/test-vp-create.yml`, `reusable-ecosystem-ci.yml`                                                              | Keep `0.0.0` for the CLI. Set core to `encodeCoreVersion(vite, 999)` and pass it as `VP_CORE_VERSION` where the workflow passes `VP_VERSION` (see Local and CI builds)                                                                                                                                                                                                         |
+| `.github/workflows/test-vp-create.yml`, `reusable-ecosystem-ci.yml`                                                              | Keep `0.0.0` for the CLI. Set core to `encodeCoreVersion(vite, 999)` before the build (see Local and CI builds)                                                                                                                                                                                                                                                                |
 | `packages/tools/src/local-npm-registry.ts`, `pack-local-vite-plus.ts`                                                            | No change: both serve the packed tarball's own version                                                                                                                                                                                                                                                                                                                         |
 | `.github/scripts/upgrade-deps.ts`                                                                                                | Reset the core version after a bundled Vite change                                                                                                                                                                                                                                                                                                                             |
 | `.github/renovate.json`                                                                                                          | No change                                                                                                                                                                                                                                                                                                                                                                      |

From 1ac6e9680913de6ad24de851d3eb58b769ebd39f Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:00:04 +0800
Subject: [PATCH 11/24] fix(migrate): keep the pnpm vite peer rule before pnpm
 10.2.0

pnpm 9.x to 10.1.x apply the `vite@*` alias override to peer ranges, so
every `vite` peer reads `vite@npm:@voidzero-dev/vite-plus-core@`
and fails against the installed core without the rule. Pass the pnpm
version to the peer-rule helpers and keep the rule on those versions.

- Add a strict pnpm peer check case in the layout that `vp migrate` writes,
  with the `vite@*` alias override.
- Correct the docs: an incompatible plugin gets a peer warning on pnpm and
  Yarn. npm applies `overrides.vite` to peer edges, and Bun reports no peer
  problems. The overrides stay because they keep one core instance.

Co-Authored-By: Claude Opus 5.5 
---
 .../snapshots.toml                            | 11 ++++++
 ..._dependency_checks_pnpm_strict_override.md | 20 ++++++++++
 docs/guide/local-cli.md                       |  2 +-
 docs/guide/migrate-rules.md                   |  3 +-
 .../vite-peer-dependency-rule.spec.ts         | 39 +++++++++++++++++++
 .../cli/src/migration/migrator/catalog.ts     |  9 +++--
 .../src/migration/migrator/orchestrators.ts   |  5 +++
 packages/cli/src/migration/migrator/shared.ts | 19 +++++++++
 .../migration/migrator/vite-plus-bootstrap.ts | 39 ++++++++++++++++---
 .../migration/migrator/vitest-ecosystem.ts    | 25 ++++++------
 rfcs/core-version-alignment.md                | 22 ++++++++---
 11 files changed, 166 insertions(+), 28 deletions(-)
 create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict_override.md

diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml
index d99c3de477..da0c021b22 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots.toml
@@ -8,3 +8,14 @@ steps = [
   { argv = ["vp", "install", "--ignore-scripts"], timeout = 120000, snapshot = false },
   { argv = ["node", "check-peers.mjs"], timeout = 120000 },
 ]
+
+[[case]]
+name = "core_peer_dependency_checks_pnpm_strict_override"
+vp = "global"
+local-registry = true
+comment = "The same strict pnpm peer checks in the layout that vp migrate writes, with the vite@* alias override and no peer rule."
+steps = [
+  { argv = ["vpt", "write-file", "pnpm-workspace.yaml", "strictPeerDependencies: true\nminimumReleaseAge: 0\noverrides:\n  vite@*: npm:@voidzero-dev/vite-plus-core@latest\n"], snapshot = false },
+  { argv = ["vp", "install", "--ignore-scripts"], timeout = 120000, snapshot = false },
+  { argv = ["node", "check-peers.mjs"], timeout = 120000 },
+]
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict_override.md
new file mode 100644
index 0000000000..81c57988d8
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/core_peer_dependency_checks/snapshots/core_peer_dependency_checks_pnpm_strict_override.md
@@ -0,0 +1,20 @@
+# core_peer_dependency_checks_pnpm_strict_override
+
+The same strict pnpm peer checks in the layout that vp migrate writes, with the vite@* alias override and no peer rule.
+
+## `vpt write-file pnpm-workspace.yaml 'strictPeerDependencies: true
+minimumReleaseAge: 0
+overrides:
+  vite@*: npm:@voidzero-dev/vite-plus-core@latest
+'`
+
+
+## `vp install --ignore-scripts`
+
+
+## `node check-peers.mjs`
+
+```
+pnpm strict peer check accepts a plugin that supports the bundled Vite
+pnpm strict peer check rejects a plugin that requires Vite 7
+```
diff --git a/docs/guide/local-cli.md b/docs/guide/local-cli.md
index 532a1e3b23..9ac189551e 100644
--- a/docs/guide/local-cli.md
+++ b/docs/guide/local-cli.md
@@ -113,7 +113,7 @@ If you also declare `vitest` directly, set its dependency entry to the exact ver
 Dependencies and plugins can import `vite` or `vitest` directly, even when your own code imports from `vite-plus`. These overrides align their dependencies with the toolchain Vite+ uses:
 
 - The `vite` alias directs those imports to Vite+'s core package. Separate Vite instances can break runtime identity checks: [issue #1391](https://github.com/voidzero-dev/vite-plus/issues/1391) reported TanStack Start returning 404s because an `instanceof` check crossed two copies. [PR #2617](https://github.com/voidzero-dev/vite-plus/pull/2617) addresses the CLI side by sharing Vite through the same alias.
-- The core version encodes the bundled Vite version. Its major and minor are the Vite major and minor, and its patch is the Vite patch times 1000 plus a release counter, the same convention that `oxlint-tsgolint` uses for TypeScript. For example, core `8.3.5000` bundles Vite `8.3.5`. Package managers compare this version with the `vite` peer ranges of plugins, so a plugin that supports the bundled Vite passes the check, and a plugin that does not support it still gets a peer warning. [Issue #2806](https://github.com/voidzero-dev/vite-plus/issues/2806) describes the earlier `1.x` versions, which failed every check.
+- The core version encodes the bundled Vite version. Its major and minor are the Vite major and minor, and its patch is the Vite patch times 1000 plus a release counter, the same convention that `oxlint-tsgolint` uses for TypeScript. For example, core `8.3.5000` bundles Vite `8.3.5`. Package managers compare this version with the `vite` peer ranges of plugins, so a plugin that supports the bundled Vite passes the check. With pnpm and Yarn, a plugin that does not support it still gets a peer warning. npm applies `overrides.vite` to peer dependencies too, so npm does not report an incompatible `vite` peer, and Bun does not report peer problems. [Issue #2806](https://github.com/voidzero-dev/vite-plus/issues/2806) describes the earlier `1.x` versions, which failed every check.
 - The exact `vitest` pin keeps dependencies and `vp test` on the same Vitest version, avoiding separate mocks, `expect` instances, and runner state. [PR #2365](https://github.com/voidzero-dev/vite-plus/pull/2365) documents this requirement for manual installation.
 
 Keep the core alias aligned with the core version of your installed `vite-plus` and update the Vitest pin to match its bundled version when upgrading. [Issue #2356](https://github.com/voidzero-dev/vite-plus/issues/2356) describes how dependency bots can update these packages independently and leave incompatible versions installed together.
diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md
index 8d9d785717..1e814b60fc 100644
--- a/docs/guide/migrate-rules.md
+++ b/docs/guide/migrate-rules.md
@@ -382,8 +382,9 @@ Migration converts the Volta `volta.node` pin to `.node-version`, because Vite+
 **Peer rules.** The core version encodes the bundled Vite version: core `8.3.5000` bundles Vite `8.3.5` (see the [RFC](https://github.com/voidzero-dev/vite-plus/blob/main/rfcs/core-version-alignment.md)). pnpm checks `vite` peer ranges against the `vite` alias, so a plugin that supports the bundled Vite passes, and a plugin that does not support it fails.
 
 - Migration does not add `vite` to `peerDependencyRules.allowAny` or `peerDependencyRules.allowedVersions`.
-- Migration removes `vite` from `allowAny` and removes `allowedVersions.vite` when its value is `'*'`, also when you wrote the entry. The only purpose of those entries was to accept the `1.x` core alias. Another `allowedVersions.vite` range, such as `'7 || 8'`, stays. Migration removes a field, or `peerDependencyRules`, when no entry remains. Other entries stay. The migrate summary names the removed rule.
+- Migration removes `vite` from `allowAny` and removes `allowedVersions.vite` when its value is `'*'`, also when you wrote the entry. Those entries accepted the `1.x` core alias. Another `allowedVersions.vite` range, such as `'7 || 8'`, stays. Migration removes a field, or `peerDependencyRules`, when no entry remains. Other entries stay. The migrate summary names the removed rule.
 - For a preview build (`0.0.0-commit.`) or a Vite+ prerelease core (such as `8.4.0-vp.0`), migration keeps writing the `vite` rule. Package managers do not accept these versions for ranges such as `^8.0.0`.
+- On pnpm before 10.2.0, migration also keeps writing the `vite` rule. These versions apply the `vite@*` alias override to peer ranges, so every `vite` peer fails against the core without the rule.
 - After migration, a plugin whose `vite` peer range excludes the bundled Vite (for example `^7.0.0`) produces a peer warning. With `strictPeerDependencies`, the install fails. Update the plugin, or add a rule scoped to that plugin, such as `allowedVersions: { 'vite-plugin-foo>vite': '*' }`. Migration removes only the plain `vite` key.
 
 **Other rules.**
diff --git a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
index ba9207575a..0a7a33303f 100644
--- a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
+++ b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
@@ -112,6 +112,22 @@ describe('vitePeerDependencyRuleRequired', () => {
   });
 });
 
+describe('vitePeerDependencyRuleRequired with a pnpm version', () => {
+  // pnpm before 10.2.0 applies the `vite@*` alias override to peer ranges, so
+  // every `vite` peer fails without the rule, whatever the core version.
+  it.each([
+    ['9.15.9', true],
+    ['10.0.0', true],
+    ['10.1.0', true],
+    ['10.2.0', false],
+    ['10.33.0', false],
+    ['12.11.2', false],
+    [undefined, false],
+  ])('pnpm %s requires the rule for a stable core: %s', (pnpmVersion, required) => {
+    expect(vitePeerDependencyRuleRequired(STABLE_CORE, pnpmVersion)).toBe(required);
+  });
+});
+
 describe('pnpm vite peer rule', () => {
   let tmpDir: string;
   const savedViteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite;
@@ -182,6 +198,29 @@ describe('pnpm vite peer rule', () => {
       expect(report.vitePeerDependencyRuleRemoved).toBe(false);
     });
 
+    it('keeps the vite rule on pnpm before 10.2.0', () => {
+      fs.writeFileSync(
+        path.join(tmpDir, 'package.json'),
+        JSON.stringify({
+          name: 'test',
+          devDependencies: { 'vite-plus': 'latest' },
+          pnpm: {
+            overrides: { react: '18.3.1' },
+            peerDependencyRules: { allowAny: ['vite'], allowedVersions: { vite: '*' } },
+          },
+        }),
+      );
+      const report = createMigrationReport();
+
+      ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, '9.15.9'), report);
+
+      expect(readPackageJsonRules(tmpDir)).toEqual({
+        allowAny: ['vite'],
+        allowedVersions: { vite: '*' },
+      });
+      expect(report.vitePeerDependencyRuleRemoved).toBe(false);
+    });
+
     it('keeps a user vite range in pnpm-workspace.yaml', () => {
       writeVitePlusProject(tmpDir, [
         'peerDependencyRules:',
diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts
index 74b99a732a..46e0017af9 100644
--- a/packages/cli/src/migration/migrator/catalog.ts
+++ b/packages/cli/src/migration/migrator/catalog.ts
@@ -306,6 +306,7 @@ export function rewritePnpmWorkspaceYaml(
   writeWorkspaceSettings = true,
   catalogAdditions: ReadonlySet = new Set(),
   usesWebdriverioProvider = false,
+  pnpmVersion?: string,
 ): void {
   const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml');
   if (!fs.existsSync(pnpmWorkspaceYamlPath)) {
@@ -411,9 +412,9 @@ export function rewritePnpmWorkspaceYaml(
       }
     }
     // Drop lingering managed `vitest` and stale `vite` entries.
-    const stalePeerRuleKeys = stalePeerDependencyRuleKeys(usesVitest);
+    const stalePeerRuleKeys = stalePeerDependencyRuleKeys(usesVitest, pnpmVersion);
     allowAny.items = allowAny.items.filter((n) => !stalePeerRuleKeys.includes(n.value));
-    const peerRuleKeys = managedPeerDependencyRuleKeys(usesVitest);
+    const peerRuleKeys = managedPeerDependencyRuleKeys(usesVitest, pnpmVersion);
     const existing = new Set(allowAny.items.map((n) => n.value));
     for (const key of peerRuleKeys) {
       if (!existing.has(key)) {
@@ -1297,6 +1298,7 @@ export function rewriteRootWorkspacePackageJson(
           peerDependencyRules: mergeManagedPnpmPeerDependencyRules(
             pkg.pnpm?.peerDependencyRules,
             workspaceUsesVitest,
+            pnpmVersion,
           ),
         };
       } else {
@@ -1460,11 +1462,12 @@ export function trackVitePeerDependencyRuleRemoval(
   projectPath: string,
   packageManager: PackageManager,
   report: MigrationReport | undefined,
+  pnpmVersion?: string,
 ): () => void {
   if (
     !report ||
     packageManager !== PackageManager.pnpm ||
-    vitePeerDependencyRuleRequired() ||
+    vitePeerDependencyRuleRequired(undefined, pnpmVersion) ||
     !hasVitePeerDependencyRule(projectPath)
   ) {
     return () => {};
diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts
index 49b760f66e..d50b9309cf 100644
--- a/packages/cli/src/migration/migrator/orchestrators.ts
+++ b/packages/cli/src/migration/migrator/orchestrators.ts
@@ -88,6 +88,7 @@ export function rewriteStandaloneProject(
     projectPath,
     packageManager,
     report,
+    workspaceInfo.downloadPackageManager.version,
   );
   const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager);
   const vitestEcosystemPackages = collectVitestEcosystemInstallDependencyNames(projectPath);
@@ -216,6 +217,7 @@ export function rewriteStandaloneProject(
           peerDependencyRules: mergeManagedPnpmPeerDependencyRules(
             pkg.pnpm?.peerDependencyRules,
             usesVitest,
+            workspaceInfo.downloadPackageManager.version,
           ),
         };
       } else {
@@ -300,6 +302,7 @@ export function rewriteStandaloneProject(
       usePnpmWorkspaceYaml,
       providerCatalogAdditions,
       usesWebdriverio,
+      workspaceInfo.downloadPackageManager.version,
     );
   }
 
@@ -367,6 +370,7 @@ export function rewriteMonorepo(
     workspaceInfo.rootDir,
     workspaceInfo.packageManager,
     report,
+    workspaceInfo.downloadPackageManager.version,
   );
   const catalogDependencyResolver = createCatalogDependencyResolver(
     workspaceInfo.rootDir,
@@ -465,6 +469,7 @@ export function rewriteMonorepo(
       usePnpmWorkspaceSettings,
       providerCatalogAdditions,
       usesWebdriverio,
+      workspaceInfo.downloadPackageManager.version,
     );
     ensurePnpmWorkspacePackages(workspaceInfo.rootDir, workspaceInfo.workspacePatterns);
     if (usePnpmWorkspaceSettings && isForceOverrideMode()) {
diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts
index 6d45114212..76df85e520 100644
--- a/packages/cli/src/migration/migrator/shared.ts
+++ b/packages/cli/src/migration/migrator/shared.ts
@@ -215,6 +215,21 @@ export const VITEST_BROWSER_DEP_NAMES = [
 // and must be left untouched.
 export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES;
 
+/**
+ * pnpm before 10.2.0 applies the `vite@*` alias override to peer ranges too, so
+ * every `vite` peer reads `vite@npm:@voidzero-dev/vite-plus-core@` and
+ * fails against the installed core. Those versions need the `vite` peer rule
+ * whatever the core version. An unknown version counts as a current pnpm.
+ */
+function pnpmAppliesAliasOverrideToPeers(pnpmVersion: string | undefined): boolean {
+  const version = pnpmVersion ? coerce(pnpmVersion) : undefined;
+  return (
+    version !== undefined &&
+    version !== null &&
+    (version.major < 10 || (version.major === 10 && version.minor < 2))
+  );
+}
+
 /**
  * Whether pnpm still needs the managed `vite` peer rule (`allowAny: [vite]`,
  * `allowedVersions: { vite: '*' }`) for the given core alias.
@@ -232,7 +247,11 @@ export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGE
  */
 export function vitePeerDependencyRuleRequired(
   viteSpec: string | undefined = VITE_PLUS_OVERRIDE_PACKAGES.vite,
+  pnpmVersion?: string,
 ): boolean {
+  if (pnpmAppliesAliasOverrideToPeers(pnpmVersion)) {
+    return true;
+  }
   if (!viteSpec?.startsWith(VITE_PLUS_CORE_ALIAS_PREFIX)) {
     return true;
   }
diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
index 8df7cf1bb7..18285797d3 100644
--- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
+++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
@@ -218,16 +218,20 @@ function pnpmPeerDependencyRulesSatisfyVitePlus(
     | { allowAny?: string[]; allowedVersions?: Record }
     | undefined,
   usesVitest: boolean,
+  pnpmVersion: string | undefined,
 ): boolean {
   // A lingering managed `vitest` rule or a stale `vite` rule is NOT satisfied.
   if (
-    peerDependencyRulesHaveStaleEntry(peerDependencyRules, stalePeerDependencyRuleKeys(usesVitest))
+    peerDependencyRulesHaveStaleEntry(
+      peerDependencyRules,
+      stalePeerDependencyRuleKeys(usesVitest, pnpmVersion),
+    )
   ) {
     return false;
   }
   const allowAny = new Set(peerDependencyRules?.allowAny ?? []);
   const allowedVersions = peerDependencyRules?.allowedVersions ?? {};
-  const ruleKeys = managedPeerDependencyRuleKeys(usesVitest);
+  const ruleKeys = managedPeerDependencyRuleKeys(usesVitest, pnpmVersion);
   return ruleKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*');
 }
 
@@ -763,7 +767,11 @@ export function detectVitePlusBootstrapPending(
             'pnpm-ranged',
             usesWebdriverio,
           ) ||
-          !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest)
+          !pnpmPeerDependencyRulesSatisfyVitePlus(
+            pkg.pnpm?.peerDependencyRules,
+            usesVitest,
+            detectedPackageManagerVersion,
+          )
         );
       }
       return (
@@ -775,7 +783,11 @@ export function detectVitePlusBootstrapPending(
           'pnpm-ranged',
           usesWebdriverio,
         ) ||
-        !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest)
+        !pnpmPeerDependencyRulesSatisfyVitePlus(
+          pkg.pnpm?.peerDependencyRules,
+          usesVitest,
+          detectedPackageManagerVersion,
+        )
       );
     }
     const resolver = readPnpmWorkspaceCatalogDependencyResolver(projectPath);
@@ -791,6 +803,7 @@ export function detectVitePlusBootstrapPending(
       !pnpmPeerDependencyRulesSatisfyVitePlus(
         readPnpmWorkspacePeerDependencyRules(projectPath),
         usesVitest,
+        detectedPackageManagerVersion,
       ) ||
       pnpmWorkspaceMinimumReleaseAgeExemptionsPending(projectPath)
     );
@@ -913,11 +926,16 @@ function ensureOverrideEntries(
   return { overrides: next, changed };
 }
 
-function ensurePnpmPeerDependencyRules(pkg: BootstrapPackageJson, usesVitest: boolean): boolean {
+function ensurePnpmPeerDependencyRules(
+  pkg: BootstrapPackageJson,
+  usesVitest: boolean,
+  pnpmVersion: string | undefined,
+): boolean {
   pkg.pnpm ??= {};
   const peerDependencyRules = mergeManagedPnpmPeerDependencyRules(
     pkg.pnpm.peerDependencyRules,
     usesVitest,
+    pnpmVersion,
   );
   const changed =
     JSON.stringify(pkg.pnpm.peerDependencyRules ?? {}) !==
@@ -949,6 +967,7 @@ export function ensureVitePlusBootstrap(
     projectPath,
     workspaceInfo.packageManager,
     report,
+    workspaceInfo.downloadPackageManager.version,
   );
 
   // Shared override/catalog sinks are workspace-wide, so keep vitest managed
@@ -1024,6 +1043,7 @@ export function ensureVitePlusBootstrap(
         false,
         providerCatalogAdditions,
         usesWebdriverio,
+        workspaceInfo.downloadPackageManager.version,
       );
     }
   }
@@ -1102,7 +1122,12 @@ export function ensureVitePlusBootstrap(
         pkg.pnpm.overrides = ensured.overrides;
         packageJsonChanged = true;
       }
-      packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged;
+      packageJsonChanged =
+        ensurePnpmPeerDependencyRules(
+          pkg,
+          usesVitest,
+          workspaceInfo.downloadPackageManager.version,
+        ) || packageJsonChanged;
       if (pnpmMajorVersion !== undefined && pkg.pnpm) {
         const beforePnpm = JSON.stringify(pkg.pnpm);
         applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds);
@@ -1202,6 +1227,7 @@ export function ensureVitePlusBootstrap(
         !pnpmPeerDependencyRulesSatisfyVitePlus(
           readPnpmWorkspacePeerDependencyRules(projectPath),
           usesVitest,
+          workspaceInfo.downloadPackageManager.version,
         )
       ) {
         rewritePnpmWorkspaceYaml(
@@ -1213,6 +1239,7 @@ export function ensureVitePlusBootstrap(
           true,
           providerCatalogAdditions,
           usesWebdriverio,
+          workspaceInfo.downloadPackageManager.version,
         );
       }
       if (fs.existsSync(pnpmWorkspaceYamlPath)) {
diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
index 70cd8df17c..bdb68825ce 100644
--- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts
+++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
@@ -543,17 +543,17 @@ function managedVitestOverrideKeys(keyStyle: ManagedOverrideKeyStyle): string[]
 
 // The pnpm peerDependencyRules keys that migration removes:
 // - `vitest`, a lingering managed rule, when the workspace does not use Vitest.
-// - `vite`, once its core alias satisfies `vite` peer ranges (see
-//   `vitePeerDependencyRuleRequired`). Then the rule only hides real
-//   incompatibilities. Migration cannot tell whether an earlier migration or the
-//   user wrote the entry; its only purpose was to accept the `1.x` alias, so
-//   both go.
-export function stalePeerDependencyRuleKeys(usesVitest: boolean): string[] {
+// - `vite`, once its core alias satisfies `vite` peer ranges and pnpm is 10.2.0
+//   or later (see `vitePeerDependencyRuleRequired`). Then the rule only hides
+//   real incompatibilities. Migration cannot tell whether an earlier migration
+//   or the user wrote the entry; its only purpose was to accept the `1.x` alias,
+//   so both go.
+export function stalePeerDependencyRuleKeys(usesVitest: boolean, pnpmVersion?: string): string[] {
   const keys: string[] = [];
   if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) {
     keys.push('vitest');
   }
-  if (!vitePeerDependencyRuleRequired()) {
+  if (!vitePeerDependencyRuleRequired(undefined, pnpmVersion)) {
     keys.push('vite');
   }
   return keys;
@@ -588,9 +588,11 @@ export function peerDependencyRulesHaveStaleEntry(
 
 // The pnpm peerDependencyRules keys vite-plus manages: the managed override
 // keys, minus `vite` once its core alias satisfies `vite` peer ranges.
-export function managedPeerDependencyRuleKeys(usesVitest: boolean): string[] {
+export function managedPeerDependencyRuleKeys(usesVitest: boolean, pnpmVersion?: string): string[] {
   const keys = Object.keys(managedOverridePackages(usesVitest));
-  return vitePeerDependencyRuleRequired() ? keys : keys.filter((key) => key !== 'vite');
+  return vitePeerDependencyRuleRequired(undefined, pnpmVersion)
+    ? keys
+    : keys.filter((key) => key !== 'vite');
 }
 
 // Re-derive the managed pnpm peerDependencyRules on top of the package.json
@@ -599,9 +601,10 @@ export function managedPeerDependencyRuleKeys(usesVitest: boolean): string[] {
 export function mergeManagedPnpmPeerDependencyRules(
   rules: PnpmPeerDependencyRules | undefined,
   usesVitest: boolean,
+  pnpmVersion?: string,
 ): PnpmPeerDependencyRules | undefined {
-  const stale = stalePeerDependencyRuleKeys(usesVitest);
-  const keys = managedPeerDependencyRuleKeys(usesVitest);
+  const stale = stalePeerDependencyRuleKeys(usesVitest, pnpmVersion);
+  const keys = managedPeerDependencyRuleKeys(usesVitest, pnpmVersion);
   const allowAny = [
     ...new Set([
       ...(Array.isArray(rules?.allowAny) ? rules.allowAny : []).filter(
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 6e642d717b..84095574c4 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -451,20 +451,30 @@ Peer rules change only for pnpm:
   delete an empty `peerDependencyRules` object or YAML map. Other entries
   stay, including `vitest`.
 - The writers cannot tell whether an earlier migration or the user wrote
-  `allowAny: [vite]` or `allowedVersions.vite: '*'`, so they remove both. The
-  only purpose of those entries is to accept the `1.x` alias. Migration only
-  ever wrote `'*'`, so another `allowedVersions.vite` range is the user's own
-  constraint and stays. `vp migrate` prints one line that names the removed
+  `allowAny: [vite]` or `allowedVersions.vite: '*'`, so they remove both. On
+  pnpm 10.2.0 and later, the only purpose of those entries is to accept the
+  `1.x` alias. Migration only ever wrote `'*'`, so another
+  `allowedVersions.vite` range is the user's own constraint and stays. `vp migrate` prints one line that names the removed
   rule, and `docs/guide/migrate-rules.md` describes the removal.
 - When the target core version is not a stable encoded version (a preview
   `0.0.0-commit.` or a Vite+ prerelease such as `8.4.0-vp.0`), the
   writers keep the current behavior and write the `vite` rule.
+- On pnpm before 10.2.0, the writers also keep the `vite` rule. These versions
+  apply the `vite@*` alias override to peer ranges: every `vite` peer reads
+  `vite@npm:@voidzero-dev/vite-plus-core@` and fails against the
+  installed core, also for `vitest` and `@vitest/mocker`. pnpm 10.2.0 and
+  later check peers against the alias target version.
 - `pnpmPeerDependencyRulesSatisfyVitePlus` reports a project as pending when
   a `vite` entry exists and the target core is a stable encoded version, or
   when the entry is missing and the target core is not.
 
-npm, Yarn, and Bun have no peer suppression to remove. Their alias handling
-is unchanged: npm keeps the direct dependency and `overrides.vite` on the same
+npm, Yarn, and Bun have no peer suppression to remove. On npm, the
+`overrides.vite` entry also rewrites peer edges, so a migrated npm project does
+not report an incompatible `vite` peer: the third clause of the goal ("an
+incompatible plugin still fails") holds on pnpm and Yarn, not on npm. Bun
+reports no peer problems in any layout. The override stays on every package
+manager, because it keeps one core instance for direct, transitive, and peer
+`vite` dependencies. Their alias handling is unchanged: npm keeps the direct dependency and `overrides.vite` on the same
 spec (`EOVERRIDE` rule), `npm-reinstall.ts` keeps moving stale `vite`
 installs aside by package name, Yarn keeps `resolutions.vite`, and Bun keeps
 the mirrored direct `vite` dependency.

From 163fb139ffa05b80d62cb27fa99cb5edc5b302a5 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:11:18 +0800
Subject: [PATCH 12/24] docs(rfc): show the effect before and after the core
 version change

Add a section with the user-visible effect: the version mapping, pnpm
strict installs for compatible and incompatible plugins, the
`pnpm-workspace.yaml` change that `vp migrate` makes, the npm `ERESOLVE`
warnings from #2806, and a summary by package manager with the known
limits on npm and Bun.

Co-Authored-By: Claude Opus 5.5 
---
 rfcs/core-version-alignment.md | 116 +++++++++++++++++++++++++++++++++
 1 file changed, 116 insertions(+)

diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 84095574c4..0d28c75f1e 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -50,6 +50,122 @@ Projects keep the same alias in their manifests, overrides, catalogs, and
 resolutions. `vp migrate` stops adding `vite` to pnpm `peerDependencyRules`
 and removes the entry that earlier migrations wrote.
 
+## Before and after
+
+This section shows the effect for users. The "before" output comes from
+issue #2806. The "after" output comes from the `core_peer_dependency_checks`
+snapshot cases, the recorded migration snapshots, and the review experiments
+against the packed checkout (`vite-plus` `1.5.1`, core `8.3.7000`, Vitest
+`5.1.0`; pnpm 12.11.2, npm 11.19.1, Yarn 4.18.1, Bun 1.4.4).
+
+### Versions
+
+| Release           | Bundled Vite | Core version before | Core version after |
+| ----------------- | ------------ | ------------------- | ------------------ |
+| `vite-plus@1.5.1` | `8.3.7`      | `1.5.1`             | `8.3.7000`         |
+
+Before, the core version said nothing about Vite. After, `8.3.7000` reads as
+Vite `8.3.7`, release counter 0.
+
+### pnpm with `strictPeerDependencies`, compatible plugins
+
+Before (#2806, core `1.0.0-rc.0`), the install failed on Vitest's own peer:
+
+```text
+[ERR_PNPM_PEER_DEP_ISSUES] Unmet peer dependencies
+
+✕ unmet peer vite
+  Installed: 1.0.0-rc.0
+  Wanted:
+    "^6.0.0 || ^7.0.0 || ^8.0.0":
+      @vitest/mocker@5.0.1
+    "^6.4.0 || ^7.0.0 || ^8.0.0":
+      vitest@5.0.1
+```
+
+The only fix was the broad rule `allowAny: [vite]` and `allowedVersions: { vite: '*' }`.
+
+After, the same install succeeds with no peer rule, both with and without the
+`vite@*` alias override that `vp migrate` writes. A plugin with the peer range
+`^8.0.0` also installs.
+
+### pnpm, a plugin that does not support the bundled Vite
+
+Before, the broad rule hid it: a plugin with the peer range `^7.0.0` installed
+with no message.
+
+After, pnpm reports it, and the install fails under `strictPeerDependencies`:
+
+```text
+[ERR_PNPM_PEER_DEP_ISSUES] Unmet peer dependencies
+
+✕ unmet peer vite
+  Installed: 8.3.7000
+  Wanted:
+    ^7.0.0:
+      vite-plugin-legacy@1.0.0
+```
+
+Yarn reports the same plugin:
+
+```text
+➤ YN0060: │ vite is listed by your project with version 8.3.7000 (p6facb0), which doesn't satisfy what vite-plugin-legacy requests (^7.0.0).
+```
+
+### What `vp migrate` changes in `pnpm-workspace.yaml`
+
+For a project on pnpm 10.2.0 or later, migration removes the rule that it wrote
+earlier and names the change in its summary:
+
+```diff
+ overrides:
+   vite@*: 'catalog:'
+-peerDependencyRules:
+-  allowAny:
+-    - vite
+-  allowedVersions:
+-    vite: '*'
+ catalog:
+   vite: npm:@voidzero-dev/vite-plus-core@
+```
+
+```text
+• Package manager settings configured
+• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
+```
+
+On pnpm before 10.2.0, and for preview and prerelease cores, the rule stays.
+
+### npm
+
+Before (#2806), every `npm install` of a project with `vite-plus` printed
+warnings from the `vite-plus` dependency tree itself, and npm had no
+per-package way to silence them:
+
+```text
+npm warn While resolving: vitest@5.0.1
+npm warn Found: vite@1.0.0
+npm warn Could not resolve dependency:
+npm warn peer vite@"^6.4.0 || ^7.0.0 || ^8.0.0" from vitest@5.0.1
+```
+
+After, the same projects install with no `ERESOLVE` output. This holds for a
+project that declares only `vite-plus`, and for one with the `vite` alias.
+
+### Summary by package manager
+
+| Package manager    | Compatible plugin                               | Incompatible plugin (`^7.0.0`)                       | `vite` peer rule |
+| ------------------ | ----------------------------------------------- | ---------------------------------------------------- | ---------------- |
+| pnpm 10.2.0+       | Before: warning or failure. After: passes       | Before: hidden. After: warning, failure under strict | Removed          |
+| pnpm before 10.2.0 | Unchanged: passes with the rule                 | Unchanged: hidden by the rule                        | Kept             |
+| npm                | Before: `ERESOLVE` warnings. After: no warnings | Not reported with `overrides.vite`, before and after | Not used         |
+| Yarn               | Before: `YN0060`. After: passes                 | After: `YN0060`                                      | Not used         |
+| Bun                | No peer messages, before and after              | Not reported, before and after                       | Not used         |
+
+The `vite` and `vitest` overrides and resolutions stay on every package
+manager. They keep one core instance and one Vitest instance, which the version
+scheme does not replace (see Migration).
+
 ## Problem
 
 A migrated project aliases `vite` to the core package:

From 6ec8f888ed60e2371208bf37060d6df613f1c827 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:15:29 +0800
Subject: [PATCH 13/24] docs: recommend pnpm's :peer override for one
 incompatible plugin

pnpm 12.17.0 deprecates `peerDependencyRules` (pnpm/pnpm#17457). For a
plugin whose `vite` peer range excludes the bundled Vite, the migrate rules
now recommend the override `'vite-plugin-foo>vite:peer': '*'` on pnpm
12.17.0 and later, and the scoped `allowedVersions` entry on earlier pnpm.

The RFC records the deprecation: migration still writes the setting for
preview and prerelease cores, pnpm before 10.2.0, and the redundant `vitest`
rule, which the planned follow-up removes.

Co-Authored-By: Claude Opus 5.5 
---
 docs/guide/migrate-rules.md    |  6 +++++-
 rfcs/core-version-alignment.md | 14 +++++++++++++-
 2 files changed, 18 insertions(+), 2 deletions(-)

diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md
index 1e814b60fc..307d7c4da6 100644
--- a/docs/guide/migrate-rules.md
+++ b/docs/guide/migrate-rules.md
@@ -385,7 +385,11 @@ Migration converts the Volta `volta.node` pin to `.node-version`, because Vite+
 - Migration removes `vite` from `allowAny` and removes `allowedVersions.vite` when its value is `'*'`, also when you wrote the entry. Those entries accepted the `1.x` core alias. Another `allowedVersions.vite` range, such as `'7 || 8'`, stays. Migration removes a field, or `peerDependencyRules`, when no entry remains. Other entries stay. The migrate summary names the removed rule.
 - For a preview build (`0.0.0-commit.`) or a Vite+ prerelease core (such as `8.4.0-vp.0`), migration keeps writing the `vite` rule. Package managers do not accept these versions for ranges such as `^8.0.0`.
 - On pnpm before 10.2.0, migration also keeps writing the `vite` rule. These versions apply the `vite@*` alias override to peer ranges, so every `vite` peer fails against the core without the rule.
-- After migration, a plugin whose `vite` peer range excludes the bundled Vite (for example `^7.0.0`) produces a peer warning. With `strictPeerDependencies`, the install fails. Update the plugin, or add a rule scoped to that plugin, such as `allowedVersions: { 'vite-plugin-foo>vite': '*' }`. Migration removes only the plain `vite` key.
+- After migration, a plugin whose `vite` peer range excludes the bundled Vite (for example `^7.0.0`) produces a peer warning. With `strictPeerDependencies`, the install fails. Update the plugin, or widen the peer range of that plugin only:
+  - pnpm 12.17.0 and later: add the override `'vite-plugin-foo>vite:peer': '*'`. pnpm deprecated `peerDependencyRules` in this version.
+  - Earlier pnpm versions: add the rule `allowedVersions: { 'vite-plugin-foo>vite': '*' }`.
+
+  Migration removes only the plain `vite` key, so both forms stay.
 
 **Other rules.**
 
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 0d28c75f1e..917f9c7b4a 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -723,7 +723,19 @@ manifest `version` takes part in the check.
 - Peer warnings appear for real incompatibilities after migration. Projects
   that relied on the broad rule to hide a `^7.0.0` plugin see a warning or,
   with `strict-peer-dependencies`, an install failure. The migrate output
-  should name the change.
+  should name the change. For one plugin, the migrate rules document
+  recommends the pnpm override `'vite-plugin-foo>vite:peer': '*'` on pnpm
+  12.17.0 and later, and a scoped `allowedVersions` entry on earlier pnpm.
+- pnpm 12.17.0 deprecates `peerDependencyRules` (pnpm/pnpm#17457). The setting
+  still works for backward compatibility. Migration still writes it in three
+  cases: the `vite` rule for preview and prerelease cores and for pnpm before
+  10.2.0, and the `vitest` rule. The `:peer` override is not a drop-in
+  replacement, because it rewrites the declared peer ranges instead of only
+  hiding the report, and pnpm before 12.17.0 does not support it. The `vitest`
+  rule is redundant (the `vitest` override already sets the version that peer
+  checks see), so the follow-up that removes it also removes one of the three
+  cases. The data in #2806 confirms that a workspace on `vite-plus` 1.5.0 with
+  Vitest 5.1.0 needs no `vitest` rule.
 - Vite+ prereleases still warn in npm: `8.4.0-vp.0` does not satisfy
   `^8.0.0`. Accepted; see Prereleases.
 - Preview builds still warn in npm. The bridge forces `0.0.0-commit.`,

From 0962c7cc94c0d670764ecd2e78a4380919b5375e Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:18:24 +0800
Subject: [PATCH 14/24] docs(rfc): reduce the before-and-after section to the
 pnpm config change

Keep only the `pnpm-workspace.yaml` diff that `vp migrate` produces and the
cases that keep the rule. The npm and Bun limits stay in the Migration
section.

Co-Authored-By: Claude Opus 5.5 
---
 rfcs/core-version-alignment.md | 111 +++------------------------------
 1 file changed, 10 insertions(+), 101 deletions(-)

diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 917f9c7b4a..09915858a2 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -52,70 +52,10 @@ and removes the entry that earlier migrations wrote.
 
 ## Before and after
 
-This section shows the effect for users. The "before" output comes from
-issue #2806. The "after" output comes from the `core_peer_dependency_checks`
-snapshot cases, the recorded migration snapshots, and the review experiments
-against the packed checkout (`vite-plus` `1.5.1`, core `8.3.7000`, Vitest
-`5.1.0`; pnpm 12.11.2, npm 11.19.1, Yarn 4.18.1, Bun 1.4.4).
-
-### Versions
-
-| Release           | Bundled Vite | Core version before | Core version after |
-| ----------------- | ------------ | ------------------- | ------------------ |
-| `vite-plus@1.5.1` | `8.3.7`      | `1.5.1`             | `8.3.7000`         |
-
-Before, the core version said nothing about Vite. After, `8.3.7000` reads as
-Vite `8.3.7`, release counter 0.
-
-### pnpm with `strictPeerDependencies`, compatible plugins
-
-Before (#2806, core `1.0.0-rc.0`), the install failed on Vitest's own peer:
-
-```text
-[ERR_PNPM_PEER_DEP_ISSUES] Unmet peer dependencies
-
-✕ unmet peer vite
-  Installed: 1.0.0-rc.0
-  Wanted:
-    "^6.0.0 || ^7.0.0 || ^8.0.0":
-      @vitest/mocker@5.0.1
-    "^6.4.0 || ^7.0.0 || ^8.0.0":
-      vitest@5.0.1
-```
-
-The only fix was the broad rule `allowAny: [vite]` and `allowedVersions: { vite: '*' }`.
-
-After, the same install succeeds with no peer rule, both with and without the
-`vite@*` alias override that `vp migrate` writes. A plugin with the peer range
-`^8.0.0` also installs.
-
-### pnpm, a plugin that does not support the bundled Vite
-
-Before, the broad rule hid it: a plugin with the peer range `^7.0.0` installed
-with no message.
-
-After, pnpm reports it, and the install fails under `strictPeerDependencies`:
-
-```text
-[ERR_PNPM_PEER_DEP_ISSUES] Unmet peer dependencies
-
-✕ unmet peer vite
-  Installed: 8.3.7000
-  Wanted:
-    ^7.0.0:
-      vite-plugin-legacy@1.0.0
-```
-
-Yarn reports the same plugin:
-
-```text
-➤ YN0060: │ vite is listed by your project with version 8.3.7000 (p6facb0), which doesn't satisfy what vite-plugin-legacy requests (^7.0.0).
-```
-
-### What `vp migrate` changes in `pnpm-workspace.yaml`
-
-For a project on pnpm 10.2.0 or later, migration removes the rule that it wrote
-earlier and names the change in its summary:
+For a pnpm project, `vp migrate` removes the broad `vite` peer rule from
+`pnpm-workspace.yaml`. Plugins that support the bundled Vite pass the peer
+check without it, and incompatible plugins are reported again. Example: an
+upgrade from `vite-plus` `1.5.1` to a release that bundles Vite `8.3.7`:
 
 ```diff
  overrides:
@@ -126,45 +66,14 @@ earlier and names the change in its summary:
 -  allowedVersions:
 -    vite: '*'
  catalog:
-   vite: npm:@voidzero-dev/vite-plus-core@
+-  vite: npm:@voidzero-dev/vite-plus-core@1.5.1
++  vite: npm:@voidzero-dev/vite-plus-core@8.3.7000
+-  vite-plus: 1.5.1
++  vite-plus: 1.6.0
 ```
 
-```text
-• Package manager settings configured
-• Removed `vite` from pnpm peerDependencyRules: the core version now matches the bundled Vite
-```
-
-On pnpm before 10.2.0, and for preview and prerelease cores, the rule stays.
-
-### npm
-
-Before (#2806), every `npm install` of a project with `vite-plus` printed
-warnings from the `vite-plus` dependency tree itself, and npm had no
-per-package way to silence them:
-
-```text
-npm warn While resolving: vitest@5.0.1
-npm warn Found: vite@1.0.0
-npm warn Could not resolve dependency:
-npm warn peer vite@"^6.4.0 || ^7.0.0 || ^8.0.0" from vitest@5.0.1
-```
-
-After, the same projects install with no `ERESOLVE` output. This holds for a
-project that declares only `vite-plus`, and for one with the `vite` alias.
-
-### Summary by package manager
-
-| Package manager    | Compatible plugin                               | Incompatible plugin (`^7.0.0`)                       | `vite` peer rule |
-| ------------------ | ----------------------------------------------- | ---------------------------------------------------- | ---------------- |
-| pnpm 10.2.0+       | Before: warning or failure. After: passes       | Before: hidden. After: warning, failure under strict | Removed          |
-| pnpm before 10.2.0 | Unchanged: passes with the rule                 | Unchanged: hidden by the rule                        | Kept             |
-| npm                | Before: `ERESOLVE` warnings. After: no warnings | Not reported with `overrides.vite`, before and after | Not used         |
-| Yarn               | Before: `YN0060`. After: passes                 | After: `YN0060`                                      | Not used         |
-| Bun                | No peer messages, before and after              | Not reported, before and after                       | Not used         |
-
-The `vite` and `vitest` overrides and resolutions stay on every package
-manager. They keep one core instance and one Vitest instance, which the version
-scheme does not replace (see Migration).
+The rule stays on pnpm before 10.2.0, and for preview and prerelease cores
+(see Migration).
 
 ## Problem
 

From 6495704d12bfda8d2b8f96fdf0173242f55b347c Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:28:41 +0800
Subject: [PATCH 15/24] docs(rfc): add an example of Vite+ betas on a stable
 Vite

Show the core versions that `nextCoreVersion` gives for `vite-plus` betas on
the stable Vite 8.3.7: each beta appends `-vp.` to the next stable core
version, and the stable release then takes that base.

Co-Authored-By: Claude Opus 5.5 
---
 rfcs/core-version-alignment.md | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 09915858a2..38a310b3f1 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -232,6 +232,21 @@ Every Vite+ prerelease uses the identifier `vp` and a counter `k` instead:
   Vite prerelease. Vite `9.0.0-beta.3` gives `9.0.0-beta.3.vp.0`, then
   `9.0.0-beta.3.vp.1`.
 
+Example: Vite+ betas on the stable Vite `8.3.7`, after a stable release on the
+same Vite. `nextCoreVersion` gives:
+
+| `vite-plus`    | Bundled Vite | Core version    | Reason                                  |
+| -------------- | ------------ | --------------- | --------------------------------------- |
+| `1.6.0`        | `8.3.7`      | `8.3.7000`      | First stable release on Vite `8.3.7`    |
+| `1.7.0-beta.0` | `8.3.7`      | `8.3.7001-vp.0` | Base `8.3.7001` is the next stable core |
+| `1.7.0-beta.1` | `8.3.7`      | `8.3.7001-vp.1` | Same base, next `k`                     |
+| `1.7.0`        | `8.3.7`      | `8.3.7001`      | The stable release takes the base       |
+| `1.8.0-beta.0` | `8.3.7`      | `8.3.7002-vp.0` | The next stable core is now `8.3.7002`  |
+
+The `beta` or `rc` identifier of `vite-plus` does not change the core
+version. A beta core such as `8.3.7001-vp.0` does not satisfy `^8.0.0` in npm,
+so migration keeps the pnpm `vite` rule for it (see Migration).
+
 The `vite-plus` prerelease identifiers do not appear in the core version. The
 exact alias in the published CLI manifest links the two versions, as it does
 for stable releases (see Relation to the `vite-plus` version).

From 74a5832ffb1df28149f354239a1464a9eb04c473 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:32:55 +0800
Subject: [PATCH 16/24] docs(rfc): add an example of several vite-plus releases
 on one Vite

Show the core versions for three minor releases and one major release of
vite-plus that all bundle Vite 8.3.7: only the release counter changes. Add a
risk item: a vite-plus breaking change can be a core patch bump, so ranges on
the core package are not supported.

Co-Authored-By: Claude Opus 5.5 
---
 rfcs/core-version-alignment.md | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 38a310b3f1..3b4b4a82ae 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -206,6 +206,23 @@ moved from `8.3.1` to `8.3.5`. Two releases on one Vite patch happened once
 Core versions keep sorting correctly:
 `8.3.5001 < 8.3.5002-vp.0 < 8.3.5002 < 8.3.6000`.
 
+Example: three `vite-plus` minor releases and one major release that all
+bundle Vite `8.3.7`. Only the release counter changes, because the core major
+and minor follow Vite, not `vite-plus`. `nextCoreVersion` gives:
+
+| `vite-plus` | Bundled Vite | Core version |
+| ----------- | ------------ | ------------ |
+| `1.6.0`     | `8.3.7`      | `8.3.7000`   |
+| `1.7.0`     | `8.3.7`      | `8.3.7001`   |
+| `1.8.0`     | `8.3.7`      | `8.3.7002`   |
+| `2.0.0`     | `8.3.7`      | `8.3.7003`   |
+
+All four core versions decode to Vite `8.3.7`, so peer checks give the same
+result for each release. A `vite-plus` major release can be only a patch bump
+of the core, so the core version does not signal a `vite-plus` breaking
+change. The CLI and `vp migrate` pin the exact core version, so no project
+receives a new core through a semver range (see Risks).
+
 ### Prereleases and new Vite majors
 
 A Vite+ prerelease does not copy its own prerelease identifiers into the core
@@ -644,6 +661,13 @@ manifest `version` takes part in the check.
   `latest` moves forward as usual and a range on core would jump majors.
   Vite+ documents exact pins only. The stale `alpha` and `test` dist-tags
   point at `0.x` versions and can be removed or left alone.
+- Ranges on the core package. The core version follows Vite, so a
+  `vite-plus` breaking change can ship as a core patch bump (for example
+  `8.3.7002` to `8.3.7003` for `vite-plus` `1.8.0` to `2.0.0`). A dependency
+  such as `"@voidzero-dev/vite-plus-core": "^8.3.7000"` would then receive a
+  breaking change as a patch. Vite+ does not support ranges on the core: the
+  `vite` alias, overrides, and catalogs that `vp migrate` writes use the exact
+  version.
 - Peer warnings appear for real incompatibilities after migration. Projects
   that relied on the broad rule to hide a `^7.0.0` plugin see a warning or,
   with `strict-peer-dependencies`, an install failure. The migrate output

From 297aaa080d40290bc866029abffc6d3becdf2976 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:35:01 +0800
Subject: [PATCH 17/24] test(tools): cover the RFC release sequences for core
 versions

Run `nextCoreVersion` over the two release sequences from the RFC: minor
and major vite-plus releases on one Vite version, and betas on a stable
Vite followed by the stable release.

Co-Authored-By: Claude Opus 5.5 
---
 .../tools/src/__tests__/core-version.spec.ts  | 24 +++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
index a0f7dff6e3..c85258d62e 100644
--- a/packages/tools/src/__tests__/core-version.spec.ts
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -44,6 +44,15 @@ describe('decodeCoreVersion', () => {
   });
 });
 
+// Each release publishes its core version before the next one is prepared.
+function releaseSequence(vite: string, vitePlusVersions: string[]): string[] {
+  const published: string[] = [];
+  for (const vitePlus of vitePlusVersions) {
+    published.push(nextCoreVersion({ vite, vitePlus, published }));
+  }
+  return published;
+}
+
 describe('nextCoreVersion', () => {
   test('starts the counter at 0 for a new Vite version', () => {
     expect(
@@ -104,6 +113,21 @@ describe('nextCoreVersion', () => {
       'the maximum',
     );
   });
+
+  test('changes only the counter for minor and major releases on one Vite version', () => {
+    expect(releaseSequence('8.3.7', ['1.6.0', '1.7.0', '1.8.0', '2.0.0'])).toEqual([
+      '8.3.7000',
+      '8.3.7001',
+      '8.3.7002',
+      '8.3.7003',
+    ]);
+  });
+
+  test('gives betas on a stable Vite the base of the next stable release', () => {
+    expect(
+      releaseSequence('8.3.7', ['1.6.0', '1.7.0-beta.0', '1.7.0-beta.1', '1.7.0', '1.8.0-beta.0']),
+    ).toEqual(['8.3.7000', '8.3.7001-vp.0', '8.3.7001-vp.1', '8.3.7001', '8.3.7002-vp.0']);
+  });
 });
 
 describe('initialCoreVersion', () => {

From 00a97366a51c19fbf2ec0698fc5e9f0ebccdcaeb Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 13:58:12 +0800
Subject: [PATCH 18/24] refactor: compute the pnpm peer-rule keys once per
 migration step

- Replace `stalePeerDependencyRuleKeys` and `managedPeerDependencyRuleKeys`
  with `pnpmPeerDependencyRuleKeys(usesVitest, pnpmVersion)`, and pass its
  result to the merge, the satisfied check, and the bootstrap writer. The
  pnpm version is now a required parameter, so a new caller cannot drop the
  `vite` rule on old pnpm by omission.
- Compare the pnpm version with verkit `isLessThan` and a named constant.
- Add `coreAliasVersion` for the CLI's core alias parsing, and use the alias
  prefix constant in ecosystem CI.
- Read the published core manifests in `core-version.ts` with one packument
  fetch for both `prepare` and `check --release`.

Co-Authored-By: Claude Opus 5.5 
---
 ecosystem-ci/patch-project.ts                 |  7 +-
 .../vite-peer-dependency-rule.spec.ts         |  5 +-
 .../cli/src/migration/migrator/catalog.ts     | 26 +++---
 .../src/migration/migrator/orchestrators.ts   |  4 +-
 packages/cli/src/migration/migrator/shared.ts | 23 +++--
 .../migration/migrator/vite-plus-bootstrap.ts | 38 +++------
 .../migration/migrator/vitest-ecosystem.ts    | 84 ++++++++++---------
 packages/cli/src/resolve-core.ts              |  7 +-
 packages/cli/src/utils/constants.ts           |  7 ++
 packages/tools/src/core-version.ts            | 52 ++++++------
 10 files changed, 122 insertions(+), 131 deletions(-)

diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts
index 65b5fbaabd..a2bd3d8c18 100644
--- a/ecosystem-ci/patch-project.ts
+++ b/ecosystem-ci/patch-project.ts
@@ -3,7 +3,10 @@ import { existsSync, readFileSync } from 'node:fs';
 import { appendFile, readFile, writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
 
-import { VITEST_VERSION } from '../packages/cli/src/utils/constants.ts';
+import {
+  VITEST_VERSION,
+  VITE_PLUS_CORE_ALIAS_PREFIX,
+} from '../packages/cli/src/utils/constants.ts';
 import vitePlusCorePkg from '../packages/core/package.json' with { type: 'json' };
 import { patchNpmxVitestConfig } from './npmx.ts';
 import { ecosystemCiDir, tgzDir, vitePlusCoreTgzVersion, vitePlusTgzVersion } from './paths.ts';
@@ -320,7 +323,7 @@ const migrateEnv: NodeJS.ProcessEnv = {
   ...registryInfo.env,
   ...(forceFreshMigration ? { VP_FORCE_MIGRATE: '1' } : {}),
   VP_OVERRIDE_PACKAGES: JSON.stringify({
-    vite: `npm:@voidzero-dev/vite-plus-core@${vitePlusCoreVersion}`,
+    vite: `${VITE_PLUS_CORE_ALIAS_PREFIX}${vitePlusCoreVersion}`,
     ...vitestOverrides,
   }),
   // The vp binary was built before the pack step pinned the package versions,
diff --git a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
index 0a7a33303f..0084284cdb 100644
--- a/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
+++ b/packages/cli/src/migration/__tests__/vite-peer-dependency-rule.spec.ts
@@ -7,6 +7,7 @@ import { parse as parseYaml } from 'yaml';
 
 import { PackageManager, type WorkspaceInfo } from '../../types/index.js';
 import { VITE_PLUS_OVERRIDE_PACKAGES } from '../../utils/constants.js';
+import type { PnpmPeerDependencyRules as PeerDependencyRules } from '../migrator/shared.js';
 import { createMigrationReport } from '../report.js';
 
 // Keep the vite-plus spec stable; each test selects the core alias it needs.
@@ -29,8 +30,6 @@ const {
 const STABLE_CORE = 'npm:@voidzero-dev/vite-plus-core@8.3.5000';
 const PREVIEW_CORE = 'npm:@voidzero-dev/vite-plus-core@0.0.0-commit.0c515e3';
 
-type PeerDependencyRules = { allowAny?: string[]; allowedVersions?: Record };
-
 function makeWorkspaceInfo(rootDir: string, version = '10.33.0'): WorkspaceInfo {
   return {
     rootDir,
@@ -108,7 +107,7 @@ describe('vitePeerDependencyRuleRequired', () => {
     ['file:../voidzero-dev-vite-plus-core-8.3.5999.tgz', true],
     ['https://pkg.pr.new/voidzero-dev/vite-plus/@voidzero-dev/vite-plus-core@1891', true],
   ])('%s requires the rule: %s', (spec, required) => {
-    expect(vitePeerDependencyRuleRequired(spec)).toBe(required);
+    expect(vitePeerDependencyRuleRequired(spec, undefined)).toBe(required);
   });
 });
 
diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts
index 46e0017af9..5fbd0f5c15 100644
--- a/packages/cli/src/migration/migrator/catalog.ts
+++ b/packages/cli/src/migration/migrator/catalog.ts
@@ -23,16 +23,15 @@ import {
   isAlignableVitestEcosystemPackage,
   isLegacyWrapperSpec,
   managedOverridePackages,
-  managedPeerDependencyRuleKeys,
   mergeManagedPnpmPeerDependencyRules,
-  isStaleAllowedVersion,
+  isManagedAllowedVersion,
   peerDependencyRulesHaveStaleEntry,
   pruneLegacyWrapperAliases,
   removeManagedVitestEntry,
   removeYamlMapVitestEntry,
   rewriteMonorepoProject,
   shouldDropProviderOverrideKey,
-  stalePeerDependencyRuleKeys,
+  pnpmPeerDependencyRuleKeys,
   workspaceUsesWebdriverioProvider,
 } from '../migrator.ts';
 import { type MigrationReport } from '../report.ts';
@@ -306,7 +305,7 @@ export function rewritePnpmWorkspaceYaml(
   writeWorkspaceSettings = true,
   catalogAdditions: ReadonlySet = new Set(),
   usesWebdriverioProvider = false,
-  pnpmVersion?: string,
+  pnpmVersion: string | undefined,
 ): void {
   const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml');
   if (!fs.existsSync(pnpmWorkspaceYamlPath)) {
@@ -412,11 +411,11 @@ export function rewritePnpmWorkspaceYaml(
       }
     }
     // Drop lingering managed `vitest` and stale `vite` entries.
-    const stalePeerRuleKeys = stalePeerDependencyRuleKeys(usesVitest, pnpmVersion);
+    const peerRuleKeys = pnpmPeerDependencyRuleKeys(usesVitest, pnpmVersion);
+    const stalePeerRuleKeys = peerRuleKeys.stale;
     allowAny.items = allowAny.items.filter((n) => !stalePeerRuleKeys.includes(n.value));
-    const peerRuleKeys = managedPeerDependencyRuleKeys(usesVitest, pnpmVersion);
     const existing = new Set(allowAny.items.map((n) => n.value));
-    for (const key of peerRuleKeys) {
+    for (const key of peerRuleKeys.managed) {
       if (!existing.has(key)) {
         allowAny.add(scalarString(key));
       }
@@ -434,11 +433,11 @@ export function rewritePnpmWorkspaceYaml(
       allowedVersions = new YAMLMap, Scalar>();
     }
     for (const key of stalePeerRuleKeys) {
-      if (isStaleAllowedVersion(key, allowedVersions.get(key), stalePeerRuleKeys)) {
+      if (isManagedAllowedVersion(key, allowedVersions.get(key))) {
         allowedVersions.delete(key);
       }
     }
-    for (const key of peerRuleKeys) {
+    for (const key of peerRuleKeys.managed) {
       // - vite: '*'
       allowedVersions.set(scalarString(key), scalarString('*'));
     }
@@ -1297,8 +1296,7 @@ export function rewriteRootWorkspacePackageJson(
           overrides: mergeManagedPnpmOverrides(pkg.pnpm?.overrides, managed),
           peerDependencyRules: mergeManagedPnpmPeerDependencyRules(
             pkg.pnpm?.peerDependencyRules,
-            workspaceUsesVitest,
-            pnpmVersion,
+            pnpmPeerDependencyRuleKeys(workspaceUsesVitest, pnpmVersion),
           ),
         };
       } else {
@@ -1443,7 +1441,7 @@ export function readPnpmWorkspacePeerDependencyRules(
 
 // Whether the root pnpm config (the package.json `pnpm` field or
 // pnpm-workspace.yaml) holds a `vite` peer rule.
-export function hasVitePeerDependencyRule(projectPath: string): boolean {
+function hasVitePeerDependencyRule(projectPath: string): boolean {
   if (
     peerDependencyRulesHaveStaleEntry(readPnpmWorkspacePeerDependencyRules(projectPath), ['vite'])
   ) {
@@ -1462,12 +1460,12 @@ export function trackVitePeerDependencyRuleRemoval(
   projectPath: string,
   packageManager: PackageManager,
   report: MigrationReport | undefined,
-  pnpmVersion?: string,
+  pnpmVersion: string | undefined,
 ): () => void {
   if (
     !report ||
     packageManager !== PackageManager.pnpm ||
-    vitePeerDependencyRuleRequired(undefined, pnpmVersion) ||
+    vitePeerDependencyRuleRequired(VITE_PLUS_OVERRIDE_PACKAGES.vite, pnpmVersion) ||
     !hasVitePeerDependencyRule(projectPath)
   ) {
     return () => {};
diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts
index d50b9309cf..11217fc05d 100644
--- a/packages/cli/src/migration/migrator/orchestrators.ts
+++ b/packages/cli/src/migration/migrator/orchestrators.ts
@@ -27,6 +27,7 @@ import {
   managedOverridePackages,
   mergeManagedPnpmOverrides,
   mergeManagedPnpmPeerDependencyRules,
+  pnpmPeerDependencyRuleKeys,
   mergeStagedConfigToViteConfig,
   mergeTsdownConfigFile,
   mergeViteConfigFiles,
@@ -216,8 +217,7 @@ export function rewriteStandaloneProject(
           overrides: mergeManagedPnpmOverrides(pkg.pnpm?.overrides, managed),
           peerDependencyRules: mergeManagedPnpmPeerDependencyRules(
             pkg.pnpm?.peerDependencyRules,
-            usesVitest,
-            workspaceInfo.downloadPackageManager.version,
+            pnpmPeerDependencyRuleKeys(usesVitest, workspaceInfo.downloadPackageManager.version),
           ),
         };
       } else {
diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts
index 76df85e520..95a6cfcf68 100644
--- a/packages/cli/src/migration/migrator/shared.ts
+++ b/packages/cli/src/migration/migrator/shared.ts
@@ -3,12 +3,12 @@ import path from 'node:path';
 
 import * as prompts from '@voidzero-dev/vite-plus-prompts';
 import type { OxlintConfig } from 'oxlint';
-import { coerce, satisfies, tryParse } from 'verkit';
+import { coerce, isLessThan, satisfies, tryParse } from 'verkit';
 
 import {
   VITEST_VERSION,
-  VITE_PLUS_CORE_ALIAS_PREFIX,
   VITE_PLUS_OVERRIDE_PACKAGES,
+  coreAliasVersion,
 } from '../../utils/constants.ts';
 import { readJsonFile } from '../../utils/json.ts';
 import { detectPackageMetadata } from '../../utils/package.ts';
@@ -221,13 +221,11 @@ export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGE
  * fails against the installed core. Those versions need the `vite` peer rule
  * whatever the core version. An unknown version counts as a current pnpm.
  */
+const PNPM_PEERS_USE_ALIAS_TARGET_VERSION = '10.2.0';
+
 function pnpmAppliesAliasOverrideToPeers(pnpmVersion: string | undefined): boolean {
-  const version = pnpmVersion ? coerce(pnpmVersion) : undefined;
-  return (
-    version !== undefined &&
-    version !== null &&
-    (version.major < 10 || (version.major === 10 && version.minor < 2))
-  );
+  const version = pnpmVersion ? coerce(pnpmVersion) : null;
+  return version !== null && isLessThan(version, PNPM_PEERS_USE_ALIAS_TARGET_VERSION);
 }
 
 /**
@@ -246,16 +244,17 @@ function pnpmAppliesAliasOverrideToPeers(pnpmVersion: string | undefined): boole
  * owns the version scheme; the CLI cannot import it, so keep the two in sync.
  */
 export function vitePeerDependencyRuleRequired(
-  viteSpec: string | undefined = VITE_PLUS_OVERRIDE_PACKAGES.vite,
-  pnpmVersion?: string,
+  viteSpec: string | undefined,
+  pnpmVersion: string | undefined,
 ): boolean {
   if (pnpmAppliesAliasOverrideToPeers(pnpmVersion)) {
     return true;
   }
-  if (!viteSpec?.startsWith(VITE_PLUS_CORE_ALIAS_PREFIX)) {
+  const aliasVersion = coreAliasVersion(viteSpec);
+  if (aliasVersion === undefined) {
     return true;
   }
-  const version = tryParse(viteSpec.slice(VITE_PLUS_CORE_ALIAS_PREFIX.length));
+  const version = tryParse(aliasVersion);
   return (
     !version ||
     version.major < 8 ||
diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
index 18285797d3..36821013d7 100644
--- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
+++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
@@ -27,8 +27,8 @@ import {
   isLegacyWrapperSpec,
   isProtocolPinnedSpec,
   managedOverridePackages,
-  managedPeerDependencyRuleKeys,
   mergeManagedPnpmPeerDependencyRules,
+  type PnpmPeerDependencyRuleKeys,
   peerDependencyRulesHaveStaleEntry,
   migratePnpmSettingsToWorkspaceYaml,
   normalizeVitestPeerCatalogSpec,
@@ -48,7 +48,7 @@ import {
   rewritePnpmWorkspaceYaml,
   rewriteYarnrcYml,
   setDirectViteEdge,
-  stalePeerDependencyRuleKeys,
+  pnpmPeerDependencyRuleKeys,
   takePnpmWorkspaceSettings,
   trackVitePeerDependencyRuleRemoval,
   vitestEcosystemCatalogReferencesPending,
@@ -217,22 +217,15 @@ function pnpmPeerDependencyRulesSatisfyVitePlus(
   peerDependencyRules:
     | { allowAny?: string[]; allowedVersions?: Record }
     | undefined,
-  usesVitest: boolean,
-  pnpmVersion: string | undefined,
+  { managed, stale }: PnpmPeerDependencyRuleKeys,
 ): boolean {
   // A lingering managed `vitest` rule or a stale `vite` rule is NOT satisfied.
-  if (
-    peerDependencyRulesHaveStaleEntry(
-      peerDependencyRules,
-      stalePeerDependencyRuleKeys(usesVitest, pnpmVersion),
-    )
-  ) {
+  if (peerDependencyRulesHaveStaleEntry(peerDependencyRules, stale)) {
     return false;
   }
   const allowAny = new Set(peerDependencyRules?.allowAny ?? []);
   const allowedVersions = peerDependencyRules?.allowedVersions ?? {};
-  const ruleKeys = managedPeerDependencyRuleKeys(usesVitest, pnpmVersion);
-  return ruleKeys.every((key) => allowAny.has(key) && allowedVersions[key] === '*');
+  return managed.every((key) => allowAny.has(key) && allowedVersions[key] === '*');
 }
 
 function npmVitePlusManagedDependenciesPending(
@@ -769,8 +762,7 @@ export function detectVitePlusBootstrapPending(
           ) ||
           !pnpmPeerDependencyRulesSatisfyVitePlus(
             pkg.pnpm?.peerDependencyRules,
-            usesVitest,
-            detectedPackageManagerVersion,
+            pnpmPeerDependencyRuleKeys(usesVitest, detectedPackageManagerVersion),
           )
         );
       }
@@ -785,8 +777,7 @@ export function detectVitePlusBootstrapPending(
         ) ||
         !pnpmPeerDependencyRulesSatisfyVitePlus(
           pkg.pnpm?.peerDependencyRules,
-          usesVitest,
-          detectedPackageManagerVersion,
+          pnpmPeerDependencyRuleKeys(usesVitest, detectedPackageManagerVersion),
         )
       );
     }
@@ -802,8 +793,7 @@ export function detectVitePlusBootstrapPending(
       ) ||
       !pnpmPeerDependencyRulesSatisfyVitePlus(
         readPnpmWorkspacePeerDependencyRules(projectPath),
-        usesVitest,
-        detectedPackageManagerVersion,
+        pnpmPeerDependencyRuleKeys(usesVitest, detectedPackageManagerVersion),
       ) ||
       pnpmWorkspaceMinimumReleaseAgeExemptionsPending(projectPath)
     );
@@ -928,14 +918,12 @@ function ensureOverrideEntries(
 
 function ensurePnpmPeerDependencyRules(
   pkg: BootstrapPackageJson,
-  usesVitest: boolean,
-  pnpmVersion: string | undefined,
+  peerRuleKeys: PnpmPeerDependencyRuleKeys,
 ): boolean {
   pkg.pnpm ??= {};
   const peerDependencyRules = mergeManagedPnpmPeerDependencyRules(
     pkg.pnpm.peerDependencyRules,
-    usesVitest,
-    pnpmVersion,
+    peerRuleKeys,
   );
   const changed =
     JSON.stringify(pkg.pnpm.peerDependencyRules ?? {}) !==
@@ -1125,8 +1113,7 @@ export function ensureVitePlusBootstrap(
       packageJsonChanged =
         ensurePnpmPeerDependencyRules(
           pkg,
-          usesVitest,
-          workspaceInfo.downloadPackageManager.version,
+          pnpmPeerDependencyRuleKeys(usesVitest, workspaceInfo.downloadPackageManager.version),
         ) || packageJsonChanged;
       if (pnpmMajorVersion !== undefined && pkg.pnpm) {
         const beforePnpm = JSON.stringify(pkg.pnpm);
@@ -1226,8 +1213,7 @@ export function ensureVitePlusBootstrap(
         ) ||
         !pnpmPeerDependencyRulesSatisfyVitePlus(
           readPnpmWorkspacePeerDependencyRules(projectPath),
-          usesVitest,
-          workspaceInfo.downloadPackageManager.version,
+          pnpmPeerDependencyRuleKeys(usesVitest, workspaceInfo.downloadPackageManager.version),
         )
       ) {
         rewritePnpmWorkspaceYaml(
diff --git a/packages/cli/src/migration/migrator/vitest-ecosystem.ts b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
index bdb68825ce..ba065f75a3 100644
--- a/packages/cli/src/migration/migrator/vitest-ecosystem.ts
+++ b/packages/cli/src/migration/migrator/vitest-ecosystem.ts
@@ -541,85 +541,89 @@ function managedVitestOverrideKeys(keyStyle: ManagedOverrideKeyStyle): string[]
   return keyStyle === 'pnpm-ranged' ? ['vitest', pnpmOverrideKey('vitest')] : ['vitest'];
 }
 
-// The pnpm peerDependencyRules keys that migration removes:
+/** The pnpm peerDependencyRules keys for one migration step. */
+export interface PnpmPeerDependencyRuleKeys {
+  /** Keys that migration writes to `allowAny` and `allowedVersions` as `'*'`. */
+  managed: string[];
+  /** Keys that migration removes. */
+  stale: string[];
+}
+
+// The pnpm peerDependencyRules keys vite-plus manages and removes. The managed
+// keys are the managed override keys, minus `vite` once its core alias
+// satisfies `vite` peer ranges on this pnpm version (see
+// `vitePeerDependencyRuleRequired`). The stale keys are:
 // - `vitest`, a lingering managed rule, when the workspace does not use Vitest.
-// - `vite`, once its core alias satisfies `vite` peer ranges and pnpm is 10.2.0
-//   or later (see `vitePeerDependencyRuleRequired`). Then the rule only hides
-//   real incompatibilities. Migration cannot tell whether an earlier migration
-//   or the user wrote the entry; its only purpose was to accept the `1.x` alias,
-//   so both go.
-export function stalePeerDependencyRuleKeys(usesVitest: boolean, pnpmVersion?: string): string[] {
-  const keys: string[] = [];
+// - `vite`, when the rule is not required. Then it only hides real
+//   incompatibilities. Migration cannot tell whether an earlier migration or the
+//   user wrote the entry; its only purpose was to accept the `1.x` alias, so
+//   both go.
+export function pnpmPeerDependencyRuleKeys(
+  usesVitest: boolean,
+  pnpmVersion: string | undefined,
+): PnpmPeerDependencyRuleKeys {
+  const viteRuleRequired = vitePeerDependencyRuleRequired(
+    VITE_PLUS_OVERRIDE_PACKAGES.vite,
+    pnpmVersion,
+  );
+  const managed = Object.keys(managedOverridePackages(usesVitest)).filter(
+    (key) => viteRuleRequired || key !== 'vite',
+  );
+  const stale: string[] = [];
   if (!usesVitest && VITEST_IS_MANAGED_OVERRIDE) {
-    keys.push('vitest');
+    stale.push('vitest');
   }
-  if (!vitePeerDependencyRuleRequired(undefined, pnpmVersion)) {
-    keys.push('vite');
+  if (!viteRuleRequired) {
+    stale.push('vite');
   }
-  return keys;
+  return { managed, stale };
 }
 
-// Whether an `allowedVersions` entry is stale. Migration only ever wrote `'*'`
+// Whether migration wrote this `allowedVersions` value. It only ever wrote `'*'`
 // for `vite`, so another `vite` range is the user's own constraint and stays.
-export function isStaleAllowedVersion(
-  key: string,
-  value: unknown,
-  staleKeys: readonly string[],
-): boolean {
-  return staleKeys.includes(key) && (key !== 'vite' || value === '*');
+export function isManagedAllowedVersion(key: string, value: unknown): boolean {
+  return key !== 'vite' || value === '*';
 }
 
 // Whether pnpm peerDependencyRules hold a stale `allowAny` or `allowedVersions`
-// entry for one of `keys`. Works on both the package.json
+// entry for one of `staleKeys`. Works on both the package.json
 // `pnpm.peerDependencyRules` JSON shape and the same shape read back from
 // pnpm-workspace.yaml.
 export function peerDependencyRulesHaveStaleEntry(
   rules: { allowAny?: unknown; allowedVersions?: unknown } | null | undefined,
-  keys: readonly string[],
+  staleKeys: readonly string[],
 ): boolean {
   return (
-    (Array.isArray(rules?.allowAny) && rules.allowAny.some((key) => keys.includes(key))) ||
+    (Array.isArray(rules?.allowAny) && rules.allowAny.some((key) => staleKeys.includes(key))) ||
     (isPlainRecord(rules?.allowedVersions) &&
-      Object.entries(rules.allowedVersions).some(([key, value]) =>
-        isStaleAllowedVersion(key, value, keys),
+      Object.entries(rules.allowedVersions).some(
+        ([key, value]) => staleKeys.includes(key) && isManagedAllowedVersion(key, value),
       ))
   );
 }
 
-// The pnpm peerDependencyRules keys vite-plus manages: the managed override
-// keys, minus `vite` once its core alias satisfies `vite` peer ranges.
-export function managedPeerDependencyRuleKeys(usesVitest: boolean, pnpmVersion?: string): string[] {
-  const keys = Object.keys(managedOverridePackages(usesVitest));
-  return vitePeerDependencyRuleRequired(undefined, pnpmVersion)
-    ? keys
-    : keys.filter((key) => key !== 'vite');
-}
-
 // Re-derive the managed pnpm peerDependencyRules on top of the package.json
 // `pnpm.peerDependencyRules` shape. Returns the new rules, or `undefined` when
 // no rule remains so the caller can drop the field. Does not mutate `rules`.
 export function mergeManagedPnpmPeerDependencyRules(
   rules: PnpmPeerDependencyRules | undefined,
-  usesVitest: boolean,
-  pnpmVersion?: string,
+  { managed, stale }: PnpmPeerDependencyRuleKeys,
 ): PnpmPeerDependencyRules | undefined {
-  const stale = stalePeerDependencyRuleKeys(usesVitest, pnpmVersion);
-  const keys = managedPeerDependencyRuleKeys(usesVitest, pnpmVersion);
   const allowAny = [
     ...new Set([
       ...(Array.isArray(rules?.allowAny) ? rules.allowAny : []).filter(
         (key) => !stale.includes(key),
       ),
-      ...keys,
+      ...managed,
     ]),
   ];
   const allowedVersions = {
     ...Object.fromEntries(
       Object.entries(rules?.allowedVersions ?? {}).filter(
-        ([key, value]) => !isStaleAllowedVersion(key, value, stale),
+        ([key, value]) => !(stale.includes(key) && isManagedAllowedVersion(key, value)),
       ),
     ),
-    ...Object.fromEntries(keys.map((key) => [key, '*'])),
+    ...Object.fromEntries(managed.map((key) => [key, '*'])),
   };
   const next: PnpmPeerDependencyRules = { ...rules, allowAny, allowedVersions };
   if (allowAny.length === 0) {
diff --git a/packages/cli/src/resolve-core.ts b/packages/cli/src/resolve-core.ts
index 92d20fcbed..7d34692930 100644
--- a/packages/cli/src/resolve-core.ts
+++ b/packages/cli/src/resolve-core.ts
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
 import { createRequire } from 'node:module';
 import { join } from 'node:path';
 
-import { VITE_PLUS_CORE_ALIAS_PREFIX, VITE_PLUS_CORE_NAME } from './utils/constants.ts';
+import { VITE_PLUS_CORE_NAME, coreAliasVersion } from './utils/constants.ts';
 import { readNearestPackageJson } from './utils/package.ts';
 
 /**
@@ -43,10 +43,7 @@ function readAliasCoreVersion(cliPackageJsonPath: string): string | undefined {
   const { dependencies } = JSON.parse(readFileSync(cliPackageJsonPath, 'utf8')) as {
     dependencies?: Record;
   };
-  const spec = dependencies?.vite;
-  return spec?.startsWith(VITE_PLUS_CORE_ALIAS_PREFIX)
-    ? spec.slice(VITE_PLUS_CORE_ALIAS_PREFIX.length)
-    : undefined;
+  return coreAliasVersion(dependencies?.vite);
 }
 
 function checkProjectCoreVersion(
diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts
index 9c09edbcc3..06e40dc0b8 100644
--- a/packages/cli/src/utils/constants.ts
+++ b/packages/cli/src/utils/constants.ts
@@ -9,6 +9,13 @@ export const VITE_PLUS_CORE_NAME = '@voidzero-dev/vite-plus-core';
 // Projects and the CLI alias `vite` to the core: `vite: npm:@voidzero-dev/vite-plus-core@`.
 export const VITE_PLUS_CORE_ALIAS_PREFIX = `npm:${VITE_PLUS_CORE_NAME}@`;
 
+/** Returns the version of a core alias spec, or `undefined` for any other spec. */
+export function coreAliasVersion(spec: string | undefined): string | undefined {
+  return spec?.startsWith(VITE_PLUS_CORE_ALIAS_PREFIX)
+    ? spec.slice(VITE_PLUS_CORE_ALIAS_PREFIX.length)
+    : undefined;
+}
+
 // The core version encodes the bundled Vite version (rfcs/core-version-alignment.md),
 // so it differs from the vite-plus version. Release, preview, and local CI builds
 // set the core manifest version before the build, so this is the packed version.
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
index 7922c7ba96..124d094daa 100644
--- a/packages/tools/src/core-version.ts
+++ b/packages/tools/src/core-version.ts
@@ -23,7 +23,6 @@
  *   node packages/tools/src/core-version.ts check [--release]
  */
 
-import { execFileSync } from 'node:child_process';
 import { readFileSync, writeFileSync } from 'node:fs';
 import { parseArgs } from 'node:util';
 
@@ -268,36 +267,31 @@ function readManifest(url: URL): CoreManifest {
   return JSON.parse(readFileSync(url, 'utf8'));
 }
 
-function readPublishedCoreVersions(): string[] {
-  const output = execFileSync('npm', ['view', CORE_PACKAGE_NAME, 'versions', '--json'], {
-    encoding: 'utf8',
-  });
-  const versions: unknown = JSON.parse(output);
-  if (typeof versions === 'string') {
-    return [versions];
-  }
-  if (!Array.isArray(versions) || versions.some((version) => typeof version !== 'string')) {
-    throw new Error(`Unexpected output from npm view ${CORE_PACKAGE_NAME} versions`);
-  }
-  return versions;
-}
+const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
 
-async function readPublishedCoreManifest(
-  core: string,
-): Promise<{ vitePlusVersion?: string } | undefined> {
-  const registry = (process.env.PUBLISH_REGISTRY ?? 'https://registry.npmjs.org').replace(
-    /\/+$/,
-    '',
-  );
-  const url = `${registry}/${CORE_PACKAGE_NAME.replace('/', '%2f')}/${core}`;
-  const response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
+/**
+ * Reads the published core manifests by version from the full packument.
+ * `prepare` uses the version list, and `check --release` uses the
+ * `vitePlusVersion` field of one manifest. Returns an empty map when the
+ * package is not published.
+ */
+async function readPublishedCoreManifests(): Promise> {
+  const registry = (process.env.PUBLISH_REGISTRY ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, '');
+  const url = `${registry}/${CORE_PACKAGE_NAME.replace('/', '%2f')}`;
+  const response = await fetch(url, {
+    headers: { accept: 'application/json' },
+    signal: AbortSignal.timeout(60_000),
+  });
   if (response.status === 404) {
-    return undefined;
+    return {};
   }
   if (!response.ok) {
     throw new Error(`Registry returned HTTP ${response.status} for ${url}`);
   }
-  return (await response.json()) as { vitePlusVersion?: string };
+  const packument = (await response.json()) as {
+    versions?: Record;
+  };
+  return packument.versions ?? {};
 }
 
 async function main(argv: string[]): Promise {
@@ -321,7 +315,11 @@ async function main(argv: string[]): Promise {
       if (!vitePlus) {
         throw new Error('prepare requires --vite-plus ');
       }
-      core.version = nextCoreVersion({ vite, vitePlus, published: readPublishedCoreVersions() });
+      core.version = nextCoreVersion({
+        vite,
+        vitePlus,
+        published: Object.keys(await readPublishedCoreManifests()),
+      });
       core.vitePlusVersion = vitePlus;
       writeFileSync(CORE_MANIFEST_URL, `${JSON.stringify(core, null, 2)}\n`);
       console.log(core.version);
@@ -335,7 +333,7 @@ async function main(argv: string[]): Promise {
       assertCoreVersionMatchesVite(core, cli.version, { release: values.release });
       if (values.release) {
         assertCoreVersionUnclaimed(
-          await readPublishedCoreManifest(core.version),
+          (await readPublishedCoreManifests())[core.version],
           core.version,
           cli.version,
         );

From a6d63f0efd72c032826c9b67c7b3041472534f9f Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 14:05:58 +0800
Subject: [PATCH 19/24] docs: leave the tsgolint convention out of the user
 guide

Co-Authored-By: Claude Opus 5.5 
---
 docs/guide/local-cli.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/guide/local-cli.md b/docs/guide/local-cli.md
index 9ac189551e..0284aab330 100644
--- a/docs/guide/local-cli.md
+++ b/docs/guide/local-cli.md
@@ -113,7 +113,7 @@ If you also declare `vitest` directly, set its dependency entry to the exact ver
 Dependencies and plugins can import `vite` or `vitest` directly, even when your own code imports from `vite-plus`. These overrides align their dependencies with the toolchain Vite+ uses:
 
 - The `vite` alias directs those imports to Vite+'s core package. Separate Vite instances can break runtime identity checks: [issue #1391](https://github.com/voidzero-dev/vite-plus/issues/1391) reported TanStack Start returning 404s because an `instanceof` check crossed two copies. [PR #2617](https://github.com/voidzero-dev/vite-plus/pull/2617) addresses the CLI side by sharing Vite through the same alias.
-- The core version encodes the bundled Vite version. Its major and minor are the Vite major and minor, and its patch is the Vite patch times 1000 plus a release counter, the same convention that `oxlint-tsgolint` uses for TypeScript. For example, core `8.3.5000` bundles Vite `8.3.5`. Package managers compare this version with the `vite` peer ranges of plugins, so a plugin that supports the bundled Vite passes the check. With pnpm and Yarn, a plugin that does not support it still gets a peer warning. npm applies `overrides.vite` to peer dependencies too, so npm does not report an incompatible `vite` peer, and Bun does not report peer problems. [Issue #2806](https://github.com/voidzero-dev/vite-plus/issues/2806) describes the earlier `1.x` versions, which failed every check.
+- The core version encodes the bundled Vite version. Its major and minor are the Vite major and minor, and its patch is the Vite patch times 1000 plus a release counter. For example, core `8.3.5000` bundles Vite `8.3.5`. Package managers compare this version with the `vite` peer ranges of plugins, so a plugin that supports the bundled Vite passes the check. With pnpm and Yarn, a plugin that does not support it still gets a peer warning. npm applies `overrides.vite` to peer dependencies too, so npm does not report an incompatible `vite` peer, and Bun does not report peer problems. [Issue #2806](https://github.com/voidzero-dev/vite-plus/issues/2806) describes the earlier `1.x` versions, which failed every check.
 - The exact `vitest` pin keeps dependencies and `vp test` on the same Vitest version, avoiding separate mocks, `expect` instances, and runner state. [PR #2365](https://github.com/voidzero-dev/vite-plus/pull/2365) documents this requirement for manual installation.
 
 Keep the core alias aligned with the core version of your installed `vite-plus` and update the Vitest pin to match its bundled version when upgrading. [Issue #2356](https://github.com/voidzero-dev/vite-plus/issues/2356) describes how dependency bots can update these packages independently and leave incompatible versions installed together.

From 13cc82a69a2360746bc47294ccbf7dc2b1c37570 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 14:28:11 +0800
Subject: [PATCH 20/24] fix(release): never select a used core version, and run
 releases in order

- Count every version number that npm has used when selecting the core
  counter, including unpublished ones. npm never accepts an unpublished
  number again, but the packument keeps it only in `time`.
- Queue release runs with `concurrency: release`. A second run that overlaps
  the first could check the same core version while npm still hides the
  first publish, pass, and pin its CLI to the first run's core.

Co-Authored-By: Claude Opus 5.5 
---
 .github/workflows/release.yml                 | 11 +++++
 .../tools/src/__tests__/core-version.spec.ts  | 29 ++++++++++++
 packages/tools/src/core-version.ts            | 47 ++++++++++++++-----
 rfcs/core-version-alignment.md                | 13 +++++
 4 files changed, 89 insertions(+), 11 deletions(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 29343b0246..62aef8be14 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,6 +8,17 @@ on:
 
 permissions: {}
 
+# Run releases one at a time. Each release selects its core version from the
+# registry (rfcs/core-version-alignment.md). A second run that overlaps the
+# first could check the same core version while npm still hides the first
+# publish, pass, and then pin its CLI to the first run's core. Queue runs
+# instead of cancelling them, because a release must not stop halfway. GitHub
+# keeps one pending run per group, so a third run replaces the pending one;
+# run the replaced release again by hand.
+concurrency:
+  group: release
+  cancel-in-progress: false
+
 env:
   RELEASE_BUILD: 'true'
   DEBUG: 'napi:*'
diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
index c85258d62e..7a3dbdf575 100644
--- a/packages/tools/src/__tests__/core-version.spec.ts
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -7,6 +7,7 @@ import {
   encodeCoreVersion,
   initialCoreVersion,
   nextCoreVersion,
+  usedVersionNumbers,
 } from '../core-version.ts';
 
 const PUBLISHED_BEFORE_ALIGNMENT = ['0.1.21-alpha.7', '0.0.2-test.1', '1.0.0-rc.0', '1.4.0'];
@@ -223,3 +224,31 @@ describe('assertCoreVersionUnclaimed', () => {
     );
   });
 });
+
+describe('usedVersionNumbers', () => {
+  test('includes versions that npm removed after an unpublish', () => {
+    const packument = {
+      versions: { '8.3.7000': {} },
+      time: { created: '', modified: '', '8.3.7000': '', '8.3.7001': '' },
+    };
+    expect(usedVersionNumbers(packument).toSorted()).toEqual(['8.3.7000', '8.3.7001']);
+    expect(
+      nextCoreVersion({
+        vite: '8.3.7',
+        vitePlus: '1.7.0',
+        published: usedVersionNumbers(packument),
+      }),
+    ).toBe('8.3.7002');
+  });
+
+  test('includes versions of a fully unpublished package', () => {
+    const packument = {
+      time: { created: '', modified: '', unpublished: { versions: ['8.3.7000'] } },
+    };
+    expect(usedVersionNumbers(packument)).toEqual(['8.3.7000']);
+  });
+
+  test('returns no versions for an unpublished package name', () => {
+    expect(usedVersionNumbers({})).toEqual([]);
+  });
+});
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
index 124d094daa..a0f472d840 100644
--- a/packages/tools/src/core-version.ts
+++ b/packages/tools/src/core-version.ts
@@ -269,13 +269,41 @@ function readManifest(url: URL): CoreManifest {
 
 const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
 
+interface CorePackument {
+  versions?: Record;
+  time?: Record & { unpublished?: { versions?: unknown } };
+}
+
+/**
+ * Returns every version number that npm has used for the package. npm never
+ * accepts a version number again after an unpublish, but it removes that
+ * version from `versions` and keeps it in `time` (or in `time.unpublished`
+ * when every version is unpublished).
+ */
+export function usedVersionNumbers(packument: CorePackument): string[] {
+  const used = new Set(Object.keys(packument.versions ?? {}));
+  for (const key of Object.keys(packument.time ?? {})) {
+    if (key !== 'created' && key !== 'modified' && key !== 'unpublished') {
+      used.add(key);
+    }
+  }
+  const unpublished = packument.time?.unpublished?.versions;
+  if (Array.isArray(unpublished)) {
+    for (const version of unpublished) {
+      if (typeof version === 'string') {
+        used.add(version);
+      }
+    }
+  }
+  return [...used];
+}
+
 /**
- * Reads the published core manifests by version from the full packument.
- * `prepare` uses the version list, and `check --release` uses the
- * `vitePlusVersion` field of one manifest. Returns an empty map when the
- * package is not published.
+ * Reads the full packument of the core package. `prepare` uses the version
+ * numbers, and `check --release` uses the `vitePlusVersion` field of one
+ * manifest. Returns an empty packument when the package is not published.
  */
-async function readPublishedCoreManifests(): Promise> {
+async function readCorePackument(): Promise {
   const registry = (process.env.PUBLISH_REGISTRY ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, '');
   const url = `${registry}/${CORE_PACKAGE_NAME.replace('/', '%2f')}`;
   const response = await fetch(url, {
@@ -288,10 +316,7 @@ async function readPublishedCoreManifests(): Promise;
-  };
-  return packument.versions ?? {};
+  return (await response.json()) as CorePackument;
 }
 
 async function main(argv: string[]): Promise {
@@ -318,7 +343,7 @@ async function main(argv: string[]): Promise {
       core.version = nextCoreVersion({
         vite,
         vitePlus,
-        published: Object.keys(await readPublishedCoreManifests()),
+        published: usedVersionNumbers(await readCorePackument()),
       });
       core.vitePlusVersion = vitePlus;
       writeFileSync(CORE_MANIFEST_URL, `${JSON.stringify(core, null, 2)}\n`);
@@ -333,7 +358,7 @@ async function main(argv: string[]): Promise {
       assertCoreVersionMatchesVite(core, cli.version, { release: values.release });
       if (values.release) {
         assertCoreVersionUnclaimed(
-          (await readPublishedCoreManifests())[core.version],
+          (await readCorePackument()).versions?.[core.version],
           core.version,
           cli.version,
         );
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 3b4b4a82ae..91b5c1436e 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -390,6 +390,19 @@ core version and fails unless the version is unpublished or was published for
 the same `vite-plus` version. The field also maps a published core back to
 its `vite-plus` release.
 
+Two more rules close the remaining gaps:
+
+- The counters count every version number that npm has used, including
+  unpublished ones. npm never accepts an unpublished version number again, but
+  it removes the number from the packument `versions` and keeps it in `time`.
+- `release.yml` runs one release at a time (`concurrency: release`, without
+  cancelling). npm can hide a new version for some minutes while it scans the
+  upload. Without the queue, a second release could check the same core
+  version during that time, see it as unpublished, and then pin its CLI to the
+  first release's core, because the publish step treats "already published"
+  as success. GitHub keeps one pending run per group, so a third run replaces
+  the pending one, which then needs a manual rerun.
+
 A unit test in `packages/core/__tests__/` runs `assertCoreVersionMatchesVite`
 against the committed manifest. It skips the check when the version starts
 with `0.0.0-commit.`, which the preview flow stamps. `packages/core/build.ts`

From dc9d7eb186ad5805b04eb3ae5e467874e712e33f Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 22:28:15 +0800
Subject: [PATCH 21/24] fix(release): give local builds a reserved core version
 on a Vite prerelease

`core-version.ts local` called `encodeCoreVersion(vite, 999)`, which
rejects a Vite prerelease, so "Set local core version" failed in CI when
the bundled Vite was a prerelease. Local builds now use
`.vp.999` on a Vite prerelease. Counter 999 is reserved in both
forms: releases never select it, and `check --release` rejects it.

Co-Authored-By: Claude Opus 5.5 
---
 packages/core/BUNDLING.md                     |  5 +-
 .../tools/src/__tests__/core-version.spec.ts  | 69 ++++++++++++++++++-
 packages/tools/src/core-version.ts            | 46 ++++++++-----
 rfcs/core-version-alignment.md                | 42 ++++++-----
 4 files changed, 123 insertions(+), 39 deletions(-)

diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md
index 1e0607fca3..1e2746ffb8 100644
--- a/packages/core/BUNDLING.md
+++ b/packages/core/BUNDLING.md
@@ -416,9 +416,10 @@ The core version encodes `bundledVersions.vite`, so it differs from the `vite-pl
 | Stable `vite-plus`                      | `..` | Vite `8.3.5`, `n = 1`: `8.3.5001` |
 | `vite-plus` prerelease, stable Vite     | `-vp.`                 | `8.4.0-vp.0`                      |
 | `vite-plus` prerelease, Vite prerelease | `.vp.`                             | `9.0.0-beta.3.vp.0`               |
-| Local and CI builds                     | `n = 999`                                           | `8.3.5999`                        |
+| Local and CI builds, stable Vite        | `n = 999`                                           | `8.3.5999`                        |
+| Local and CI builds, Vite prerelease    | `.vp.999`                             | `9.0.0-beta.3.vp.999`             |
 
-`n` counts the stable releases on one Vite version, from 0 to 998. `k` counts the prereleases on one base. This follows the [`oxlint-tsgolint` versioning](https://github.com/oxc-project/tsgolint#versioning), where `7.0.2000` is the first release on TypeScript `7.0.2`. [`packages/tools/src/core-version.ts`](../tools/src/core-version.ts) implements the scheme:
+`n` counts the stable releases on one Vite version, from 0 to 998. `k` counts the prereleases on one base, from 0 to 998. This follows the [`oxlint-tsgolint` versioning](https://github.com/oxc-project/tsgolint#versioning), where `7.0.2000` is the first release on TypeScript `7.0.2`. [`packages/tools/src/core-version.ts`](../tools/src/core-version.ts) implements the scheme:
 
 - `prepare_release.yml` runs `core-version.ts prepare` to select the next unpublished version from the registry. It writes that version and the `vite-plus` version (`vitePlusVersion`) to `package.json`.
 - `release.yml` runs `core-version.ts check --release`. It fails when the registry already holds the core version for a different `vite-plus` release.
diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
index 7a3dbdf575..7f35346237 100644
--- a/packages/tools/src/__tests__/core-version.spec.ts
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -1,4 +1,9 @@
-import { describe, expect, test } from 'vitest';
+import { execFileSync } from 'node:child_process';
+import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+
+import { describe, expect, onTestFinished, test } from 'vitest';
 
 import {
   assertCoreVersionMatchesVite,
@@ -6,6 +11,7 @@ import {
   decodeCoreVersion,
   encodeCoreVersion,
   initialCoreVersion,
+  localCoreVersion,
   nextCoreVersion,
   usedVersionNumbers,
 } from '../core-version.ts';
@@ -252,3 +258,64 @@ describe('usedVersionNumbers', () => {
     expect(usedVersionNumbers({})).toEqual([]);
   });
 });
+
+describe('localCoreVersion', () => {
+  test('reserves counter 999 on a stable and a prerelease Vite', () => {
+    expect(localCoreVersion('8.3.7')).toBe('8.3.7999');
+    expect(localCoreVersion('9.0.0-beta.3')).toBe('9.0.0-beta.3.vp.999');
+    expect(decodeCoreVersion(localCoreVersion('9.0.0-beta.3'))).toEqual({
+      vite: '9.0.0-beta.3',
+      k: 999,
+    });
+  });
+
+  test('is never selected or accepted for a release', () => {
+    expect(
+      nextCoreVersion({
+        vite: '9.0.0-beta.3',
+        vitePlus: '2.0.0-beta.1',
+        published: ['9.0.0-beta.3.vp.0', localCoreVersion('9.0.0-beta.3')],
+      }),
+    ).toBe('9.0.0-beta.3.vp.1');
+    expect(() =>
+      assertCoreVersionMatchesVite(
+        manifest('9.0.0-beta.3.vp.999', '9.0.0-beta.3', '2.0.0-beta.1'),
+        '2.0.0-beta.1',
+        { release: true },
+      ),
+    ).toThrow('reserved for local builds');
+  });
+});
+
+// The CI workflows run `node ../tools/src/core-version.ts local` from
+// packages/core before the build. Run the script the same way in a copy of
+// that layout with the given bundled Vite version.
+function runLocal(vite: string): string {
+  const root = mkdtempSync(path.join(tmpdir(), 'vp-core-version-local-'));
+  onTestFinished(() => rmSync(root, { recursive: true, force: true }));
+  const toolsDir = path.join(root, 'packages/tools/src');
+  const coreDir = path.join(root, 'packages/core');
+  mkdirSync(toolsDir, { recursive: true });
+  mkdirSync(coreDir, { recursive: true });
+  copyFileSync(
+    path.join(import.meta.dirname, '../core-version.ts'),
+    path.join(toolsDir, 'core-version.ts'),
+  );
+  writeFileSync(
+    path.join(coreDir, 'package.json'),
+    JSON.stringify({ version: initialCoreVersion(vite), bundledVersions: { vite } }),
+  );
+  return execFileSync(process.execPath, ['../tools/src/core-version.ts', 'local'], {
+    cwd: coreDir,
+    encoding: 'utf8',
+  }).trim();
+}
+
+describe('core-version.ts local', () => {
+  test.each([
+    ['8.3.7', '8.3.7999'],
+    ['9.0.0-beta.3', '9.0.0-beta.3.vp.999'],
+  ])('prints the local version for Vite %s', (vite, expected) => {
+    expect(runLocal(vite)).toBe(expected);
+  });
+});
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
index a0f472d840..bf1bb1a442 100644
--- a/packages/tools/src/core-version.ts
+++ b/packages/tools/src/core-version.ts
@@ -9,9 +9,11 @@
  *   Vite+ prerelease, stable Vite: -vp.
  *   Vite+ prerelease, Vite pre:    .vp.
  *
- * `n` counts the stable Vite+ releases on one Vite version (0 to 998). `n = 999`
+ * `n` counts the stable Vite+ releases on one Vite version (0 to 998). `k`
+ * counts the Vite+ prereleases on one base (0 to 998). The counter value 999
  * is reserved for local and CI builds, so they never collide with a published
- * version. `k` counts the Vite+ prereleases on one base.
+ * version: `` on a stable Vite, `.vp.999`
+ * on a Vite prerelease.
  *
  * A release records its `vite-plus` version in the core manifest
  * (`vitePlusVersion`). The release check uses it to tell a retry of the same
@@ -121,14 +123,31 @@ export function tryDecodeCoreVersion(core: string): DecodedCoreVersion | undefin
   }
 }
 
-function nextCounter(values: Iterable): number {
+// Returns the next release counter. The local build counter is ignored, and a
+// counter above the release maximum fails.
+function nextCounter(values: Iterable, what: string): number {
   let next = 0;
   for (const value of values) {
-    next = Math.max(next, value + 1);
+    if (value <= MAX_RELEASE_COUNTER) {
+      next = Math.max(next, value + 1);
+    }
+  }
+  if (next > MAX_RELEASE_COUNTER) {
+    throw new Error(`${what} already has ${MAX_RELEASE_COUNTER + 1} core releases, the maximum`);
   }
   return next;
 }
 
+/**
+ * Returns the reserved core version for a local or CI build: counter 999 on a
+ * stable Vite, `.vp.999` on a Vite prerelease. Releases never use it.
+ */
+export function localCoreVersion(vite: string): string {
+  return isPrereleaseVersion(vite)
+    ? `${vite}.${PRERELEASE_TAG}.${LOCAL_BUILD_COUNTER}`
+    : encodeCoreVersion(vite, LOCAL_BUILD_COUNTER);
+}
+
 /**
  * Returns the core version for a release.
  *
@@ -159,25 +178,17 @@ export function nextCoreVersion({
     }
     const k = nextCounter(
       decoded.flatMap((entry) => (entry.vite === vite && entry.k !== undefined ? [entry.k] : [])),
+      `Vite ${vite}`,
     );
     return `${vite}.${PRERELEASE_TAG}.${k}`;
   }
 
   const n = nextCounter(
     decoded.flatMap((entry) =>
-      entry.vite === vite &&
-      entry.k === undefined &&
-      entry.n !== undefined &&
-      entry.n <= MAX_RELEASE_COUNTER
-        ? [entry.n]
-        : [],
+      entry.vite === vite && entry.k === undefined && entry.n !== undefined ? [entry.n] : [],
     ),
+    `Vite ${vite}`,
   );
-  if (n > MAX_RELEASE_COUNTER) {
-    throw new Error(
-      `Vite ${vite} already has ${MAX_RELEASE_COUNTER + 1} core releases, the maximum`,
-    );
-  }
   const base = encodeCoreVersion(vite, n);
   if (!isVitePlusPrerelease) {
     return base;
@@ -186,6 +197,7 @@ export function nextCoreVersion({
     decoded.flatMap((entry) =>
       entry.vite === vite && entry.n === n && entry.k !== undefined ? [entry.k] : [],
     ),
+    `Core ${base}`,
   );
   return `${base}-${PRERELEASE_TAG}.${k}`;
 }
@@ -230,7 +242,7 @@ export function assertCoreVersionMatchesVite(
       `${CORE_PACKAGE_NAME}@${pkg.version} and vite-plus@${vitePlus} must both be prereleases or both be stable`,
     );
   }
-  if (decoded.n === LOCAL_BUILD_COUNTER) {
+  if (decoded.n === LOCAL_BUILD_COUNTER || decoded.k === LOCAL_BUILD_COUNTER) {
     throw new Error(
       `${CORE_PACKAGE_NAME}@${pkg.version} uses the counter ${LOCAL_BUILD_COUNTER}, which is reserved for local builds`,
     );
@@ -351,7 +363,7 @@ async function main(argv: string[]): Promise {
       break;
     }
     case 'local':
-      console.log(encodeCoreVersion(vite, LOCAL_BUILD_COUNTER));
+      console.log(localCoreVersion(vite));
       break;
     case 'check': {
       const cli = readManifest(new URL('../../cli/package.json', import.meta.url));
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index 91b5c1436e..d7d38f70ff 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -21,8 +21,8 @@ Vite+ prerelease, Vite pre:    .vp.
 ```
 
 `n` counts the stable Vite+ releases that bundle the same Vite patch, from 0
-to 998. `n = 999` is reserved for local and CI builds. `k` counts the Vite+
-prereleases on the same base, from 0. Decoding is exact: the bundled Vite
+to 998. `k` counts the Vite+ prereleases on the same base, from 0 to 998. The
+counter value 999 is reserved for local and CI builds in both forms. Decoding is exact: the bundled Vite
 patch is `floor(core patch / 1000)`.
 
 This is the convention of `oxlint-tsgolint`, which Vite+ bundles. Its first
@@ -31,18 +31,19 @@ release on TypeScript `7.0.2` is `7.0.2000`, and the next ones are `7.0.2001`,
 ([tsgolint versioning](https://github.com/oxc-project/tsgolint#versioning)).
 One rule then applies to the versions that `vp --version` shows.
 
-| Vite+ release     | Bundled Vite   | Core version        |
-| ----------------- | -------------- | ------------------- |
-| `1.5.0` (first)   | `8.3.5`        | `8.3.5000`          |
-| `1.5.1`           | `8.3.5`        | `8.3.5001`          |
-| `1.6.0`           | `8.3.6`        | `8.3.6000`          |
-| `1.7.0-rc.0`      | `8.4.0`        | `8.4.0-vp.0`        |
-| `1.7.0-rc.1`      | `8.4.0`        | `8.4.0-vp.1`        |
-| `1.7.0`           | `8.4.0`        | `8.4.0`             |
-| `1.7.1`           | `8.4.1`        | `8.4.1000`          |
-| `2.0.0-beta.0`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.0` |
-| `2.0.0-beta.1`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.1` |
-| Local or CI build | `8.3.5`        | `8.3.5999`          |
+| Vite+ release     | Bundled Vite   | Core version          |
+| ----------------- | -------------- | --------------------- |
+| `1.5.0` (first)   | `8.3.5`        | `8.3.5000`            |
+| `1.5.1`           | `8.3.5`        | `8.3.5001`            |
+| `1.6.0`           | `8.3.6`        | `8.3.6000`            |
+| `1.7.0-rc.0`      | `8.4.0`        | `8.4.0-vp.0`          |
+| `1.7.0-rc.1`      | `8.4.0`        | `8.4.0-vp.1`          |
+| `1.7.0`           | `8.4.0`        | `8.4.0`               |
+| `1.7.1`           | `8.4.1`        | `8.4.1000`            |
+| `2.0.0-beta.0`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.0`   |
+| `2.0.0-beta.1`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.1`   |
+| Local or CI build | `8.3.5`        | `8.3.5999`            |
+| Local or CI build | `9.0.0-beta.3` | `9.0.0-beta.3.vp.999` |
 
 `vite-plus` keeps its own version line (`1.x` today). The CLI keeps its exact
 alias dependency `vite: npm:@voidzero-dev/vite-plus-core@`.
@@ -198,7 +199,8 @@ are rare in `vite` peer ranges. Vite+ follows Vite patch releases within days,
 so the window for the first case is short.
 
 The counter supports 999 stable Vite+ releases per Vite patch (`n` from 0 to
-998). `n = 999` is reserved for local and CI builds (see Local and CI builds).
+998), and 999 Vite+ prereleases per base (`k` from 0 to 998). The value 999 is
+reserved for local and CI builds (see Local and CI builds).
 Vite+ shipped five minor releases between 2026-09-28 and 2026-10-23 while Vite
 moved from `8.3.1` to `8.3.5`. Two releases on one Vite patch happened once
 (`v1.2.0` and `v1.3.0` on `8.3.4`).
@@ -343,7 +345,7 @@ functions and a CLI entry:
   below.
 - `assertCoreVersionMatchesVite(pkg, vitePlus, { release })` asserts that
   `pkg.version` decodes to `pkg.bundledVersions.vite`. With `release`, it also
-  asserts the prerelease pairing, rejects `n = 999`, and requires
+  asserts the prerelease pairing, rejects `n = 999` and `k = 999`, and requires
   `pkg.vitePlusVersion` to equal the released `vite-plus` version.
 - `assertCoreVersionUnclaimed(published, core, vitePlus)` rejects a core
   version that the registry already holds for a different `vite-plus`
@@ -417,8 +419,9 @@ local build is easy to detect. Core `0.0.0` does not encode a Vite version,
 and peer checks fail against it. These flows would not test the path without a
 `vite` peer rule that users get.
 
-These flows will set core to `encodeCoreVersion(vite, 999)`, for example
-`8.3.5999` for Vite `8.3.5`. They keep `0.0.0` for the CLI, which
+These flows will set core to the output of `core-version.ts local`: counter
+999 on a stable Vite (`8.3.5999` for Vite `8.3.5`), and `.vp.999` on a
+Vite prerelease (`9.0.0-beta.3.vp.999` for Vite `9.0.0-beta.3`). They keep `0.0.0` for the CLI, which
 `ecosystem-ci/verify-install.ts` and `test-vp-create.yml` assert. They set the
 core version before the build, so the CLI build, its toolchain manifest, and
 the packed `vite` alias all read it from `packages/core/package.json`. The
@@ -427,7 +430,8 @@ native build cache key does not include that file. `vp create` and
 peer rule. No environment variable overrides the core version: the core
 manifest is the only source.
 
-`nextCoreVersion` never returns `n = 999`, and `release.yml` rejects it.
+`nextCoreVersion` never returns `n = 999` or `k = 999`, and `release.yml`
+rejects both.
 Thus a local version never reaches the registry, and the local registry
 cannot mix it up with a published version.
 

From fffe426547ccb529082ef431f2c77f9629676736 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Tue, 10 Nov 2026 22:44:43 +0800
Subject: [PATCH 22/24] test(snapshots): drop the vite peer rule from the new
 create snapshots

The create snapshots from #3197 use pnpm 10.4.1 to 10.6.1 and the
latest pnpm. With a stable core on pnpm 10.2.0 and later, `vp create`
no longer writes the `vite` peer rule.

Co-Authored-By: Claude Opus 5.5 
---
 .../snapshots/create_next_command_library.md              | 5 -----
 ...te_package_manager_override_legacy_workspace.global.md | 8 --------
 ...ate_package_manager_override_legacy_workspace.local.md | 8 --------
 ..._package_manager_override_optional_workspace.global.md | 8 --------
 ...e_package_manager_override_optional_workspace.local.md | 8 --------
 .../create_package_manager_override_version.global.md     | 8 --------
 .../create_package_manager_override_version.local.md      | 8 --------
 7 files changed, 53 deletions(-)

diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_next_command_library/snapshots/create_next_command_library.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_next_command_library/snapshots/create_next_command_library.md
index f0040c3f47..e80474fda1 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_next_command_library/snapshots/create_next_command_library.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_next_command_library/snapshots/create_next_command_library.md
@@ -21,9 +21,4 @@ catalog:
   vite-plus: 
 overrides:
   vite@*: "catalog:"
-peerDependencyRules:
-  allowAny:
-    - vite
-  allowedVersions:
-    vite: "*"
 ```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.global.md
index 926e0981e2..942892fcb0 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.global.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.global.md
@@ -44,14 +44,6 @@ pnpm 10.4.1 reads settings from package.json
   "pnpm": {
     "overrides": {
       "vite@*": "npm:@voidzero-dev/vite-plus-core@"
-    },
-    "peerDependencyRules": {
-      "allowAny": [
-        "vite"
-      ],
-      "allowedVersions": {
-        "vite": "*"
-      }
     }
   }
 }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.local.md
index 926e0981e2..942892fcb0 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.local.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_legacy_workspace.local.md
@@ -44,14 +44,6 @@ pnpm 10.4.1 reads settings from package.json
   "pnpm": {
     "overrides": {
       "vite@*": "npm:@voidzero-dev/vite-plus-core@"
-    },
-    "peerDependencyRules": {
-      "allowAny": [
-        "vite"
-      ],
-      "allowedVersions": {
-        "vite": "*"
-      }
     }
   }
 }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.global.md
index cf5c24c54f..9ec6618b3f 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.global.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.global.md
@@ -44,14 +44,6 @@ pnpm 10.5.0 reads settings from package.json
   "pnpm": {
     "overrides": {
       "vite@*": "npm:@voidzero-dev/vite-plus-core@"
-    },
-    "peerDependencyRules": {
-      "allowAny": [
-        "vite"
-      ],
-      "allowedVersions": {
-        "vite": "*"
-      }
     }
   }
 }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.local.md
index cf5c24c54f..9ec6618b3f 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.local.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_optional_workspace.local.md
@@ -44,14 +44,6 @@ pnpm 10.5.0 reads settings from package.json
   "pnpm": {
     "overrides": {
       "vite@*": "npm:@voidzero-dev/vite-plus-core@"
-    },
-    "peerDependencyRules": {
-      "allowAny": [
-        "vite"
-      ],
-      "allowedVersions": {
-        "vite": "*"
-      }
     }
   }
 }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.global.md
index d33307ced9..e1d922d5b4 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.global.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.global.md
@@ -44,14 +44,6 @@ pnpm 10.6.1 reads settings from package.json
   "pnpm": {
     "overrides": {
       "vite@*": "npm:@voidzero-dev/vite-plus-core@"
-    },
-    "peerDependencyRules": {
-      "allowAny": [
-        "vite"
-      ],
-      "allowedVersions": {
-        "vite": "*"
-      }
     }
   }
 }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.local.md
index d33307ced9..e1d922d5b4 100644
--- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.local.md
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/create_package_manager_override/snapshots/create_package_manager_override_version.local.md
@@ -44,14 +44,6 @@ pnpm 10.6.1 reads settings from package.json
   "pnpm": {
     "overrides": {
       "vite@*": "npm:@voidzero-dev/vite-plus-core@"
-    },
-    "peerDependencyRules": {
-      "allowAny": [
-        "vite"
-      ],
-      "allowedVersions": {
-        "vite": "*"
-      }
     }
   }
 }

From ce92b4c847d24d7622cc7d050b7fe27341bdc278 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Thu, 12 Nov 2026 09:57:00 +0800
Subject: [PATCH 23/24] docs(rfc): start the release examples after v1.6.0

v1.6.0 shipped core 1.6.0 with the old scheme, so the first release with
the encoded core version is the next one. Move the examples and the
matching release-sequence tests one minor version forward.

Co-Authored-By: Claude Opus 5.5 
---
 .../tools/src/__tests__/core-version.spec.ts  |  4 ++--
 rfcs/core-version-alignment.md                | 24 +++++++++----------
 2 files changed, 14 insertions(+), 14 deletions(-)

diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
index 7f35346237..0272583aed 100644
--- a/packages/tools/src/__tests__/core-version.spec.ts
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -122,7 +122,7 @@ describe('nextCoreVersion', () => {
   });
 
   test('changes only the counter for minor and major releases on one Vite version', () => {
-    expect(releaseSequence('8.3.7', ['1.6.0', '1.7.0', '1.8.0', '2.0.0'])).toEqual([
+    expect(releaseSequence('8.3.7', ['1.7.0', '1.8.0', '1.9.0', '2.0.0'])).toEqual([
       '8.3.7000',
       '8.3.7001',
       '8.3.7002',
@@ -132,7 +132,7 @@ describe('nextCoreVersion', () => {
 
   test('gives betas on a stable Vite the base of the next stable release', () => {
     expect(
-      releaseSequence('8.3.7', ['1.6.0', '1.7.0-beta.0', '1.7.0-beta.1', '1.7.0', '1.8.0-beta.0']),
+      releaseSequence('8.3.7', ['1.7.0', '1.8.0-beta.0', '1.8.0-beta.1', '1.8.0', '1.9.0-beta.0']),
     ).toEqual(['8.3.7000', '8.3.7001-vp.0', '8.3.7001-vp.1', '8.3.7001', '8.3.7002-vp.0']);
   });
 });
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index d7d38f70ff..ef4ce6ff30 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -56,7 +56,7 @@ and removes the entry that earlier migrations wrote.
 For a pnpm project, `vp migrate` removes the broad `vite` peer rule from
 `pnpm-workspace.yaml`. Plugins that support the bundled Vite pass the peer
 check without it, and incompatible plugins are reported again. Example: an
-upgrade from `vite-plus` `1.5.1` to a release that bundles Vite `8.3.7`:
+upgrade from `vite-plus` `1.6.0` to a release that bundles Vite `8.3.7`:
 
 ```diff
  overrides:
@@ -67,10 +67,10 @@ upgrade from `vite-plus` `1.5.1` to a release that bundles Vite `8.3.7`:
 -  allowedVersions:
 -    vite: '*'
  catalog:
--  vite: npm:@voidzero-dev/vite-plus-core@1.5.1
+-  vite: npm:@voidzero-dev/vite-plus-core@1.6.0
 +  vite: npm:@voidzero-dev/vite-plus-core@8.3.7000
--  vite-plus: 1.5.1
-+  vite-plus: 1.6.0
+-  vite-plus: 1.6.0
++  vite-plus: 1.7.0
 ```
 
 The rule stays on pnpm before 10.2.0, and for preview and prerelease cores
@@ -214,9 +214,9 @@ and minor follow Vite, not `vite-plus`. `nextCoreVersion` gives:
 
 | `vite-plus` | Bundled Vite | Core version |
 | ----------- | ------------ | ------------ |
-| `1.6.0`     | `8.3.7`      | `8.3.7000`   |
-| `1.7.0`     | `8.3.7`      | `8.3.7001`   |
-| `1.8.0`     | `8.3.7`      | `8.3.7002`   |
+| `1.7.0`     | `8.3.7`      | `8.3.7000`   |
+| `1.8.0`     | `8.3.7`      | `8.3.7001`   |
+| `1.9.0`     | `8.3.7`      | `8.3.7002`   |
 | `2.0.0`     | `8.3.7`      | `8.3.7003`   |
 
 All four core versions decode to Vite `8.3.7`, so peer checks give the same
@@ -256,11 +256,11 @@ same Vite. `nextCoreVersion` gives:
 
 | `vite-plus`    | Bundled Vite | Core version    | Reason                                  |
 | -------------- | ------------ | --------------- | --------------------------------------- |
-| `1.6.0`        | `8.3.7`      | `8.3.7000`      | First stable release on Vite `8.3.7`    |
-| `1.7.0-beta.0` | `8.3.7`      | `8.3.7001-vp.0` | Base `8.3.7001` is the next stable core |
-| `1.7.0-beta.1` | `8.3.7`      | `8.3.7001-vp.1` | Same base, next `k`                     |
-| `1.7.0`        | `8.3.7`      | `8.3.7001`      | The stable release takes the base       |
-| `1.8.0-beta.0` | `8.3.7`      | `8.3.7002-vp.0` | The next stable core is now `8.3.7002`  |
+| `1.7.0`        | `8.3.7`      | `8.3.7000`      | First stable release on Vite `8.3.7`    |
+| `1.8.0-beta.0` | `8.3.7`      | `8.3.7001-vp.0` | Base `8.3.7001` is the next stable core |
+| `1.8.0-beta.1` | `8.3.7`      | `8.3.7001-vp.1` | Same base, next `k`                     |
+| `1.8.0`        | `8.3.7`      | `8.3.7001`      | The stable release takes the base       |
+| `1.9.0-beta.0` | `8.3.7`      | `8.3.7002-vp.0` | The next stable core is now `8.3.7002`  |
 
 The `beta` or `rc` identifier of `vite-plus` does not change the core
 version. A beta core such as `8.3.7001-vp.0` does not satisfy `^8.0.0` in npm,

From 9507271ad6a2ae552a8adc1d71914e47ad1aecc1 Mon Sep 17 00:00:00 2001
From: "MK (fengmk2)" 
Date: Thu, 12 Nov 2026 14:17:42 +0800
Subject: [PATCH 24/24] fix(release): recover from a rejected core version and
 reject unpublished ones

- `release.yml` also starts on changes to `packages/core/package.json`.
  When `check --release` rejects the core version after the merge, the
  PR from a new Prepare Release run changes only that file, and before
  this change its merge started no release.
- `check --release` rejects a core version number that was unpublished.
  npm does not accept it again, and the publish script would take npm's
  rejection for an earlier upload of the same release.
- Test `prepare` and `check --release` against a local mock registry.
- The release-manager skill covers the recovery and a bundled Vite
  change merged into the release branch.
- The RFC examples start after v1.6.0, and the RFC describes the
  packument read, patch ceilings, the local version scope, the checked
  `version` export, and the snapshot changes on a prerelease Vite.

Co-Authored-By: Claude Opus 5.5 
---
 .claude/skills/release-manager/SKILL.md       |   8 +-
 .github/workflows/release.yml                 |   5 +
 packages/core/BUNDLING.md                     |   2 +-
 .../tools/src/__tests__/core-version.spec.ts  | 203 ++++++++++++++++--
 packages/tools/src/core-version.ts            |  38 ++--
 rfcs/core-version-alignment.md                |  85 +++++---
 6 files changed, 268 insertions(+), 73 deletions(-)

diff --git a/.claude/skills/release-manager/SKILL.md b/.claude/skills/release-manager/SKILL.md
index 88da19434c..d22d5aa05f 100644
--- a/.claude/skills/release-manager/SKILL.md
+++ b/.claude/skills/release-manager/SKILL.md
@@ -386,6 +386,8 @@ git checkout release/vX.Y.Z && git merge origin/main --no-edit && git push origi
 
 Do not assume the merge brought in only the fix PR: `main` may have accumulated several. Before merging, list everything that will come in with `git log origin/release/vX.Y.Z..origin/main --oneline`, then add a changelog entry for **every** newly included PR and rerun the step 3 validation (its missing/extra diff against `generate-notes` catches any entry you missed). When the merged commits change bundled tool versions or `vp migrate` behavior, rebuild the preview (step 4) and repeat the smoke test on the new build: start each fork from its current test branch so earlier fixes carry over, open new fork PRs, close the superseded ones, and report results per build.
 
+When the merged commits change `bundledVersions.vite` in `packages/core/package.json`, the core version on the release branch no longer encodes the bundled Vite, and the merge usually conflicts on that `version` line. Take either side, then select a new core version on the release branch: run `node packages/tools/src/core-version.ts prepare --vite-plus X.Y.Z` (it reads the registry and writes `version` and `vitePlusVersion`), commit `packages/core/package.json`, and change the core version in the first line of the PR body. `node packages/tools/src/core-version.ts check --release` must pass before the merge.
+
 When several fix PRs merge back to back, the required `done` check must pass again on each later PR after `main` moves: update that PR's branch and let CI rerun before merging it.
 
 Known release-branch-only failure modes:
@@ -404,9 +406,9 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me
 
 ## 7. Automated release pipeline (what happens after merge)
 
-`release.yml` runs on the `main` push because `packages/cli/package.json` changed:
+`release.yml` runs on the `main` push because `packages/cli/package.json` or `packages/core/package.json` changed:
 
-1. `check`: compares the local version against `unpkg.com/vite-plus@latest`; everything below is skipped unless it changed.
+1. `check`: compares the local version against `unpkg.com/vite-plus@latest`; everything below is skipped unless it changed. Then `Check core version` runs `core-version.ts check --release`, which fails when the core version does not encode the bundled Vite, names another `vite-plus` version, or is already used on npm by another release.
 2. `build-rust`: full multi-platform build.
 3. `request-approval`: posts an approval request to the releases Discord channel, and the `Release` job waits on the `release` GitHub environment. **A person with environment approval rights must approve the run in the Actions UI.** The environment sets `prevent_self_review: true`, so whoever merged the release PR triggered the run and cannot approve it: a _different_ reviewer must. Check who can, and tell the release manager rather than leaving them waiting on themselves:
 
@@ -421,6 +423,8 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me
 7. `discord-notify`: announces to Discord after Docker publishing and docs deployment succeed (docs are skipped for prereleases).
 8. `update-homebrew`: after a stable release is published, verifies its archives and npm packages, then opens the Homebrew formula update PR. Published-artifact installation tests run on that PR on all four supported platforms before maintainer review and merge.
 
+**When `Check core version` fails, select a new core version through a PR.** The usual cause is a second release PR that was prepared before the first one published the same core version. Run the Prepare Release workflow again with the same version. Its PR changes only `packages/core/package.json`, and merging it starts `release.yml` again, because that file is a trigger and the CLI version is still unpublished. Do not rerun the failed run: it checks the same commit and fails again.
+
 **When the `Release` job fails partway, fix the cause, then use "Re-run failed jobs", never "Re-run all jobs".** A full rerun repeats `check`, which now sees the new version on `unpkg.com/vite-plus@latest` and skips the whole release. A failed-jobs rerun reuses `check` and the build artifacts, and every npm publish step is idempotent: `.github/scripts/publish-npm-package.ts` skips versions the registry already serves (`@X.Y.Z is already published; skipping upload.`), so only the unpublished packages upload. Confirm that from the rerun log. The rerun waits on the `release` environment again, and whoever started the rerun cannot approve it.
 
 **A successful publish command does not mean the packages are installable.** `pnpm publish` prints `✅ Published package @X.Y.Z` as soon as the registry accepts the request, and the registry can then take tens of minutes to actually serve that version. This has shipped a broken release: `vite-plus@X.Y.Z` went live on `latest` with an exact dependency on its `@voidzero-dev/vite-plus-core` version that was invisible for about 35 minutes, so every `npm install vite-plus` failed with `ETARGET` and both `publish-docker` and `Deploy docs` failed on `ERR_PNPM_NO_MATCHING_VERSION`. The downstream job failures are the symptom, not the cause; do not re-run them until the registry has the package.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 62aef8be14..57441e2c43 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,8 +3,13 @@ name: Release
 on:
   push:
     branches: [main]
+    # The core manifest is a trigger too: when `check --release` rejects the
+    # core version after the merge, the fix PR from a new Prepare Release run
+    # changes only that file. The `check` job skips the release when the CLI
+    # version is already published.
     paths:
       - 'packages/cli/package.json'
+      - 'packages/core/package.json'
 
 permissions: {}
 
diff --git a/packages/core/BUNDLING.md b/packages/core/BUNDLING.md
index 1e2746ffb8..d70ae50c8b 100644
--- a/packages/core/BUNDLING.md
+++ b/packages/core/BUNDLING.md
@@ -422,6 +422,6 @@ The core version encodes `bundledVersions.vite`, so it differs from the `vite-pl
 `n` counts the stable releases on one Vite version, from 0 to 998. `k` counts the prereleases on one base, from 0 to 998. This follows the [`oxlint-tsgolint` versioning](https://github.com/oxc-project/tsgolint#versioning), where `7.0.2000` is the first release on TypeScript `7.0.2`. [`packages/tools/src/core-version.ts`](../tools/src/core-version.ts) implements the scheme:
 
 - `prepare_release.yml` runs `core-version.ts prepare` to select the next unpublished version from the registry. It writes that version and the `vite-plus` version (`vitePlusVersion`) to `package.json`.
-- `release.yml` runs `core-version.ts check --release`. It fails when the registry already holds the core version for a different `vite-plus` release.
+- `release.yml` runs `core-version.ts check --release`. It fails when the registry already holds the core version for a different `vite-plus` release, or when the version number was unpublished.
 - `.github/scripts/upgrade-deps.ts` resets `version` to `n = 0` when the bundled Vite changes.
 - `__tests__/core-version.spec.ts` checks that the committed `version` encodes `bundledVersions.vite`.
diff --git a/packages/tools/src/__tests__/core-version.spec.ts b/packages/tools/src/__tests__/core-version.spec.ts
index 0272583aed..e81ad4039e 100644
--- a/packages/tools/src/__tests__/core-version.spec.ts
+++ b/packages/tools/src/__tests__/core-version.spec.ts
@@ -1,7 +1,10 @@
-import { execFileSync } from 'node:child_process';
-import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { execFile } from 'node:child_process';
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { createServer } from 'node:http';
+import type { AddressInfo } from 'node:net';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
+import { promisify } from 'node:util';
 
 import { describe, expect, onTestFinished, test } from 'vitest';
 
@@ -16,6 +19,8 @@ import {
   usedVersionNumbers,
 } from '../core-version.ts';
 
+const execFileAsync = promisify(execFile);
+
 const PUBLISHED_BEFORE_ALIGNMENT = ['0.1.21-alpha.7', '0.0.2-test.1', '1.0.0-rc.0', '1.4.0'];
 
 describe('encodeCoreVersion', () => {
@@ -213,22 +218,52 @@ describe('assertCoreVersionMatchesVite', () => {
   });
 });
 
+// A packument where core 8.3.7001 is published with the given manifest fields.
+function publishedPackument(fields: { vitePlusVersion?: string }) {
+  return { versions: { '8.3.7001': fields } };
+}
+
 describe('assertCoreVersionUnclaimed', () => {
-  test('accepts an unpublished core version and a retry of the same release', () => {
-    expect(() => assertCoreVersionUnclaimed(undefined, '8.3.5001', '1.5.1')).not.toThrow();
+  test('accepts an unused core version and a retry of the same release', () => {
+    expect(() => assertCoreVersionUnclaimed({}, '8.3.7001', '1.7.1')).not.toThrow();
     expect(() =>
-      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.5001', '1.5.1'),
+      assertCoreVersionUnclaimed(
+        publishedPackument({ vitePlusVersion: '1.7.1' }),
+        '8.3.7001',
+        '1.7.1',
+      ),
     ).not.toThrow();
   });
 
   test('rejects a core version that another release published', () => {
     expect(() =>
-      assertCoreVersionUnclaimed({ vitePlusVersion: '1.5.1' }, '8.3.5001', '1.6.0'),
-    ).toThrow('already published for vite-plus 1.5.1');
-    expect(() => assertCoreVersionUnclaimed({}, '8.3.5000', '1.6.0')).toThrow(
+      assertCoreVersionUnclaimed(
+        publishedPackument({ vitePlusVersion: '1.7.1' }),
+        '8.3.7001',
+        '1.8.0',
+      ),
+    ).toThrow('already published for vite-plus 1.7.1');
+    expect(() => assertCoreVersionUnclaimed(publishedPackument({}), '8.3.7001', '1.8.0')).toThrow(
       'already published for vite-plus (unknown)',
     );
   });
+
+  test('rejects an unpublished core version', () => {
+    const unpublished = {
+      versions: { '8.3.7000': {} },
+      time: { created: '', modified: '', '8.3.7000': '', '8.3.7001': '' },
+    };
+    expect(() => assertCoreVersionUnclaimed(unpublished, '8.3.7001', '1.7.1')).toThrow(
+      'was unpublished',
+    );
+    expect(() =>
+      assertCoreVersionUnclaimed(
+        { time: { created: '', modified: '', unpublished: { versions: ['8.3.7000'] } } },
+        '8.3.7000',
+        '1.7.0',
+      ),
+    ).toThrow('was unpublished');
+  });
 });
 
 describe('usedVersionNumbers', () => {
@@ -287,35 +322,157 @@ describe('localCoreVersion', () => {
   });
 });
 
-// The CI workflows run `node ../tools/src/core-version.ts local` from
-// packages/core before the build. Run the script the same way in a copy of
-// that layout with the given bundled Vite version.
-function runLocal(vite: string): string {
-  const root = mkdtempSync(path.join(tmpdir(), 'vp-core-version-local-'));
+// The workflows run `core-version.ts` from the repository root (`prepare`,
+// `check`) or from packages/core (`local`). Make a temporary copy of that
+// layout with the given core manifest and CLI version.
+function repoLayout(core: object, cliVersion = '0.0.0'): string {
+  const root = mkdtempSync(path.join(tmpdir(), 'vp-core-version-'));
   onTestFinished(() => rmSync(root, { recursive: true, force: true }));
   const toolsDir = path.join(root, 'packages/tools/src');
-  const coreDir = path.join(root, 'packages/core');
   mkdirSync(toolsDir, { recursive: true });
-  mkdirSync(coreDir, { recursive: true });
+  mkdirSync(path.join(root, 'packages/core'));
+  mkdirSync(path.join(root, 'packages/cli'));
   copyFileSync(
     path.join(import.meta.dirname, '../core-version.ts'),
     path.join(toolsDir, 'core-version.ts'),
   );
+  writeFileSync(path.join(root, 'packages/core/package.json'), JSON.stringify(core, null, 2));
   writeFileSync(
-    path.join(coreDir, 'package.json'),
-    JSON.stringify({ version: initialCoreVersion(vite), bundledVersions: { vite } }),
+    path.join(root, 'packages/cli/package.json'),
+    JSON.stringify({ version: cliVersion }),
   );
-  return execFileSync(process.execPath, ['../tools/src/core-version.ts', 'local'], {
-    cwd: coreDir,
-    encoding: 'utf8',
-  }).trim();
+  return root;
+}
+
+function readCoreManifest(root: string): Record {
+  return JSON.parse(readFileSync(path.join(root, 'packages/core/package.json'), 'utf8'));
+}
+
+interface ScriptResult {
+  code: number;
+  stdout: string;
+  stderr: string;
+}
+
+// Runs the script without blocking, so the mock registry in this process can
+// answer its request.
+async function runScript(
+  root: string,
+  args: string[],
+  { cwd = root, registry }: { cwd?: string; registry?: string } = {},
+): Promise {
+  const script = path.relative(cwd, path.join(root, 'packages/tools/src/core-version.ts'));
+  try {
+    const { stdout, stderr } = await execFileAsync(process.execPath, [script, ...args], {
+      cwd,
+      env: { ...process.env, PUBLISH_REGISTRY: registry },
+      encoding: 'utf8',
+    });
+    return { code: 0, stdout: stdout.trim(), stderr };
+  } catch (error) {
+    const failure = error as { code: number; stdout: string; stderr: string };
+    return { code: failure.code, stdout: failure.stdout.trim(), stderr: failure.stderr };
+  }
+}
+
+// Serves the core packument with the given status and body, and 404 for every
+// other path.
+async function startRegistry(status: number, body: unknown = {}): Promise {
+  const server = createServer((request, response) => {
+    if (request.url !== '/@voidzero-dev%2fvite-plus-core') {
+      response.writeHead(404).end();
+      return;
+    }
+    response.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
+  });
+  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+  onTestFinished(() => new Promise((resolve) => server.close(() => resolve())));
+  return `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
 }
 
 describe('core-version.ts local', () => {
   test.each([
     ['8.3.7', '8.3.7999'],
     ['9.0.0-beta.3', '9.0.0-beta.3.vp.999'],
-  ])('prints the local version for Vite %s', (vite, expected) => {
-    expect(runLocal(vite)).toBe(expected);
+  ])('prints the local version for Vite %s', async (vite, expected) => {
+    const root = repoLayout({ version: initialCoreVersion(vite), bundledVersions: { vite } });
+    const result = await runScript(root, ['local'], { cwd: path.join(root, 'packages/core') });
+    expect(result).toEqual({ code: 0, stdout: expected, stderr: '' });
+  });
+});
+
+describe('core-version.ts prepare', () => {
+  const core = {
+    name: '@voidzero-dev/vite-plus-core',
+    version: '8.3.7000',
+    bundledVersions: { vite: '8.3.7' },
+  };
+
+  test('writes the next unused version and the vite-plus version', async () => {
+    const root = repoLayout(core);
+    const registry = await startRegistry(200, {
+      versions: { '8.3.7000': { vitePlusVersion: '1.7.0' } },
+      time: { created: '', modified: '', '8.3.7000': '', '8.3.7001': '' },
+    });
+    const result = await runScript(root, ['prepare', '--vite-plus', '1.8.0'], { registry });
+    expect(result).toMatchObject({ code: 0, stdout: '8.3.7002' });
+    expect(readCoreManifest(root)).toEqual({
+      ...core,
+      version: '8.3.7002',
+      vitePlusVersion: '1.8.0',
+    });
+  });
+
+  test('starts the counter when the package is not published', async () => {
+    const root = repoLayout(core);
+    const registry = await startRegistry(404);
+    const result = await runScript(root, ['prepare', '--vite-plus', '1.7.0'], { registry });
+    expect(result).toMatchObject({ code: 0, stdout: '8.3.7000' });
+  });
+
+  test('fails without a change when the registry fails', async () => {
+    const root = repoLayout(core);
+    const registry = await startRegistry(500);
+    const result = await runScript(root, ['prepare', '--vite-plus', '1.7.0'], { registry });
+    expect(result.code).toBe(1);
+    expect(result.stderr).toContain('Registry returned HTTP 500');
+    expect(readCoreManifest(root)).toEqual(core);
+  });
+});
+
+describe('core-version.ts check --release', () => {
+  const core = {
+    version: '8.3.7001',
+    bundledVersions: { vite: '8.3.7' },
+    vitePlusVersion: '1.8.0',
+  };
+
+  test('passes for an unused version and for a retry of the same release', async () => {
+    const root = repoLayout(core, '1.8.0');
+    for (const registry of [
+      await startRegistry(404),
+      await startRegistry(200, { versions: { '8.3.7001': { vitePlusVersion: '1.8.0' } } }),
+    ]) {
+      const result = await runScript(root, ['check', '--release'], { registry });
+      expect(result).toMatchObject({ code: 0 });
+    }
+  });
+
+  test('fails when another release published the version', async () => {
+    const root = repoLayout(core, '1.8.0');
+    const registry = await startRegistry(200, {
+      versions: { '8.3.7001': { vitePlusVersion: '1.7.1' } },
+    });
+    const result = await runScript(root, ['check', '--release'], { registry });
+    expect(result.code).toBe(1);
+    expect(result.stderr).toContain('already published for vite-plus 1.7.1');
+  });
+
+  test('fails when the manifest names another vite-plus version', async () => {
+    const root = repoLayout(core, '1.8.1');
+    const registry = await startRegistry(404);
+    const result = await runScript(root, ['check', '--release'], { registry });
+    expect(result.code).toBe(1);
+    expect(result.stderr).toContain('but the release is vite-plus 1.8.1');
   });
 });
diff --git a/packages/tools/src/core-version.ts b/packages/tools/src/core-version.ts
index bf1bb1a442..90257e3956 100644
--- a/packages/tools/src/core-version.ts
+++ b/packages/tools/src/core-version.ts
@@ -256,19 +256,30 @@ export function assertCoreVersionMatchesVite(
 }
 
 /**
- * Asserts that the registry does not already hold the core version for a
- * different release. `published` is the published manifest of that version, or
- * `undefined` when it is not published. A retry of the same release passes.
+ * Asserts that npm still accepts the core version for this release. It fails
+ * when another release published the version, and when the version was
+ * unpublished: npm never accepts that number again, and the publish script
+ * would take npm's rejection for an earlier upload. A retry of the same
+ * release passes.
  */
 export function assertCoreVersionUnclaimed(
-  published: { vitePlusVersion?: string } | undefined,
+  packument: CorePackument,
   core: string,
   vitePlus: string,
 ): void {
-  if (published && published.vitePlusVersion !== vitePlus) {
+  const published = packument.versions?.[core];
+  const rerun = 'Run the Prepare Release workflow again to select an unused core version.';
+  if (published) {
+    if (published.vitePlusVersion !== vitePlus) {
+      throw new Error(
+        `${CORE_PACKAGE_NAME}@${core} is already published for vite-plus ${published.vitePlusVersion ?? '(unknown)'}. ${rerun}`,
+      );
+    }
+    return;
+  }
+  if (usedVersionNumbers(packument).includes(core)) {
     throw new Error(
-      `${CORE_PACKAGE_NAME}@${core} is already published for vite-plus ${published.vitePlusVersion ?? '(unknown)'}. ` +
-        'Run the Prepare Release workflow again to select an unpublished core version.',
+      `${CORE_PACKAGE_NAME}@${core} was unpublished, and npm does not accept it again. ${rerun}`,
     );
   }
 }
@@ -281,7 +292,7 @@ function readManifest(url: URL): CoreManifest {
 
 const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
 
-interface CorePackument {
+export interface CorePackument {
   versions?: Record;
   time?: Record & { unpublished?: { versions?: unknown } };
 }
@@ -312,8 +323,9 @@ export function usedVersionNumbers(packument: CorePackument): string[] {
 
 /**
  * Reads the full packument of the core package. `prepare` uses the version
- * numbers, and `check --release` uses the `vitePlusVersion` field of one
- * manifest. Returns an empty packument when the package is not published.
+ * numbers, and `check --release` uses the version numbers and the
+ * `vitePlusVersion` field of one manifest. Returns an empty packument when the
+ * package is not published.
  */
 async function readCorePackument(): Promise {
   const registry = (process.env.PUBLISH_REGISTRY ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, '');
@@ -369,11 +381,7 @@ async function main(argv: string[]): Promise {
       const cli = readManifest(new URL('../../cli/package.json', import.meta.url));
       assertCoreVersionMatchesVite(core, cli.version, { release: values.release });
       if (values.release) {
-        assertCoreVersionUnclaimed(
-          (await readCorePackument()).versions?.[core.version],
-          core.version,
-          cli.version,
-        );
+        assertCoreVersionUnclaimed(await readCorePackument(), core.version, cli.version);
       }
       console.log(`${CORE_PACKAGE_NAME}@${core.version} encodes Vite ${vite}`);
       break;
diff --git a/rfcs/core-version-alignment.md b/rfcs/core-version-alignment.md
index ef4ce6ff30..4acd88e4aa 100644
--- a/rfcs/core-version-alignment.md
+++ b/rfcs/core-version-alignment.md
@@ -33,16 +33,16 @@ One rule then applies to the versions that `vp --version` shows.
 
 | Vite+ release     | Bundled Vite   | Core version          |
 | ----------------- | -------------- | --------------------- |
-| `1.5.0` (first)   | `8.3.5`        | `8.3.5000`            |
-| `1.5.1`           | `8.3.5`        | `8.3.5001`            |
-| `1.6.0`           | `8.3.6`        | `8.3.6000`            |
-| `1.7.0-rc.0`      | `8.4.0`        | `8.4.0-vp.0`          |
-| `1.7.0-rc.1`      | `8.4.0`        | `8.4.0-vp.1`          |
-| `1.7.0`           | `8.4.0`        | `8.4.0`               |
-| `1.7.1`           | `8.4.1`        | `8.4.1000`            |
+| `1.7.0` (first)   | `8.3.7`        | `8.3.7000`            |
+| `1.7.1`           | `8.3.7`        | `8.3.7001`            |
+| `1.8.0`           | `8.3.8`        | `8.3.8000`            |
+| `1.9.0-rc.0`      | `8.4.0`        | `8.4.0-vp.0`          |
+| `1.9.0-rc.1`      | `8.4.0`        | `8.4.0-vp.1`          |
+| `1.9.0`           | `8.4.0`        | `8.4.0`               |
+| `1.9.1`           | `8.4.1`        | `8.4.1000`            |
 | `2.0.0-beta.0`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.0`   |
 | `2.0.0-beta.1`    | `9.0.0-beta.3` | `9.0.0-beta.3.vp.1`   |
-| Local or CI build | `8.3.5`        | `8.3.5999`            |
+| Local or CI build | `8.3.7`        | `8.3.7999`            |
 | Local or CI build | `9.0.0-beta.3` | `9.0.0-beta.3.vp.999` |
 
 `vite-plus` keeps its own version line (`1.x` today). The CLI keeps its exact
@@ -194,8 +194,9 @@ same Vite patch cannot publish. `v1.2.0` and `v1.3.0` both bundled Vite
 
 Option B+ is the selected scheme. Its residual false results are limited to
 patch floors above the bundled patch inside the same minor (`>=8.3.6` passes
-against `8.3.5000`) and to exact pins (`8.3.5` fails against `8.3.5000`). Both
-are rare in `vite` peer ranges. Vite+ follows Vite patch releases within days,
+against `8.3.5000`), patch ceilings inside the same minor (`<8.3.10` fails
+against `8.3.5000`), and exact pins (`8.3.5` fails against `8.3.5000`). All
+three are rare in `vite` peer ranges. Vite+ follows Vite patch releases within days,
 so the window for the first case is short.
 
 The counter supports 999 stable Vite+ releases per Vite patch (`n` from 0 to
@@ -231,7 +232,7 @@ A Vite+ prerelease does not copy its own prerelease identifiers into the core
 version. A copied suffix has two problems:
 
 - Two Vite+ prereleases can have the same identifiers, for example
-  `1.7.0-rc.0` and `1.8.0-rc.0`. If no stable release ships between them on
+  `1.9.0-rc.0` and `1.10.0-rc.0`. If no stable release ships between them on
   the same Vite, both map to `8.4.0-rc.0`, and the second publish fails.
 - On a stable Vite, a copied suffix looks like a Vite prerelease and can sort
   out of order. A Vite+ RC on Vite `9.0.0` would publish `9.0.0-rc.0`, which
@@ -242,8 +243,8 @@ Every Vite+ prerelease uses the identifier `vp` and a counter `k` instead:
 - On a stable Vite `X.Y.Z`, the base is the next stable core version, which
   is the version that the next stable release on that Vite would get. The core
   version is that base with `-vp.` appended. `k` counts the published core
-  prereleases with the same base, from 0. Vite+ `1.7.0-rc.0` and `1.7.0-rc.1`
-  on Vite `8.4.0` publish `8.4.0-vp.0` and `8.4.0-vp.1`. A later `1.8.0-rc.0`
+  prereleases with the same base, from 0. Vite+ `1.9.0-rc.0` and `1.9.0-rc.1`
+  on Vite `8.4.0` publish `8.4.0-vp.0` and `8.4.0-vp.1`. A later `1.10.0-rc.0`
   on the same Vite, with no stable release between them, publishes
   `8.4.0-vp.2`. The next stable release publishes `8.4.0`.
 - On a Vite prerelease `X.Y.Z-
`, the core version is the Vite version
@@ -347,9 +348,10 @@ functions and a CLI entry:
   `pkg.version` decodes to `pkg.bundledVersions.vite`. With `release`, it also
   asserts the prerelease pairing, rejects `n = 999` and `k = 999`, and requires
   `pkg.vitePlusVersion` to equal the released `vite-plus` version.
-- `assertCoreVersionUnclaimed(published, core, vitePlus)` rejects a core
+- `assertCoreVersionUnclaimed(packument, core, vitePlus)` rejects a core
   version that the registry already holds for a different `vite-plus`
-  version. A retry of the same release passes.
+  version, and a version number that was unpublished. A retry of the same
+  release passes.
 
 The name `checkCoreVersion` is not used, because
 `packages/cli/src/resolve-core.ts` already defines a function with that name
@@ -374,10 +376,10 @@ The committed `packages/core/package.json` must always encode the committed
    bundled Vite change it sets `version` to `initialCoreVersion(vite)`.
    Renovate ignores `vite` (`.github/renovate.json`), so this script is the
    only automated path that changes the bundled Vite.
-2. `prepare_release.yml` runs `core-version.ts prepare`. It reads the
-   published core versions
-   (`npm view @voidzero-dev/vite-plus-core versions --json`), calls
-   `nextCoreVersion` with the input `vite-plus` version, and writes the
+2. `prepare_release.yml` runs `core-version.ts prepare`. It fetches the full
+   core packument from the registry (`PUBLISH_REGISTRY`, default
+   `https://registry.npmjs.org`), collects every used version number (see the
+   rules below), calls `nextCoreVersion` with the input `vite-plus` version, and writes the
    result and `vitePlusVersion` to the core manifest. The workflow prints
    both versions in the release PR body.
 
@@ -387,16 +389,24 @@ publishes get the same core version, and a manual `vite-plus` bump skips the
 selection. The publish step skips a version that is already published, so
 the CLI could then pin a core tarball from another commit. The
 `vitePlusVersion` field closes this gap: `release.yml` runs
-`core-version.ts check --release`, which reads the published manifest of the
-core version and fails unless the version is unpublished or was published for
-the same `vite-plus` version. The field also maps a published core back to
-its `vite-plus` release.
+`core-version.ts check --release`, which reads the core packument. It fails
+when the version was published for another `vite-plus` version. The field
+also maps a published core back to its `vite-plus` release.
+
+When the check fails after the merge, a new Prepare Release run for the same
+`vite-plus` version selects a new core version. Its PR changes only
+`packages/core/package.json`. That file is a `release.yml` trigger with
+`packages/cli/package.json`, so the merge starts the release again. The
+`check` job still skips the release when the CLI version is already
+published.
 
 Two more rules close the remaining gaps:
 
 - The counters count every version number that npm has used, including
   unpublished ones. npm never accepts an unpublished version number again, but
   it removes the number from the packument `versions` and keeps it in `time`.
+  `check --release` also rejects such a number. Otherwise the publish script
+  would take npm's rejection for an earlier upload of the same release.
 - `release.yml` runs one release at a time (`concurrency: release`, without
   cancelling). npm can hide a new version for some minutes while it scans the
   upload. Without the queue, a second release could check the same core
@@ -420,20 +430,24 @@ and peer checks fail against it. These flows would not test the path without a
 `vite` peer rule that users get.
 
 These flows will set core to the output of `core-version.ts local`: counter
-999 on a stable Vite (`8.3.5999` for Vite `8.3.5`), and `.vp.999` on a
-Vite prerelease (`9.0.0-beta.3.vp.999` for Vite `9.0.0-beta.3`). They keep `0.0.0` for the CLI, which
+999 on a stable Vite (`8.3.7999` for Vite `8.3.7`), and `.vp.999` on a
+Vite prerelease (`9.0.0-beta.3.vp.999` for Vite `9.0.0-beta.3`). They keep
+`0.0.0` for the CLI, which
 `ecosystem-ci/verify-install.ts` and `test-vp-create.yml` assert. They set the
 core version before the build, so the CLI build, its toolchain manifest, and
 the packed `vite` alias all read it from `packages/core/package.json`. The
 native build cache key does not include that file. `vp create` and
-`vp migrate` in these flows write the `8.3.5999` alias and remove the `vite`
+`vp migrate` in these flows write the `8.3.7999` alias and remove the `vite`
 peer rule. No environment variable overrides the core version: the core
 manifest is the only source.
 
 `nextCoreVersion` never returns `n = 999` or `k = 999`, and `release.yml`
-rejects both.
-Thus a local version never reaches the registry, and the local registry
-cannot mix it up with a published version.
+rejects both. Thus a local version never reaches the registry, and in these
+two flows the local registry cannot mix it up with a published version.
+Other local builds (`pnpm bootstrap-cli`, the CLI snapshot runner, and
+`ci.yml`) pack the committed core version, which is a published version
+after a release. This was also true before this RFC, when core used the CLI
+version.
 
 ### Release pipeline changes
 
@@ -445,7 +459,7 @@ cannot mix it up with a published version.
 | `packages/core/build.ts`                                                                                                         | Pass `cliPkgJson.version` instead of `pkgJson.version` to `rewriteRolldownBindingRequires`. The platform packages publish at the CLI version, and the guard must compare against that version (`packages/core/build-support/rewrite-rolldown-binding.ts`)                                                                                                                      |
 | `packages/cli/publish-native-addons.ts`                                                                                          | No change: it mirrors the CLI's platform pins into core by name and does not use the core version                                                                                                                                                                                                                                                                              |
 | `.github/workflows/publish-preview.yml`, `publish-preview-register.yml`                                                          | No change: the bridge serves every workspace package as `0.0.0-commit.`, and the stamp before `pnpm pack` keeps the alias on that version. The sticky comment keeps printing one version for both packages                                                                                                                                                                |
-| `.github/workflows/test-vp-create.yml`, `reusable-ecosystem-ci.yml`                                                              | Keep `0.0.0` for the CLI. Set core to `encodeCoreVersion(vite, 999)` before the build (see Local and CI builds)                                                                                                                                                                                                                                                                |
+| `.github/workflows/test-vp-create.yml`, `reusable-ecosystem-ci.yml`                                                              | Keep `0.0.0` for the CLI. Set core to the output of `core-version.ts local` before the build (see Local and CI builds)                                                                                                                                                                                                                                                         |
 | `packages/tools/src/local-npm-registry.ts`, `pack-local-vite-plus.ts`                                                            | No change: both serve the packed tarball's own version                                                                                                                                                                                                                                                                                                                         |
 | `.github/scripts/upgrade-deps.ts`                                                                                                | Reset the core version after a bundled Vite change                                                                                                                                                                                                                                                                                                                             |
 | `.github/renovate.json`                                                                                                          | No change                                                                                                                                                                                                                                                                                                                                                                      |
@@ -713,8 +727,9 @@ manifest `version` takes part in the check.
 - Tools that read the `vite` version. Vite's `version` export is rewritten in
   `packages/core/build.ts` to import `../../package.json` relative to Vite's
   `src/node/constants.ts`, which is Vite's own manifest, so the export keeps
-  the real Vite version. This was checked by reading the build script; the
-  built output was not inspected in this worktree. Code that reads
+  the real Vite version. Importing the built
+  `packages/core/dist/vite/node/index.js` confirms this: `version` is the
+  bundled Vite version, not the core version. Code that reads
   `node_modules/vite/package.json` directly, such as
   `readInstalledRawViteVersion` in `vite-plus-bootstrap.ts`, keys on
   `name === '@voidzero-dev/vite-plus-core'` and reads `bundledVersions.vite`.
@@ -725,6 +740,12 @@ manifest `version` takes part in the check.
   platform package version with a baked version. Today it bakes the core
   version, which equals the CLI version. The build must bake the CLI version
   after this change, or every release build fails the guard.
+- Migration snapshots follow the kind of bundled Vite. Migration keeps the
+  pnpm `vite` rule for a prerelease core, so when the bundled Vite moves to a
+  prerelease (expected for Vite 9), the committed core becomes
+  `.vp.0`, and the CLI snapshots that print pnpm settings show the rule
+  again. They change back on the next stable Vite. The upgrade-deps PR that
+  makes either move must record those snapshots again.
 - Counter lookup needs the registry. `prepare_release.yml` already needs the
   network for `cargo check`. A registry failure must stop the job with a
   clear error rather than guess `n = 0`.