Repository navigation
Commit 85b4ac6
fix(cli): plugin scaffolds declare vitest ~4.0.0 so npm install works on npm 10 (#22920)
Fixes #22912
Clause-②: no
## What changes
`SCAFFOLD_VITEST_RANGE` (`packages/cli/src/commands/init.ts`) moves from
`^4.0.0` to `~4.0.0`. It is the one range both CLI scaffolders emit
(`objectstack init -t plugin` and `objectstack create plugin`), and
neither holds a copy. A comment beside the constant records the upstream
cause, why `^5.0.0` was not chosen, and the re-check that lets the range
move back. One `patch` changeset for `@objectstack/cli`.
There is no `.npmrc`, no `legacy-peer-deps`, no per-scaffolder range and
no `packages/create-objectstack` change. `SCAFFOLD_VITEST_RANGE` is not
one of the `POLICY_STAMPS` that
`scripts/sync-scaffold-emission-policy.mjs` writes into that package's
template, and its blank template declares no vitest.
## Install matrix (measured 2026-10-12)
Every row ran on Node v22.22.0, in an empty directory outside the repo,
with a fresh `--cache` directory per row, as `npm install --no-audit
--no-fund`. npm 10.9.4 is this container's global npm, the major that
Node 22 bundles. npm 10.9.9 (newest 10.x) and npm 11.21.0 (newest 11.x)
were installed into scratch prefixes and run by path. The global npm was
not changed.
### The card's one-line manifest, `{"devDependencies":{"vitest":RANGE}}`
| npm | RANGE | exit | vitest | vite | first error line |
|---|---|---|---|---|---|
| 10.9.4 | `^4.0.0` | 1 | none | none | `npm error Cannot read
properties of null (reading 'edgesOut')` |
| 10.9.9 | `^4.0.0` | 1 | none | none | same |
| 11.21.0 | `^4.0.0` | 0 | 4.1.11 | 8.3.4 | none |
| 10.9.4 | `~4.1.0` | 1 | none | none | same |
| 10.9.9 | `~4.1.0` | 1 | none | none | same |
| 10.9.4 | `~4.0.0` | 0 | 4.0.18 | 7.3.7 | none |
| 10.9.9 | `~4.0.0` | 0 | 4.0.18 | 7.3.7 | none |
| 11.21.0 | `~4.0.0` | 0 | 4.0.18 | 7.3.7 | none |
| 10.9.9 | `^5.0.0` | 0 | 5.0.3 | 8.3.4 | none |
| 11.21.0 | `^5.0.0` | 0 | 5.0.3 | 8.3.4 | none |
| 10.9.9 | `5.0.0` (exact) | 1 | none | none | same |
| 10.9.9 | `5.0.2` (exact) | 1 | none | none | same |
### The CLI-emitted manifests (the card's "not measured" item)
These were emitted by driving the real commands from this worktree (`tsx
packages/cli/bin/run-dev.js init probe-plugin -t plugin --no-install`,
and `create plugin probe-create`). Each emitted project directory was
then installed as-is. Their `@objectstack/*` ranges are `^17.7.0`, which
is the published `latest`.
| emission | vitest declared | npm | exit | vitest | vite | first error
line |
|---|---|---|---|---|---|---|
| `os init -t plugin` | `^4.0.0` (before) | 10.9.4 | 1 | none | none |
`edgesOut`, as above |
| `os create plugin` | `^4.0.0` (before) | 10.9.4 | 1 | none | none |
`edgesOut`, as above |
| `os init -t plugin` | `~4.0.0` | 10.9.4 | 0 | 4.0.18 | 7.3.7 | none |
| `os create plugin` | `~4.0.0` | 10.9.4 | 0 | 4.0.18 | 7.3.7 | none |
| `os init -t plugin` | `~4.0.0` | 10.9.9 | 0 | 4.0.18 | 7.3.7 | none |
| `os create plugin` | `~4.0.0` | 10.9.9 | 0 | 4.0.18 | 7.3.7 | none |
| `os init -t plugin` | `~4.0.0` | 11.21.0 | 0 | 4.0.18 | 7.3.7 | none |
| `os create plugin` | `~4.0.0` | 11.21.0 | 0 | 4.0.18 | 7.3.7 | none |
In the npm 10.9.4 `os init -t plugin` install, the only vite is 7.3.7,
the only vitest is 4.0.18, and no `@vitejs/devtools*` package is
present. In the npm 10.9.4 `os create plugin` install, `vitest run` of a
one-test file passes (1 passed, exit 0).
Not measured:
- npm 12.2.0, now npm's `latest`. It refuses Node 22.22.0 (its engines
are `^22.22.2 || ^24.15.0 || >=26.0.0`), and no other Node is on the
box.
- pnpm, yarn and bun.
- `~4.1.0` on npm 11.
## Why `~4.0.0`: the mechanism, read off the registry and npm's own log
- The npm 10.9.9 debug log fetches these manifests in order:
`@vitejs/devtools@^0.7.1`, `devtools-oxc`, `devtools-rolldown`,
`devtools-vite`, `@vitejs/devtools-vitest@^0.7.6`, `vitest@*`,
`@vitest/browser-playwright@5.0.3` and `vitest@4.1.11`. Then it throws
`TypeError: Cannot read properties of null (reading 'edgesOut')` at
`#loadPeerSet` (`build-ideal-tree.js:1289`).
- `vite@8.3.4` declares the optional peer `@vitejs/devtools: ^0.7.1`,
and 33 vite 8.x releases declare some devtools peer. The union of peer
keys over all 80 stable vite 6.x and 7.x releases has no devtools key.
- `@vitejs/devtools-vitest` 0.7.x peers `vitest: *`. vitest's `latest`
is 5.0.3, and `@vitest/browser-playwright@5.0.3` peers `vitest: 5.0.3`
exactly.
- All 19 vitest 4.0.x releases depend on vite `^6.0.0 || ^7.0.0`. vitest
4.1.0 adds `^8.0.0-0`, and 4.1.1 to 4.1.11 add `^8.0.0`. So every 4.1.x
resolves vite 8.3.4, and 4.0.x resolves vite 7.3.7.
So npm 10 crashes when two things are both true: vite 8 is in the tree,
and the root vitest is not the version that `latest` names. The exact
`5.0.0` and `5.0.2` rows show the second half. They crash the same way
although they are vitest 5.
**Why not `^5.0.0`, though it is newer and installs today?** It installs
only because it resolves to exactly the version that `latest` names. It
would crash again on npm 10, with no change in this repo, the day
`latest` moves past 5.x. It is also a major bump of the scaffold's test
runner. By contrast, every 4.0.x is already inside today's `^4.0.0`, so
`~4.0.0` admits no version the scaffold did not already admit. `~4.0.0`
keeps vite on 7.x, where the `vitest@*` peer never enters the graph, so
its install does not depend on the `latest` tag.
The cost: a scaffold installs vitest 4.0.18 (published 2026-01-22)
instead of 4.1.11. Engines do not move. vitest 4.0.18 and 4.1.11 both
declare node `^20.0.0 || ^22.0.0 || >=24.0.0`, and vite 7.3.7 and 8.3.4
both declare `^20.19.0 || >=22.12.0`.
## The one-range pin already exists, and it can fail
`packages/cli/test/scaffold-emission-policy.e2e.test.ts` already makes
two assertions across all four CLI emissions: each third-party
dependency has exactly one range, and the vitest range IS
`SCAFFOLD_VITEST_RANGE`. Its expectation is the imported constant, not a
literal, so the file is unchanged here and no new test was added. It is
a nightly-tier file, selected by its `.e2e` name.
Ablation (one-shot, `scripts/ablation-replace.mjs` in wrap mode, at
`cd7e34ee27`): in `create.ts`, `vitest: SCAFFOLD_VITEST_RANGE,` was
replaced with the copied literal `vitest: '^4.0.0',`.
- The mutation landed: anchor count 1 to 0, blob `d2c94f6a66f5` to
`98d8393f80b4`.
- Under `OS_TEST_TIERS=nightly` the file went red, with 3 failed and 7
passed: "declares exactly one range per third-party dependency", "emits
the exported constant rather than a literal, for every policy range" and
"declares exactly one range per third-party dependency, across ALL THREE
scaffolders".
- The restore was proven: the blob equals HEAD's and `git diff HEAD` is
empty.
## Verification (all at `cd7e34ee27`, the PR head)
- Closure build, `pnpm --workspace-concurrency=2 --filter
'@objectstack/cli^...' build`: VERDICT command-exit 0.
- `pnpm --filter @objectstack/cli typecheck`: VERDICT command-exit 0
(`tsc --noEmit`, and `check:test-typecheck` OK).
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: 282 files and 4262 tests passed, VERDICT command-exit
0. The `integration` layer is left to CI, because the diff touches no
integration-layer file and no spawn entry.
- `OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run
--maxWorkers=2 test/scaffold-emission-policy.e2e.test.ts`: 10 passed,
VERDICT command-exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands`, with no paths,
derived 59 commands from the merge base. All 59 ran and each exited 0.
Reconciled with `--ran`: "59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN"
(a derived zero, all 59 exit-coded).
- `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3
(PREREQUISITE NOT MET), because ten packages outside the cli closure had
no `dist/`. After those ten were built under the lock, both exited 0.
- `check:scaffold-emission-policy`: "2 carrier(s) emit the shared
policy".
- Lint, by proven narrowing. `pnpm exec eslint --no-inline-config
--format json packages/cli/src/commands/init.ts` exited 0, with 1 file,
0 errors and 0 warnings.
- Population, read from eslint itself: `--print-config` resolves 5 rules
for the file, so it is not ignored.
- Invariance: the config enables no type-aware linting (no
`parserOptions.project` and no `projectService`, as its own comment
states and `--print-config` confirms). So this diff cannot move any
untouched file's verdict.
- The `.changeset` file is outside eslint's population. Repo-wide `pnpm
lint` is left to CI.
## Acceptance notes
- The one-range pin runs nightly, not per PR, because its file name puts
it in the nightly tier. As the ablation shows, a copied literal would
still turn it red, but up to a day later. Noted only, no card.
- #15952 remains open and is not touched here. Its held scaffold half
adds vitest to `create-objectstack`'s blank template, which cannot
import this constant. `SCAFFOLD_VITEST_RANGE` is not a `POLICY_STAMPS`
row in `scripts/sync-scaffold-emission-policy.mjs`, so a vitest line in
that template would need a new stamp row to stay tied to this value.
- The branch is one commit behind `origin/main` (`eab7236ff7`). That
commit touches `packages/cli/src/commands/verify.ts` and other files
outside this diff, so it was not merged in.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 018b077 commit 85b4ac6
2 files changed
Lines changed: 49 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
342 | 342 | | |
343 | 343 | | |
344 | 344 | | |
345 | | - | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
346 | 349 | | |
347 | 350 | | |
348 | 351 | | |
| |||
379 | 382 | | |
380 | 383 | | |
381 | 384 | | |
382 | | - | |
383 | | - | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
384 | 417 | | |
385 | 418 | | |
386 | 419 | | |
| |||
0 commit comments