diff --git a/.claude/skills/local-dev/SKILL.md b/.claude/skills/local-dev/SKILL.md index e830d9af..947211e7 100644 --- a/.claude/skills/local-dev/SKILL.md +++ b/.claude/skills/local-dev/SKILL.md @@ -126,7 +126,8 @@ The point of deleting the client-side copies rather than merely keeping them in step is that **a package installs only into an install root that declares it**. Aligned duplicate declarations still drift the next time someone bumps one of them; no declaration at all cannot. `npm run verify:dep-lockstep` is the detector -for the skew, and consolidation is what removes the opportunity. +for the skew — in both its tiers since #2226 — and consolidation is what removes +the opportunity. Two consequences that read as bugs and are not: @@ -209,18 +210,27 @@ on disk. The two mechanisms are **not** equally safe, and neither is a guarantee wide range (`eslint-plugin-react-refresh` accepts `^9 || ^10`) the copies agree only because npm happens to resolve the same latest in both installs, which is a coincidence that holds until it doesn't. -- A **transitive** copy is constrained by nothing of ours whatsoever, and one has - already diverged: cli's `@types/node` is `24.13.1` against the root's - `24.13.3`, and was `24.13.1` on `v2/main` too — a declared `^24.12.4` loses to - a nearer transitive. +- A **transitive** copy is constrained by nothing of ours whatsoever, and one had + already diverged: cli's `@types/node` was `24.13.1` against the root's + `24.13.3`, on `v2/main` too — a declared `^24.12.4` loses to a nearer + transitive. It is pinned with an `overrides` entry in `clients/cli` since + #2226. -⚠️ **Nothing gates either of those, and `verify:dep-lockstep` is not it.** -That guard derives its candidate set from what each `tsc` **program** resolves -(see below), so it sees only packages a program loads from two installs. A tool -*binary* — `eslint`, `prettier`, `vitest` — never enters a program, so it is -outside the candidate set no matter how far it drifts, and the cli `@types/node` -skew above passes for a second reason on top of that: no one program sees both -copies. When you change what a client declares, check by hand from that client: +✅ **`verify:dep-lockstep` gates both of those since #2226.** Its second tier +compares every package **any** install *declares* — `dependencies`, +`devDependencies` and `optionalDependencies`, unioned across the root and all +four clients — against every **top-level** copy in every install, independent of +what a `tsc` program resolves. So a tool *binary* that no program loads +(`eslint`, `typescript`, `vitest`) and a transitive copy that no single program +meets (the cli `@types/node` above) are both in scope now, as is a skew between +two **clients** with no root copy involved (`@types/react`, web against tui). + +⚠️ **Two gaps remain, and they are why the by-hand check below is still worth +running.** The tier reads **lockfiles**, so a copy you installed by hand and +never committed is invisible to it; and it compares only names some manifest +declares, so a purely transitive package no manifest anywhere names stays the +program tier's business. When you change what a client declares, check by hand +from that client: ```sh cd clients/web && npm exec -- which eslint prettier tsc vitest @@ -307,9 +317,17 @@ recursive-generic surface is exponential. A zod `4.3.6` / `4.4.3` skew exhausted the 4 GB tsc heap outright with `TS2589` (#1896). ⚠️ **Raising the heap hides the class rather than fixing it.** Align the -versions; `npm run verify:dep-lockstep` is the guard, and it derives its -candidate set from what actually enters each `tsc` program, so a package whose -declarations arrive only through another package's `.d.ts` is still seen. +versions; `npm run verify:dep-lockstep` is the guard, and it runs **two tiers**. +The first derives its candidate set from what actually enters each `tsc` +program, so a package whose declarations arrive only through another package's +`.d.ts` is still seen. The second (#2226) compares every **declared** package's +top-level copies across installs whatever any program loads, so a transitive or +peer-shadow drift no program can meet is caught too. + +Aligning a stale install is `npm update ` there — it moves the lockfile +within the declared range without widening the range, which +`npm install @` would. A transitive copy that will not move takes +an `overrides` entry in that install (see the next section). ### Why `overrides` beats `npm audit fix` diff --git a/docs/quality-gate.md b/docs/quality-gate.md index a7c988a3..bfea1f64 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -29,7 +29,7 @@ That is the readable half, and prose rots. The enforced half is `scripts/lib/wor | Script | What it does | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the four durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate — including the `core/react/**` React-hooks block added by #2192, whose plugin is a **root** devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the four durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:skills` (every `.claude/skills` manifest parses and declares its invocation mode; the **model-invoked** ones also carry eval cases — the name-only skills intentionally have none), `verify:typecheck-coverage` (every tracked TS file lands in a tsconfig project), `verify:dep-lockstep` (two tiers: no dependency reaching one `tsc` program from two installs skews across them, and — since #2226 — no **declared** package holds two different top-level versions across installs at all) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate — including the `core/react/**` React-hooks block added by #2192, whose plugin is a **root** devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web — the TUI one runs under a **pseudoterminal** and asserts the Ink app is still running two seconds after its first frame, not merely that it painted one, [#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)), plus three headless-browser smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. Those three take their engine from `SMOKE_BROWSER` — see [Supported browsers](#supported-browsers). A fourth, **`smoke:web:tabs`**, drives the core tabs against one server on one browser launch — Tools runs a tool and asserts its `structuredContent` section, Resources reads a resource with templates listed, Prompts fetches one ([#2148](https://github.com/modelcontextprotocol/inspector/issues/2148)) — asserting the `data-*` contract in [the web README](../clients/web/README.md#core-tab-automation-contract) rather than visible copy. It is **Chromium-only**: those tabs are ordinary React and Mantine, so unlike the sandbox they are not engine-sensitive. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run local:gate` and of GitHub CI. | @@ -39,7 +39,11 @@ That is the readable half, and prose rots. The enforced half is `scripts/lib/wor | `npm run verify:skills:cli` | Runs `claude plugin validate` — the **authoritative** skill schema — as a guaranteed step, in `local:gate` and in CI. `verify:skills` reimplements the parse and *skips* when the CLI is absent, which is right for `validate` (fast, offline, no Claude Code required) but adds up to "never runs". This one resolves the CLI instead: an installed one **only when it matches the pin exactly**, otherwise the pinned package via `npx -y @anthropic-ai/claude-code@…`. Exact, not a floor: accepting a newer local CLI would validate against a different schema than CI's, so the same `local:gate` could disagree across machines — which is the failure a pin exists to prevent. Needs no authentication. Not in `validate` — it can reach the network. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; `render-smoke.test.mjs` and `pty.test.mjs` cover the TUI boot harness ([#2147](https://github.com/modelcontextprotocol/inspector/issues/2147)) — the former driving real `node -e` stubs to prove that a child which paints the marker and *then* dies is a **failure**, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible `script(1)` invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. `workflow-gate.test.mjs` is a different shape again ([#2146](https://github.com/modelcontextprotocol/inspector/issues/2146)): besides the table of parser cases it runs the parser over the repo's real `.github/workflows/**`, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Five more arrive with the skills (#2163). `skill-manifest.test.mjs` pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description. `verify-skills.main.test.mjs` drives that guard's `main()` over fixture directories, the sibling guards' `*.main.test.mjs` pattern, so it cannot stop enforcing while the parser tests stay green. `skill-eval.test.mjs` covers the trigger eval's stream parsing and its run classification, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. `verify-skills-cli.test.mjs` covers which validator gets run — a local CLI only at the exact pin — and the orchestration around it, neither of which an ordinary run on a working machine exercises. `claude-cli.test.mjs` covers the one place that decides how the CLI is spawned — the Windows `.cmd` shim needs a shell, and its arguments then need quoting — with the platform **injected**, since a win32-only bug that no runner here executes is exactly the kind that ships. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`. +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. + +A **second tier** (#2226) runs alongside it, asking the weaker but broader question the `AGENTS.md` rule actually states: does a package this repo *declares* anywhere resolve to two versions across our installs at all? Its candidate set is every name in any install's `dependencies`/`devDependencies`/`optionalDependencies` — unioned across the root and all four clients, so a copy declared by only one of them still counts — that **more than one install holds a top-level copy of** (17 packages today). Nested copies are excluded: one exists because some dependency asked for a different version, so it is that dependency's range to govern, not ours. Neither tier subsumes the other — the program tier sees a copy no manifest names (`@modelcontextprotocol/sdk`, arriving through another package's `.d.ts`), while the declared tier sees a **transitive** copy no program loads (cli's `@types/node`, hoisted via `@types/express` — the case that motivated it), two **clients** disagreeing with no root copy involved (`@types/react`, web against tui), and the peer shadows `eslint`/`typescript`/`vitest` that never enter a program. Same deny-by-default and same within-a-major rule, against its own `TOLERATED_DECLARED_SKEW` — also empty. Two limits: it reads **lockfiles**, so an uncommitted hand-installed copy is invisible, and it compares only declared names, so a purely transitive package no manifest names stays the first tier's business. + +Runs in `validate`. | `npm run local:gate` | **Mandatory pre-push command.** `validate` → `verify:skills:cli` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → `local:storybook`. A strict superset of GitHub CI — see [Two tiers](#two-tiers-github-ci-and-the-local-gate). Named `local:` rather than `ci` on purpose (#2146); there is no `npm run ci` alias. | | `npm run pack:verify` | Publish smoke — see [Publishing](./publishing.md). | diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 9295b5cd..78f947a2 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -528,16 +528,20 @@ test("main: exits 1 when the root validate no longer runs the sibling guard", () // --------------------------------------------------------------------------- test("main: a DECLARED package skewed across installs fails even when no program holds both copies (#2226)", () => { - // The exact shape the issue reported. `solo` is installed under the root and - // under `clients/web`, and both manifests name it — but only the shared - // `core/` imports it, so the client's program resolves the root's copy alone - // and the program tier is silent, correctly. The declared tier is what sees - // it, which is the whole reason it exists: cli's `@types/node` was hoisted in - // via `@types/express` and no program ever met the root's copy. + // The exact shape the issue reported, and the client copy is deliberately + // UNDECLARED (Copilot): only the ROOT manifest names `solo`, while + // `clients/web` merely holds a top-level copy — the way cli's `@types/node` + // arrives, hoisted via `@types/express` with no range of ours governing it. + // That is what makes the candidate set a UNION across installs rather than a + // per-install intersection, and declaring it in both manifests would let a + // per-install implementation pass this test. + // + // Only the shared `core/` imports `solo`, so the client's program resolves the + // root's copy alone and the program tier is silent — correctly. The declared + // tier is the one that sees it, which is the whole reason it exists. withFixture( { rootManifestDeps: { solo: "^7.0.0" }, - webManifestDeps: { solo: "^7.0.0" }, webSolo: "7.0.0", }, (dir) => { @@ -553,10 +557,11 @@ test("main: a DECLARED package skewed across installs fails even when no program }); test("main: a declared package held at the same version in both installs passes and is counted", () => { + // Same union shape as above — root-declared, client copy undeclared — so the + // pass and the fail differ only in the version, not in how the name is found. withFixture( { rootManifestDeps: { solo: "^7.0.0" }, - webManifestDeps: { solo: "^7.0.0" }, webSolo: "7.8.9", }, (dir) => { @@ -567,6 +572,26 @@ test("main: a declared package held at the same version in both installs passes ); }); +test("main: the union runs the other way too — declared only by a CLIENT, held by the root", () => { + // The mirror of the case above, and the reason `declaredPackages` unions every + // install's manifest rather than reading the root's. `@types/react` is the + // live shape: web and tui declare it, the root does not, and the two clients + // disagreed. A root-manifest-only candidate set would report success here. + withFixture( + { + webManifestDeps: { solo: "^7.0.0" }, + webSolo: "7.0.0", + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /1 declared dependency resolves/); + assert.match(out, /7\.8\.9\s+\(\.\/node_modules\/solo\)/); + assert.match(out, /7\.0\.0\s+\(clients\/web\/node_modules\/solo\)/); + }, + ); +}); + test("main: an installed package NO manifest declares is not a declared-tier candidate", () => { // `outer` and `inner` sit in both installs in every fixture, but no manifest // names them. The declared tier's boundary is what the repo declares, so its diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 26eebe9d..962b6c1d 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -278,18 +278,33 @@ export function toleratesSkew(name, holders, tolerated) { * Every package name any install declares, across `dependencies` and * `devDependencies`. * - * `manifests` is `[{ dir, manifest }]`. Both fields count because the boundary - * this tier polices is "does the repo name it", not "does it ship": a - * devDependency skew is exactly the `@types/*` and toolchain case #2226 is - * about. `peerDependencies` and `optionalDependencies` are NOT declarations of - * what this repo installs — a peer range is a constraint on the consumer, and - * the copy npm auto-installs to satisfy one is caught anyway, because it lands - * top-level in an install whose sibling declares the same name. + * `manifests` is `[{ dir, manifest }]`. All three fields count because the + * boundary this tier polices is "does the repo name a package npm will install + * for us", not "does it ship": + * + * • `devDependencies` — the `@types/*` and toolchain case #2226 is about; every + * one of the four skews this tier first surfaced was a devDependency + * somewhere. + * • `optionalDependencies` — a direct declaration npm attempts to install like + * any other. Omitting it would leave an optional-only package free to hold + * conflicting top-level copies in two installs while this tier reported + * success (Copilot, #2226). + * + * `peerDependencies` is the one exclusion, and it is not an oversight: a peer + * range is a constraint the consumer places on its *host*, not a statement that + * this repo installs the package. The copy npm auto-installs to satisfy an unmet + * peer is caught anyway whenever some manifest declares that name — which is the + * `eslint`/`typescript`/`vitest` shadow case, since all three are root + * devDependencies. */ export function declaredPackages(manifests) { const names = new Set(); for (const { manifest } of manifests) - for (const field of ["dependencies", "devDependencies"]) + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]) for (const name of Object.keys(manifest?.[field] ?? {})) names.add(name); return names; } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index b648c276..69a40720 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -413,10 +413,12 @@ test("majorOf: prerelease and build metadata are irrelevant", () => { // The declared tier (#2226). One case per rule, same convention as above. // --------------------------------------------------------------------------- -test("declaredPackages: dependencies and devDependencies from every install", () => { - // devDependencies count because the boundary is "does the repo name it", not - // "does it ship" — `@types/node` and the toolchain are exactly the case #2226 - // is about, and all of them are devDependencies. +test("declaredPackages: dependencies, devDependencies and optionalDependencies from every install", () => { + // devDependencies count because the boundary is "does the repo name a package + // npm installs for us", not "does it ship" — `@types/node` and the toolchain + // are exactly the case #2226 is about, and all of them are devDependencies. + // optionalDependencies count for the same reason: npm attempts to install one + // like any other direct declaration (Copilot, #2226). assert.deepEqual( [ ...declaredPackages([ @@ -426,28 +428,24 @@ test("declaredPackages: dependencies and devDependencies from every install", () manifest: { dependencies: { "react-dom": "^19.2.4" }, devDependencies: { "@types/react": "^19.2.14" }, + optionalDependencies: { fsevents: "^2.3.3" }, }, }, ]), ].sort(), - ["@types/react", "react", "react-dom"], + ["@types/react", "fsevents", "react", "react-dom"], ); }); -test("declaredPackages: peer and optional ranges are not declarations", () => { - // A peer range constrains the consumer rather than naming what we install; - // the copy npm auto-installs to satisfy one is still caught, because it lands - // top-level in an install whose sibling declares the same name. +test("declaredPackages: a peer range is not a declaration", () => { + // A peer range constrains the consumer's HOST rather than naming what this + // repo installs; the copy npm auto-installs to satisfy an unmet one is still + // caught whenever some manifest declares that name, which is the + // eslint/typescript/vitest shadow case (all three are root devDependencies). assert.deepEqual( [ ...declaredPackages([ - { - dir: ".", - manifest: { - peerDependencies: { react: "^19.0.0" }, - optionalDependencies: { fsevents: "^2.3.3" }, - }, - }, + { dir: ".", manifest: { peerDependencies: { react: "^19.0.0" } } }, ]), ], [],