Skip to content

fix(cli): plugin scaffolds declare vitest ~4.0.0 so npm install works on npm 10 - #22920

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22912-scaffold-vitest-range-npm10
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-22912-scaffold-vitest-range-npm10

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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


Generated by Claude Code

SCAFFOLD_VITEST_RANGE moves from ^4.0.0 to ~4.0.0. With ^4.0.0, npm 10
(Node 22's bundled major) crashes in arborist #loadPeerSet with
"Cannot read properties of null (reading 'edgesOut')": vitest 4.1.x
admits vite 8, whose optional @vitejs/devtools peer leads to a
vitest@* peer that resolves to vitest's latest and contradicts the
root vitest. vitest 4.0.x admits only vite 6 or 7, which declare no
such peer. The comment beside the constant records the measured cause,
why ^5.0.0 is not the fix, and the re-check that lets it move back.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tooling labels Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 4 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/getting-started/examples.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/getting-started/your-first-project.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/plugins/index.mdx (via devDependencies (literal, a string literal in a comment on a changed line), os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/protocol/kernel/index.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/protocol/kernel/plugin-spec.mdx (via devDependencies (literal, a string literal in a comment on a changed line))
  • content/docs/upgrading.mdx (via devDependencies (literal, a string literal in a comment on a changed line))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/releases/v17/17-4.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/releases/v17/17-5.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/releases/v17/17-7.mdx (via os init (command, read off packages/cli/src/commands/init.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 018b077fbf8876c0fd5f46e66dcc551a4e74c895 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 34dd3b9e177fddede9bb5c2ac5efebe0113d83e8 — the merge of head cd7e34ee272da80a2d43504408a166b6c3996a11 into base 018b077fbf8876c0fd5f46e66dcc551a4e74c895, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 34dd3b9e177fddede9bb5c2ac5efebe0113d83e8 && git checkout 34dd3b9e177fddede9bb5c2ac5efebe0113d83e8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 018b077fbf8876c0fd5f46e66dcc551a4e74c895 cd7e34ee272da80a2d43504408a166b6c3996a11 && git checkout -B drift-repro 018b077fbf8876c0fd5f46e66dcc551a4e74c895 && git merge --no-ff cd7e34ee272da80a2d43504408a166b6c3996a11

node scripts/docs-audit/affected-docs.mjs --json 018b077fbf8876c0fd5f46e66dcc551a4e74c895

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 018b077fbf8876c0fd5f46e66dcc551a4e74c895 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tooling

Projects

None yet

2 participants