Skip to content

chore: consolidate shared toolchain devDependencies into the root package.json - #2199

Merged
cliffhall merged 5 commits into
v2/mainfrom
v2/chore/2196-consolidate-toolchain-devdeps
Sep 1, 2026
Merged

chore: consolidate shared toolchain devDependencies into the root package.json#2199
cliffhall merged 5 commits into
v2/mainfrom
v2/chore/2196-consolidate-toolchain-devdeps

Conversation

@cliffhall

@cliffhall cliffhall commented Aug 31, 2026

Copy link
Copy Markdown
Member

Closes #2196

Declares the toolchain every client uses once, at the repo root, and deletes the per-client copies.

What moved

Package Was declared in Now
eslint, @eslint/js, typescript-eslint, globals, prettier, typescript root + all four clients root only (already there — the client copies are deleted)
vitest, @vitest/coverage-v8, @types/node web, cli, tui, launcher root only (added to the root manifest)

clients/launcher now declares no devDependencies at all; the key is removed rather than left as {}.

Left where they are, because only one client runs them: tsup, tsx, vite, vite-node, playwright, storybook, happy-dom, ink-testing-library, @vitest/browser-playwright, the @storybook/* and @testing-library/* addons, and each client's own @types/* (@types/react, @types/express, @types/papaparse, …). Hoisting those would make every client install the union of all four.

These all stay devDependencies — nothing here is consumed at runtime, and the published tarball ships only each client's build/.

Why it works — and what it does not buy

npm run prepends every ancestor node_modules/.bin to PATH, and Node and TypeScript walk parent node_modules / node_modules/@types the same way, so a client that declares none of the toolchain resolves the root's copy. clients/launcher is the clean case:

$ (cd clients/launcher && npm exec -- which prettier eslint tsc vitest)
<repo>/node_modules/.bin/prettier
<repo>/node_modules/.bin/eslint
<repo>/node_modules/.bin/tsc
<repo>/node_modules/.bin/vitest

⚠️ The other three are not that clean, and deleting a declaration does not always delete the copy (Copilot, round 1). npm auto-installs an unmet peer into the install that needs it, and a client install cannot see the root's tree, so the root copy can never satisfy it; a hoisted transitive does the same. Those copies sit nearer and win. Measured after a fresh root install:

Package root web cli tui launcher
prettier 3.8.4
eslint 10.8.0 10.8.0 (peer) 10.8.0 (peer)
typescript 5.9.3 5.9.3 (peer)
vitest 4.1.10 4.1.10 (peer)
@vitest/coverage-v8 4.1.10
@types/node 24.13.3 24.13.3 24.13.1

The holders are web's eslint-plugin-react-refresh / eslint-plugin-storybook and the TUI's eslint-plugin-react-hooks (ESLint), tsup / @storybook/react-vite (TypeScript), @vitest/browser-playwright / @storybook/addon-vitest (Vitest), and @types/express (@types/node). Eliminating them is not available — hoisting the plugins cascades (eslint-plugin-storybook also peers on storybook) and would make every client install the union of all four.

The one real skew is cli's @types/node 24.13.1 against the root's 24.13.3, and it predates this PR: cli was already on 24.13.1 on v2/main, because a declared ^24.12.4 still loses to a nearer transitive.

So what the change buys is one declaration and one place to bump, not one copy on disk — and the two mechanisms are not equally safe (Copilot, round 3). A peer copy is constrained by its holder's peer range, which is a real pin only when that range is exact: @vitest/browser-playwright pins vitest to a single version, while eslint-plugin-react-refresh accepts ^9 || ^10, so the ESLint copies agree only because npm resolves the same latest in both installs. A transitive copy is constrained by nothing of ours at all — which is why cli's @types/node is the one that has already diverged. Nothing gates either (see the guard note below).

Everything else is unchanged: each client's npm run validate passes from its own directory, npm run test:storybook still resolves its Playwright browser (local:gate's local:storybook stage is green), and the postinstall cascade (scripts/install-clients.mjs) runs npm install in each client dir whenever a package.json is present, regardless of what that manifest declares.

The vitest trio is pinned exactly

@vitest/browser-playwright@4.1.10 declares an exact peer on vitest ("vitest": "4.1.10", not a range), so that package — not the root's range — decides which vitest lands in clients/web. The first cut of this PR left the root floating ^4.1.10, it resolved 4.1.11, and web then ran its tests on the local 4.1.10 while loading a @vitest/coverage-v8@4.1.11 provider. It passed either way, which is the bad part, and it was a regression this PR introduced (on v2/main web declared both at ^4.1.10 and got a matched pair). Caught by Copilot.

vitest and @vitest/coverage-v8 at the root and @vitest/browser-playwright in clients/web are now pinned exactly to 4.1.10 and move together, the same discipline as the exact prettier pin (#1790).

It had already drifted

This is not a cosmetic dedupe — two of these were already skewed before this change:

Package Root All four clients
globals ^17.7.0 ^17.4.0
typescript-eslint ^8.65.0 ^8.56.1

Consolidating takes the root's version in both cases. It also makes the exact prettier pin from #1790 a single pin instead of five that can diverge independently, so the format gate cannot return a different verdict depending on which client you ran it in.

Guards

npm run verify:dep-lockstepOK: 4 install-crossing dependencies agree across 5 installs (derived from 10 tsc programs), unchanged from the v2/main baseline. The candidate set is:

Package Reaches
@standard-schema/spec clients/web/tsconfig.test.json
hono clients/web/tsconfig.{app,node,test}.json
pino clients/tui/tsconfig.json, clients/tui/tsconfig.test.json
zod clients/web/tsconfig.{node,test}.json

None of the consolidated packages is a candidate. ⚠️ That is a narrower guarantee than it looks (Copilot, round 2): the candidate set is derived from what each tsc program resolves, so a tool binary never enters a program and is outside the set no matter how far it drifts — and cli's @types/node skew above passes for the same reason (no single program sees both copies). verify:dep-lockstep is not what keeps the tool copies aligned, and both AGENTS.md and the skill now say so. TOLERATED_SKEW remains empty.

Docs

  • AGENTS.mdDependency placement gains the rule.
  • The local-dev skill gains the reasoning (Why the shared toolchain is root-only, What the walk-up does not buy you, Why the vitest trio is pinned exactly), per the rules-here / recipes-there split.
  • Both state the boundary as used by every client, not "used by one" — tsup (web, cli, tui) and vite (web, tui, plus the root runtime dependency --web --dev needs) are shared by several clients without being shared by all, and consolidating them is a separate call with a separate rationale.
  • The root README.md section on this points at the local-dev skill rather than restating placement, so it needed no edit. There is no .github/copilot-instructions.md in this repo, so the mirror named in the issue does not exist.

Ordering note

#2196 is step 2 of #2030 and names #2195 (runtime dependencies) as a prerequisite. #2195 has not been started and has no PR, so this was built directly on v2/main. The two steps touch disjoint sets — runtime dependencies there, toolchain devDependencies here — and no manifest line is claimed by both, so #2195 should rebase cleanly on top of this (or vice versa); only the lockfiles will need regenerating.

Verification

Re-run after each review round:

  • npm run format — clean
  • npm run local:gatepass
  • npm run pack:verifypass (tarball installs clean into a fresh consumer; web/cli/tui all drive end to end)

…kage.json

Declare the toolchain every client uses once, at the repo root, and delete the
per-client copies: eslint, @eslint/js, typescript-eslint, globals, prettier and
typescript (all five manifests), plus vitest, @vitest/coverage-v8 and
@types/node (four). Adds the latter three to the root manifest; the first six
were already there.

Every client reaches the root copy by walk-up — npm run puts each ancestor
node_modules/.bin on PATH, and Node and TypeScript resolve modules and @types
the same way — so nothing about the client scripts changes. clients/launcher now
declares no devDependencies at all and its validate is unchanged.

The copies had already drifted: globals sat at ^17.7.0 at the root against
^17.4.0 in all four clients, and typescript-eslint at ^8.65.0 against ^8.56.1.
Consolidating takes the root's version in both cases, and makes the exact
prettier pin (#1790) a single pin rather than five that can diverge.

Tools only one client runs stay where they are — tsup, tsx, vite, vite-node,
playwright, storybook, happy-dom, ink-testing-library, and each client's own
@types/*.

Documents the rule in AGENTS.md's Dependency placement section and the reasoning
in the local-dev skill.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 31, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 31, 2026 23:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Client-local peer and transitive installations still shadow root tooling, including mismatched web Vitest versions.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Consolidates shared development tooling at the repository root to reduce version drift across clients.

Changes:

  • Moves shared toolchain declarations to the root manifest.
  • Regenerates root and client lockfiles.
  • Documents the root-only dependency policy.
File summaries
File Description
package.json Adds shared root tooling.
package-lock.json Locks root tooling dependencies.
clients/web/package.json Removes shared declarations.
clients/web/package-lock.json Regenerates web dependency tree.
clients/cli/package.json Removes shared declarations.
clients/cli/package-lock.json Regenerates CLI dependency tree.
clients/tui/package.json Removes shared declarations.
clients/tui/package-lock.json Regenerates TUI dependency tree.
clients/launcher/package.json Removes all launcher devDependencies.
clients/launcher/package-lock.json Regenerates launcher dependency tree.
AGENTS.md Adds the root-only toolchain rule.
.claude/skills/local-dev/SKILL.md Explains toolchain placement.
Review details

Files not reviewed (3)

  • clients/launcher/package-lock.json: Generated file
  • clients/tui/package-lock.json: Generated file
  • clients/web/package-lock.json: Generated file

Suppressed comments (1)

clients/web/package-lock.json:9653

  • The regenerated web lock still installs Vitest 4.1.10 locally as an auto-installed peer of @vitest/browser-playwright, while the root lock resolves Vitest and @vitest/coverage-v8 to 4.1.11. Because clients/web/node_modules/.bin precedes the root bin directory, web tests use 4.1.10 and coverage loads the root 4.1.11 provider, so the toolchain is both unconsolidated and version-mismatched. The peer provider and adapter need to share an install root (or client peer installation must be deliberately omitted) before regenerating the locks.
  • Files reviewed: 7/12 changed files
  • Comments generated: 6
  • Review effort level: Balanced

Comment thread clients/web/package-lock.json
Comment thread clients/tui/package-lock.json
Comment thread clients/web/package-lock.json
Comment thread package.json
Comment thread .claude/skills/local-dev/SKILL.md Outdated
Comment thread AGENTS.md Outdated
…ceptions

Copilot review round 1.

@vitest/browser-playwright declares an EXACT peer on vitest ("vitest":
"4.1.10"), so that package — not the root's range — decides which vitest lands in
clients/web. Dropping web's own vitest declaration left the root free to resolve
4.1.11 while web's peer stayed at 4.1.10, so web's tests ran on one vitest while
loading a @vitest/coverage-v8 provider built against another. Both still passed,
which is the bad part.

vitest and @vitest/coverage-v8 at the root and @vitest/browser-playwright in
clients/web are now pinned exactly to 4.1.10 and move together, the same
discipline as the exact prettier pin (#1790).

The docs also claimed more than the change delivers. npm auto-installs an unmet
peer into the install that needs it, and a client install cannot see the root's
tree — so web's eslint-plugin-react-refresh / -storybook and the TUI's
eslint-plugin-react-hooks each pull a client-local eslint, and web's
Storybook/Vitest stack pulls a local typescript and vitest; @types/express
hoists an @types/node into web and cli. Those copies sit nearer than the root's
and win. AGENTS.md and the local-dev skill now say so, and state what the
consolidation actually buys: one declaration and one place to bump, with
verify:dep-lockstep as the guard that the copies agree.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review round 1 — responses

Mirroring the inline replies here, since they go outdated once the fix is pushed.

One of these was a real defect and is fixed (edef659); the other five were accurate observations about what the change claims, and the docs now say the true thing.

Fixed — the vitest version mismatch

@vitest/browser-playwright@4.1.10 declares an exact peer on vitest ("vitest": "4.1.10", not a range), so that package — not the root's range — decides which vitest lands in clients/web. With the root floating ^4.1.10 it resolved 4.1.11, so web ran its tests on the local 4.1.10 while loading the root @vitest/coverage-v8@4.1.11 as the coverage provider. It passed either way, which is the bad part, and it is a regression this PR introduced — on v2/main web declared both at ^4.1.10 and got a matched 4.1.10 pair.

vitest and @vitest/coverage-v8 at the root and @vitest/browser-playwright in clients/web are now pinned exactly to 4.1.10 and move together, the same discipline as the exact prettier pin (#1790). Verified after a fresh root install:

version
root vitest 4.1.10
root @vitest/coverage-v8 4.1.10
clients/web vitest (peer) 4.1.10
clients/web @vitest/browser-playwright 4.1.10

Documented, not fixed — the surviving local copies

Copilot is right that deleting a declaration does not delete the copy, and that where a copy survives it is the one that wins. Two mechanisms, neither visible in a manifest:

  • An unmet peer, auto-installed into the install that needs it. A client install has no visibility into the root's tree, so the root copy can never satisfy it. eslint-plugin-react-refresh / eslint-plugin-storybook (web) and eslint-plugin-react-hooks (tui) each pull a client-local eslint; tsup / @storybook/react-vite pull a local typescript.
  • A hoisted transitive@types/express puts an @types/node in web and cli.

Measured, after the fix:

Package root web cli tui launcher
eslint 10.8.0 10.8.0 (peer) 10.8.0 (peer)
typescript 5.9.3 5.9.3 (peer)
vitest 4.1.10 4.1.10 (peer)
@types/node 24.13.3 24.13.3 24.13.1
prettier 3.8.4

No skew except the @types/node patch in cli, which predates this PR (cli was already on 24.13.1 on v2/main — a declared ^24.12.4 still loses to a nearer transitive) and which verify:dep-lockstep does not flag, because no single tsc program sees both copies. Eliminating the peers is not available: hoisting the plugins cascades (eslint-plugin-storybook also peers on storybook) and would make every client install the union of all four.

So the docs now state what the consolidation actually buys — one declaration and one place to bump, not one copy on disk — with verify:dep-lockstep as the guard that the copies agree. AGENTS.md gains two ⚠️ sub-bullets (the peer/transitive exception, and the exact vitest pin); the local-dev skill gains "What the walk-up does not buy you" and "Why the vitest trio is pinned exactly", including npm exec -- which eslint as the way to check rather than assume.

Re-verified after the fix

  • npm run local:gatepass
  • npm run pack:verifypass
  • npm run verify:dep-lockstep — OK, 4 install-crossing dependencies across 5 installs, unchanged; TOLERATED_SKEW still empty

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new guidance and PR description inaccurately characterize multi-client tooling and the scope of the dependency lockstep guard.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Files not reviewed (3)

  • clients/launcher/package-lock.json: Generated file
  • clients/tui/package-lock.json: Generated file
  • clients/web/package-lock.json: Generated file

Suppressed comments (4)

Previously missed (1) — in code that hasn't changed since the last review.

AGENTS.md:90

  • The “one client runs” rule is contradicted by the manifests: tsup remains in web, CLI, and TUI, while vite is declared at the root and in web and TUI. The actual boundary is whether tooling is shared by all clients; documenting it as single-client ownership can lead future dependency moves to the wrong manifest.

This issue also appears on line 91 of the same file.

- **The shared toolchain is declared once, at the repo root, and in no client manifest.** `eslint`, `@eslint/js`, `typescript-eslint`, `globals`, `prettier`, `typescript`, `vitest`, `@vitest/coverage-v8` and `@types/node` are used by every client's own scripts, and a client that declares none of them still resolves the root copy by walk-up — `npm run` puts each ancestor `node_modules/.bin` on `PATH`, and Node and TypeScript walk parent `node_modules` / `node_modules/@types` the same way. `clients/launcher` declares no `devDependencies` at all and its `validate` is unchanged. A client-side declaration buys nothing and installs a second copy free to drift, as `globals` (`^17.7.0` root / `^17.4.0` clients) and `typescript-eslint` (`^8.65.0` / `^8.56.1`) had before #2196. These stay **`devDependencies`** — none is consumed at runtime and the tarball ships only each client's `build/`. A tool **one** client runs stays in that client (`tsup`, `tsx`, `vite`, `vite-node`, `playwright`, `storybook`, `happy-dom`, `ink-testing-library`, and the `@types/*` for its own libraries).

AGENTS.md:91

  • verify:dep-lockstep does not guarantee that these surviving tool copies agree. It only compares packages loaded from two installs into one tsc program (scripts/verify-dep-lockstep.mjs:23-29), so local ESLint/Vitest binaries are outside its candidate set; even the documented CLI @types/node copy is 24.13.1 while the root resolves 24.13.3. This rule needs to distinguish that narrow type-level guard from whatever keeps tool binaries aligned.
  - ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit *nearer* than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk; what must hold is that the copies **agree**, and `npm run verify:dep-lockstep` is the guard that says so.

.claude/skills/local-dev/SKILL.md:149

  • This overstates verify:dep-lockstep: it only compares dependencies that one tsc program loads from multiple installs, not surviving ESLint, Vitest, or other tool binaries. Those copies can drift without this guard reporting them, so the text should state the narrower guarantee.
So the consolidation buys **one declaration and one place to bump**, not one copy
on disk. What has to hold is that the copies *agree*, and
`npm run verify:dep-lockstep` is the guard for exactly that (see below).

clients/web/package-lock.json:5064

  • The PR description still says web, CLI, and TUI produce the same root-bin result as launcher, but this regenerated entry auto-installs a web-local ESLint peer, and the accepted verification shows that local binary wins. Update the PR description’s “Why it works” section to reflect the documented peer/transitive exceptions rather than claiming identical root resolution.
  • Files reviewed: 7/12 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread .claude/skills/local-dev/SKILL.md Outdated
Copilot review round 2. Docs only; no manifest or lockfile change.

Two claims were wrong:

- "A tool one client runs stays in that client" is contradicted by the
  manifests — tsup is declared in web, cli and tui, and vite in web and tui on
  top of the root runtime dependency that --web --dev needs. The boundary is
  "used by every client", not "used by one"; anything narrower stays put whether
  one client declares it or several do. Both are out of scope here and say so.

- verify:dep-lockstep was named as the guard that the surviving copies agree. It
  is not: it derives its candidate set from what each tsc program resolves, so a
  tool binary never enters a program and is outside the set no matter how far it
  drifts — and cli's @types/node 24.13.1 against the root's 24.13.3 is a live
  skew that passes for the same reason. Both files now state the narrower
  guarantee, say that nothing gates the binaries automatically, note that each
  surviving copy is pinned by its holder's peer range, and give
  `npm exec -- which` as the manual check.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review round 2 — responses

All four comments landed in the Suppressed block, so there is nothing inline to reply to. All four are correct; all four are addressed in 613c494 (docs only — no manifest or lockfile change) plus a PR-description rewrite.

1. AGENTS.md:90 — "a tool one client runs stays in that client" is contradicted by the manifests

Right. tsup is declared in web, cli and tui, and vite in web and tui on top of the root runtime dependency that --web --dev needs. Stating the rule as single-client ownership would send a future move to the wrong manifest, which is exactly the failure it was meant to prevent.

Reworded in both files: the boundary is used by every client, not "used by one". Anything narrower stays where it is whether one client declares it (tsx, playwright, storybook, happy-dom, ink-testing-library, vite-node, each client's own @types/*) or several do (tsup, vite) — and both of those are named as out of scope here, with vite flagged specifically because its root declaration is a dependency, not a devDependency, so consolidating it is a different question with a different rationale.

2 & 3. AGENTS.md:91 and SKILL.md:149verify:dep-lockstep was overstated

Also right, and this was the weakest sentence in the round-1 fix: I replaced an untrue guarantee with a guard that does not cover the case. The guard derives its candidate set from what each tsc program resolves, so a tool binary never enters a program and is outside the set no matter how far it drifts — and cli's @types/node 24.13.1 against the root's 24.13.3 passes for precisely that reason, as Copilot notes.

Both files now:

  • state the narrower guarantee explicitly — one tsc program, two installs, and nothing else;
  • name cli's @types/node as a live skew the guard does not report, rather than leaving it as an implied clean bill;
  • say what actually keeps the surviving copies aligned — each is pinned by its holder's peer range, not by a second declaration of ours — and that nothing gates it automatically;
  • give the manual check: cd clients/web && npm exec -- which eslint prettier tsc vitest.

4. clients/web/package-lock.json:5064 — the PR description still claimed identical root resolution

Fixed. Why it works is now Why it works — and what it does not buy: clients/launcher stays as the clean walk-up demonstration, followed by the measured per-install table, the named peer/transitive holders, why eliminating them is not available, and the statement that the change buys one declaration and one place to bump rather than one copy on disk. The exact-pin rationale for the vitest trio is now a section of its own, and the guards section carries the narrowed verify:dep-lockstep claim.

Re-verified

  • npm run format — clean
  • npm run local:gatepass
  • npm run verify:skills — OK (10 skills, listing 2882/4000 chars)

pack:verify was re-run on edef659 and is unaffected by this round, which touches only AGENTS.md and the local-dev skill.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new guidance incorrectly claims all surviving copies remain peer-range aligned despite documenting a transitive version skew.

Review details

Files not reviewed (3)

  • clients/launcher/package-lock.json: Generated file
  • clients/tui/package-lock.json: Generated file
  • clients/web/package-lock.json: Generated file

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

.claude/skills/local-dev/SKILL.md:154

  • “Each surviving copy” includes hoisted transitives, yet the next paragraph records CLI @types/node@24.13.1 versus root 24.13.3. Only peer-installed copies are constrained by holder peer ranges; transitive copies can already differ, so this rationale currently contradicts its own example.
So the consolidation buys **one declaration and one place to bump**, not one copy
on disk. Each surviving copy is pinned by its holder's peer range rather than by
a second declaration of ours, which is why they agree today.

AGENTS.md:91

  • This says every surviving copy is kept aligned by a peer range, but the same paragraph documents a surviving transitive CLI @types/node@24.13.1 against root 24.13.3. Transitive copies are not constrained by a holder's peer range, so distinguish the currently aligned peer-installed copies from independently resolved transitives.
  - ⚠️ **Deleting the declaration does not always delete the copy, and the local copy still wins.** npm auto-installs an unmet **peer** into the install that needs it, and it has no visibility into the root's tree — so a client-only ESLint plugin drags a client-local `eslint` in (`eslint-plugin-react-refresh`/`-storybook` in web, `eslint-plugin-react-hooks` in tui), and web's Storybook/Vitest stack drags in a local `typescript` and `vitest`. A hoisted transitive does the same: `@types/express` puts an `@types/node` in web and cli. Those copies sit *nearer* than the root's and take precedence. The consolidation is therefore about **one declaration and one place to bump**, not about a single copy on disk. What keeps the surviving copies aligned is that each is pinned by its holder's peer range rather than by a second declaration of ours — and **nothing gates it automatically**. ⚠️ `verify:dep-lockstep` is *not* that gate: it compares only packages that one `tsc` **program** loads from two installs, so a stray `eslint`, `prettier` or `vitest` binary is outside its candidate set entirely, and the `@types/node` patch difference between cli (`24.13.1`) and the root (`24.13.3`) is real and unreported for exactly that reason. Check a tool copy by hand — `npm exec -- which eslint` from the client — when you change what a client declares.
  • Files reviewed: 7/12 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Copilot review round 3. Docs only.

The round-2 wording said every surviving copy is pinned by its holder's peer
range, in the same paragraph that documents cli's @types/node 24.13.1 against
the root's 24.13.3 — a transitive, which no peer range constrains. The rationale
contradicted its own example.

Both files now split the two mechanisms and grade them:

- A peer copy is constrained by its holder's peer range, which is a real pin only
  when that range is exact. @vitest/browser-playwright pins vitest to a single
  version (hence the exact trio). eslint-plugin-react-refresh accepts ^9 || ^10,
  so the eslint copies agree only because npm resolves the same latest in both
  installs — a coincidence, not a guarantee.
- A transitive copy is constrained by nothing of ours, and cli's @types/node has
  already diverged. It was 24.13.1 on v2/main too: a declared ^24.12.4 still
  loses to a nearer transitive.

Nothing gates either, and verify:dep-lockstep is not that gate.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review round 3 — response

One comment, appearing twice (AGENTS.md:91 and SKILL.md:154), both in the Suppressed block. Correct, and a fair catch on my own round-2 wording: I wrote that every surviving copy is pinned by its holder's peer range in the same paragraph that documents cli's @types/node@24.13.1 against the root's 24.13.3 — a transitive, which no peer range constrains. The rationale contradicted its own example.

Fixed in e76e32c (docs only). Both files now split the two mechanisms and grade them separately:

Mechanism Constrained by In practice
Peer copy the holder's peer range A real pin only when that range is exact. @vitest/browser-playwright pins vitest to a single version — which is why the trio is pinned too. eslint-plugin-react-refresh accepts ^9 || ^10, so the ESLint copies agree only because npm resolves the same latest in both installs: a coincidence that holds until it doesn't.
Transitive copy nothing of ours Already diverged — cli @types/node 24.13.1 vs root 24.13.3. And it was 24.13.1 on v2/main too, so a declared ^24.12.4 does not help: a nearer transitive wins regardless.

Followed by the unchanged point that nothing gates either, and that verify:dep-lockstep is not that gate. The PR description's "Why it works — and what it does not buy" section carries the same correction.

Re-verified

  • npm run format — clean
  • npm run local:gatepass

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Issue #2196 requires #2195 to land first, so this PR must be rebased and its lockfiles regenerated afterward.

Review details

Files not reviewed (3)

  • clients/launcher/package-lock.json: Generated file
  • clients/tui/package-lock.json: Generated file
  • clients/web/package-lock.json: Generated file

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

package.json:118

  • The linked #2196 explicitly requires #2195 to land first, but this PR states that #2195 has not started and is based directly on v2/main. That prerequisite is therefore unmet; hold and rebase this consolidation after #2195, then regenerate the affected lockfiles before approval.
    "@types/node": "^24.12.4",
  • Files reviewed: 7/12 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review round 4 — response

One comment, in the Suppressed block, marked "Previously missed" and reporting 0 new comments generated. It restates the ordering caveat this PR already discloses in its own Ordering note, rather than finding anything in the diff. No code or docs change follows from it.

On the substance: #2196 does name #2195 as step 1, and #2195 has not been started. I built this on v2/main deliberately, and the note says so. The two steps are disjoint#2195 moves runtime dependencies (ajv, atomically, chokidar, commander, open, pino, undici, zod, @napi-rs/keyring, hono, @hono/node-server), this one moves toolchain devDependencies. No manifest line is claimed by both, so neither reads the other's result and the sequence is a convenience, not a dependency. Whichever lands second rebases cleanly on the first with only lockfile regeneration.

Holding this until #2195 exists would leave a live version skew in place for no benefit — the @vitest/browser-playwright exact-peer mismatch this PR fixes, plus globals ^17.7.0/^17.4.0 and typescript-eslint ^8.65.0/^8.56.1. Merge order is a maintainer call; I am leaving it to the reviewer rather than pre-emptively rebasing onto a branch that does not exist.

npm run local:gate and npm run pack:verify both pass against current v2/main.


Review loop closed

Four rounds. Summary of what changed as a result:

Round Finding Outcome
1 Web vitest 4.1.10 (local peer) against root @vitest/coverage-v8 4.1.11 — a real regression this PR introduced Fixed — the vitest trio pinned exactly and moved together (edef659)
1 Docs claimed root resolution as a guarantee; local ESLint/TypeScript/Vitest/@types/node copies survive and win Fixed — peer and hoisted-transitive mechanisms documented, npm exec -- which given as the check
2 "A tool one client runs" contradicted by tsup (3 clients) and vite (2 + a root runtime dep) Fixed — boundary restated as "used by every client", both named as out of scope (613c494)
2 verify:dep-lockstep named as the guard that the copies agree — it is not Fixed — narrowed to "one tsc program, two installs", with the cli @types/node skew named as unreported
2 PR description still claimed identical root resolution across all four clients Fixed — description rewritten with the measured per-install table
3 "Each surviving copy is pinned by its holder's peer range" contradicted by the transitive @types/node example Fixed — peer and transitive split and graded separately (e76e32c)
4 Ordering against #2195 Declined, with reasons above — already disclosed in the PR body; a merge-sequencing call for the maintainer

…olidate-toolchain-devdeps

Signed-off-by: cliffhall <cliff@futurescale.com>

# Conflicts:
#	clients/cli/package-lock.json
#	clients/launcher/package-lock.json
#	clients/launcher/package.json
#	clients/tui/package-lock.json
#	clients/web/package-lock.json
@cliffhall
cliffhall merged commit faada87 into v2/main Sep 1, 2026
4 checks passed
@cliffhall
cliffhall deleted the v2/chore/2196-consolidate-toolchain-devdeps branch September 1, 2026 04:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore: consolidate shared toolchain devDependencies into the root package.json

2 participants