Skip to content

Commit 85b4ac6

Browse files
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

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): plugin scaffolds declare `vitest ~4.0.0`, so `npm install` works on npm 10
6+
7+
Clause-②: no
8+
9+
`objectstack init -t plugin` and `objectstack create plugin` now declare `vitest ~4.0.0` instead of `^4.0.0`. With `^4.0.0`, a fresh `npm install` of the emitted project crashed on npm 10, the npm that Node 22 bundles, with `Cannot read properties of null (reading 'edgesOut')`. npm 11 was not affected.
10+
11+
The cause is upstream. vitest 4.1.x accepts vite 8, and vite 8's optional devtools peers lead back to a `vitest@*` peer that npm 10 cannot resolve against an older vitest at the root. vitest 4.0.x accepts only vite 6 or 7, which carry no such peer. A scaffolded project now installs vitest 4.0.18 with vite 7.3.7 on npm 10 and npm 11.
12+
13+
Projects that were already scaffolded are unchanged. If one of them fails to install on npm 10, change its `vitest` devDependency to `~4.0.0`, or install with npm 11.

‎packages/cli/src/commands/init.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,10 @@ export const SCAFFOLD_PNPM_RANGE = '>=10.15';
342342
//
343343
// `^4.0.0` over `^4.0.18`: no recorded decision exists for either, both were
344344
// written in the same 2026-02-07 commit, and `^4.0.18` claims a PATCH-level
345-
// floor nothing justifies while being strictly the narrower of the two.
345+
// floor nothing justifies while being strictly the narrower of the two. (The
346+
// vitest range has since moved to `~4.0.0` because `^4.0.0` stopped
347+
// installing on npm 10. That was an install failure, not a support decision;
348+
// the comment on `SCAFFOLD_VITEST_RANGE` holds the reading.)
346349
//
347350
// ⚠️ Neither choice changes what a scaffolded project INSTALLS. Measured
348351
// against the registry: `^5.3.0` and `^5.8.0` both resolve to typescript
@@ -379,8 +382,38 @@ export const SCAFFOLD_PNPM_RANGE = '>=10.15';
379382
/** The TypeScript range every scaffolded project declares. */
380383
export const SCAFFOLD_TYPESCRIPT_RANGE = '^5.3.0';
381384

382-
/** The vitest range a scaffolded project declares when its template tests. */
383-
export const SCAFFOLD_VITEST_RANGE = '^4.0.0';
385+
/**
386+
* The vitest range a scaffolded project declares when its template tests.
387+
*
388+
* `~4.0.0`, not `^4.0.0`, because `^4.0.0` does not INSTALL on npm 10, the npm
389+
* that Node 22 bundles. Measured 2026-10-12, in an empty directory with a fresh
390+
* `--cache` and a manifest holding only this one devDependency (the full matrix
391+
* is in the PR that set this value): on npm 10.9.4 and 10.9.9, `npm install`
392+
* exits 1 with `Cannot read properties of null (reading 'edgesOut')` from
393+
* arborist's `#loadPeerSet`, and npm 11.21.0 installs it. `~4.0.0` installs on
394+
* all three (vitest 4.0.18, vite 7.3.7), and so do both CLI-emitted plugin
395+
* manifests. The cost: a scaffold gets vitest 4.0.18 rather than 4.1.11.
396+
*
397+
* The cause is upstream, in the peer graph. Every vitest 4.1.x admits vite 8.
398+
* vite 8 declares an optional `@vitejs/devtools` peer, which reaches
399+
* `@vitejs/devtools-vitest`, which peers `vitest@*`. npm 10 resolves that peer
400+
* to vitest's `latest` release, whose exact-version peers then contradict the
401+
* vitest at the root, and npm 10's arborist crashes on it. vitest 4.0.x admits
402+
* only vite `^6 || ^7`, and no vite 6 or 7 release declares the devtools peer,
403+
* so the `vitest@*` peer never enters the graph.
404+
*
405+
* ⛔ Not `^5.0.0`, although it installs today. It installs only because it
406+
* resolves to exactly the version `latest` names: pinned to 5.0.0 or 5.0.2 it
407+
* crashes the same way. So it would break again, with no change here, the day
408+
* `latest` moves past 5.x. ⛔ Not a shipped `.npmrc` with `legacy-peer-deps`
409+
* either, which changes peer resolution for every npm user of the scaffold.
410+
*
411+
* The re-check that lets this move back: run `npm install` of
412+
* `{"devDependencies":{"vitest":"^4.0.0"}}` (or the range you want) in an empty
413+
* directory with a fresh `--cache`, on the newest npm 10 and on npm 11. When
414+
* both exit 0, widen this.
415+
*/
416+
export const SCAFFOLD_VITEST_RANGE = '~4.0.0';
384417

385418
/** The `@types/node` range a scaffolded project declares. */
386419
export const SCAFFOLD_TYPES_NODE_RANGE = '^22.0.0';

0 commit comments

Comments
 (0)